import { describe, test } from "node:test"; import assert from "node:assert/strict"; import { mkdtempSync, writeFileSync, rmSync, existsSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { normalizeContent, specificId, emptyBank, loadBank, saveBank, addSpecific, queryByTopic, } from "../src/bank.js"; import { SCHEMA_VERSION } from "../src/types.js"; import type { Bank } from "../src/types.js"; const prov = (source = "test", capturedAt = "2026-06-20") => ({ source, capturedAt }); const tmp = () => mkdtempSync(join(tmpdir(), "specifics-bank-")); describe("specifics-bank store", () => { describe("normalizeContent + specificId", () => { test("id is deterministic and content-derived", () => { assert.equal(specificId("Sparte to dager."), specificId("Sparte to dager.")); }); test("id is stable across case + surrounding/collapsed whitespace", () => { const a = specificId("Sparte to dager."); const b = specificId(" sparte TO dager. "); assert.equal(a, b, "normalization should collapse case + whitespace"); }); test("different content yields different ids", () => { assert.notEqual(specificId("Sparte to dager."), specificId("Sparte tre dager.")); }); test("normalizeContent collapses whitespace + lowercases", () => { assert.equal(normalizeContent(" Hei DU\n da "), "hei du da"); }); }); describe("emptyBank + load/save round-trip", () => { test("emptyBank has the schema version and no specifics", () => { const b = emptyBank(); assert.equal(b.schemaVersion, SCHEMA_VERSION); assert.deepEqual(b.specifics, []); }); test("loadBank on a missing file returns an empty bank (never throws)", () => { const dir = tmp(); try { const b = loadBank(join(dir, "does-not-exist.json")); assert.equal(b.schemaVersion, SCHEMA_VERSION); assert.deepEqual(b.specifics, []); } finally { rmSync(dir, { recursive: true, force: true }); } }); test("saveBank then loadBank round-trips", () => { const dir = tmp(); const path = join(dir, "bank.json"); try { let bank: Bank = emptyBank(); bank = addSpecific(bank, { type: "number", content: "Saksbehandleren sparte to dager i uka.", topicTags: ["saksbehandling", "produktivitet"], provenance: prov("seres/05"), verification: "unverified", }).bank; saveBank(path, bank); assert.ok(existsSync(path)); const loaded = loadBank(path); assert.equal(loaded.specifics.length, 1); assert.equal(loaded.specifics[0].content, "Saksbehandleren sparte to dager i uka."); assert.equal(loaded.specifics[0].type, "number"); } finally { rmSync(dir, { recursive: true, force: true }); } }); }); describe("addSpecific", () => { test("adds a new specific with a computed id and reports added:true", () => { const res = addSpecific(emptyBank(), { type: "named-case", content: "En kontraktsjurist hos en kunde automatiserte avtalegjennomgang.", topicTags: ["juss", "automatisering"], provenance: prov(), verification: "n/a", }); assert.equal(res.added, true); assert.equal(res.bank.specifics.length, 1); assert.equal(res.bank.specifics[0].id, specificId(res.bank.specifics[0].content)); assert.equal(res.bank.specifics[0].status, "active"); }); test("preserves the operator's content verbatim (normalizes only for id)", () => { const content = " KI-en nådde IKKE arkivdataene. "; const res = addSpecific(emptyBank(), { type: "what-broke", content, topicTags: ["data"], provenance: prov(), }); assert.equal(res.bank.specifics[0].content, content, "content must not be mutated"); }); test("dedupes on normalized content: same material twice → one entry, tags unioned", () => { let bank = emptyBank(); bank = addSpecific(bank, { type: "number", content: "Sparte to dager.", topicTags: ["produktivitet"], provenance: prov("seres/04"), }).bank; const res2 = addSpecific(bank, { type: "number", content: " sparte to dager. ", topicTags: ["saksbehandling"], provenance: prov("seres/05"), }); assert.equal(res2.added, false, "duplicate content must not add a second entry"); assert.equal(res2.bank.specifics.length, 1); assert.deepEqual( [...res2.bank.specifics[0].topicTags].sort(), ["produktivitet", "saksbehandling"], "tags must be unioned on re-capture", ); }); test("defaults verification to unverified and status to active when omitted", () => { const res = addSpecific(emptyBank(), { type: "contrarian", content: "De fleste KI-pilotene feiler på data, ikke på modellvalg.", topicTags: ["data"], provenance: prov(), }); assert.equal(res.bank.specifics[0].verification, "unverified"); assert.equal(res.bank.specifics[0].status, "active"); }); }); describe("queryByTopic", () => { const seed = (): Bank => { let bank = emptyBank(); bank = addSpecific(bank, { type: "named-case", content: "Saksbehandler-case A.", topicTags: ["saksbehandling", "arkiv"], provenance: prov("seres/04", "2026-05-01"), }).bank; bank = addSpecific(bank, { type: "number", content: "Tallet B.", topicTags: ["saksbehandling", "produktivitet", "ledelse"], provenance: prov("seres/05", "2026-06-01"), }).bank; bank = addSpecific(bank, { type: "other", content: "Irrelevant C.", topicTags: ["privatliv"], provenance: prov("manual", "2026-06-10"), }).bank; return bank; }; test("ranks hits by tag overlap (desc)", () => { const hits = queryByTopic(seed(), ["saksbehandling", "produktivitet"]); assert.equal(hits.length, 2); assert.equal(hits[0].specific.content, "Tallet B."); // overlap 2 assert.equal(hits[0].tagOverlap, 2); assert.equal(hits[1].specific.content, "Saksbehandler-case A."); // overlap 1 assert.equal(hits[1].tagOverlap, 1); }); test("returns [] when nothing matches", () => { assert.deepEqual(queryByTopic(seed(), ["ingenting"]), []); }); test("excludes archived specifics", () => { const bank = seed(); bank.specifics[1].status = "archived"; // archive "Tallet B." const hits = queryByTopic(bank, ["saksbehandling"]); assert.equal(hits.length, 1); assert.equal(hits[0].specific.content, "Saksbehandler-case A."); }); test("ties on overlap are broken by capturedAt (newest first)", () => { const hits = queryByTopic(seed(), ["saksbehandling"]); // both A and B overlap 1 assert.equal(hits[0].specific.content, "Tallet B."); // 2026-06-01 newer than 2026-05-01 assert.equal(hits[1].specific.content, "Saksbehandler-case A."); }); }); });