import { afterEach, describe, expect, test, vi } from "vitest"; import { render, screen, fireEvent, waitFor } from "@testing-library/svelte"; import type { AcoustIDOverview } from "#lib/api/admin.js"; vi.mock("#lib/api/admin.js", () => ({ getAcoustID: vi.fn(), updateAcoustIDSettings: vi.fn(), runAcoustIDLookup: vi.fn(), listUnsettledAcoustID: vi.fn(), })); vi.mock("#lib/stores/toast.svelte.js", () => ({ pushToast: vi.fn() })); import AcoustIDCard from "./AcoustIDCard.svelte"; import { getAcoustID, listUnsettledAcoustID, runAcoustIDLookup, updateAcoustIDSettings, } from "#lib/api/admin.js"; const base: AcoustIDOverview = { settings: { enabled: true, min_score: 0.85, api_key_set: true }, ready: true, status: { running: false, last_pass_at: null, problem: "" }, coverage: { total: 1000, from_tag: 700, from_acoustid: 150, ambiguous: 12, no_match: 80, failed: 3, pending: 55, }, }; function overview(over: Partial = {}): AcoustIDOverview { return { ...base, ...over, settings: { ...base.settings, ...over.settings } }; } afterEach(() => vi.clearAllMocks()); async function renderCard(o: AcoustIDOverview = base) { vi.mocked(getAcoustID).mockResolvedValue(o); render(AcoustIDCard); await screen.findByRole("switch", { name: /look up untagged tracks/i }); } const saveButton = () => screen.getByRole("button", { name: /^save$/i }); const lookUpNow = () => screen.getByRole("button", { name: /look up now/i }); describe("AcoustIDCard", () => { test("shows where the library’s recording ids came from", async () => { await renderCard(); const cov = screen.getByTestId("acoustid-coverage").textContent?.replace(/\s+/g, " "); // 1000 - 700 - 150 have no id. expect(cov).toMatch(/Recording ids 700 from tags · 150 looked up · 150 none/); expect(cov).toMatch(/Without one 55 waiting · 80 no match · 12 ambiguous · 3 failed/); }); test("never shows the stored key, and sends one only when typed", async () => { vi.mocked(updateAcoustIDSettings).mockResolvedValue(base.settings); await renderCard(); const key = screen.getByLabelText(/api key/i) as HTMLInputElement; expect(key.value).toBe(""); expect(key.placeholder).toBe("Stored"); expect(saveButton()).toHaveProperty("disabled", true); await fireEvent.input( screen.getByRole("spinbutton", { name: /minimum score/i }), { target: { value: "0.9" }, }, ); await fireEvent.click(saveButton()); await waitFor(() => expect(updateAcoustIDSettings).toHaveBeenCalledWith({ enabled: true, min_score: 0.9, }), ); await fireEvent.input(key, { target: { value: " new-key " } }); await fireEvent.click(saveButton()); await waitFor(() => expect(updateAcoustIDSettings).toHaveBeenLastCalledWith({ enabled: true, min_score: 0.85, api_key: "new-key", }), ); }); test("a score outside 0.5 to 1 is named and cannot be saved", async () => { await renderCard(); await fireEvent.input( screen.getByRole("spinbutton", { name: /minimum score/i }), { target: { value: "0.3" }, }, ); await waitFor(() => expect(screen.getByTestId("acoustid-problems").textContent).toMatch( /0\.5 to 1/, ), ); expect(saveButton()).toHaveProperty("disabled", true); }); // Rule 164: off, keyless or unreachable is a visible state, not a silent one. test("says why it is not looking anything up", async () => { await renderCard( overview({ ready: false, settings: { enabled: true, min_score: 0.85, api_key_set: false }, }), ); expect(screen.getByTestId("acoustid-state").textContent).toMatch( /needs a key/i, ); expect(lookUpNow()).toHaveProperty("disabled", true); }); test("a pass that stopped short says why", async () => { await renderCard( overview({ status: { running: false, last_pass_at: "2026-10-07T03:00:00Z", problem: "AcoustID refused the API key. Check it in Settings.", }, }), ); expect(screen.getByTestId("acoustid-problem").textContent).toMatch( /refused the API key/, ); expect(screen.getByTestId("acoustid-state").textContent).toMatch( /needs attention/i, ); }); test("look up now starts a pass", async () => { vi.mocked(runAcoustIDLookup).mockResolvedValue(); await renderCard(); await fireEvent.click(lookUpNow()); await waitFor(() => expect(runAcoustIDLookup).toHaveBeenCalledTimes(1)); }); test("the tracks a lookup could not settle can be found", async () => { vi.mocked(listUnsettledAcoustID).mockResolvedValue([ { track_id: "t1", title: "WWW", artist: "Moe Shop", album: "WWW", file_path: "/music/a.mp3", state: "ambiguous", best_score: 0.97, candidates: 2, detail: null, looked_up_at: "2026-10-07T03:00:00Z", }, ]); await renderCard(); const details = screen .getByText(/couldn't settle \(15\)/i) .closest("details")!; details.open = true; await fireEvent(details, new Event("toggle")); const list = await screen.findByTestId("acoustid-unsettled"); expect(list.textContent).toMatch(/WWW/); expect(list.textContent).toMatch(/2 recordings/); }); test("a failed load offers a retry", async () => { vi.mocked(getAcoustID).mockRejectedValue(new Error("boom")); render(AcoustIDCard); await screen.findByText(/couldn't load acoustid settings/i); expect(screen.getByRole("button", { name: /try again/i })).toBeTruthy(); }); });