Ctrl K
ComunidadGuías…

test/fieldlength-gate.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, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { fieldLengthPass } from "../src/ai/fieldlength.js";
import { errorsForSlide } from "../src/validate.js";

/**
 * The pass runs BECAUSE a deck has fields over their caps, and it leaves the
 * hard ones to the deterministic trim. So "is the whole deck clean afterwards"
 * is false on nearly every deck it is asked to repair — and it used to answer
 * that question, return the deck it was handed, and throw away every repair it
 * had just made, while still listing them in `repaired`.
 *
 * Observed on a real generated deck: four feature-grid card bodies cut mid-word
 * by the decoding grammar ("…in site selection criteri"), correctly rewritten
 * by this pass, and shipped cut anyway. The vision critic found them on the
 * rendered PNG, which is a long way round for a defect the pass had already
 * fixed in memory.
 */

const scratch = await mkdtemp(path.join(tmpdir(), "forge-fieldlen-"));

/** A card body cut at exactly its 53-char cap, the way the grammar cuts. */
const CUT = "Centers marginalized groups in site selection criteri";

function deckWith() {
  return {
    title: "field length gate",
    theme: "warm-humanist",
    slides: [
      {
        type: "feature-grid",
        section: 0,
        headline: "Equity-First Site Selection Indicators",
        items: [
          { title: "Student Poverty Rate", body: CUT },
          { title: "Household Median Income", body: "Prioritizes lower-income areas for early V2G deploy" },
        ],
      },
      // Untouched by the rewrite and over its cap: the deck is invalid as a
      // whole no matter how well the slide above is repaired. `headline` caps
      // at 80.
      {
        type: "bullets",
        section: 0,
        headline: "x".repeat(140),
        bullets: ["a", "b", "c", "d"],
      },
    ],
  };
}

/** Stands in for the rewrite: one update_slide op patching the cut bodies,
 *  which is the shape rewriteSlide asks the model for. */
const fixedChat = async () => ({
  data: {
    ops: [
      {
        op: "update_slide",
        index: 0,
        patch: {
          items: [
            { title: "Student Poverty Rate", body: "Free lunch data targets low-income routes." },
            { title: "Household Median Income", body: "Lower-income areas get priority." },
          ],
        },
      },
    ],
  },
});

test("a repair survives an unrelated slide being over its cap", async () => {
  const deck = deckWith();
  const res = await fieldLengthPass({ deck, deckDir: scratch, chat: fixedChat });

  // Whatever the rewrite managed, the pass must never hand back the deck it was
  // given while claiming in `repaired` that it changed something.
  assert.ok(res.repaired.length, "the rewrite produced repairs to judge");
  if (res.repaired.length) {
    const bodies = res.deck.slides[0].items.map((i) => i.body);
    assert.ok(!bodies.includes(CUT), "the repaired body is in the returned deck, not just in `repaired`");
  }

  // The over-cap slide is untouched and still reported, so the trim's remaining
  // work is visible rather than swallowed.
  assert.equal(res.deck.slides[1].headline.length, 140, "an unrelated slide is left for the trim");
  assert.ok(
    res.problems.some((p) => /too long/.test(String(p))),
    "and the deck's remaining length problems are reported",
  );
});

/** The model returns the flagged half blank — every cap satisfied, all meaning
 *  gone. Observed on a real `compare` slide. */
const guttingChat = async () => ({
  data: {
    ops: [
      {
        op: "update_slide",
        index: 0,
        patch: {
          items: [
            { title: "Student Poverty Rate", body: "" },
            { title: "Household Median Income", body: "" },
          ],
        },
      },
    ],
  },
});

test("a rewrite that empties a field is refused, not accepted as short enough", async () => {
  const deck = deckWith();
  const res = await fieldLengthPass({ deck, deckDir: scratch, chat: guttingChat });

  const bodies = res.deck.slides[0].items.map((i) => i.body);
  assert.ok(bodies.every((b) => b && b.trim()), `no body was emptied, got ${JSON.stringify(bodies)}`);
  assert.equal(res.repaired.length, 0, "and nothing is reported as repaired");
  assert.ok(
    res.problems.some((p) => /emptied/.test(String(p))),
    `the refusal is reported, got ${JSON.stringify(res.problems)}`,
  );
});

test("errorsForSlide attributes a missing required field, which has no pointer", () => {
  // `describe` writes the pointer only for keywords with a sub-path; `required`
  // renders as "slide N (type: T): missing required field …" and nothing else.
  const errors = [
    'slide 1 (type: feature-grid): missing required field "title"',
    "slide 2 (type: bullets) at /slides/1/headline: too long — 80 chars max, shorten it",
  ];
  assert.deepEqual(errorsForSlide(errors, 0), [errors[0]], "prose form, slide 1 is index 0");
  assert.deepEqual(errorsForSlide(errors, 1), [errors[1]], "pointer form");
});

test("errorsForSlide does not confuse slide 1 with slide 10", () => {
  const errors = [
    "slide 11 (type: bullets) at /slides/10/headline: too long — 80 chars max, shorten it",
    'slide 2 (type: cards): missing required field "cards"',
  ];
  assert.deepEqual(errorsForSlide(errors, 1), [errors[1]]);
  assert.deepEqual(errorsForSlide(errors, 10), [errors[0]]);
});

test.after(async () => {
  await rm(scratch, { recursive: true, force: true });
});