import { test } from "node:test"; import assert from "node:assert/strict"; import { applyOps, applyOp, buildOpsSchema, diffDecks } from "../src/ai/ops.js"; import { deckSchema } from "../src/ai/catalog.js"; import { DIVIDER_TYPES } from "../src/ai/team.js"; const S = (type, i) => ({ type, headline: `slide ${i}`, notes: `n${i}` }); const deck = { title: "t", slides: [S("bullets", 1), S("cards", 2), S("quote", 3)] }; test("applyOps applies structural ops transactionally", () => { const { ok, deck: out, changes } = applyOps(deck, [ { op: "duplicate_slide", index: 0 }, { op: "delete_slide", index: 2 }, ]); assert.equal(ok, true); assert.deepEqual(out.slides.map((s) => s.headline), ["slide 1", "slide 1", "slide 3"]); assert.equal(changes.length, 2); }); test("applyOps refuses to empty the deck via delete", () => { const only = { slides: [S("title", 1)] }; const r = applyOps(only, [{ op: "delete_slide", index: 0 }]); assert.equal(r.ok, false); assert.ok(r.errors.some((e) => e.includes("refuses"))); }); test("duplicate_slide out of range fails and leaves the deck untouched", () => { const before = JSON.stringify(deck.slides); const r = applyOps(deck, [{ op: "duplicate_slide", index: 99 }]); assert.equal(r.ok, false); assert.equal(JSON.stringify(deck.slides), before); }); test("applyOp refuses an unknown op", () => { assert.throws(() => applyOp({ slides: [] }, { op: "nope" }), /Unknown op/); }); test("buildOpsSchema includes duplicate_slide once a deck has slides", async () => { const schema = await deckSchema(); const forEmpty = buildOpsSchema(schema, { slideCount: 0 }); const forDeck = buildOpsSchema(schema, { slideCount: 4 }); const opEnums = (s) => s.properties.ops.items.properties.op.enum; assert.ok(!opEnums(forEmpty).includes("duplicate_slide")); assert.ok(opEnums(forDeck).includes("duplicate_slide")); }); test("buildOpsSchema can remove a shared field — dividers lose presenter", async () => { const schema = await deckSchema(); const slideProps = (s) => s.properties.ops.items.properties.slide.properties; assert.ok(slideProps(buildOpsSchema(schema, { slideCount: 0, onlyTypes: ["bullets"] })).presenter); assert.equal( slideProps(buildOpsSchema(schema, { slideCount: 0, onlyTypes: ["section"], excludeProps: ["presenter"] })).presenter, undefined, ); }); test("the divider set covers the non-content types, and nothing else", () => { assert.deepEqual([...DIVIDER_TYPES].sort(), ["chapter", "closing", "epigraph", "section", "title"]); }); test("diffDecks reads a new slide as added", () => { const after = { slides: [...deck.slides, S("stats", 4)] }; const d = diffDecks(deck, after); assert.ok(d.some((c) => c.kind === "added")); }); /* ------------------------------------------- the patch key space vs a selection */ /** Every property name declared by more than one slide type, with the distinct * shapes hiding behind it. Derived, so it cannot drift from the schema. */ function collisions(schema) { const byName = new Map(); for (const rule of schema.definitions.slide.allOf ?? []) { const t = rule.if?.properties?.type?.const; if (!t) continue; for (const [name, spec] of Object.entries(rule.then?.properties ?? {})) { if (!byName.has(name)) byName.set(name, []); byName.get(name).push({ type: t, spec: JSON.stringify(spec) }); } } return [...byName.entries()].filter(([, uses]) => new Set(uses.map((u) => u.spec)).size > 1); } const patchProps = (ops) => ops.properties.ops.items.properties.patch.properties; const slideProps = (ops) => ops.properties.ops.items.properties.slide.properties; test("the flat patch grammar really does collide — the reason patchTypes exists", async () => { const schema = await deckSchema(); const clashing = collisions(schema); // Not asserting the exact count, which moves with the catalogue — asserting // that the problem is real and that `items` is the worst of it. assert.ok(clashing.length >= 10, `expected many colliding names, got ${clashing.length}`); const items = clashing.find(([name]) => name === "items"); assert.ok(items, "`items` should be declared by several types"); assert.ok(items[1].length >= 8, `items is declared by ${items[1].length} types`); }); test("patchTypes gives a single-slide selection the exact shape, not the last one walked", async () => { const schema = await deckSchema(); const own = new Map( (schema.definitions.slide.allOf ?? []) .filter((r) => r.if?.properties?.type?.const) .map((r) => [r.if.properties.type.const, r.then?.properties ?? {}]), ); // Two types that both declare `items` with different element shapes. const [a, b] = ["feature-grid", "contact"]; assert.notDeepEqual(own.get(a).items, own.get(b).items, "fixture types must actually collide"); const flat = buildOpsSchema(schema, { slideCount: 4 }); const scoped = buildOpsSchema(schema, { slideCount: 4, patchTypes: [a] }); // The flat grammar hands every turn one arbitrary winner. assert.notDeepEqual(patchProps(flat).items, own.get(a).items); // The scoped one hands it the shape of the slide actually being patched. assert.deepEqual(patchProps(scoped).items, own.get(a).items); }); test("patchTypes narrows the patch key space and leaves full slides alone", async () => { const schema = await deckSchema(); const flat = buildOpsSchema(schema, { slideCount: 4 }); const scoped = buildOpsSchema(schema, { slideCount: 4, patchTypes: ["feature-grid"] }); assert.ok( Object.keys(patchProps(scoped)).length < Object.keys(patchProps(flat)).length, "a scoped patch should carry fewer keys", ); // A turn scoped to one slide may still be asked to append a different type, // and scopeOpsToSelection lets append through — so the full-slide grammar // must not narrow with it. assert.deepEqual(Object.keys(slideProps(scoped)).sort(), Object.keys(slideProps(flat)).sort()); assert.deepEqual(slideProps(scoped).type, slideProps(flat).type); }); test("patchTypes over several selected slides covers all of their fields", async () => { const schema = await deckSchema(); const types = ["feature-grid", "chronology", "data-table"]; const ops = buildOpsSchema(schema, { slideCount: 8, patchTypes: types }); const props = patchProps(ops); for (const rule of schema.definitions.slide.allOf ?? []) { const t = rule.if?.properties?.type?.const; if (!types.includes(t)) continue; for (const name of Object.keys(rule.then?.properties ?? {})) { assert.ok(props[name], `${t}.${name} missing from the patch grammar`); } } }); test("onlyTypes still narrows both key spaces — the critic's path is unchanged", async () => { const schema = await deckSchema(); const ops = buildOpsSchema(schema, { slideCount: 4, onlyTypes: ["feature-grid"] }); assert.deepEqual(slideProps(ops).type, { enum: ["feature-grid"] }); assert.deepEqual( Object.keys(patchProps(ops)).sort(), Object.keys(slideProps(ops)).sort(), ); });