Source: task/album-detection (objective/linker-improvements) — project brief asks for artist OR album linking; MVP linked artists only.
- service/lib.js: pickAlbum (the exact-name gate, named for the album path) + linkResult now carries kind:"artist"|"album" (additive; defaults to artist so older clients keep working).
- service/resolver.js: one Spotify type=artist,album search per candidate, exact-match against either; returns {url,kind}; cache key includes the kind hint. resolveOne/resolveAll thread the LLM kind hint through.
- service/llm.js: parseClassification now returns Map(name -> "artist"|"album") so album-kind candidates route to the album path (.has() still works like the old Set).
- extension/extract.js: albumMatchesIn over two precise signals (quoted strings + album cue phrases "the album X", "X LP/EP"), folded into allMatchesIn.
- extension/content.js + styles.css: album links render italicised (.rsl-album) with an album tooltip; artist behavior unchanged.
- Tests: new pure-logic coverage in lib.test.js / llm.test.js / extract.test.cjs (album shaping, Map-kind routing, album candidate extraction). scripts/check.sh green.
- README.md + AGENTS.md: document album linking.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
91 lines
4.2 KiB
JavaScript
91 lines
4.2 KiB
JavaScript
// Unit tests for the resolver's pure logic (run with `node --test`). Zero-dep.
|
|
import { test } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { norm, googleSearch, pickArtist, pickAlbum, linkResult, isSearchUrl, isHttpUrl } from "./lib.js";
|
|
|
|
test("norm lowercases, trims, NFKC-normalizes", () => {
|
|
assert.equal(norm(" Cerebral Bore "), "cerebral bore");
|
|
assert.equal(norm("DEVOURMENT"), "devourment");
|
|
});
|
|
|
|
test("pickArtist matches case-insensitively, rejects non-matches", () => {
|
|
const items = [{ name: "Cattle Decapitation" }, { name: "Cerebral Bore" }];
|
|
assert.equal(pickArtist("cerebral bore", items)?.name, "Cerebral Bore");
|
|
assert.equal(pickArtist("CATTLE DECAPITATION", items)?.name, "Cattle Decapitation");
|
|
assert.equal(pickArtist("Notarealband", items), null);
|
|
});
|
|
|
|
test("pickArtist tolerates empty / garbage input", () => {
|
|
assert.equal(pickArtist("x", null), null);
|
|
assert.equal(pickArtist("x", []), null);
|
|
assert.equal(pickArtist("x", [null, { name: "y" }]), null);
|
|
});
|
|
|
|
test("googleSearch points at a Google query for '<name> bandcamp', url-encoded", () => {
|
|
const u = googleSearch("Cerebral Bore");
|
|
assert.match(u, /^https:\/\/www\.google\.com\/search\?q=/);
|
|
assert.ok(u.includes("Cerebral%20Bore%20bandcamp"));
|
|
assert.ok(googleSearch("A&B").includes("A%26B"));
|
|
});
|
|
|
|
test("pickAlbum is the same exact-name gate as pickArtist (album items)", () => {
|
|
const items = [{ name: "Scum" }, { name: "Reek of Putrefaction" }];
|
|
assert.equal(pickAlbum("reek of putrefaction", items)?.name, "Reek of Putrefaction");
|
|
assert.equal(pickAlbum("SCUM", items)?.name, "Scum");
|
|
assert.equal(pickAlbum("Not an album", items), null);
|
|
assert.equal(pickAlbum, pickArtist); // intentionally the same function, named for readability
|
|
});
|
|
|
|
test("linkResult prefers a direct Spotify url, primary = spotify", () => {
|
|
const r = linkResult("Devourment", "https://open.spotify.com/artist/abc");
|
|
assert.equal(r.spotify, "https://open.spotify.com/artist/abc");
|
|
assert.deepEqual(r.primary, { platform: "spotify", url: "https://open.spotify.com/artist/abc" });
|
|
assert.equal(r.google, undefined);
|
|
});
|
|
|
|
test("linkResult defaults kind to artist, carries kind=album when asked", () => {
|
|
assert.equal(linkResult("Devourment", "https://open.spotify.com/artist/abc").kind, "artist");
|
|
const al = linkResult("Reek of Putrefaction", "https://open.spotify.com/album/xyz", "album");
|
|
assert.equal(al.kind, "album");
|
|
assert.equal(al.spotify, "https://open.spotify.com/album/xyz");
|
|
assert.deepEqual(al.primary, { platform: "spotify", url: "https://open.spotify.com/album/xyz" });
|
|
// an unknown kind string degrades to "artist" rather than passing through
|
|
assert.equal(linkResult("X", "https://open.spotify.com/artist/abc", "bogus").kind, "artist");
|
|
});
|
|
|
|
test("linkResult keeps kind on the Google fallback path too", () => {
|
|
const r = linkResult("Some Local Album", null, "album");
|
|
assert.equal(r.kind, "album");
|
|
assert.equal(r.primary.platform, "google");
|
|
assert.match(r.primary.url, /^https:\/\/www\.google\.com\/search\?q=/);
|
|
});
|
|
|
|
test("linkResult falls back to Google->Bandcamp when there is no direct Spotify url", () => {
|
|
const r = linkResult("Some Local Band", null);
|
|
assert.equal(r.spotify, undefined);
|
|
assert.equal(r.primary.platform, "google");
|
|
assert.match(r.primary.url, /^https:\/\/www\.google\.com\/search\?q=/);
|
|
assert.equal(r.google, r.primary.url);
|
|
});
|
|
|
|
test("linkResult rejects non-http(s) / search Spotify urls and falls back to Google", () => {
|
|
for (const bad of ["javascript:alert(1)", "ftp://x/y", "https://open.spotify.com/search/X", "not a url"]) {
|
|
const r = linkResult("X", bad);
|
|
assert.equal(r.primary.platform, "google", `expected fallback for ${bad}`);
|
|
}
|
|
});
|
|
|
|
test("isSearchUrl detects /search urls", () => {
|
|
assert.equal(isSearchUrl("https://open.spotify.com/search/X"), true);
|
|
assert.equal(isSearchUrl("https://open.spotify.com/artist/abc"), false);
|
|
});
|
|
|
|
test("isHttpUrl accepts http/https only", () => {
|
|
assert.equal(isHttpUrl("https://x.com"), true);
|
|
assert.equal(isHttpUrl("http://x.com"), true);
|
|
assert.equal(isHttpUrl("javascript:alert(1)"), false);
|
|
assert.equal(isHttpUrl("ftp://x/y"), false);
|
|
assert.equal(isHttpUrl("not a url"), false);
|
|
assert.equal(isHttpUrl(null), false);
|
|
});
|