import { describe, test } from "node:test"; import assert from "node:assert/strict"; import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { mintContentId } from "../src/id.js"; import type { PublishedRecord } from "../src/ingest.js"; import { assemblePostGraph, type AnalyticsRowInput } from "../src/assemble.js"; import { parseRecentPosts, reconcileRecentPosts, summarizeReconcile, loadRecentPosts, } from "../src/reconcile.js"; const DATE = "2026-05-26"; function rec(body: string, over: Partial = {}): PublishedRecord { return { id: mintContentId(body), provenance: "published", published_date: DATE, captured_at: "2026-06-23", source: "manual", specifics: [], trends: [], body, ...over, }; } function row(title: string, over: Partial = {}): AnalyticsRowInput { return { title, publishedDate: DATE, metrics: { engagementRate: 4.2 }, ...over }; } // Bodies whose normalized opener is ≥ HOOK_PREFIX_FLOOR (24) chars. const BODY_A = "Jeg lærte noe viktig om dømmekraft i dag.\n\nDel 1 av serien."; const HOOK_A = "Jeg lærte noe viktig om dømmekraft i dag."; const BODY_B = "En helt annen tekst om automatisering og styring.\n\nDel 2."; const HOOK_B = "En helt annen tekst om automatisering og styring."; // The writer's exact entry format (state-updater.mjs:116): - [date] "hook" (chars) - topic function entry(date: string, hook: string, chars: number, topic: string): string { return `- [${date}] "${hook}" (${chars}) - ${topic}`; } function stateWith(...entries: string[]): string { return [ "---", "posts_this_week: 3", "---", "", "## Recent Posts", "", ...entries, "", "## Milestone Log", "", "- [2026-05] 1048 (+10)", "", ].join("\n"); } describe("SB-S3e — parseRecentPosts (writer-format reader)", () => { test("extracts {date, hook, charCount, topic} from the ## Recent Posts section", () => { const text = stateWith( entry("2026-05-26", HOOK_A, 1400, "dømmekraft"), entry("2026-05-20", HOOK_B, 900, "automatisering"), ); const posts = parseRecentPosts(text); assert.equal(posts.length, 2); assert.deepEqual(posts[0], { date: "2026-05-26", hook: HOOK_A, charCount: 1400, topic: "dømmekraft" }); assert.deepEqual(posts[1], { date: "2026-05-20", hook: HOOK_B, charCount: 900, topic: "automatisering" }); }); test("golden-string: a literal writer-produced entry round-trips verbatim", () => { // The byte-exact string updatePostTracking emits (state-updater.mjs:116). const writerLine = `- [2026-06-01] "${HOOK_A}" (1234) - strategi`; const posts = parseRecentPosts(["## Recent Posts", "", writerLine, ""].join("\n")); assert.equal(posts.length, 1); assert.equal(posts[0].hook, HOOK_A); assert.equal(posts[0].charCount, 1234); assert.equal(posts[0].topic, "strategi"); }); test("stops at the next section (## Milestone Log is not parsed as an entry)", () => { const text = stateWith(entry("2026-05-26", HOOK_A, 1400, "dømmekraft")); const posts = parseRecentPosts(text); assert.equal(posts.length, 1); assert.ok(posts.every((p) => /^\d{4}-\d{2}-\d{2}$/.test(p.date))); }); test("a doc with no ## Recent Posts section → []", () => { assert.deepEqual(parseRecentPosts("# Some other doc\n\n## Notes\n\n- a line"), []); }); test("$-bearing hook/topic round-trips verbatim (capture on read — no replace, no injection)", () => { const dollarHook = "Hvorfor $100 i budsjettkutt endrer alt for $deg"; const text = ["## Recent Posts", "", entry("2026-05-26", dollarHook, 800, "$penger$"), ""].join("\n"); const posts = parseRecentPosts(text); assert.equal(posts.length, 1); assert.equal(posts[0].hook, dollarHook); assert.equal(posts[0].topic, "$penger$"); }); }); describe("SB-S3e — reconcileRecentPosts (coverage tiers)", () => { test("in-graph: created post matches a published record that HAS analytics", () => { const r = rec(BODY_A); const graph = assemblePostGraph({ records: [r], analytics: [row(HOOK_A)] }); const recentPosts = parseRecentPosts( ["## Recent Posts", "", entry("2026-05-26", HOOK_A, 1400, "dømmekraft"), ""].join("\n"), ); const nodes = reconcileRecentPosts({ recentPosts, records: [r], graph }); assert.equal(nodes.length, 1); assert.equal(nodes[0].tier, "in-graph"); assert.equal(nodes[0].contentId, r.id); }); test("in-brain-only: created post matches a record but the record has NO analytics", () => { const r = rec(BODY_B); const graph = assemblePostGraph({ records: [r], analytics: [] }); // no analytics → match none const recentPosts = parseRecentPosts( ["## Recent Posts", "", entry("2026-05-26", HOOK_B, 900, "automatisering"), ""].join("\n"), ); const nodes = reconcileRecentPosts({ recentPosts, records: [r], graph }); assert.equal(nodes[0].tier, "in-brain-only"); assert.equal(nodes[0].contentId, r.id); }); test("orphaned-in-state: created post matches NO published record (never ingested)", () => { const r = rec(BODY_A); const graph = assemblePostGraph({ records: [r], analytics: [row(HOOK_A)] }); const recentPosts = parseRecentPosts( ["## Recent Posts", "", entry("2026-05-19", "En post som aldri ble ingestet i hjernen", 700, "annet"), ""].join("\n"), ); const nodes = reconcileRecentPosts({ recentPosts, records: [r], graph }); assert.equal(nodes[0].tier, "orphaned-in-state"); assert.equal(nodes[0].contentId, undefined); }); test("truncated-hook (…) degradation: a '…'-suffixed preview still prefix-matches", () => { const r = rec(BODY_A); const graph = assemblePostGraph({ records: [r], analytics: [row(HOOK_A)] }); const recentPosts = parseRecentPosts( ["## Recent Posts", "", entry("2026-05-26", "Jeg lærte noe viktig om dømmekraft i…", 1400, "dømmekraft"), ""].join("\n"), ); const nodes = reconcileRecentPosts({ recentPosts, records: [r], graph }); assert.equal(nodes[0].tier, "in-graph"); }); test("below-floor hook → orphaned (too short to discriminate, never a false match)", () => { const r = rec(BODY_A); const graph = assemblePostGraph({ records: [r], analytics: [row(HOOK_A)] }); const recentPosts = parseRecentPosts( ["## Recent Posts", "", entry("2026-05-26", "Jeg lærte", 1400, "dømmekraft"), ""].join("\n"), ); const nodes = reconcileRecentPosts({ recentPosts, records: [r], graph }); assert.equal(nodes[0].tier, "orphaned-in-state"); }); test("summary counts created / in-graph / in-brain-only / orphaned", () => { const rA = rec(BODY_A); const rB = rec(BODY_B); const graph = assemblePostGraph({ records: [rA, rB], analytics: [row(HOOK_A)] }); const recentPosts = parseRecentPosts( [ "## Recent Posts", "", entry("2026-05-26", HOOK_A, 1400, "a"), // in-graph (rA has analytics) entry("2026-05-26", HOOK_B, 900, "b"), // in-brain-only (rB no analytics) entry("2026-05-10", "Helt ukjent post som ingen record dekker", 600, "c"), // orphaned "", ].join("\n"), ); const nodes = reconcileRecentPosts({ recentPosts, records: [rA, rB], graph }); const sum = summarizeReconcile(nodes); assert.deepEqual(sum, { created: 3, inGraph: 1, inBrainOnly: 1, orphaned: 1 }); }); test("empty ## Recent Posts → empty report, no throw", () => { const nodes = reconcileRecentPosts({ recentPosts: [], records: [rec(BODY_A)], graph: [] }); assert.deepEqual(nodes, []); assert.deepEqual(summarizeReconcile(nodes), { created: 0, inGraph: 0, inBrainOnly: 0, orphaned: 0 }); }); }); describe("SB-S3e — loadRecentPosts (state-file IO seam, STATE_FILE precedence)", () => { test("reads the state file pointed at by STATE_FILE; unset/absent → []", () => { const dir = mkdtempSync(join(tmpdir(), "ls-recon-")); const file = join(dir, "linkedin-studio.local.md"); writeFileSync(file, stateWith(entry("2026-05-26", HOOK_A, 1400, "dømmekraft"))); const prev = process.env.STATE_FILE; try { process.env.STATE_FILE = file; const posts = loadRecentPosts(); assert.equal(posts.length, 1); assert.equal(posts[0].hook, HOOK_A); // Absent file via STATE_FILE → [] (fresh-clone safe, no throw). process.env.STATE_FILE = join(dir, "does-not-exist.md"); assert.deepEqual(loadRecentPosts(), []); } finally { if (prev === undefined) delete process.env.STATE_FILE; else process.env.STATE_FILE = prev; rmSync(dir, { recursive: true, force: true }); } }); });