Files
linker/extension/extract.test.cjs
T
paulandClaude Opus 4.8 16d8f74997 fix: rebalance album cue recall — admit number/lowercase-led + abbreviation-period titles, bound 'the album X', strip curly quote (closes task/album-cue-recall-rebalance)
Source: task/album-cue-recall-rebalance (plan/steward-linker-design) — the a35dd95 precision tightening over-corrected, dropping real number/lowercase-led and abbreviation-period album titles.

- Loosen the album-cue lead token to admit a DIGIT (and, after an explicit
  "the album X" cue, a lowercase-styled run), so number-led ("the album 1989",
  "1989 LP", "36 Chambers") and lowercase-styled ("good kid maad city") titles
  survive; ALBUM_LEAD_STOP still rejects an is/was/by/the/a/an lead.
- Narrow QUOTED_RE's mid-span reject to SENTENCE-ENDING punctuation only (a .!?
  before whitespace/EOL, abbreviation periods exempted), so "M.A.A.D City",
  "Mr. Bungle", "Sgt. Pepper's" survive while prose quotes still reject; raise
  the word cap 6 → 8.
- Add the curly quotes ”“ (U+201D/U+201C) to TRAILING_PUNCT so an LP/EP run that
  grabbed a trailing curly quote ("Reek of Putrefaction”") is trimmed, with the
  span index/length kept so text.substr(index,length)===name still holds.
- Bound the "the album X" branch to the same 1-4-token run as the LP/EP branch,
  so "the album Money Store dropped" yields "Money Store", not the sentence tail.
- Tests assert BOTH directions: recovered recall AND retained precision, plus the
  curly-quote-trim position-mapping invariant.

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

200 lines
10 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);
}
});
// --- Recall rebalance: the a35dd95 precision tightening over-corrected. These assert the recovered
// recall (number/lowercase-led + abbreviation-period titles, bounded "the album X", no curly-quote
// leak) AND that the precision floor still holds (album path emits nothing on prose/filler). ---
test("albumMatchesIn: recall — number-led titles via an explicit album cue", () => {
// The cue ("the album" / "LP") is the precision signal that justifies a number as a title.
assert.ok(albumMatchesIn("the album 1989").map((m) => m.name).includes("1989"));
assert.ok(albumMatchesIn("1989 LP").map((m) => m.name).includes("1989"));
assert.ok(albumMatchesIn("the album 36 Chambers").map((m) => m.name).includes("36 Chambers"));
});
test("albumMatchesIn: recall — lowercase-styled titles after a cue", () => {
// good kid maad city is fully lowercase — a stylized title, not prose; the cue admits it.
assert.ok(
albumMatchesIn("the album good kid maad city").map((m) => m.name).includes("good kid maad city")
);
});
test("albumMatchesIn: recall — internal abbreviation periods survive in a quoted title", () => {
// A `.` mid-span is only sentence-ending when it follows a long word; "M.A.A.D"/"Mr."/"Sgt." abbreviate.
assert.ok(albumMatchesIn('"M.A.A.D City"').map((m) => m.name).includes("M.A.A.D City"));
assert.ok(albumMatchesIn('"Mr. Bungle"').map((m) => m.name).includes("Mr. Bungle"));
assert.ok(
albumMatchesIn('"Sgt. Pepper\'s Lonely Hearts Club Band"')
.map((m) => m.name)
.includes("Sgt. Pepper's Lonely Hearts Club Band")
);
});
test("albumMatchesIn: recall — curly close-quote is trimmed off an LP/EP title run", () => {
// Separate the title from the “…” so the trailing curly quote, not an artist possessive, is the target.
const text = "their “Reek of Putrefaction” LP";
const ms = albumMatchesIn(text);
const names = ms.map((m) => m.name);
assert.ok(names.includes("Reek of Putrefaction")); // exact string — no trailing ”
assert.ok(!names.some((n) => /[“”]/.test(n))); // no curly quote leaked into any candidate
// position mapping holds even after the leading-/trailing-quote trim shifts the span
for (const m of ms) assert.equal(text.substr(m.index, m.length), m.name);
});
test("albumMatchesIn: recall — 'the album X' is bounded to the title, not the trailing prose", () => {
// a35dd95 left "the album X" an unbounded lazy run; it now reuses the 1-4-token bound.
const names = albumMatchesIn("the album Money Store dropped").map((m) => m.name);
assert.ok(names.includes("Money Store")); // the title
assert.ok(!names.includes("Money Store dropped")); // not the title + trailing verb
});
test("albumMatchesIn: precision — album path still emits nothing on prose / filler", () => {
// Each of these must yield NO album candidate (the precision guards a35dd95 added must survive).
assert.equal(albumMatchesIn("I think their new LP is amazing").length, 0); // lowercase filler before LP
assert.equal(albumMatchesIn("the album by Carcass").length, 0); // verb/article left edge
assert.equal(albumMatchesIn("the album is great but I forgot the name").length, 0); // "is" lead
assert.equal(albumMatchesIn('"We hold these truths to be self-evident."').length, 0); // prose quote
assert.equal(albumMatchesIn('"lol"').length, 0); // lone interjection
});
test("albumMatchesIn: every rebalance candidate maps back to the source text", () => {
// Position-mapping invariant across the recall cases, including the curly-quote-trim shift.
const texts = [
"the album 1989",
"1989 LP",
"the album good kid maad city",
'"M.A.A.D City"',
'"Mr. Bungle"',
"their “Reek of Putrefaction” LP",
"the album Money Store dropped",
];
for (const text of texts) {
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
});