Source: task/album-cue-lower-run-converge (plan/steward-linker-design) — the 16d8f74 LOWER_RUN both leaked lowercase prose and truncated digit-led titles; this converges the precision/recall balance.
- Weak cue (album|record|ep|lp): admit a capital/digit-led run OR a SINGLE lowercase token only (LOWER_ONE, guarded by a negative lookahead so a second lowercase word reads as prose and is rejected) — closes the 'makes me cry' / 'changed everything' prose leak.
- Strong cue (called|titled|named): the ONLY place a multi-word lowercase run (LOWER_RUN) is admitted — 'an album called good kid maad city' → 'good kid maad city'.
- New DIGIT_RUN (digit lead absorbs trailing lowercase tokens, bounded 1-4 words, tried before TITLE_RUN) fixes the digit truncation — '36 chambers'/'1989 deluxe'/'808s and heartbreak' capture in full.
- Position-mapping invariant enforced in the album push helper; bounded regexes (ReDoS test on a multi-KB pathological input); comprehensive BOTH-direction tests (precision: non-stop-set lowercase leads emit nothing; recall: digit continuation, single token, strong cue).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
289 lines
15 KiB
JavaScript
289 lines
15 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);
|
||
}
|
||
});
|
||
|
||
// --- 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 — a lowercase-styled MULTI-word title needs a STRONG cue", () => {
|
||
// good kid maad city is fully lowercase. The WEAK cue (album|record|ep|lp) can't tell a stylized
|
||
// title from prose, so it emits NOTHING; a STRONG explicit cue (called|titled|named) licenses it.
|
||
assert.equal(albumMatchesIn("the album good kid maad city").length, 0); // weak cue → nothing
|
||
assert.ok(
|
||
albumMatchesIn("an album called 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);
|
||
}
|
||
});
|
||
|
||
// --- Convergence: the prior LOWER_RUN both leaked lowercase prose and truncated digit-led titles.
|
||
// The stable design: weak cue (album|record|ep|lp) admits a capital/digit-led run OR a SINGLE
|
||
// lowercase token only; a MULTI-word lowercase title needs a STRONG cue (called|titled|named); a
|
||
// digit lead absorbs its full lowercase continuation. These assert BOTH directions — the precision
|
||
// floor (album path emits NOTHING on non-stop-set lowercase leads, the bug the old test missed) AND
|
||
// the recovered recall (digit continuation, single-token, strong-cue). ---
|
||
|
||
test("albumMatchesIn: precision — weak-cue lowercase leads OUTSIDE the stop-set emit nothing", () => {
|
||
// The shipped leak: LOWER_RUN was guarded only by a 6-word stop-set, so any OTHER lowercase lead
|
||
// ('changed', 'dropped', 'makes', 'really', 'sounds') after a weak cue leaked a prose fragment.
|
||
for (const text of [
|
||
"the album i bought yesterday",
|
||
"this record changed everything for me",
|
||
"their ep dropped on bandcamp last night",
|
||
"the album makes me cry",
|
||
"the album really grew on me",
|
||
"the album sounds great",
|
||
]) {
|
||
assert.equal(albumMatchesIn(text).length, 0, `expected no album candidate from: ${text}`);
|
||
}
|
||
// The old stop-set precision cases must still hold too.
|
||
assert.equal(albumMatchesIn("the album by Carcass").length, 0); // verb/article left edge
|
||
assert.equal(albumMatchesIn("I think their new LP is amazing").length, 0); // lowercase filler before LP
|
||
assert.equal(albumMatchesIn('"We hold these truths to be self-evident."').length, 0); // prose quote
|
||
});
|
||
|
||
test("albumMatchesIn: recall — a digit-led title captures its full lowercase continuation", () => {
|
||
// The shipped bug: TITLE_RUN's [A-Z0-9] lead matched the bare digit and stopped, so '36 chambers'
|
||
// truncated to '36'. The digit-led run now absorbs trailing lowercase tokens (bounded to 4 words).
|
||
assert.ok(albumMatchesIn("the album 36 chambers").map((m) => m.name).includes("36 chambers"));
|
||
assert.ok(albumMatchesIn("the album 1989 deluxe").map((m) => m.name).includes("1989 deluxe"));
|
||
assert.ok(
|
||
albumMatchesIn("the album 808s and heartbreak").map((m) => m.name).includes("808s and heartbreak")
|
||
);
|
||
// Prior digit recall cases (bare number, "X LP", capital-led mixed) still pass.
|
||
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 — weak cue admits a SINGLE lowercase token (whole-title only)", () => {
|
||
// 'untitled' is the entire title — a lone lowercase token survives; a second lowercase word would
|
||
// be prose and is rejected (covered by the precision test above).
|
||
assert.ok(albumMatchesIn("the album untitled").map((m) => m.name).includes("untitled"));
|
||
assert.ok(albumMatchesIn("the album untitled.").map((m) => m.name).includes("untitled"));
|
||
});
|
||
|
||
test("albumMatchesIn: recall — a MULTI-word lowercase title needs a STRONG cue (called/titled/named)", () => {
|
||
// The ONLY place a bare multi-word lowercase run is admitted: an explicit naming verb.
|
||
assert.ok(
|
||
albumMatchesIn("an album called good kid maad city").map((m) => m.name).includes("good kid maad city")
|
||
);
|
||
assert.ok(albumMatchesIn("the record titled good kid maad city").map((m) => m.name).includes("good kid maad city"));
|
||
assert.ok(albumMatchesIn("the album named good kid maad city").map((m) => m.name).includes("good kid maad city"));
|
||
// The same multi-word lowercase run WITHOUT a strong cue emits nothing (the precision invariant).
|
||
assert.equal(albumMatchesIn("the album good kid maad city").length, 0);
|
||
});
|
||
|
||
test("albumMatchesIn: convergence cases all map back to the source text", () => {
|
||
// Position-mapping invariant across the new precision/recall cases (digit continuation, single
|
||
// token, strong cue) — text.substr(index, length) === name.
|
||
const texts = [
|
||
"the album 36 chambers",
|
||
"the album 1989 deluxe",
|
||
"the album 808s and heartbreak",
|
||
"the album untitled",
|
||
"the album untitled.",
|
||
"an album called good kid maad city",
|
||
"the record titled good kid maad city",
|
||
"the album Reign in Blood", // capital-led unchanged
|
||
"Reign in Blood 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("albumMatchesIn: no ReDoS — a pathological multi-KB input returns promptly", () => {
|
||
// Bounded runs ({0,3}, single linear lookahead) must stay linear. A worst-case repeat of the
|
||
// cue + many tokens must not blow up; assert it completes well under a generous wall-clock bound.
|
||
const evil = "the album " + "a ".repeat(20000) + "!".repeat(20000);
|
||
const t0 = Date.now();
|
||
albumMatchesIn(evil);
|
||
assert.ok(Date.now() - t0 < 1000, "albumMatchesIn took too long on a pathological input");
|
||
});
|
||
|
||
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
|
||
});
|