/** * Unit tests for validate-snapshot.mjs — the rules a snapshot must satisfy. * * Run: node --test library/skills/hoen-data-assist/scripts/validate-snapshot.test.mjs */ import { test } from "node:test"; import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; import { existsSync, readFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { buildCatalogues, checkFiles, checkGroup, DEFAULT_MODEL_PATH, loadCatalogues, normaliseText, parseArgs, renderJson, renderText, surveyReport, withDuplicateCodeWarnings, } from "./validate-snapshot.mjs"; const dir = dirname(fileURLToPath(import.meta.url)); const script = join(dir, "validate-snapshot.mjs"); const assets = join(dir, "..", "assets"); const hasModel = existsSync(DEFAULT_MODEL_PATH); const skipWithoutModel = hasModel ? false : "requires packages/hoen-model/dist/model.json (hoen-assessment)"; /** A tiny stand-in model, so the tests do not depend on the real catalogue. */ const cat = buildCatalogues({ needs: [ { id: "1-people-purpose", level: 1, name: "People & Purpose" }, { id: "2-alerting", level: 2, name: "Alerting" }, ], surveyQuestions: [ { id: "q0001", text: "We trust our alerting mechanism.", priority: 2, needWeights: {} }, { id: "q0002", text: "Our builds are reliable.", priority: 1, needWeights: {} }, ], }); const validGroup = { id: "grp-a", code: "GRP-A", asAt: "2026-07-14T09:00:00.000Z", participants: { survey: 14, workshop: 9 }, questions: [{ id: "q0001", average: 6.4 }], rawMetrics: { leadTimeHours: 14 }, needs: { "2-alerting": { maturity: 1, spendRank: 1, workshopDotVote: 0.3 } }, }; /* ------------------------------------------------------------- rejections */ test("rejects a missing rawMetrics block", () => { const { rawMetrics, ...withoutMetrics } = validGroup; const report = checkGroup(withoutMetrics, "f.json", cat); assert.match(report.errors.join(" "), /missing "rawMetrics"/); }); test("rejects an unparseable asAt", () => { const report = checkGroup({ ...validGroup, asAt: "last Tuesday" }, "f.json", cat); assert.match(report.errors.join(" "), /invalid "asAt"/); }); test("rejects questions that all miss the catalogue", () => { const report = checkGroup( { ...validGroup, questions: [{ id: "q9999", average: 8 }] }, "f.json", cat, ); assert.match(report.errors.join(" "), /no survey question matched/); }); test("a complete group has no errors", () => { assert.deepEqual(checkGroup(validGroup, "f.json", cat).errors, []); }); test("an empty rawMetrics block is accepted — absent telemetry is honest", () => { const report = checkGroup({ ...validGroup, rawMetrics: {} }, "f.json", cat); assert.deepEqual(report.errors, []); }); /* --------------------------------------------------------------- warnings */ test("flags readings outside their scale", () => { const report = checkGroup( { ...validGroup, needs: { "2-alerting": { maturity: 7, spendRank: 0, workshopDotVote: 12 } }, }, "f.json", cat, ); assert.match(report.warnings.join("\n"), /maturity = 7 is outside the 0–3/); assert.match(report.warnings.join("\n"), /spendRank = 0 is outside the 1–5/); assert.match(report.warnings.join("\n"), /workshopDotVote = 12 is outside the 0–1/); }); test("null means not supplied, and is not a warning", () => { const report = checkGroup( { ...validGroup, needs: { "2-alerting": { maturity: null, spendRank: null } } }, "f.json", cat, ); assert.equal( report.warnings.filter((w) => w.includes("2-alerting")).length, 0, ); }); test('"N/A" is a warning, because it silently becomes the default', () => { const report = checkGroup( { ...validGroup, needs: { "2-alerting": { maturity: "N/A" } } }, "f.json", cat, ); assert.match(report.warnings.join("\n"), /is "N\/A", not a number/); }); test("flags unrecognised need ids", () => { const report = checkGroup( { ...validGroup, needs: { "not-a-need": { maturity: 2 } } }, "f.json", cat, ); assert.match(report.warnings.join("\n"), /unrecognised need id/); }); test("flags telemetry unit mix-ups", () => { const report = checkGroup( { ...validGroup, rawMetrics: { changeFailPct: 0.12, recoveryTimeHours: 5000 } }, "f.json", cat, ); assert.match(report.warnings.join("\n"), /recoveryTimeHours = 5000 looks wrong/); }); test("changeFailPct of 0.12 is in range — the unit trap needs the reader, not the bounds", () => { const report = checkGroup( { ...validGroup, rawMetrics: { changeFailPct: 0.12 } }, "f.json", cat, ); assert.equal( report.warnings.filter((w) => w.includes("changeFailPct")).length, 0, ); }); test("partial spendAmount coverage is called out", () => { const report = checkGroup( { ...validGroup, needs: { "2-alerting": { maturity: 1, spendAmount: 500 } } }, "f.json", cat, ); assert.match(report.warnings.join("\n"), /spendAmount on only 1\/2 needs/); }); /* ------------------------------------------------------------ composition */ test("surveyReport separates matched, scored and unscored", () => { const report = surveyReport( [ { id: "q0001", average: 6 }, { id: "q0002" }, { id: "nope", average: 4 }, { text: "Our builds are reliable.", average: 7 }, ], cat, ); assert.equal(report.matched, 3); assert.equal(report.scored, 2); assert.deepEqual(report.unmatched, ["nope"]); assert.deepEqual(report.unscored, ["q0002"]); }); test("question text matches on collapsed whitespace and case", () => { const report = surveyReport( [{ text: ' "OUR BUILDS are reliable." ', average: 7 }], cat, ); assert.equal(report.matched, 1); }); test("normaliseText collapses whitespace, case and wrapping quotes", () => { assert.equal(normaliseText(' "A B" '), "a b".replace(/\s+/g, " ")); }); test("duplicate group codes warn once, on the later entry", () => { const reports = withDuplicateCodeWarnings([ { label: "a.json", code: "GRP-A", errors: [], warnings: [], notes: [] }, { label: "b.json", code: "GRP-A", errors: [], warnings: [], notes: [] }, ]); assert.equal(reports[0].warnings.length, 0); assert.match(reports[1].warnings.join(" "), /also used by a\.json/); }); test("checkFiles labels array entries individually", () => { const read = () => ({ ok: true, entries: [validGroup, validGroup] }); const reports = checkFiles(["pair.json"], cat, read); assert.deepEqual( reports.map((report) => report.label), ["pair.json [1]", "pair.json [2]"], ); }); test("unreadable JSON becomes an error report, not a throw", () => { const read = () => ({ ok: false, message: "not valid JSON: boom" }); const [report] = checkFiles(["broken.json"], cat, read); assert.match(report.errors[0], /not valid JSON/); }); /* ------------------------------------------------------------ CLI surface */ test("parseArgs collects files and options, and rejects unknown flags", () => { const parsed = parseArgs(["--format", "text", "a.json", "b.json"]); assert.deepEqual(parsed.files, ["a.json", "b.json"]); assert.equal(parsed.format, "text"); assert.equal(parseArgs(["--nope"]).error, "unknown option '--nope'"); assert.equal(parseArgs(["--help"]).help, true); }); test("renderJson is parseable and counts rejections", () => { const reports = [checkGroup(validGroup, "ok.json", cat), checkGroup({}, "bad.json", cat)]; const parsed = JSON.parse(renderJson(reports)); assert.equal(parsed.checked, 2); assert.equal(parsed.rejected, 1); }); test("renderText marks each entry and totals rejections", () => { const text = renderText([checkGroup(validGroup, "ok.json", cat)]); assert.match(text, /✓ ok\.json/); assert.match(text, /1 entry checked, 0 would be rejected/); }); /* ----------------------------------------------- end-to-end, real catalogue */ test("the shipped example passes against the real model", { skip: skipWithoutModel }, () => { const reports = checkFiles( [join(assets, "snapshot-example.json")], loadCatalogues(DEFAULT_MODEL_PATH), ); assert.deepEqual(reports[0].errors, []); }); test("the shipped template parses and carries every need id", { skip: skipWithoutModel }, () => { const template = JSON.parse( readFileSync(join(assets, "snapshot-template.json"), "utf8"), ); const model = JSON.parse(readFileSync(DEFAULT_MODEL_PATH, "utf8")); assert.deepEqual( Object.keys(template.needs), model.needs.map((need) => need.id), ); }); test("CLI: a clean file exits 0 with parseable JSON on stdout", { skip: skipWithoutModel }, () => { const result = spawnSync( process.execPath, [script, join(assets, "snapshot-template.json"), "--format", "json"], { encoding: "utf8" }, ); assert.equal(result.status, 0, result.stderr); assert.equal(JSON.parse(result.stdout).rejected, 0); }); test("CLI: a rejected file exits 1 and still reports rather than throwing", { skip: skipWithoutModel }, () => { const result = spawnSync( process.execPath, [script, join(assets, "no-such-file.json"), "--format", "json"], { encoding: "utf8" }, ); assert.equal(result.status, 1); const parsed = JSON.parse(result.stdout); assert.equal(parsed.rejected, 1); assert.match(parsed.reports[0].errors[0], /not valid JSON|ENOENT/); }); test("CLI: --help exits 0, unknown flag exits 2", () => { assert.equal(spawnSync(process.execPath, [script, "--help"]).status, 0); assert.equal(spawnSync(process.execPath, [script, "--nope"]).status, 2); assert.equal(spawnSync(process.execPath, [script]).status, 2); });