Source: task/album-cue-precision (plan/steward-linker-design) — album cue regexes over-generated junk candidates (no left anchors), feeding noise to the resolver/LLM and risking fallback mis-links.
- X LP/EP branch: anchor the capture to a 1-4 capitalized/quoted title run (mirrors CAND_RE) so 'I think their new LP' no longer yields the sentence prefix.
- 'the album X' branch: require the name to start with a capital/quote and reject a leading verb/article (is|was|by|the|a|an) so 'the album by Carcass' / 'the album is great' yield no candidate.
- QUOTED_RE: reject prose quotes — a span with mid-string sentence punctuation (. ! ?), over ~6 words, or a lone stopword interjection ('lol') — so prose quotes never become album candidates.
- Preserve position mapping (trim a captured leading quote and shift the index) and dedupe identical spans across the two signals; recall of real titles ('Reign in Blood', 'Human Jerky') unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
128 lines
6.2 KiB
JavaScript
128 lines
6.2 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, isSafeHttpUrl } = 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("albumMatchesIn: 'X LP/EP' branch is anchored, not the sentence prefix", () => {
|
||
// No left anchor used to yield "I think their new" (the prefix before " LP").
|
||
const ms = albumMatchesIn("I think their new LP is amazing");
|
||
assert.ok(!ms.some((m) => m.name === "I think their new"));
|
||
// Either nothing, or only a title-shaped run — never a sentence fragment.
|
||
for (const m of ms) assert.ok(/^["“'‘]?[A-Z]/.test(m.name));
|
||
});
|
||
|
||
test("albumMatchesIn: 'the album X' guards a lowercase/verb left edge", () => {
|
||
// 'the album by Carcass' must not emit 'by Carcass' / 'by' as an album.
|
||
const a = albumMatchesIn("the album by Carcass").map((m) => m.name);
|
||
assert.ok(!a.includes("by Carcass"));
|
||
assert.ok(!a.includes("by"));
|
||
// 'the album is great…' must emit no album candidate at all.
|
||
const b = albumMatchesIn("the album is great but I forgot the name");
|
||
assert.equal(b.length, 0);
|
||
});
|
||
|
||
test("albumMatchesIn: prose quotes are not album candidates", () => {
|
||
// A quote with sentence-ending punctuation mid-span, or a lone interjection, is not a title.
|
||
const a = albumMatchesIn('"We hold these truths to be self-evident."').map((m) => m.name);
|
||
assert.ok(!a.includes("We hold these truths to be self-evident."));
|
||
assert.equal(albumMatchesIn('"lol"').length, 0);
|
||
});
|
||
|
||
test("albumMatchesIn: recall preserved — real titles still emit", () => {
|
||
assert.ok(albumMatchesIn("the album Reign in Blood").map((m) => m.name).includes("Reign in Blood"));
|
||
assert.ok(albumMatchesIn("Reign in Blood LP").map((m) => m.name).includes("Reign in Blood"));
|
||
assert.ok(albumMatchesIn('"Human Jerky"').map((m) => m.name).includes("Human Jerky"));
|
||
});
|
||
|
||
test("albumMatchesIn: tightened cues still map positions back to the text", () => {
|
||
for (const text of ["Reign in Blood LP rules", "the album by Carcass", '"Human Jerky" demo']) {
|
||
for (const m of albumMatchesIn(text)) assert.equal(text.substr(m.index, m.length), m.name);
|
||
}
|
||
});
|
||
|
||
test("isSafeHttpUrl accepts http/https and rejects javascript/data/garbage/empty", () => {
|
||
// accepted: the only schemes a resolver link may inject as an href
|
||
assert.ok(isSafeHttpUrl("https://open.spotify.com/artist/abc"));
|
||
assert.ok(isSafeHttpUrl("http://localhost:8787/x"));
|
||
assert.ok(isSafeHttpUrl("https://www.google.com/search?q=Scum+bandcamp"));
|
||
// rejected: dangerous schemes + non-URLs
|
||
assert.ok(!isSafeHttpUrl("javascript:alert(1)"));
|
||
assert.ok(!isSafeHttpUrl("data:text/html,<script>alert(1)</script>"));
|
||
assert.ok(!isSafeHttpUrl("ftp://example.com/x"));
|
||
assert.ok(!isSafeHttpUrl("not a url"));
|
||
assert.ok(!isSafeHttpUrl(""));
|
||
assert.ok(!isSafeHttpUrl(null));
|
||
assert.ok(!isSafeHttpUrl(undefined));
|
||
});
|
||
|
||
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
|
||
});
|