/** * N6 — the proposal layer: the discovery agent's angle/rationale becomes a PERSISTED * entity (step 2, A1-4) and the operator's approval gets a home (step 3, A1-7). Eight * additive-optional fields carry the proposal (angle/targetLevel/rationale/relatedIds), * the reader-side F7 signal the N7 band-cap gate reads (actionability/verdict), and the * F9 reader fields the N7.5 demand-sweep fills (readerQuestion/painPoint/saturation). * Plus the `selected` lifecycle state, the `--ids` batch, and the brief's "I produksjon" * section. Every field is optional → an existing store reads UNCHANGED (backward-compat). */ import { describe, test } from "node:test"; import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { fileURLToPath } from "node:url"; import { emptyStore, loadStore, saveStore, addTrend, effectiveStatus, setStatus, setStatusMany, } from "../src/store.js"; import { normalizeItem, itemToInput } from "../src/item.js"; import { rankForBrief, renderBrief } from "../src/brief.js"; import { SCHEMA_VERSION } from "../src/types.js"; import type { TrendRecord, TrendStore } from "../src/types.js"; const tmp = () => mkdtempSync(join(tmpdir(), "trends-n6-")); // The full proposal payload, reused across the schema tests. const FULL_INPUT = { title: "AI agents reshape public-sector workflows", url: "https://example.com/agents", source: "tavily", capturedAt: "2026-07-20", topics: ["ai", "public sector"], angle: "What a caseworker actually does differently on Monday", targetLevel: "praktiker", // a value from the user's OWN profile span, never a hardcoded enum rationale: "The procurement window opens in Q3 — timely for buyers deciding now", relatedIds: ["abc123def456", "0011223344ff"], actionability: { formulated: true, note: "reader can pilot one workflow this week" }, verdict: "BÆRENDE" as const, readerQuestion: "How do I start without a platform team?", painPoint: "competes against the M365 licence they already pay for", saturation: "thin — two vendor blogs, no independent walkthrough", }; describe("N6 — schema round-trip (the proposal becomes a persisted entity)", () => { test("addTrend persists all eight proposal fields, and load/save round-trips them byte-for-byte", () => { const dir = tmp(); try { const path = join(dir, "trends.json"); const { store } = addTrend(emptyStore(), FULL_INPUT); saveStore(path, store); const reloaded = loadStore(path); const t = reloaded.trends[0]; assert.equal(t.angle, FULL_INPUT.angle); assert.equal(t.targetLevel, FULL_INPUT.targetLevel); assert.equal(t.rationale, FULL_INPUT.rationale); assert.deepEqual(t.relatedIds, FULL_INPUT.relatedIds); assert.deepEqual(t.actionability, FULL_INPUT.actionability); assert.equal(t.verdict, "BÆRENDE"); assert.equal(t.readerQuestion, FULL_INPUT.readerQuestion); assert.equal(t.painPoint, FULL_INPUT.painPoint); assert.equal(t.saturation, FULL_INPUT.saturation); } finally { rmSync(dir, { recursive: true, force: true }); } }); test("the new fields are additive-optional — schema stays v4, no bump (no record needs migrating)", () => { assert.equal(SCHEMA_VERSION, 4); }); test("an existing v4 store WITHOUT the new fields loads and re-saves unchanged (backward-compat)", () => { const dir = tmp(); try { const path = join(dir, "trends.json"); // A record shaped exactly like a pre-N6 v4 record (no proposal fields at all). const legacy: TrendStore = { schemaVersion: 4, trends: [ { id: "legacy00", title: "Old trend", url: "https://example.com/old", source: "websearch", capturedAt: "2026-06-01", topics: ["ai"], }, ], }; writeFileSync(path, JSON.stringify(legacy, null, 2) + "\n", "utf8"); const loaded = loadStore(path); assert.equal(loaded.schemaVersion, 4); const t = loaded.trends[0]; assert.equal(t.angle, undefined); assert.equal(t.verdict, undefined); assert.equal(Object.prototype.hasOwnProperty.call(t, "angle"), false, "no phantom key added on load"); } finally { rmSync(dir, { recursive: true, force: true }); } }); test("proposal fields are first-sight — a re-capture with a different angle does NOT overwrite the stored one", () => { const first = addTrend(emptyStore(), FULL_INPUT).store; const second = addTrend(first, { ...FULL_INPUT, angle: "A completely different angle", topics: ["ai", "govtech"] }); assert.equal(second.added, false); // topics still union (existing discipline); angle is provenance — first sight wins. assert.equal(first.trends[0].angle, FULL_INPUT.angle); assert.deepEqual(first.trends[0].topics, ["ai", "public sector", "govtech"]); }); }); describe("N6 — lifecycle: selected + setStatusMany batch", () => { test("effectiveStatus round-trips the new `selected` state", () => { const rec: TrendRecord = { id: "x", title: "t", url: "u", source: "s", capturedAt: "2026-07-20", topics: [], status: "selected" }; assert.equal(effectiveStatus(rec), "selected"); }); test("setStatus accepts `selected` (widened TrendStatus)", () => { const { store } = addTrend(emptyStore(), FULL_INPUT); const id = store.trends[0].id; const res = setStatus(store, id, "selected"); assert.equal(res.found, true); assert.equal(store.trends[0].status, "selected"); }); test("setStatusMany marks every matched id and reports the unknown ones (partial batch)", () => { let store = emptyStore(); store = addTrend(store, { ...FULL_INPUT, title: "A", url: "https://example.com/a" }).store; store = addTrend(store, { ...FULL_INPUT, title: "B", url: "https://example.com/b" }).store; const ids = store.trends.map((t) => t.id); const res = setStatusMany(store, [ids[0], ids[1], "ghost99"], "selected"); assert.deepEqual(res.found.sort(), [ids[0], ids[1]].sort()); assert.deepEqual(res.notFound, ["ghost99"]); assert.equal(store.trends[0].status, "selected"); assert.equal(store.trends[1].status, "selected"); }); test("setStatusMany on an all-unknown batch mutates nothing and reports every id not-found", () => { const { store } = addTrend(emptyStore(), FULL_INPUT); const res = setStatusMany(store, ["nope1", "nope2"], "acted"); assert.deepEqual(res.found, []); assert.deepEqual(res.notFound, ["nope1", "nope2"]); assert.equal(effectiveStatus(store.trends[0]), "new"); }); }); describe("N6 — item validation (typed fields hard-fail, free-text lenient)", () => { test("a well-formed item with every proposal field normalizes, and itemToInput carries them all", () => { const res = normalizeItem({ source: "tavily", title: "T", url: "https://example.com/t", topics: ["ai"], angle: "the angle", targetLevel: "beslutter", rationale: "why now", relatedIds: ["id1", "id2"], actionability: { formulated: true, note: "do X" }, verdict: "STØTTE", readerQuestion: "how?", painPoint: "cost", saturation: "saturated", }); assert.equal(res.ok, true); if (!res.ok) return; const input = itemToInput(res.item, "2026-07-20"); assert.equal(input.angle, "the angle"); assert.equal(input.targetLevel, "beslutter"); assert.equal(input.verdict, "STØTTE"); assert.deepEqual(input.actionability, { formulated: true, note: "do X" }); assert.deepEqual(input.relatedIds, ["id1", "id2"]); assert.equal(input.readerQuestion, "how?"); assert.equal(input.painPoint, "cost"); assert.equal(input.saturation, "saturated"); }); test("an out-of-vocabulary verdict is a hard error (closed mechanism vocabulary)", () => { const res = normalizeItem({ source: "s", title: "t", url: "https://example.com/t", topics: ["ai"], verdict: "MEGABÆRENDE" }); assert.equal(res.ok, false); if (res.ok) return; assert.ok(res.errors.some((e) => e.toLowerCase().includes("verdict")), "error names the offending field"); }); test("a malformed actionability (formulated not a boolean) is a hard error", () => { const res = normalizeItem({ source: "s", title: "t", url: "https://example.com/t", topics: ["ai"], actionability: { formulated: "yes" } }); assert.equal(res.ok, false); if (res.ok) return; assert.ok(res.errors.some((e) => e.toLowerCase().includes("actionability"))); }); test("blank free-text fields are dropped, not errors (summary-idiom); relatedIds junk is normalized away", () => { const res = normalizeItem({ source: "s", title: "t", url: "https://example.com/t", topics: ["ai"], angle: " ", rationale: "", relatedIds: ["ok1", "", 42, "ok1"], // blank + non-string dropped, dupe collapsed }); assert.equal(res.ok, true); if (!res.ok) return; assert.equal(Object.prototype.hasOwnProperty.call(res.item, "angle"), false); assert.equal(Object.prototype.hasOwnProperty.call(res.item, "rationale"), false); assert.deepEqual(res.item.relatedIds, ["ok1"]); }); test("an item with NONE of the new fields still normalizes (backward-compat)", () => { const res = normalizeItem({ source: "s", title: "t", url: "https://example.com/t", topics: ["ai"] }); assert.equal(res.ok, true); if (!res.ok) return; assert.equal(res.item.verdict, undefined); assert.equal(res.item.actionability, undefined); }); }); // ── Brief rendering ────────────────────────────────────────────────────────── const TODAY = "2026-07-20"; function mk(p: Partial & { title: string; url: string; topics: string[] }): TrendRecord { return { id: p.title + "|" + p.url, source: "tavily", capturedAt: "2026-07-20", ...p, } as TrendRecord; } describe("N6 — brief renders the proposal fields + the 'I produksjon' section", () => { test("a fresh, top-matched candidate renders its angle/målnivå/verdict/reader fields in the brief", () => { const store: TrendStore = { schemaVersion: 4, trends: [ mk({ title: "Agents in casework", url: "https://example.com/a", topics: ["ai", "public sector"], publishedAt: "2026-07-20", angle: "Monday-morning angle", targetLevel: "praktiker", rationale: "timely for Q3 buyers", actionability: { formulated: true, note: "pilot one workflow" }, verdict: "BÆRENDE", readerQuestion: "How do I start without a platform team?", painPoint: "competes with M365", saturation: "thin market", }), ], }; const md = renderBrief(rankForBrief(store, ["ai", "public sector"], TODAY)); assert.ok(md.includes("Monday-morning angle"), "angle rendered"); assert.ok(md.includes("praktiker"), "målnivå rendered"); assert.ok(md.includes("BÆRENDE"), "verdict rendered"); assert.ok(md.includes("How do I start without a platform team?"), "readerQuestion rendered"); assert.ok(md.includes("competes with M365"), "painPoint rendered"); assert.ok(md.includes("thin market"), "saturation rendered"); }); test("the 'I produksjon' section lists selected (valgt) and acted (skrevet) trends", () => { const store: TrendStore = { schemaVersion: 4, trends: [ mk({ title: "Chosen one", url: "https://example.com/s", topics: ["ai"], status: "selected", angle: "selected angle", verdict: "BÆRENDE" }), mk({ title: "Written one", url: "https://example.com/w", topics: ["ai"], status: "acted" }), mk({ title: "Fresh queue item", url: "https://example.com/n", topics: ["ai"], publishedAt: "2026-07-20" }), ], }; const md = renderBrief(rankForBrief(store, ["ai"], TODAY)); assert.ok(md.includes("I produksjon"), "section header present"); assert.ok(md.includes("Chosen one") && md.includes("valgt"), "selected trend shown as valgt"); assert.ok(md.includes("Written one") && md.includes("skrevet"), "acted trend shown as skrevet"); // A selected/acted trend must NOT leak back into the 'new' queue sections. assert.ok(!md.includes("### 1. Chosen one"), "selected trend is not in the ranked queue"); }); test("an empty in-production board renders the explicit empty marker", () => { const store: TrendStore = { schemaVersion: 4, trends: [mk({ title: "Only new", url: "https://example.com/n", topics: ["ai"], publishedAt: "2026-07-20" })], }; const md = renderBrief(rankForBrief(store, ["ai"], TODAY)); assert.ok(md.includes("I produksjon"), "section header always present (stable structure)"); assert.ok(md.includes("Ingen i produksjon"), "explicit empty marker"); }); test("the render is deterministic (same store → byte-identical brief)", () => { const store: TrendStore = { schemaVersion: 4, trends: [ mk({ title: "A", url: "https://example.com/a", topics: ["ai"], status: "selected", angle: "x" }), mk({ title: "B", url: "https://example.com/b", topics: ["ai"], publishedAt: "2026-07-20", verdict: "NYHET" }), ], }; const a = renderBrief(rankForBrief(store, ["ai"], TODAY)); const b = renderBrief(rankForBrief(store, ["ai"], TODAY)); assert.equal(a, b); }); }); // ── CLI: select verb + --ids batch ─────────────────────────────────────────── const trendsRoot = fileURLToPath(new URL("..", import.meta.url)); function run(args: string[], input = ""): { status: number | null; stdout: string } { const res = spawnSync("node", ["--import", "tsx", "src/cli.ts", ...args], { cwd: trendsRoot, input, encoding: "utf8", }); return { status: res.status, stdout: res.stdout + res.stderr }; } function seed(store: string): string[] { // capture three trends; return their ids (read via list --json). const batch = JSON.stringify([ { source: "tavily", title: "One", url: "https://example.com/1", topics: ["ai"] }, { source: "tavily", title: "Two", url: "https://example.com/2", topics: ["ai"] }, { source: "tavily", title: "Three", url: "https://example.com/3", topics: ["ai"] }, ]); run(["capture", "--store", store], batch); const rows = JSON.parse(run(["list", "--store", store, "--json"]).stdout) as Array<{ id: string }>; return rows.map((r) => r.id); } describe("N6 — CLI select verb + --ids batch", () => { test("select --id sets `selected`; read back via list --json", () => { const dir = tmp(); try { const store = join(dir, "trends.json"); const [id] = seed(store); assert.equal(run(["select", "--id", id, "--store", store]).status, 0); const rows = JSON.parse(run(["list", "--store", store, "--json"]).stdout) as Array<{ id: string; status?: string }>; assert.equal(rows.find((r) => r.id === id)!.status, "selected"); } finally { rmSync(dir, { recursive: true, force: true }); } }); test("--ids marks a whole batch in one call (ten candidates, one call)", () => { const dir = tmp(); try { const store = join(dir, "trends.json"); const ids = seed(store); const res = run(["act", "--ids", ids.join(","), "--store", store]); assert.equal(res.status, 0); const rows = JSON.parse(run(["list", "--store", store, "--json"]).stdout) as Array<{ status?: string }>; assert.ok(rows.every((r) => r.status === "acted"), "every seeded trend is acted"); } finally { rmSync(dir, { recursive: true, force: true }); } }); test("a partial batch (some ids unknown) saves the matches, exits 0, and reports the misses", () => { const dir = tmp(); try { const store = join(dir, "trends.json"); const ids = seed(store); const res = run(["skip", "--ids", `${ids[0]},ghost`, "--store", store]); assert.equal(res.status, 0, "partial success is exit 0"); assert.ok(res.stdout.toLowerCase().includes("ghost") || res.stdout.toLowerCase().includes("not found"), "misses reported"); const rows = JSON.parse(run(["list", "--store", store, "--json"]).stdout) as Array<{ id: string; status?: string }>; assert.equal(rows.find((r) => r.id === ids[0])!.status, "skipped"); } finally { rmSync(dir, { recursive: true, force: true }); } }); test("an all-unknown batch changes nothing and exits 2", () => { const dir = tmp(); try { const store = join(dir, "trends.json"); seed(store); assert.equal(run(["select", "--ids", "nope1,nope2", "--store", store]).status, 2); } finally { rmSync(dir, { recursive: true, force: true }); } }); });