Add bounded chart loading, per-layer retry states, and shared SVG skeletons. Add report block downloads with SVG, localized metadata, and inline deletion. Record verification and retain CRLF export, full-build, and controlled-device acceptance blockers. User authorized staging delivery with these gaps documented. Co-Authored-By: Claude Code <noreply@anthropic.com>
105 lines
6.6 KiB
TypeScript
105 lines
6.6 KiB
TypeScript
import assert from "node:assert/strict";
|
||
import { readFileSync } from "node:fs";
|
||
import test from "node:test";
|
||
import { buildReportExportBlocks, chartBlockText, exportFilename, factTableToMarkdown, joinExportBlocks, presetSelection } from "../src/lib/report-export-blocks";
|
||
import { ordinaryReportDownloadMarkdown } from "../src/lib/personal-report-longform-download";
|
||
import { buildLongformOutline } from "../src/lib/personal-report-longform-outline";
|
||
import { ordinaryOutputLeaks, projectOrdinaryReportMarkdown } from "../src/lib/report-public-projection";
|
||
import { assembleReportFactTables } from "../src/lib/report-fact-tables";
|
||
import { assembleLongformCharts } from "../src/lib/personal-report-generation";
|
||
import { parseReportChartBlock, toNorthIndianChart } from "../src/lib/report-chart-block";
|
||
|
||
const fixture = JSON.parse(readFileSync(new URL("./fixtures/report-density-fictional-reader.json", import.meta.url), "utf8"));
|
||
const engine = JSON.parse(readFileSync(new URL("./fixtures/report-density-fictional-engine.json", import.meta.url), "utf8"));
|
||
const factTables = assembleReportFactTables(engine);
|
||
const blocks = buildReportExportBlocks({ markdown: fixture.markdown, factTables });
|
||
const body = blocks.filter(block => block.group === "body");
|
||
|
||
test("export golden uses the same outline order plus 22 charts and eight fact groups", () => {
|
||
const outline = buildLongformOutline(projectOrdinaryReportMarkdown(fixture.markdown));
|
||
assert.deepEqual(body.map(block => block.id), [...(outline.sourcePreamble ? ["body:lead"] : []), ...outline.sections.map(section => `body:${section.id}`)]);
|
||
assert.equal(blocks.filter(block => block.group === "chart").length, 22);
|
||
assert.equal(blocks.filter(block => block.group === "table").length, 8);
|
||
assert.equal(blocks.length, outline.sections.length + (outline.sourcePreamble ? 1 : 0) + 30);
|
||
});
|
||
test("every golden export block has safe SVG but no chart fence or ordinary output leak", () => {
|
||
for (const block of blocks) {
|
||
assert.doesNotMatch(block.text, /```jyotish-chart|<script|onload=|javascript:/i, block.id);
|
||
if (block.group === "chart") assert.match(block.text, /<svg\b/);
|
||
assert.deepEqual(ordinaryOutputLeaks(block.text), [], block.id);
|
||
}
|
||
});
|
||
test("each golden chart preserves its corresponding engine SVG including the repeated D9", () => {
|
||
const projected = projectOrdinaryReportMarkdown(fixture.markdown);
|
||
const svgs = [...projected.matchAll(/<svg\b[^>]*>[\s\S]*?<\/svg>/g)].map(match => match[0]);
|
||
const chartBlocks = blocks.filter(block => block.group === "chart");
|
||
assert.equal(svgs.length, 22);
|
||
chartBlocks.forEach((block, index) => assert.ok(block.text.endsWith(svgs[index]), block.id));
|
||
assert.equal(new Set(chartBlocks.map(block => block.id)).size, 22);
|
||
});
|
||
test("fence-only and structured chart fallback reuse safe offline SVG without server rendering", () => {
|
||
const fence = /```jyotish-chart\n([\s\S]*?)```/.exec(fixture.markdown)!;
|
||
const fromFence = buildReportExportBlocks({ markdown: fence[0] }).filter(block => block.group === "chart");
|
||
const structured = buildReportExportBlocks({ markdown: "", charts: assembleLongformCharts(engine, "ev-cover-longform").slice(0, 1) }).filter(block => block.group === "chart");
|
||
for (const block of [...fromFence, ...structured]) {
|
||
assert.match(block.text, /<svg\b/); assert.match(block.text, /stroke="currentColor"/);
|
||
assert.doesNotMatch(block.text, /<clipPath|class=|```jyotish-chart/);
|
||
assert.deepEqual(ordinaryOutputLeaks(block.text), []);
|
||
}
|
||
assert.equal(fromFence.length, 1); assert.equal(structured.length, 1);
|
||
});
|
||
test("export counts actual Unicode characters, not bytes or estimates", () => {
|
||
for (const block of blocks) assert.equal(block.chars, Array.from(block.text).length, block.id);
|
||
});
|
||
test("full golden body is byte identical to the retired ordinary export", () => {
|
||
assert.equal(joinExportBlocks(body), ordinaryReportDownloadMarkdown(fixture.markdown));
|
||
});
|
||
test("all-group export adds charts and tables after unchanged ordinary body", () => {
|
||
assert.ok(joinExportBlocks(blocks).startsWith(ordinaryReportDownloadMarkdown(fixture.markdown) + "\n\n####"));
|
||
assert.notEqual(joinExportBlocks(blocks), joinExportBlocks(body));
|
||
});
|
||
test("presets replace with exactly one group each", () => {
|
||
for (const [preset, group] of [["common", "body"], ["charts", "chart"], ["tables", "table"]] as const) {
|
||
assert.deepEqual([...presetSelection(preset, blocks)], blocks.filter(block => block.group === group).map(block => block.id));
|
||
}
|
||
});
|
||
test("filenames reuse old extensionless stem and add excerpt suffix only when partial", () => {
|
||
assert.equal(exportFilename("2026-09-24T12:00:00Z", true), "个人报告-2026-09-24");
|
||
assert.equal(exportFilename("2026-09-24", false), "个人报告-2026-09-24-节选");
|
||
});
|
||
test("chart text matches all real golden houses and their occupants", () => {
|
||
const match = /```jyotish-chart\n([\s\S]*?)```/.exec(fixture.markdown)!;
|
||
const chart = parseReportChartBlock(match[1])!;
|
||
const text = chartBlockText(chart);
|
||
for (const house of toNorthIndianChart(chart).houses) {
|
||
assert.ok(text.includes(`第 ${house.houseNumber} 宫 · ${house.sign}:${house.occupants.join("、") || "—"}`));
|
||
}
|
||
});
|
||
test("fact group exports every visible subtable with original precision and no source paths", () => {
|
||
for (const table of factTables) {
|
||
const text = factTableToMarkdown(table);
|
||
for (const grid of [table, ...(table.subtables ?? [])]) {
|
||
assert.ok(text.includes(grid.title));
|
||
if (grid.rows.length) assert.ok(text.includes(`| ${grid.columns.join(" | ")} |`));
|
||
assert.ok(!text.includes(grid.sourcePath));
|
||
for (const row of grid.rows) for (const cell of row.cells) if (typeof cell === "string") assert.ok(text.includes(cell.replace(/\|/g, "\\|")));
|
||
}
|
||
}
|
||
});
|
||
test("lossless outline slices keep D5 for lead, H3 summary, eager sections, CRLF and trailing whitespace", () => {
|
||
const chartFence = /```jyotish-chart\n[\s\S]*?```/.exec(fixture.markdown)![0];
|
||
const variants = [
|
||
"plain prose with no H2\n ",
|
||
"# 标题\n\n开头\n\n## 事业\n\n正文\n",
|
||
"# 标题\n\n## 事业\n\n之前\n\n### 摘要\n\n摘要正文\n\n### 建议\n\n之后\n\n## 摘要\n\n整章\n",
|
||
"## 摘要\n\n摘要正文\n\n## 事业\n\n末尾 \n\n",
|
||
`# 标题\n\n## 盘面\n\n#### D1\n\n${chartFence}\n\n## 事业\n\n正文\n`,
|
||
"## 成品阅读导航\n\n原文\n\n## 质量验收矩阵\n\n内部\n\n## 事业\n\n正文",
|
||
"\n\n", "",
|
||
];
|
||
for (const input of variants.flatMap(text => [text, text.replace(/\n/g, "\r\n")])) {
|
||
const selected = buildReportExportBlocks({ markdown: input }).filter(block => block.group === "body");
|
||
assert.equal(joinExportBlocks(selected), ordinaryReportDownloadMarkdown(input));
|
||
}
|
||
});
|