test/chart.test.js
Rama main · réplica de lectura del repositorio original.
import { test } from "node:test";
import assert from "node:assert/strict";
import { mkdtemp, writeFile, rm, readFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import JSZip from "jszip";
import YAML from "yaml";
import { render } from "../src/render.js";
import { numericFactCount, dataAffinityNote, CHART_KINDS } from "../src/ai/catalog.js";
import { planDeck } from "../src/ai/generate.js";
import { validateDeck } from "../src/validate.js";
test("numericFactCount counts distinct numeric facts, not repetitions", () => {
assert.equal(numericFactCount(""), 0);
assert.equal(numericFactCount("no figures here at all"), 0);
// 180 GW repeated three times counts once; 4.2% and 1.23 are separate facts.
const notes =
"Capacity grows 180 GW by 2030. 180 GW is the ceiling. At 4.2% the return beats inflation. The voltage is 1.23 V.";
assert.equal(numericFactCount(notes), 4);
});
test("dataAffinityNote forbids charts below two numeric facts and names the kinds above", () => {
const thin = dataAffinityNote("a brief with no numbers in it");
assert.ok(thin, "a no-numbers brief still gets the steering note");
assert.match(thin, /Do NOT choose chart/i, "below the threshold charts are forbidden, not merely unmentioned");
assert.match(thin, /qualitative|framework|cards/, "a qualitative alternative is named");
assert.match(dataAffinityNote("only one fact: 42% uptake"), /Do NOT choose chart/i, "one fact is also below the threshold");
const note = dataAffinityNote("Two facts: 180 GW by 2030 and 4.2% annual growth.");
assert.ok(note, "two facts must trigger the note");
assert.match(note, /CHART/);
for (const kind of CHART_KINDS) assert.ok(note.includes(kind), `note names the ${kind} kind`);
});
test("the schema refuses a chart with empty series values", async () => {
const empty = {
title: "t",
slides: [{
type: "chart",
headline: "Evidence",
chart: { kind: "bar", categories: ["MPI", "Hybrid"], series: [{ name: "Speedup", values: [] }] },
}],
};
const { ok, errors } = await validateDeck(empty);
assert.equal(ok, false, "an empty-values chart must not validate");
assert.ok(errors.some((e) => e.includes("values")), "the error names the values array");
});
test("the schema refuses a chart with no categories", async () => {
const { ok } = await validateDeck({
title: "t",
slides: [{
type: "chart",
headline: "Evidence",
chart: { kind: "bar", categories: [], series: [{ name: "Speedup", values: [1.2] }] },
}],
});
assert.equal(ok, false);
});
test("planDeck coerces a proposed chart to a qualitative type when the research has no numbers", async () => {
const chat = async () => ({
data: {
title: "Trends",
sections: ["Evidence"],
slides: [
{ type: "chart", section: 0, purpose: "The evidence from papers." },
{ type: "bullets", section: 0, purpose: "The key claims." },
],
},
});
const { plan } = await planDeck({ brief: "b", research: "qualitative notes, no figures", model: "mock", chat });
const chart = plan.slides.find((s) => s.type === "chart");
assert.equal(chart, undefined, "no chart survives the coercion");
assert.ok(plan.slides.some((s) => s.type === "cards"), "the chart beat became a cards slide");
assert.equal(plan.slides.filter((s) => s.type === "bullets").length, 1, "other types untouched");
});
test("planDeck keeps a proposed chart when the research carries real numbers", async () => {
const chat = async () => ({
data: {
title: "Trends",
sections: ["Data"],
slides: [
{ type: "chart", section: 0, purpose: "180 GW vs 220 GW by source." },
],
},
});
const { plan } = await planDeck({ brief: "b", research: "Capacity is 180 GW today, rising to 220 GW by 2030.", model: "mock", chat });
assert.ok(plan.slides.some((s) => s.type === "chart"), "a numeric research keeps the chart");
});
/** Render a one-slide chart deck and return the raw chart XML from the pptx. */
async function chartXml({ kind, series = 2 }) {
const dir = await mkdtemp(path.join(tmpdir(), "forge-chart-"));
const deck = {
title: "Chart regression",
theme: "warm-humanist",
sections: ["Data"],
slides: [
{
type: "chart",
headline: "Contribution by source",
chart: {
kind,
categories: ["2024", "2025", "2026"],
series: Array.from({ length: series }, (_, i) => ({
name: `Series ${i}`,
values: [10 + i, 20 + i, 15 + i],
})),
},
},
],
};
const deckFile = path.join(dir, "deck.yaml");
await writeFile(deckFile, YAML.stringify(deck), "utf8");
try {
const r = await render({ deckFile, themeName: "warm-humanist", deckDir: dir });
assert.equal(r.problems.length, 0, "chart must render without problems");
const zip = await JSZip.loadAsync(await readFile(r.outFile));
const chartEntries = Object.keys(zip.files).filter((f) => f.startsWith("ppt/charts/") && f.endsWith(".xml"));
assert.ok(chartEntries.length, "the pptx must contain a chart part");
const xml = await zip.file(chartEntries[0]).async("string");
return xml;
} finally {
await rm(dir, { recursive: true, force: true });
}
}
test("a stacked-bar chart writes OOXML grouping stacked, not clustered", async () => {
const xml = await chartXml({ kind: "stacked-bar" });
assert.match(xml, /<c:grouping val="stacked"\/>/, "stacked-bar must group as stacked");
});
test("a plain bar chart keeps the default clustered grouping", async () => {
const xml = await chartXml({ kind: "bar" });
assert.match(xml, /<c:grouping val="clustered"\/>/, "bar stays clustered by default");
});
/**
* A chart whose categories and series disagree in length used to produce a
* .pptx that LibreOffice refused to open AT ALL — not the chart, the whole
* deck. OOXML has no opinion, pptxgenjs writes what it is handed, and
* `pres.writeFile()` succeeds, so nothing upstream noticed. A real generated
* deck had a line chart with two categories and five values and a bar chart
* with one category and four; twenty good slides were unopenable because of
* those two.
*/
async function renderChart(chart) {
const dir = await mkdtemp(path.join(tmpdir(), "forge-chartlen-"));
try {
const deck = {
title: "Chart length",
slides: [
{ type: "title", headline: "Chart length" },
{ type: "chart", headline: "A chart", chart },
],
};
const file = path.join(dir, "deck.yaml");
await writeFile(file, YAML.stringify(deck), "utf8");
const r = await render({ deckFile: file, themeName: "warm-humanist" });
const zip = await JSZip.loadAsync(await readFile(r.outFile));
const chartXml = Object.keys(zip.files).find((f) => /ppt\/charts\/chart\d+\.xml$/.test(f));
const xml = chartXml ? await zip.file(chartXml).async("string") : "";
// Reported once per deck by the quality gate, not once per theme by the
// renderer — the disagreement is theme-invariant.
const { analyzeQuality, qualityProblems } = await import("../src/ai/quality.js");
return { problems: qualityProblems(analyzeQuality(deck)), xml };
} finally {
await rm(dir, { recursive: true, force: true });
}
}
test("a chart with more values than categories keeps every value", async () => {
// Reconciled towards the DATA: a category is a label and a blank label costs
// a tick mark, while a dropped value changes what the chart says.
const { problems, xml } = await renderChart({
kind: "line",
categories: ["0", "500"],
series: [{ name: "Contact loss", values: [0, 12, 45, 78, 96] }],
});
const points = (xml.match(/<c:pt idx="\d+"><c:v>/g) ?? []).length;
assert.ok(points >= 5, `expected all five values in the chart XML, saw ${points} points`);
assert.ok(problems.some((p) => /2 categories against series of 5/.test(p)), problems.join(" | "));
});
test("a chart with fewer values than categories is drawn at the shortest series", async () => {
// The other direction: a series cannot supply a figure it does not have, so
// the extra categories go rather than being paired with invented numbers.
const { problems } = await renderChart({
kind: "bar",
categories: ["A", "B", "C", "D"],
series: [{ name: "One", values: [1, 2] }],
});
assert.ok(problems.some((p) => /drawn at 2/.test(p)), problems.join(" | "));
});
test("series that disagree with each other are cut to the shortest", async () => {
const { problems } = await renderChart({
kind: "bar",
categories: ["A", "B", "C"],
series: [{ name: "Long", values: [1, 2, 3] }, { name: "Short", values: [4, 5] }],
});
assert.ok(problems.some((p) => /drawn at 2/.test(p)), problems.join(" | "));
});
test("a well-formed chart reports nothing and is left alone", async () => {
const { problems, xml } = await renderChart({
kind: "bar",
categories: ["A", "B", "C"],
series: [{ name: "One", values: [1, 2, 3] }, { name: "Two", values: [4, 5, 6] }],
});
assert.equal(problems.filter((p) => /chart_shape/.test(p)).length, 0, problems.join(" | "));
assert.ok(xml.includes("<c:v>A</c:v>"), "the real categories survive");
});