Ctrl K
ComunidadGuías…

test/critic-render.test.js

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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 });
  }
});