refactor: extract testable resolve-core + cover album routing; check.sh checks .cjs (closes task/resolver-orchestration-testable)
Source: task/resolver-orchestration-testable (plan/steward-linker-quality) — new album orchestration in resolver.js had zero node:test coverage. - Extract the pure album/artist orchestration into service/resolve-core.js (resolveAll/resolveOne/pickDirect/cacheKey) taking injected search/classify fns; resolver.js becomes a thin server wiring the real Spotify search + ollama classify. No behavior change. - Add service/resolver.test.js: self-titled artist+album tie-break, LLM-album -> Google fallback, kind-aware cache separation, plus gate/drop/error paths. - scripts/check.sh: node --check now also matches *.cjs (extract.test.cjs). - AGENTS.md Testing: note resolve-core coverage + the extract.test.cjs extractor. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,141 @@
|
||||
// Unit tests for the resolve-core orchestration (run with `node --test`). Zero-dep, hermetic:
|
||||
// the live Spotify/ollama clients are replaced by injected fake search/classify functions, so no
|
||||
// network and no real timers. Covers the album routing the HTTP server can't unit-test directly.
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { resolveAll, resolveOne, pickDirect, cacheKey } from "./resolve-core.js";
|
||||
|
||||
const ARTIST_URL = "https://open.spotify.com/artist/abc";
|
||||
const ALBUM_URL = "https://open.spotify.com/album/xyz";
|
||||
|
||||
// A fake `search` from a { norm(name) -> { artist, album } } table (urls or null).
|
||||
const fakeSearch = (table) => (name) => Promise.resolve(table[name.trim().toLowerCase()] ?? { artist: null, album: null });
|
||||
|
||||
// A fake `classify` returning a Map(norm(name) -> kind) from a plain { name: kind } object.
|
||||
const fakeClassify = (verdicts) => (names) => {
|
||||
void names;
|
||||
return Promise.resolve(new Map(Object.entries(verdicts).map(([n, k]) => [n.toLowerCase(), k])));
|
||||
};
|
||||
|
||||
test("pickDirect tie-break: kindHint='album' prefers the album, else artist-first", () => {
|
||||
const both = { artist: ARTIST_URL, album: ALBUM_URL };
|
||||
assert.deepEqual(pickDirect(both, "album"), { url: ALBUM_URL, kind: "album" });
|
||||
assert.deepEqual(pickDirect(both, "artist"), { url: ARTIST_URL, kind: "artist" });
|
||||
assert.deepEqual(pickDirect(both, undefined), { url: ARTIST_URL, kind: "artist" });
|
||||
// single-sided hits ignore the hint
|
||||
assert.deepEqual(pickDirect({ artist: ARTIST_URL, album: null }, "album"), { url: ARTIST_URL, kind: "artist" });
|
||||
assert.equal(pickDirect({ artist: null, album: null }, "album"), null);
|
||||
});
|
||||
|
||||
test("cacheKey separates the same name by kind hint, defaults to artist", () => {
|
||||
assert.equal(cacheKey("Tool", "album"), "album:tool");
|
||||
assert.equal(cacheKey("Tool", "artist"), "artist:tool");
|
||||
assert.equal(cacheKey("Tool", undefined), "artist:tool");
|
||||
});
|
||||
|
||||
test("self-titled artist+album tie-break routes to the kindHint'd URL", async () => {
|
||||
// A self-titled name that is BOTH an exact artist hit and an exact album hit on Spotify.
|
||||
const search = fakeSearch({ "bad religion": { artist: ARTIST_URL, album: ALBUM_URL } });
|
||||
|
||||
// kindHint='album' -> the album URL.
|
||||
const asAlbum = await resolveOne("Bad Religion", { search, kindHint: "album", allowFallback: true });
|
||||
assert.equal(asAlbum.kind, "album");
|
||||
assert.equal(asAlbum.spotify, ALBUM_URL);
|
||||
assert.deepEqual(asAlbum.primary, { platform: "spotify", url: ALBUM_URL });
|
||||
|
||||
// undefined and 'artist' both -> the artist URL (artist-first default).
|
||||
for (const hint of [undefined, "artist"]) {
|
||||
const asArtist = await resolveOne("Bad Religion", { search, kindHint: hint, allowFallback: true });
|
||||
assert.equal(asArtist.kind, "artist", `hint=${hint}`);
|
||||
assert.equal(asArtist.spotify, ARTIST_URL);
|
||||
assert.deepEqual(asArtist.primary, { platform: "spotify", url: ARTIST_URL });
|
||||
}
|
||||
});
|
||||
|
||||
test("album confirmed by the LLM but absent from Spotify falls back to a Google search", async () => {
|
||||
// The LLM gate confirms the name is an album; Spotify has no exact hit -> Google fallback.
|
||||
const search = fakeSearch({}); // nothing on Spotify
|
||||
const classify = fakeClassify({ "Some Local Release": "album" });
|
||||
|
||||
const out = await resolveAll(["Some Local Release"], { search, classify });
|
||||
const r = out["Some Local Release"];
|
||||
assert.ok(r, "confirmed name should resolve, not drop");
|
||||
assert.equal(r.kind, "album");
|
||||
assert.equal(r.primary.platform, "google");
|
||||
assert.match(r.primary.url, /^https:\/\/www\.google\.com\/search\?q=/);
|
||||
assert.ok(r.primary.url.includes("bandcamp"));
|
||||
assert.equal(r.spotify, undefined);
|
||||
});
|
||||
|
||||
test("LLM confirms artist+album for two names and routes each kind's Spotify lookup", async () => {
|
||||
const search = fakeSearch({
|
||||
devourment: { artist: ARTIST_URL, album: null },
|
||||
"reek of putrefaction": { artist: null, album: ALBUM_URL },
|
||||
});
|
||||
const classify = fakeClassify({ Devourment: "artist", "Reek of Putrefaction": "album" });
|
||||
|
||||
const out = await resolveAll(["Devourment", "Reek of Putrefaction"], { search, classify });
|
||||
assert.equal(out["Devourment"].kind, "artist");
|
||||
assert.equal(out["Devourment"].spotify, ARTIST_URL);
|
||||
assert.equal(out["Reek of Putrefaction"].kind, "album");
|
||||
assert.equal(out["Reek of Putrefaction"].spotify, ALBUM_URL);
|
||||
});
|
||||
|
||||
test("names the LLM did not confirm are dropped (null)", async () => {
|
||||
const search = fakeSearch({ "real band": { artist: ARTIST_URL, album: null } });
|
||||
const classify = fakeClassify({ "Real Band": "artist" }); // "the best part" not in the map
|
||||
|
||||
const out = await resolveAll(["Real Band", "the best part"], { search, classify });
|
||||
assert.ok(out["Real Band"]);
|
||||
assert.equal(out["the best part"], null);
|
||||
});
|
||||
|
||||
test("cache-key kind separation: same name as artist vs album does not cross-contaminate", async () => {
|
||||
const cache = new Map();
|
||||
// One name exists on Spotify as BOTH; the kind hint must keep the two lookups distinct.
|
||||
const search = fakeSearch({ "self titled": { artist: ARTIST_URL, album: ALBUM_URL } });
|
||||
|
||||
const asArtist = await resolveOne("Self Titled", { search, cache, kindHint: "artist", allowFallback: true });
|
||||
const asAlbum = await resolveOne("Self Titled", { search, cache, kindHint: "album", allowFallback: true });
|
||||
|
||||
assert.equal(asArtist.spotify, ARTIST_URL);
|
||||
assert.equal(asArtist.kind, "artist");
|
||||
assert.equal(asAlbum.spotify, ALBUM_URL);
|
||||
assert.equal(asAlbum.kind, "album");
|
||||
// Two distinct cache entries, keyed by kind — no cross-contamination.
|
||||
assert.equal(cache.size, 2);
|
||||
assert.deepEqual(cache.get("artist:self titled"), { url: ARTIST_URL, kind: "artist" });
|
||||
assert.deepEqual(cache.get("album:self titled"), { url: ALBUM_URL, kind: "album" });
|
||||
});
|
||||
|
||||
test("with no LLM gate (classify null) the Spotify exact-match IS the gate; unconfirmed -> null", async () => {
|
||||
// On Spotify -> links; not on Spotify and no confirmation -> null (no Google fallback).
|
||||
const search = fakeSearch({ devourment: { artist: ARTIST_URL, album: null } });
|
||||
|
||||
const out = await resolveAll(["Devourment", "Not A Band"], { search, classify: null });
|
||||
assert.equal(out["Devourment"].kind, "artist");
|
||||
assert.equal(out["Devourment"].spotify, ARTIST_URL);
|
||||
assert.equal(out["Not A Band"], null); // not confirmed, not on Spotify -> dropped
|
||||
});
|
||||
|
||||
test("LLM-down (classify throws) falls back to the Spotify exact-match gate", async () => {
|
||||
const search = fakeSearch({ devourment: { artist: ARTIST_URL, album: null } });
|
||||
const classify = () => Promise.reject(new Error("ollama down"));
|
||||
|
||||
const out = await resolveAll(["Devourment", "Not A Band"], { search, classify });
|
||||
assert.equal(out["Devourment"].spotify, ARTIST_URL); // Spotify hit still links
|
||||
assert.equal(out["Not A Band"], null); // no fallback once the LLM gate is gone
|
||||
});
|
||||
|
||||
test("resolveAll dedups, trims, drops blanks, and caps the candidate list", async () => {
|
||||
const search = fakeSearch({ tool: { artist: ARTIST_URL, album: null } });
|
||||
const out = await resolveAll(["Tool", " Tool ", "", " "], { search, classify: null });
|
||||
assert.deepEqual(Object.keys(out), ["Tool"]); // " Tool " collapses to "Tool"; blanks gone
|
||||
assert.equal(out["Tool"].spotify, ARTIST_URL);
|
||||
});
|
||||
|
||||
test("resolveOne swallows a search error and yields null for that name", async () => {
|
||||
const search = () => Promise.reject(new Error("spotify 503"));
|
||||
const r = await resolveOne("Tool", { search, allowFallback: true });
|
||||
assert.equal(r, null);
|
||||
});
|
||||
Reference in New Issue
Block a user