Files
linker/extension/extract.test.cjs
T
paulandClaude Opus 4.8 a35dd95c88 fix: tighten album cue extraction so candidates are title-shaped, not sentence fragments (closes task/album-cue-precision)
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>
2026-06-23 04:30:22 +00:00

128 lines
6.2 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
});