import { describe, test } from "node:test"; import assert from "node:assert/strict"; import { mkdtempSync, rmSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { appendPhase, editionFacts, readEditionState, saveEditionState, seriesRootFromStatePath, } from "../src/editionState.js"; import type { EditionStateFile } from "../src/types.js"; const tmp = () => mkdtempSync(join(tmpdir(), "editions-state-")); const AT = "2026-07-21T09:30:00.000Z"; function state(): EditionStateFile { return { schemaVersion: 1, series: { slug: "seres", title: "Seres" }, currentArticle: "05", currentPhase: "skeleton-pitch", articles: { "04": { title: "Forrige", phase: "scheduling", phaseLog: [{ phase: "scheduling", completedAt: AT }] }, "05": { title: "Da pipelinen brøt sammen", phase: "skeleton-pitch", phaseLog: [] }, }, } as unknown as EditionStateFile; } describe("editionState — facts", () => { test("reads series, edition id, title and phase from the state file", () => { const facts = editionFacts(state()); assert.deepEqual(facts, { series: "seres", editionId: "05", title: "Da pipelinen brøt sammen", currentPhase: "skeleton-pitch", }); }); test("a missing currentArticle is an error, not a guess", () => { const broken = { ...state(), currentArticle: undefined } as unknown as EditionStateFile; assert.throws(() => editionFacts(broken), /currentArticle/); }); test("a currentArticle with no matching articles entry is an error", () => { const broken = { ...state(), currentArticle: "99" } as unknown as EditionStateFile; assert.throws(() => editionFacts(broken), /99/); }); test("a missing currentPhase is an error — the register must never mirror a guessed phase", () => { const broken = { ...state(), currentPhase: undefined } as unknown as EditionStateFile; assert.throws(() => editionFacts(broken), /currentPhase/); }); test("an untitled article still resolves — the title is telemetry, not a gate", () => { const s = state(); delete (s.articles["05"] as Record).title; assert.equal(editionFacts(s).title, ""); }); }); describe("editionState — phaseLog", () => { test("appends {phase, completedAt} to the article's log", () => { const { state: next, appended } = appendPhase(state(), "05", "skeleton-pitch", AT); assert.equal(appended, true); assert.deepEqual(next.articles["05"].phaseLog, [{ phase: "skeleton-pitch", completedAt: AT }]); }); test("re-running the same transition does not double-log", () => { const once = appendPhase(state(), "05", "skeleton-pitch", AT).state; const { state: twice, appended } = appendPhase(once, "05", "skeleton-pitch", "2026-07-21T10:00:00.000Z"); assert.equal(appended, false); assert.equal(twice.articles["05"].phaseLog?.length, 1); assert.equal(twice.articles["05"].phaseLog?.[0].completedAt, AT, "the first stamp is the real one"); }); test("a phase repeated AFTER another phase is logged again — /linkedin:pivot re-runs cleared gates", () => { let s = appendPhase(state(), "05", "factcheck-sweep", AT).state; s = appendPhase(s, "05", "editorial-review", "2026-07-21T11:00:00.000Z").state; const { state: pivoted, appended } = appendPhase(s, "05", "factcheck-sweep", "2026-07-22T08:00:00.000Z"); assert.equal(appended, true); assert.equal(pivoted.articles["05"].phaseLog?.length, 3); assert.deepEqual( pivoted.articles["05"].phaseLog?.map((e) => e.phase), ["factcheck-sweep", "editorial-review", "factcheck-sweep"], ); }); test("an article written before phaseLog existed gets the field created", () => { const s = state(); delete (s.articles["05"] as Record).phaseLog; const { state: next } = appendPhase(s, "05", "research", AT); assert.deepEqual(next.articles["05"].phaseLog, [{ phase: "research", completedAt: AT }]); }); test("other articles are left alone", () => { const next = appendPhase(state(), "05", "research", AT).state; assert.equal(next.articles["04"].phaseLog?.length, 1); assert.equal(next.articles["04"].phaseLog?.[0].phase, "scheduling"); }); test("an unknown article is an error, not a silently created one", () => { assert.throws(() => appendPhase(state(), "99", "research", AT), /99/); }); }); describe("editionState — io", () => { test("save then read round-trips, pretty-printed with a trailing newline", () => { const dir = tmp(); try { const path = join(dir, "linkedin", "edition-state.json"); saveEditionState(path, appendPhase(state(), "05", "research", AT).state); const raw = readFileSync(path, "utf8"); assert.ok(raw.endsWith("\n")); assert.ok(raw.includes('\n "schemaVersion"'), "must stay human-diffable (2-space indent)"); const back = readEditionState(path); assert.equal(back.articles["05"].phaseLog?.[0].phase, "research"); } finally { rmSync(dir, { recursive: true, force: true }); } }); test("reading a missing state file is an error — the register mirrors state, it never invents it", () => { const dir = tmp(); try { assert.throws(() => readEditionState(join(dir, "absent.json"))); } finally { rmSync(dir, { recursive: true, force: true }); } }); test("the series root is derived from the conventional /linkedin/edition-state.json path", () => { assert.equal(seriesRootFromStatePath("/series/seres/linkedin/edition-state.json"), "/series/seres"); }); });