import { test } from "node:test"; import assert from "node:assert/strict"; import { validateDeck } from "../src/validate.js"; import { slideCatalog, deckSchema } from "../src/ai/catalog.js"; import { buildOpsSchema } from "../src/ai/ops.js"; /** * Coverage for the 50-type vocabulary: every type validates with a realistic * payload, wrong shapes are rejected, the catalog auto-derives the details, and * the per-slide ops schema stays small for the diagram-ish types whose schemas * nest arrays of objects. */ const base = { title: "T", slides: [{ type: "title" }] }; const VALID = { chapter: { headline: "Part Two", standfirst: "A lighter pause in the argument." }, closing: { headline: "Where the field is heading", body: "A closing statement.", cta: "Get in touch" }, "numbered-list": { headline: "Steps", items: ["one", "two", "three", "four"] }, checklist: { items: [{ text: "done", checked: true }, { text: "todo", checked: false }, { text: "maybe", checked: true }, { text: "later", checked: false }] }, "feature-grid": { items: [{ icon: "⚡", title: "Fast", body: "sub-second" }, { icon: "🔒", title: "Local" }], }, "grid-items": { items: [{ label: "A", value: "1" }, { label: "B", value: "2" }, { label: "C", value: "3" }], }, "icon-list": { items: [{ icon: "★", text: "nice" }, { icon: "✎", text: "edit" }, { icon: "☀", text: "warm" }, { icon: "☂", text: "wet" }] }, "stacked-list": { items: [{ title: "a", tag: "x" }, { title: "b" }, { title: "c" }, { title: "d" }] }, "kpi-dashboard": { kpis: [ { label: "A", value: "42%", trend: "up", change: "+3" }, { label: "B", value: "7", trend: "down" }, { label: "C", value: "1", trend: "flat" }, ], }, "data-cards": { cards: [{ value: "10x", label: "Speed", body: "b" }, { value: "99%", label: "Uptime" }], }, "progress-bars": { bars: [{ label: "A", value: 60, target: 80 }, { label: "B", value: 30 }] }, "ranking-list": { items: [ { label: "Alpha", value: "9.4" }, { label: "Beta", value: "8.1" }, { label: "Gamma", value: "7.7" }, ], }, "metric-comparison": { left: { value: "180 GW", label: "target" }, right: { value: "120 GW", label: "today" }, delta: "+50%", body: "the gap", }, sparklines: { items: [{ label: "load", values: [1, 2, 3, 4], value: "4" }, { label: "temp", values: [9, 8, 7], value: "7" }], }, "before-after": { before: { title: "Old", body: "x" }, after: { title: "New", body: "y" }, }, framework: { concept: { title: "Core", body: "c" }, elements: [{ title: "E1" }, { title: "E2", body: "b" }], }, matrix: { axes: { x: { label: "Effort", low: "low", high: "high" }, y: { label: "Impact", low: "low", high: "high" }, }, quadrants: [{ title: "Q1" }, { title: "Q2" }, { title: "Q3" }, { title: "Q4" }], }, scorecard: { criteria: [{ label: "C1", weight: "30%" }, { label: "C2", weight: "20%" }], options: [{ name: "A", scores: [5, 3] }, { name: "B", scores: [4, 4] }], }, vs: { left: { title: "X" }, right: { title: "Y" }, left_body: "a", right_body: "b" }, "side-by-side": { left: { image: "l.png", title: "L", body: "b" }, right: { image: "r.png", title: "R", body: "b" }, }, funnel: { stages: [{ label: "All", value: "100%" }, { label: "Some", value: "40%" }, { label: "Few", value: "10%" }], }, pipeline: { stages: [{ title: "S1", gate: "gate-a" }, { title: "S2" }, { title: "S3" }] }, dependencies: { nodes: [{ title: "n0" }, { title: "n1", depends_on: [0] }, { title: "n2", depends_on: [1] }], }, "branching-flow": { decision: "go?", branches: [{ label: "yes", steps: [{ title: "y" }] }, { label: "no", steps: [{ title: "n" }] }], }, "layered-architecture": { layers: [{ label: "UI", items: ["a"] }, { label: "API", items: ["b"] }, { label: "DB", items: ["c"] }], }, roadmap: { time_labels: ["Now", "Next", "Later"], phases: [{ label: "P1", items: [{ title: "i" }] }, { label: "P2", items: [{ title: "j" }] }, { label: "P3" }], }, journey: { stages: [ { label: "A", sentiment: "positive" }, { label: "B", sentiment: "negative" }, { label: "C", sentiment: "neutral" }, ], }, chronology: { events: [{ year: "2020", text: "x" }, { year: "2021", text: "y" }] }, testimonial: { quote: "Great.", name: "A", role: "B" }, "pull-quote": { headline: "H", quote: "Q", attribution: "A" }, epigraph: { quote: "Q", attribution: "A", source: "S" }, warning: { body: "Careful", label: "⚠" }, tip: { body: "Do this" }, takeaway: { body: "The point", points: ["p1"] }, "image-grid": { images: [{ src: "a.png" }, { src: "b.png" }] }, "hero-image": { image: "h.png", headline: "Hero", subtitle: "s" }, "split-screen": { left: "l.png", right: "r.png" }, "data-table": { headline: "H", columns: [{ label: "A", align: "right" }, { label: "B" }], rows: [{ text: "v1", highlight: true }, { text: "v2" }], }, "decision-matrix": { headline: "H", criteria: [{ label: "C", weight: 2 }, { label: "D", weight: 1 }], options: [{ name: "A", scores: [3, 2] }, { name: "B", scores: [4, 1] }], }, diagram: { layout: "vertical", nodes: [{ id: "a", label: "A" }, { id: "b", label: "B" }], edges: [{ from: "a", to: "b" }], }, pyramid: { levels: [{ label: "top" }, { label: "mid" }, { label: "base" }] }, venn: { sets: [{ label: "S1", items: ["a"] }, { label: "S2", items: ["b"] }] }, hierarchy: { root: { label: "R" }, children: [{ label: "C1", children: [{ label: "C1a" }] }, { label: "C2" }], }, glossary: { entries: [{ term: "T", definition: "D" }, { term: "U", definition: "E" }] }, faq: { items: [{ question: "Q?", answer: "A" }, { question: "R?", answer: "B" }] }, "team-grid": { members: [{ name: "N", role: "R" }, { name: "M", role: "S" }] }, attribution: { items: [{ name: "N", contribution: "C" }] }, contact: { items: [{ label: "email", value: "a@b.c" }] }, equation: { formula: "E = mc²", body: "explain", variables: [{ symbol: "E", meaning: "energy" }], }, bibliography: { entries: [{ citation: "x", annotation: "y" }, { citation: "z" }] }, "data-source": { sources: [{ name: "N", url: "http://x", description: "d" }, { name: "M", url: "http://y" }], }, }; const SPECIFIC_BAD = { chapter: { headline: 5 }, closing: { headline: 5 }, "numbered-list": { items: [5] }, checklist: { items: [{ text: "x", checked: "yes" }] }, "feature-grid": { items: [{ title: 5 }] }, "grid-items": { items: [{ label: 5, value: "v" }] }, "icon-list": { items: [{ text: 5 }] }, "stacked-list": { items: [{ title: 5 }] }, "kpi-dashboard": { kpis: [{ label: "A", value: 5 }] }, "data-cards": { cards: [{ value: 5, label: "L" }] }, "progress-bars": { bars: [{ label: "A", value: "60" }] }, "ranking-list": { items: [{ label: 5 }] }, "metric-comparison": { left: { value: 5, label: "x" }, right: { value: "y", label: "z" }, delta: "+1" }, sparklines: { items: [{ label: "x", values: ["a"] }] }, "before-after": { before: { title: 5, body: "x" }, after: { title: "y", body: "z" } }, framework: { concept: { title: 5 }, elements: [{ title: "E" }] }, matrix: { axes: { x: { label: 5 }, y: { label: "y" } }, quadrants: [{ title: "Q" }, { title: "Q" }, { title: "Q" }, { title: "Q" }] }, scorecard: { criteria: [{ label: 5 }], options: [{ name: "A", scores: [1] }] }, vs: { left: { title: 5 }, right: { title: "y" } }, "side-by-side": { left: { image: "a.png", title: 5 }, right: { image: "b.png" } }, funnel: { stages: [{ label: 5 }] }, pipeline: { stages: [{ title: 5 }] }, dependencies: { nodes: [{ title: 5 }] }, "branching-flow": { branches: [{ label: 5, steps: [{ title: "s" }] }] }, "layered-architecture": { layers: [{ label: 5 }] }, roadmap: { phases: [{ label: 5 }] }, journey: { stages: [{ label: "A", sentiment: "happy" }] }, chronology: { events: [{ year: 5, text: "x" }] }, testimonial: { quote: "q", name: 5 }, "pull-quote": { headline: "H", quote: 5 }, epigraph: { quote: 5 }, warning: { body: 5 }, tip: { body: 5 }, takeaway: { body: 5 }, "image-grid": { images: [{ src: 5 }] }, "hero-image": { image: "h.png", headline: 5 }, "split-screen": { left: 5, right: "r.png" }, "data-table": { headline: "H", columns: [{ label: 5 }], rows: [{ text: "v" }] }, "decision-matrix": { headline: "H", criteria: [{ label: 5 }], options: [{ name: "A", scores: [1] }] }, diagram: { nodes: [{ id: 5, label: "A" }] }, pyramid: { levels: [{ label: 5 }] }, venn: { sets: [{ label: 5 }] }, hierarchy: { root: { label: 5 }, children: [{ label: "C" }] }, glossary: { entries: [{ term: 5, definition: "d" }] }, faq: { items: [{ question: 5, answer: "a" }] }, "team-grid": { members: [{ name: 5, role: "r" }] }, attribution: { items: [{ name: 5 }] }, contact: { items: [{ label: 5, value: "v" }] }, equation: { formula: 5, body: "b" }, bibliography: { entries: [{ citation: 5 }] }, "data-source": { sources: [{ name: 5 }] }, }; /** * Types where a headline legitimately does not apply — the deck's own title * card, the dividers, and the quote forms whose text IS the slide. Every other * content type requires one: a slide that reserves a heading band and gets no * headline renders as an empty band above stretched content, and a model omits * the field freely wherever the schema lets it. */ const NO_HEADLINE = new Set(["title", "section", "chapter", "epigraph", "quote", "freeform", "image", "contact", "closing"]); const withHeadline = (type, payload) => NO_HEADLINE.has(type) || "headline" in payload ? { type, ...payload } : { type, headline: "H", ...payload }; for (const [type, payload] of Object.entries(VALID)) { test(`${type}: valid payload passes, wrong field shapes are rejected`, async () => { const ok = await validateDeck({ ...base, slides: [...base.slides, withHeadline(type, payload)] }); assert.equal(ok.ok, true, JSON.stringify(ok.errors)); const wrong = await validateDeck({ ...base, slides: [...base.slides, { ...withHeadline(type, payload), ...SPECIFIC_BAD[type] }], }); assert.equal(wrong.ok, false, `expected ${type} bad payload to fail`); assert.ok(wrong.errors.some((e) => /must be|too short|needs at least|one of/.test(e)), wrong.errors.join("; ")); }); } test("speaker_note is a shared field that survives on any standard type", async () => { const ok = await validateDeck({ ...base, slides: [...base.slides, { type: "bullets", headline: "H", bullets: ["a", "b", "c", "d"], speaker_note: "Ask the audience this." }], }); assert.equal(ok.ok, true, JSON.stringify(ok.errors)); }); test("the catalog derives every new type and family, and no grammar breaker leaks", async () => { const schema = await deckSchema(); const enumTypes = schema.definitions.slide.properties.type.enum; for (const t of Object.keys(VALID)) assert.ok(enumTypes.includes(t), `${t} missing from enum`); const cat = await slideCatalog(); assert.ok(cat.includes("PROCESS & FLOW: flow, funnel, pipeline")); assert.ok(cat.includes("DIAGRAM-ISH: diagram, pyramid, venn, hierarchy")); assert.ok(cat.includes("SPECIAL: equation, bibliography, data-source")); assert.ok(cat.includes("- hierarchy: root")); }); test("per-slide ops schemas stay small for nested-object types", async () => { const schema = await deckSchema(); // The hierarchy schema recurses three levels deep by explicit nesting (no // $refs), so the ops grammar for it must still be well-formed and bounded. const ops = buildOpsSchema(schema, { slideCount: 1, onlyTypes: ["hierarchy"] }); // `headline` is required on every content type, so the ops grammar demands // one as well — which is the point: a model writing through this grammar // cannot omit the field the way it could when the schema left it optional. assert.deepEqual(ops.properties.ops.items.properties.slide.required.sort(), ["children", "headline", "root", "type"]); const children = ops.properties.ops.items.properties.slide.properties.children; assert.ok(children.maxItems === 4); }); test("no schema string field has maxLength >= 2000 (the grammar-breaking threshold)", async () => { const schema = await deckSchema(); // The freeform html field is the one documented exception — a whole slide of // HTML is deliberately large, and it is never part of a generated grammar // (freeform is written as a single html string, not constrained fields). const walk = (node, path = "") => { if (!node || typeof node !== "object") return; if (Array.isArray(node)) return node.forEach((n, i) => walk(n, `${path}[${i}]`)); if (typeof node.maxLength === "number" && !path.endsWith("html")) { assert.ok(node.maxLength < 2000, `maxLength ${node.maxLength} too large at ${path}`); } Object.entries(node).forEach(([k, v]) => walk(v, `${path}.${k}`)); }; walk(schema); });