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>
76 lines
3.5 KiB
JavaScript
76 lines
3.5 KiB
JavaScript
// Unit tests for the pure candidate extraction (run with `node --test`). Zero-dep.
|
|
// CJS so it can require() extract.js, which is a UMD script (also a content script).
|
|
const { test } = require("node:test");
|
|
const assert = require("node:assert/strict");
|
|
const { matchesIn, cueMatchesIn, albumMatchesIn, allMatchesIn } = require("./extract.js");
|
|
|
|
test("matchesIn finds Title-Case / ALL-CAPS runs, skips stopwords", () => {
|
|
// Note: "and" is a connector, so "X and Y" greedily joins into one run (by design,
|
|
// for names like "Iron and Wine") — keep the two names in separate clauses here.
|
|
const names = matchesIn("Cattle Decapitation is great; DEVOURMENT rules. I agree").map((m) => m.name);
|
|
assert.ok(names.includes("Cattle Decapitation"));
|
|
assert.ok(names.includes("DEVOURMENT"));
|
|
assert.ok(!names.includes("I"));
|
|
});
|
|
|
|
test("matchesIn reports positions that map back to the text", () => {
|
|
const text = "Check out Devourment now";
|
|
const m = matchesIn(text).find((x) => x.name === "Devourment");
|
|
assert.equal(text.substr(m.index, m.length), "Devourment");
|
|
});
|
|
|
|
test("cueMatchesIn catches lowercase names after a cue phrase", () => {
|
|
const names = cueMatchesIn("you should check out devourment honestly").map((m) => m.name);
|
|
assert.ok(names.includes("devourment"));
|
|
});
|
|
|
|
test("cueMatchesIn positions map back to the text", () => {
|
|
const text = "i'm a big fan of cattle decapitation"; // "fan of" cue
|
|
for (const m of cueMatchesIn(text)) assert.equal(text.substr(m.index, m.length), m.name);
|
|
});
|
|
|
|
test("cueMatchesIn skips lowercase stopwords right after the cue", () => {
|
|
const names = cueMatchesIn("check out the new album").map((m) => m.name);
|
|
assert.ok(!names.includes("the"));
|
|
});
|
|
|
|
test("albumMatchesIn pulls quoted strings (double + single), excluding the quotes", () => {
|
|
const text = `their album "Reek of Putrefaction" and the 'Scum' demo`;
|
|
const ms = albumMatchesIn(text);
|
|
const names = ms.map((m) => m.name);
|
|
assert.ok(names.includes("Reek of Putrefaction"));
|
|
assert.ok(names.includes("Scum"));
|
|
// index/length cover the inner text only (no quote chars) so the wrapper links the title
|
|
for (const m of ms) assert.equal(text.substr(m.index, m.length), m.name);
|
|
});
|
|
|
|
test("albumMatchesIn catches album cue phrases ('the album X', 'X LP/EP')", () => {
|
|
const a = albumMatchesIn("the album Scum is a classic").map((m) => m.name);
|
|
assert.ok(a.includes("Scum"));
|
|
const b = albumMatchesIn("Reign in Blood LP rules").map((m) => m.name);
|
|
assert.ok(b.includes("Reign in Blood"));
|
|
const c = albumMatchesIn("the record called Bonus Tracks").map((m) => m.name);
|
|
assert.ok(c.includes("Bonus Tracks"));
|
|
});
|
|
|
|
test("albumMatchesIn cue positions map back to the text", () => {
|
|
const text = "the album Scum is great";
|
|
for (const m of albumMatchesIn(text)) assert.equal(text.substr(m.index, m.length), m.name);
|
|
});
|
|
|
|
test("albumMatchesIn skips pure-number and too-short quotes", () => {
|
|
const names = albumMatchesIn(`"42" and "X"`).map((m) => m.name);
|
|
assert.ok(!names.includes("42"));
|
|
assert.ok(!names.includes("X")); // length < 2
|
|
});
|
|
|
|
test("allMatchesIn merges cue + Title-Case + album and sorts ascending by index", () => {
|
|
const text = `Check out devourment and Cattle Decapitation; the album "Human Jerky" rules`;
|
|
const ms = allMatchesIn(text);
|
|
for (let i = 1; i < ms.length; i++) assert.ok(ms[i].index >= ms[i - 1].index);
|
|
const names = ms.map((m) => m.name);
|
|
assert.ok(names.includes("devourment")); // cue (lowercase)
|
|
assert.ok(names.includes("Cattle Decapitation")); // title-case
|
|
assert.ok(names.includes("Human Jerky")); // quoted album
|
|
});
|