import { describe, test } from "node:test"; import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; import { fileURLToPath } from "node:url"; import { mkdtempSync, rmSync, writeFileSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { addSpecific, emptyBank, specificId, saveBank } from "../src/bank.js"; // Resolve the package root so the subprocess `src/cli.ts` path + the `tsx` loader // resolve regardless of the runner's cwd. const bankDir = fileURLToPath(new URL("..", import.meta.url)); function run(args: string[]): { status: number | null; stdout: string; stderr: string } { const res = spawnSync("node", ["--import", "tsx", "src/cli.ts", ...args], { encoding: "utf8", cwd: bankDir, }); return { status: res.status, stdout: res.stdout, stderr: res.stderr }; } const tmp = () => mkdtempSync(join(tmpdir(), "specifics-cli-usage-")); const CONTENT = "Vi målte 40 prosent kortere ledetid etter omleggingen."; /** A bank + an edition-state whose slot-map binds one slot to the bank specific. */ function fixture(dir: string): { bankPath: string; editionPath: string; id: string } { const bankPath = join(dir, "specifics-bank.json"); const id = specificId(CONTENT); const bank = addSpecific(emptyBank(), { type: "number", content: CONTENT, topicTags: ["ai"], provenance: { capturedAt: "2026-06-20", source: "test" }, }).bank; saveBank(bankPath, bank); const editionPath = join(dir, "edition-state.json"); writeFileSync( editionPath, JSON.stringify({ currentArticle: "05", articles: { "05": { title: "Da pipelinen brøt sammen", livedSpecifics: { status: "bound", slots: [ { slotId: "kp1", kind: "key-point", label: "ledetid", binding: { type: "specific", specificId: id } }, { slotId: "kp2", kind: "key-point", label: "annet", binding: { type: "abstrakt", rationale: "bevisst" } }, ], }, }, }, }), "utf8", ); return { bankPath, editionPath, id }; } describe("specifics-bank CLI — record-usage (Step 8 lock)", () => { test("stamps the edition on the bound specifics and persists it", () => { const dir = tmp(); try { const { bankPath, editionPath, id } = fixture(dir); const res = run(["record-usage", "--edition", editionPath, "--edition-id", "seres/05", "--bank", bankPath]); assert.equal(res.status, 0, res.stderr); const onDisk = JSON.parse(readFileSync(bankPath, "utf8")); const stamped = onDisk.specifics.find((s: { id: string }) => s.id === id); assert.deepEqual(stamped.usedIn, ["seres/05"]); } finally { rmSync(dir, { recursive: true, force: true }); } }); test("only specific-bound slots are stamped — abstrakt/ekstern consume no bank material", () => { const dir = tmp(); try { const { bankPath, editionPath } = fixture(dir); const res = run(["record-usage", "--edition", editionPath, "--edition-id", "seres/05", "--bank", bankPath, "--json"]); assert.equal(res.status, 0, res.stderr); const out = JSON.parse(res.stdout); assert.equal(out.recorded.length, 1); assert.deepEqual(out.unknown, []); } finally { rmSync(dir, { recursive: true, force: true }); } }); test("re-running after a pivot re-lock is idempotent", () => { const dir = tmp(); try { const { bankPath, editionPath, id } = fixture(dir); run(["record-usage", "--edition", editionPath, "--edition-id", "seres/05", "--bank", bankPath]); const again = run(["record-usage", "--edition", editionPath, "--edition-id", "seres/05", "--bank", bankPath, "--json"]); assert.equal(again.status, 0, again.stderr); const out = JSON.parse(again.stdout); assert.deepEqual(out.recorded, []); assert.equal(out.alreadyRecorded.length, 1); const onDisk = JSON.parse(readFileSync(bankPath, "utf8")); const stamped = onDisk.specifics.find((s: { id: string }) => s.id === id); assert.deepEqual(stamped.usedIn, ["seres/05"], "a re-lock must not double-stamp"); } finally { rmSync(dir, { recursive: true, force: true }); } }); test("an edition with no bound specifics is a no-op, exit 0", () => { const dir = tmp(); try { const { bankPath } = fixture(dir); const empty = join(dir, "empty-edition.json"); writeFileSync(empty, JSON.stringify({ currentArticle: "06", articles: { "06": {} } }), "utf8"); const res = run(["record-usage", "--edition", empty, "--edition-id", "seres/06", "--bank", bankPath, "--json"]); assert.equal(res.status, 0, res.stderr); assert.deepEqual(JSON.parse(res.stdout).recorded, []); } finally { rmSync(dir, { recursive: true, force: true }); } }); test("bad invocations exit 2", () => { const dir = tmp(); try { const { bankPath, editionPath } = fixture(dir); assert.equal(run(["record-usage", "--bank", bankPath]).status, 2); // no --edition assert.equal( run(["record-usage", "--edition", editionPath, "--bank", bankPath]).status, 2, // no --edition-id: an unattributable use is worse than none ); } finally { rmSync(dir, { recursive: true, force: true }); } }); });