// 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, chunk } = 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,")); 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("chunk splits an array into <= size batches, in order, losslessly", () => { // exact multiple: 200 -> [100, 100] const a = Array.from({ length: 200 }, (_, i) => i); const ca = chunk(a, 100); assert.deepEqual(ca.map((b) => b.length), [100, 100]); assert.deepEqual([].concat(...ca), a); // concatenation reproduces the input in order // remainder: 250 -> [100, 100, 50] const b = Array.from({ length: 250 }, (_, i) => i); const cb = chunk(b, 100); assert.deepEqual(cb.map((x) => x.length), [100, 100, 50]); assert.deepEqual([].concat(...cb), b); // under-size: 5 -> [5] const c = [1, 2, 3, 4, 5]; const cc = chunk(c, 100); assert.deepEqual(cc.map((x) => x.length), [5]); assert.deepEqual([].concat(...cc), c); // empty: [] -> [] assert.deepEqual(chunk([], 100), []); }); 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 });