/** * Unit tests for validate-narrative.mjs. * * The parser mirrors `parseNarrativeDoc` in hoen-assessment, so the tests that * matter are the ones pinning that agreement: what counts as a thesis, which * headings resolve, and what the tool does with the ones that do not. * * Run: node --test library/skills/hoen-data-assist/scripts/validate-narrative.test.mjs */ import { test } from "node:test"; import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { checkNarrative, parseNarrative, readNeedIds, readSnapshotIds } from "./validate-narrative.mjs"; const dir = dirname(fileURLToPath(import.meta.url)); const script = join(dir, "validate-narrative.mjs"); const assets = join(dir, "..", "assets"); const needIds = new Set(["1-tech-capability", "2-delivery-metrics", "3-incident-mgmt"]); const opts = (extra = {}) => ({ needIds, ...extra }); const doc = (body, front = "scope: overall\n") => `---\n${front}---\n\n${body}`; const codes = (result) => result.findings.map((item) => item.code); /* ------------------------------------------------------------------ parsing */ test("frontmatter scope and title are read; extra keys do not break it", () => { const parsed = parseNarrative( doc("## 1-tech-capability\n**Claim**\n", "scope: grp-b\ntitle: Top five # trailing comment\nengagement: Hydra\n"), needIds, ); assert.equal(parsed.scope, "grp-b"); assert.equal(parsed.title, "Top five"); assert.equal(parsed.hasScope, true); }); test("a document with no frontmatter defaults to overall and is flagged", () => { const result = checkNarrative("## 1-tech-capability\n**Claim**\n", opts()); assert.equal(result.scope, "overall"); assert.ok(codes(result).includes("no-scope")); }); test("the first non-blank line is the thesis, bold stripped", () => { const parsed = parseNarrative(doc("## 1-tech-capability\n\n**Skills silos**\n"), needIds); assert.equal(parsed.entries[0].thesis, "Skills silos"); }); test("a second paragraph is not folded into the thesis", () => { const parsed = parseNarrative(doc("## 1-tech-capability\n**Claim**\n\nMore prose.\n"), needIds); assert.equal(parsed.entries[0].thesis, "Claim"); }); test("Symptoms and Signals & measures are split; 'and' spelling is accepted", () => { const parsed = parseNarrative( doc("## 1-tech-capability\n**Claim**\n\n### Symptoms\nIt hurts.\n\n### Signals and measures\n- 16 of 52 dots\n"), needIds, ); assert.equal(parsed.entries[0].symptoms, "It hurts."); assert.equal(parsed.entries[0].signals, "- 16 of 52 dots"); }); test("headings carry their source line number", () => { const parsed = parseNarrative(doc("## 1-tech-capability\n**A**\n\n## 2-delivery-metrics\n**B**\n"), needIds); assert.deepEqual( parsed.entries.map((entry) => entry.heading), ["1-tech-capability", "2-delivery-metrics"], ); assert.ok(parsed.entries[1].line > parsed.entries[0].line); }); /* --------------------------------------------------------------- resolution */ test("a need id resolves; the need: prefix is stripped", () => { const parsed = parseNarrative(doc("## 1-tech-capability\n**A**\n\n## need: 2-delivery-metrics\n**B**\n"), needIds); assert.deepEqual( parsed.entries.map((entry) => entry.kind), ["target", "target"], ); }); test("tbc is provisional, not unresolved", () => { const parsed = parseNarrative(doc("## tbc\n**Not placed yet**\n"), needIds); assert.equal(parsed.entries[0].kind, "provisional"); }); test("an unknown need heading is held, not dropped, and warns rather than errors", () => { const result = checkNarrative( doc("## Internal platform friction\n**Teams route around it**\n\n### Symptoms\nAvoided.\n\n### Signals & measures\n- 8 dots\n"), opts(), ); assert.equal(result.entries, 1); assert.equal(result.unresolved, 1); const item = result.findings.find((f) => f.code === "unresolved-heading"); assert.equal(item.level, "warn"); }); test("a duplicate heading is an error — the second is unreachable", () => { const result = checkNarrative(doc("## 1-tech-capability\n**A**\n\n## 1-tech-capability\n**B**\n"), opts()); assert.equal(result.findings.filter((f) => f.code === "duplicate-heading").length, 1); }); /* ------------------------------------------------------------- completeness */ test("no thesis is an error; thesis without evidence is a warning", () => { const noThesis = checkNarrative(doc("## 1-tech-capability\n\n### Symptoms\nIt hurts.\n"), opts()); assert.ok(codes(noThesis).includes("no-thesis")); const noEvidence = checkNarrative(doc("## 1-tech-capability\n**Claim**\n\n### Symptoms\nIt hurts.\n"), opts()); assert.ok(codes(noEvidence).includes("no-evidence")); assert.equal(noEvidence.findings.find((f) => f.code === "no-evidence").level, "warn"); }); test("a provisional slot is exempt from completeness checks", () => { const result = checkNarrative(doc("## tbc\n"), opts()); assert.deepEqual(codes(result), []); }); test("a complete entry produces no findings", () => { const result = checkNarrative( doc("## 1-tech-capability\n**Claim**\n\n### Symptoms\nIt hurts.\n\n### Signals & measures\n- 16 of 52 dots\n"), opts(), ); assert.deepEqual(codes(result), []); }); /* -------------------------------------------------------------------- curve */ test("curve mode resolves measure ids and rejects near-misses with a suggestion", () => { const result = checkNarrative(doc("## deploy-frequency\n**A**\n"), opts({ curves: true })); const item = result.findings.find((f) => f.code === "unknown-measure-id"); assert.equal(item.level, "error"); assert.match(item.message, /deploy-freq/); }); test("curve mode accepts the two duplicated measures separately", () => { const result = checkNarrative(doc("## recovery-time\n**A**\n\n## recovery-time-cqa\n**B**\n"), opts({ curves: true })); assert.equal(result.ranked, 2); assert.deepEqual(codes(result), []); }); test("curve mode does not demand Symptoms or Signals, only a body", () => { const withBody = checkNarrative(doc("## lead-time\n**Deploys wait on review.**\n"), opts({ curves: true })); assert.deepEqual(codes(withBody), []); const empty = checkNarrative(doc("## lead-time\n"), opts({ curves: true })); assert.ok(codes(empty).includes("empty-note")); }); test("curve mode ignores snapshot scope checks", () => { const result = checkNarrative(doc("## lead-time\n**A**\n", "scope: nope\n"), opts({ curves: true, snapshots: [{ id: "grp-a" }] })); assert.ok(!codes(result).includes("unknown-scope")); }); /* ------------------------------------------------------------------- strict */ test("strict flags an overconfident thesis", () => { const body = "## 1-tech-capability\n**The problem is clearly tooling**\n\n### Signals & measures\n- 16 of 52 dots\n"; assert.ok(!codes(checkNarrative(doc(body), opts())).includes("overconfident")); assert.ok(codes(checkNarrative(doc(body), opts({ strict: true }))).includes("overconfident")); }); test("strict flags long symptoms carried by a single evidence bullet", () => { const result = checkNarrative( doc(`## 1-tech-capability\n**Claim**\n\n### Symptoms\n${"Vivid detail about the queue. ".repeat(12)}\n\n### Signals & measures\n- One thin bullet\n`), opts({ strict: true }), ); assert.ok(codes(result).includes("thin-evidence")); }); /* -------------------------------------------------------------------- scope */ test("a scope matching no snapshot id is an error naming the ids that exist", () => { const result = checkNarrative(doc("## 1-tech-capability\n**A**\n", "scope: grp-typo\n"), opts({ snapshots: [{ id: "grp-b-payments", code: "Payments" }], })); const item = result.findings.find((f) => f.code === "unknown-scope"); assert.equal(item.level, "error"); assert.match(item.message, /grp-b-payments \[Payments\]/); }); test("scope overall never needs a matching snapshot", () => { const result = checkNarrative(doc("## 1-tech-capability\n**A**\n"), opts({ snapshots: [{ id: "grp-b" }] })); assert.ok(!codes(result).includes("unknown-scope")); }); /* ------------------------------------------------------------------ lookups */ test("readNeedIds reads all 41 model needs from the committed CSV", () => { const ids = readNeedIds(); assert.equal(ids.size, 41); assert.ok(ids.has("1-people-purpose")); assert.ok(ids.has("3-incident-mgmt")); assert.ok(!ids.has("3-incident-management")); }); test("readSnapshotIds skips override patches and returns null for a missing dir", () => { assert.equal(readSnapshotIds(join(dir, "no-such-dir")), null); }); /* ---------------------------------------------------------------------- CLI */ const run = (...args) => spawnSync(process.execPath, [script, ...args], { encoding: "utf8" }); test("the shipped examples validate clean under --strict", () => { const result = run("--strict", join(assets, "narrative-group-example.md")); assert.equal(result.status, 0, result.stdout + result.stderr); }); test("the shipped curve notes validate clean", () => { const result = run("--curves", join(assets, "curve-notes-example.md")); assert.equal(result.status, 0, result.stdout + result.stderr); }); test("the overall example reports only its deliberate unresolved heading", () => { const result = run("--strict", join(assets, "narrative-overall-example.md")); assert.equal(result.status, 1); const parsed = JSON.parse(result.stdout); assert.deepEqual( parsed.results[0].findings.map((f) => f.code), ["unresolved-heading"], ); }); test("--help exits 0 and --format garbage exits 2", () => { assert.equal(run("--help").status, 0); assert.equal(run("--format", "yaml", "x.md").status, 2); assert.equal(run().status, 2); }); test("json is the default output shape", () => { const result = run(join(assets, "narrative-group-example.md")); const parsed = JSON.parse(result.stdout); assert.equal(parsed.mode, "needs"); assert.equal(parsed.results[0].scope, "grp-b-payments"); });