feat(web): AcoustID card on Integrations — key, threshold, coverage by source (M401 #3922)
release / go (push) Successful in 2m25s
release / web (push) Failing after 26s
release / govulncheck (push) Successful in 21s
release / Attach APK to the Release (tag releases only) (push) Canceled after 0s
release / Build + push container image (push) Canceled after 0s
release / Verify release artifacts (tag releases only) (push) Canceled after 0s
release / integration (push) Canceled after 3m34s
release / android (push) Canceled after 1m3s
release / Build signed APK (releases and dev) (push) Canceled after 1m3s

The card takes the slot of the unimplemented "MusicBrainz overrides"
placeholder. Rows:
- the on switch
- a write-only key field (the stored key is never sent back), with a
  link to register an application
- the minimum score (0.5 to 1)

Below them, recording-id coverage reads as a column: from tags, looked
up, none, and of the none how many are waiting, no match, ambiguous or
failed. There is a "Look up now" button and a folded list of the tracks
the lookup could not settle.

Off, keyless and stopped-short passes are each a visible state with the
reason (rule 164).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 23:29:34 -04:00
co-authored by Claude Opus 5.5
parent 3c575b137c
commit 0a7f788390
5 changed files with 600 additions and 17 deletions
+193
View File
@@ -0,0 +1,193 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/svelte";
import type { AcoustIDOverview } from "$lib/api/admin";
vi.mock("$lib/api/admin", () => ({
getAcoustID: vi.fn(),
updateAcoustIDSettings: vi.fn(),
runAcoustIDLookup: vi.fn(),
listUnsettledAcoustID: vi.fn(),
}));
vi.mock("$lib/stores/toast.svelte", () => ({ pushToast: vi.fn() }));
import AcoustIDCard from "./AcoustIDCard.svelte";
import {
getAcoustID,
listUnsettledAcoustID,
runAcoustIDLookup,
updateAcoustIDSettings,
} from "$lib/api/admin";
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> = {}): 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("checkbox", { 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");
// 1000 - 700 - 150 have no id.
for (const [label, n] of [
["From tags", "700"],
["Looked up", "150"],
["None", "150"],
["Waiting to be looked up", "55"],
["No match", "80"],
["Ambiguous", "12"],
["Failed", "3"],
]) {
const row = [...cov.querySelectorAll("dt")].find(
(dt) => dt.textContent?.trim() === label,
);
expect(row?.nextElementSibling?.textContent?.trim(), label).toBe(n);
}
});
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();
});
});