Files
paulandClaude Opus 4.8 2b4babf955 feat: detect & link album mentions, not just artists (closes task/album-detection)
Source: task/album-detection (objective/linker-improvements) — project brief asks for artist OR album linking; MVP linked artists only.

- service/lib.js: pickAlbum (the exact-name gate, named for the album path) + linkResult now carries kind:"artist"|"album" (additive; defaults to artist so older clients keep working).
- service/resolver.js: one Spotify type=artist,album search per candidate, exact-match against either; returns {url,kind}; cache key includes the kind hint. resolveOne/resolveAll thread the LLM kind hint through.
- service/llm.js: parseClassification now returns Map(name -> "artist"|"album") so album-kind candidates route to the album path (.has() still works like the old Set).
- extension/extract.js: albumMatchesIn over two precise signals (quoted strings + album cue phrases "the album X", "X LP/EP"), folded into allMatchesIn.
- extension/content.js + styles.css: album links render italicised (.rsl-album) with an album tooltip; artist behavior unchanged.
- Tests: new pure-logic coverage in lib.test.js / llm.test.js / extract.test.cjs (album shaping, Map-kind routing, album candidate extraction). scripts/check.sh green.
- README.md + AGENTS.md: document album linking.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 22:33:32 +00:00

72 lines
2.8 KiB
JavaScript

// Unit tests for the optional LLM classifier's pure logic (run with `node --test`). Zero-dep.
import { test } from "node:test";
import assert from "node:assert/strict";
import { buildClassifyMessages, parseClassification, DEFAULT_MODEL } from "./llm.js";
test("DEFAULT_MODEL is a small ollama model", () => {
assert.equal(DEFAULT_MODEL, "qwen2.5:3b");
});
test("buildClassifyMessages embeds the candidate list as JSON for the user turn", () => {
const msgs = buildClassifyMessages(["Tool", "the best"]);
assert.equal(msgs.length, 2);
assert.equal(msgs[0].role, "system");
assert.match(msgs[0].content, /artist/i);
assert.equal(msgs[1].role, "user");
assert.deepEqual(JSON.parse(msgs[1].content), { candidates: ["Tool", "the best"] });
});
test("parseClassification keeps only artist/album candidates that were in the list", () => {
const candidates = ["Cattle Decapitation", "Devourment", "the best part"];
const reply = JSON.stringify({
results: [
{ name: "Cattle Decapitation", kind: "artist" },
{ name: "Devourment", kind: "ARTIST" }, // case-insensitive kind
{ name: "the best part", kind: "none" },
],
});
const got = parseClassification(reply, candidates);
assert.ok(got.has("cattle decapitation"));
assert.ok(got.has("devourment"));
assert.ok(!got.has("the best part"));
});
test("parseClassification returns a Map of norm(name) -> kind for routing", () => {
const got = parseClassification(
JSON.stringify({
results: [
{ name: "Cattle Decapitation", kind: "artist" },
{ name: "Reek of Putrefaction", kind: "ALBUM" }, // case-insensitive kind
],
}),
["Cattle Decapitation", "Reek of Putrefaction"],
);
assert.ok(got instanceof Map);
assert.equal(got.get("cattle decapitation"), "artist");
assert.equal(got.get("reek of putrefaction"), "album"); // normalized to lowercase kind
});
test("parseClassification accepts album kind and a bare array reply", () => {
const got = parseClassification(
JSON.stringify([{ name: "Reek of Putrefaction", kind: "album" }]),
["Reek of Putrefaction"],
);
assert.ok(got.has("reek of putrefaction"));
assert.equal(got.get("reek of putrefaction"), "album");
});
test("parseClassification ignores hallucinated names not in the candidate list", () => {
const got = parseClassification(
JSON.stringify({ results: [{ name: "Some Band I Invented", kind: "artist" }] }),
["Real Candidate"],
);
assert.equal(got.size, 0);
});
test("parseClassification returns an empty set on unparseable / junk replies", () => {
assert.equal(parseClassification("not json at all", ["X"]).size, 0);
assert.equal(parseClassification("", ["X"]).size, 0);
assert.equal(parseClassification(JSON.stringify({ nope: true }), ["X"]).size, 0);
assert.equal(parseClassification(JSON.stringify({ results: [null, { kind: "artist" }] }), ["X"]).size, 0);
});