test/turn-scope.test.js
Rama main · réplica de lectura del repositorio original.
import { test } from "node:test";
import assert from "node:assert/strict";
import { buildOpsSchema } from "../src/ai/ops.js";
import { deckSchema } from "../src/ai/catalog.js";
import { runTurn } from "../src/ai/turn.js";
/**
* `buildOpsSchema` assigns every selected type's properties into one flat
* object, so a property name that two types share belongs to whichever was
* walked last. That is harmless while a turn really may write any type, and
* wrong the moment a caller knows which type it is editing.
*
* Thirteen slide types declare `items`. Asked to repair a clipped card body on
* a `feature-grid`, the fix turn was handed `contact`'s `{label, value}` items
* and its output failed validation with `missing required field "title"`. The
* model could not have got it right: constrained decoding made the correct
* answer unrepresentable and the incorrect one mandatory.
*/
const itemsOf = (schema) => schema.properties.ops.items.properties.patch.properties.items;
test("an unscoped turn carries one type's `items` shape for all thirteen", async () => {
const merged = itemsOf(buildOpsSchema(await deckSchema(), { slideCount: 12 }));
// Not asserting WHICH type wins — that is schema order, and pinning it would
// fail for the wrong reason. The point is that one shape stands for all.
assert.equal(merged.items.type, "object");
assert.ok(merged.items.required?.length, "and it brings its own required keys");
});
test("scoping to feature-grid gives feature-grid's items, not another type's", async () => {
const scoped = itemsOf(buildOpsSchema(await deckSchema(), {
slideCount: 12,
onlyTypes: ["feature-grid"],
}));
assert.deepEqual(scoped.items.required, ["title"]);
assert.deepEqual(
Object.keys(scoped.items.properties).sort(),
["body", "icon", "title"],
"the card shape the type actually declares",
);
assert.equal(scoped.items.properties.body.maxLength, 53);
});
test("runTurn passes its type scope through to the grammar", async () => {
const deck = {
title: "t",
slides: [{ type: "feature-grid", headline: "h", items: [{ title: "a" }, { title: "b" }] }],
};
let seen = null;
const chat = async ({ schema }) => {
seen = schema;
// A patch in the RIGHT shape — the one the unscoped grammar forbade.
return {
data: {
ops: [{
op: "update_slide",
index: 0,
patch: { items: [{ title: "a", body: "A whole sentence." }, { title: "b", body: "Another." }] },
}],
},
};
};
const turn = await runTurn({ deck, instruction: "shorten", onlyTypes: ["feature-grid"], chat });
assert.ok(turn.ok, `the scoped shape validates: ${JSON.stringify(turn.errors)}`);
assert.deepEqual(itemsOf(seen).items.required, ["title"], "the turn built a feature-grid grammar");
assert.equal(turn.deck.slides[0].items[0].body, "A whole sentence.");
});
test("no scope still lets a turn write any type — chat must stay unrestricted", async () => {
const deck = { title: "t", slides: [{ type: "bullets", headline: "h", bullets: ["a", "b", "c", "d"] }] };
let seen = null;
const chat = async ({ schema }) => {
seen = schema;
return { data: { ops: [] } };
};
await runTurn({ deck, instruction: "nothing", chat });
const type = seen.properties.ops.items.properties.slide.properties.type;
assert.ok(!type?.enum || type.enum.length > 20, "every type stays available to an unscoped turn");
});
/**
* The critic names its slides and so gets `onlyTypes`. Chat has the same
* knowledge and was not using it: the panel's selection reaches `runTurn` as
* `onlySlides`, which was only ever applied AFTER the model answered, to
* filter the ops it produced. The grammar it answered against was still the
* flat merge of every type.
*/
test("a selection narrows the patch grammar to the selected slides' own types", async () => {
const deck = {
title: "t",
slides: [
{ type: "bullets", headline: "h", bullets: ["a", "b", "c"] },
{ type: "feature-grid", headline: "h", items: [{ title: "a" }, { title: "b" }] },
],
};
let seen = null;
const chat = async ({ schema }) => { seen = schema; return { data: { ops: [] } }; };
await runTurn({ deck, instruction: "shorten this", onlySlides: [1], chat });
assert.deepEqual(
itemsOf(seen).items.required, ["title"],
"the selected slide is a feature-grid, so `items` should be its shape",
);
});
test("a selection does not stop the turn adding a slide of another type", async () => {
// scopeOpsToSelection lets append and insert through on purpose, so
// narrowing the full-slide grammar with the patch grammar would trade one
// silent failure for another.
const deck = {
title: "t",
slides: [{ type: "feature-grid", headline: "h", items: [{ title: "a" }, { title: "b" }] }],
};
let seen = null;
const chat = async ({ schema }) => { seen = schema; return { data: { ops: [] } }; };
await runTurn({ deck, instruction: "add a chart after this", onlySlides: [0], chat });
const type = seen.properties.ops.items.properties.slide.properties.type;
assert.ok(!type?.enum || type.enum.length > 20, "every type stays appendable under a selection");
});
test("an empty or out-of-range selection falls back to the unscoped grammar", async () => {
const deck = { title: "t", slides: [{ type: "bullets", headline: "h", bullets: ["a", "b", "c"] }] };
for (const onlySlides of [null, [], [99]]) {
let seen = null;
const chat = async ({ schema }) => { seen = schema; return { data: { ops: [] } }; };
await runTurn({ deck, instruction: "x", onlySlides, chat });
assert.ok(
itemsOf(seen),
`a selection of ${JSON.stringify(onlySlides)} should leave the full patch grammar in place`,
);
}
});
test("an explicit onlyTypes still wins over the selection", async () => {
// The critic passes both: it names slides AND the types its findings concern.
const deck = {
title: "t",
slides: [{ type: "bullets", headline: "h", bullets: ["a", "b", "c"] }],
};
let seen = null;
const chat = async ({ schema }) => { seen = schema; return { data: { ops: [] } }; };
await runTurn({ deck, instruction: "x", onlySlides: [0], onlyTypes: ["feature-grid"], chat });
const type = seen.properties.ops.items.properties.slide.properties.type;
assert.deepEqual(type, { enum: ["feature-grid"] });
assert.deepEqual(itemsOf(seen).items.required, ["title"]);
});