test/critic-render.test.js
Rama main · réplica de lectura del repositorio original.
import { test } from "node:test";
import assert from "node:assert/strict";
import { mkdtemp, mkdir, writeFile, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import YAML from "yaml";
import JSZip from "jszip";
// finalizeDeck reads DECKS from src/paths.js at import time, so point the env
// at a scratch dir before importing the pipeline.
const scratch = await mkdtemp(path.join(tmpdir(), "forge-critic-"));
process.env.FORGE_DECKS_DIR = scratch;
// Pinned for the same reason FORGE_DECKS_DIR is: finalize now records the
// deck's score, and an unpinned config dir writes that row into the real
// config/scores.jsonl — a test polluting the operator's history.
process.env.FORGE_CONFIG_DIR = process.env.FORGE_CONFIG_DIR ?? path.join(scratch, "config");
const { finalizeDeck } = await import("../src/ai/pipeline.js");
const { render } = await import("../src/render.js");
const fakeChat = async () => ({ data: { findings: [] } });
/**
* The text of each slide in the rendered .pptx, in deck order, so an assertion
* can ask what the file people actually open says — and say it about the RIGHT
* slide. Deck-wide matching is useless here: the title slide lists every team
* member, so "is this presenter in the file" is true however the footers came
* out. Numeric order matters too — slide10.xml sorts before slide2.xml.
*/
async function pptxSlides(file) {
const zip = await JSZip.loadAsync(await readFile(file));
const names = Object.keys(zip.files)
.filter((f) => /^ppt\/slides\/slide\d+\.xml$/.test(f))
.sort((a, b) => Number(a.match(/\d+/)[0]) - Number(b.match(/\d+/)[0]));
const parts = await Promise.all(names.map((f) => zip.files[f].async("string")));
return parts.map((x) => x.replace(/<[^>]+>/g, " "));
}
async function makeDeck(slug) {
const dir = path.join(scratch, slug);
await mkdir(path.join(dir, "research"), { recursive: true });
await writeFile(path.join(dir, "meta.yaml"), YAML.stringify({
slug,
brief: "critic render test",
theme: "warm-humanist",
slidesPerMember: 1,
team: {
label: "Group T",
members: [
{ name: "Ada Lovelace", roll: "1", presenting: true },
{ name: "Alan Turing", roll: "2", presenting: true },
],
},
}));
await writeFile(path.join(dir, "plan.yaml"), YAML.stringify({
title: "critic render test",
sections: ["Intro", "Body"],
slides: [
{ type: "title", section: 0, purpose: "open" },
{ type: "section", section: 0, purpose: "intro part" },
{ type: "bullets", section: 0, purpose: "key points" },
{ type: "bullets", section: 1, purpose: "body points" },
{ type: "closing", section: 1, purpose: "close" },
],
}));
await writeFile(path.join(dir, "deck.yaml"), YAML.stringify({
title: "critic render test",
sections: ["Intro", "Body"],
theme: "warm-humanist",
slides: [
{ type: "title", headline: "critic render test", subtitle: "s" },
{ type: "section", section: 0, headline: "Intro" },
{ type: "bullets", section: 0, headline: "Before the critic", bullets: ["a", "b", "c", "d"] },
{ type: "bullets", section: 1, headline: "Body points", bullets: ["a", "b", "c", "d"] },
{ type: "closing", headline: "Thanks" },
],
}));
return dir;
}
/**
* The critic edits the deck and finalize keeps editing after it — a trim, a
* grounding pass and the presenter re-assignment all run on its output. The
* critic renders inside its own loop, so unless finalize draws the deck again
* the .pptx is the deck as it stood BEFORE those passes: a different deck from
* the deck.yaml beside it, and missing the presenters the ops layer dropped.
*/
test("a deck the critic changed is drawn again — the .pptx is the deck.yaml", async () => {
const dir = await makeDeck("critic-render");
// Stands in for the vision loop, and behaves like it in the two ways that
// matter: the fix goes through the ops layer, which does not carry
// `presenter` and so drops it; and the loop RENDERS what it hands back, so
// the .pptx on disk is the critic's own last render, not a stale one.
const critique = async ({ slug, deck }) => {
const fixed = structuredClone(deck);
const at = fixed.slides.findIndex((s) => s.headline === "Before the critic");
fixed.slides[at].headline = "Shortened by the critic";
for (const s of fixed.slides) delete s.presenter;
const deckFile = path.join(scratch, slug, "deck.yaml");
await writeFile(deckFile, YAML.stringify(fixed));
await render({ deckFile, themeName: fixed.theme });
return {
slug,
rounds: [{ round: 0, findings: [{ slide: at + 1, kind: "overflow", detail: "headline runs off" }], fixed: true }],
deck: fixed,
};
};
try {
const r = await finalizeDeck({ slug: "critic-render", model: "mock", chat: fakeChat, critic: true, critique });
const persisted = YAML.parse(await readFile(path.join(dir, "deck.yaml"), "utf8"));
assert.ok(
persisted.slides.some((s) => s.headline === "Shortened by the critic"),
"the critic's edit is on disk",
);
const named = persisted.slides.filter((s) => s.presenter);
assert.ok(named.length, "and finalize put the presenters back after it");
const drawn = await pptxSlides(path.join(dir, "out", "deck.pptx"));
assert.equal(drawn.length, persisted.slides.length, "every slide was drawn");
assert.ok(drawn.some((x) => x.includes("Shortened by the critic")), "the .pptx is post-critic");
// The load-bearing half, and it needs an exact assertion rather than a
// containment one. The critic's own render happened BEFORE finalize
// restored the presenters, and a slide with no `presenter` does not draw a
// blank footer — chrome falls back to every presenting member joined by
// "·". So the undrawn state still CONTAINS the right name; what it does is
// print the whole team on every slide. Assert the others are absent.
const roster = ["Ada Lovelace", "Alan Turing"];
for (const [i, slide] of persisted.slides.entries()) {
if (!slide.presenter) continue;
const footer = drawn[i].replace(/\s+/g, " ").trim();
assert.ok(footer.includes(slide.presenter), `slide ${i + 1} names ${slide.presenter}`);
for (const other of roster.filter((n) => n !== slide.presenter)) {
assert.ok(
!footer.includes(other),
`slide ${i + 1} presents as ${slide.presenter} alone, but drew: ${footer.slice(0, 120)}`,
);
}
}
assert.ok(r.critic?.rounds?.length, "the critic report is returned");
} finally {
await rm(dir, { recursive: true, force: true });
}
});