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
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:
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user