/** * report-template.ts 纯函数单测(大屏多页模式的契约层)。 * 运行方式(项目无 vitest,对齐 flow-graph.test.ts): * npx esbuild src/roundtable-planning/lib/report-template.test.ts --bundle --format=esm \ * --platform=node --outfile=.tmp-tests/report-template.test.mjs * node --test .tmp-tests/report-template.test.mjs */ import assert from "node:assert/strict"; import test from "node:test"; import { buildDashboardPrompt, extractReportData, isDashboardHtml, renderDashboardHtml, RT_DASHBOARD_MARKER, stripReportFence, } from "./report-template"; const SAMPLE = [ "下面是大屏数据。", "```report-json", JSON.stringify({ title: "智能机房选址方案大屏", subtitle: "三地候选 · 综合评审", kpis: [ { label: "总投资", value: "1.2", unit: "亿元", trend: "up" }, { label: "工期", value: "14", unit: "个月" }, ], consensus: { headline: "推荐 B 地块方案", points: ["供电冗余最高", "扩容空间充足"] }, charts: [ { type: "bar", title: "候选地块评分", items: [{ label: "A", value: 72 }, { label: "B", value: 89 }] }, { type: "donut", title: "投资构成", items: [{ label: "土建", value: 40 }, { label: "设备", value: 60 }] }, ], timeline: [{ label: "阶段一", desc: "勘察与立项" }], seats: [ { id: "seat-1", name: "情报收集", summary: "完成三地基础数据采集", sections: [{ heading: "数据来源", bullets: ["政务公开", "实地走访"] }], metrics: [{ label: "覆盖率", value: 92 }], risks: [], dependsOn: [] }, { id: "seat-2", name: "方案设计", summary: "B 地块双回路供电方案", sections: [], metrics: [], risks: [{ label: "供电瓶颈", level: "high" }], dependsOn: ["seat-1"] }, ], }), "```", ].join("\n"); test("extractReportData:完整块解析 + 字段归一化", () => { const d = extractReportData(SAMPLE)!; assert.equal(d.title, "智能机房选址方案大屏"); assert.equal(d.kpis.length, 2); assert.equal(d.consensus.headline, "推荐 B 地块方案"); assert.equal(d.charts.length, 2); assert.equal(d.seats.length, 2); assert.deepEqual(d.seats[1].dependsOn, ["seat-1"]); }); test("extractReportData:dependsOn 只保留真实存在的席位 id(剔除模型编造/自引用)", () => { const text = [ "```report-json", JSON.stringify({ title: "t", seats: [ { id: "s1", name: "甲", dependsOn: ["s1", "s2", "ghost"] }, { id: "s2", name: "乙", dependsOn: [] }, ], }), "```", ].join("\n"); const d = extractReportData(text)!; assert.deepEqual(d.seats[0].dependsOn, ["s2"]); }); test("extractReportData:剥离 思考过程后再抽取(思考里的假 JSON 不干扰)", () => { // 思考块里故意混入一个会「像」契约但不该被采用的裸 JSON,正文才是真数据。 const withThink = [ "", '我先草拟一版:{"title":"草稿不要用","seats":[{"id":"x","name":"废弃"}]}', "再想想……", "", SAMPLE, ].join("\n"); const d = extractReportData(withThink)!; assert.equal(d.title, "智能机房选址方案大屏"); assert.equal(d.seats.length, 2); }); test("extractReportData:只吐闭合 (chat template 注入式)也能抽到正文", () => { const onlyClose = ["模型在此思考成本与风险……", "", SAMPLE].join("\n"); const d = extractReportData(onlyClose)!; assert.equal(d.title, "智能机房选址方案大屏"); }); test("extractReportData:流式未闭合 尾段不被误抽(返回 null)", () => { // 正文还没出,模型还在思考里写了半个 JSON —— 不应抽出数据。 const streaming = '正文前导\n我打算输出 {"title":"在想","seats":[{"id":"a","name":"甲"'; assert.equal(extractReportData(streaming), null); }); test("extractReportData:无 fence / 坏 JSON / 空数据返回 null", () => { assert.equal(extractReportData("没有数据块"), null); assert.equal(extractReportData("```report-json\n{broken\n```"), null); assert.equal(extractReportData("```report-json\n{}\n```"), null); assert.equal(extractReportData(null), null); }); test("renderDashboardHtml:完整文档 + marker + 总览/席位多页 + :target 导航", () => { const d = extractReportData(SAMPLE)!; const html = renderDashboardHtml(d, { generatedAt: "2026-06-13 10:00" }); assert.ok(html.startsWith("")); assert.ok(html.includes(RT_DASHBOARD_MARKER)); assert.ok(html.trimEnd().endsWith("")); assert.ok(isDashboardHtml(html)); // 多页结构:总览 + 每席位一页 assert.ok(html.includes('id="overview"')); assert.ok(html.includes('id="seat-1"')); assert.ok(html.includes('id="seat-2"')); // 纯 CSS :target 导航(零 JS:无 allow-scripts 沙箱也能切页) assert.ok(html.includes(".page:target")); assert.ok(!html.includes(" { const d = extractReportData(SAMPLE)!; const dark = renderDashboardHtml(d, { theme: "dark" }); // 深蓝规范:页面 #0C1836 / 卡片 #122347 / 主色 #1677FF(tokens.css §1) assert.ok(dark.includes("#0C1836")); assert.ok(dark.includes("#122347")); assert.ok(dark.includes("#1677FF")); const light = renderDashboardHtml(d, { theme: "light" }); // 蓝图工程图风(参考设计图):页面 #F8FAFC / 玫瑰红主色 #E11D48 / slate 文字 assert.ok(light.includes("#F8FAFC")); assert.ok(light.includes("#E11D48")); assert.ok(light.includes("#1E293B")); assert.ok(!light.includes("#0C1836")); // 缺省 = dark(向后兼容) const def = renderDashboardHtml(d); assert.ok(def.includes("#0C1836")); }); test("renderDashboardHtml:模型文本全部 HTML 转义(防注入/破版)", () => { const text = [ "```report-json", JSON.stringify({ title: '', seats: [{ id: "s1", name: "甲", summary: 'a"b', dependsOn: [] }], consensus: { headline: "", points: [] }, }), "```", ].join("\n"); const d = extractReportData(text)!; const html = renderDashboardHtml(d); assert.ok(!html.includes("bad")); assert.ok(html.includes("<script>bad()</script>")); }); test("stripReportFence:闭合块替换为引导语;未闭合(流式中)用「正在输出」措辞", () => { const done = stripReportFence(SAMPLE, false); assert.ok(done.includes("已生成大屏数据")); assert.ok(!done.includes("```report-json")); const streaming = stripReportFence('说明\n```report-json\n{"title":', true); assert.ok(streaming.includes("正在输出大屏结构化数据")); assert.ok(!streaming.includes("```report-json")); // 无 fence 原样返回 assert.equal(stripReportFence("普通文字", false), "普通文字"); }); test("buildDashboardPrompt:禁工具声明 + 原料齐全 + 契约说明", () => { const prompt = buildDashboardPrompt({ intentText: "选址决策", seatDeliveries: [{ sender: "情报收集", content: "三地数据" }], consensusText: "推荐 B 地块", }); assert.ok(prompt.includes("不要调用任何工具")); assert.ok(prompt.includes("选址决策")); assert.ok(prompt.includes("情报收集") && prompt.includes("三地数据")); assert.ok(prompt.includes("推荐 B 地块")); assert.ok(prompt.includes("```report-json")); assert.ok(prompt.includes("dependsOn")); });