Files
Jyotisha/frontend/tests/report-export-blocks.test.ts
T

124 lines
7.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
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("LF golden ordinary export keeps its frozen output hash", () => {
const output = ordinaryReportDownloadMarkdown(fixture.markdown);
assert.equal(createHash("sha256").update(output).digest("hex"), "3c52e8bfcfcb3ce3852bd5323867a69bc47d0f9aa821a34692172e7d61c6bcb8");
});
test("CRLF and mixed-newline golden exports remove every JSON chart fence", () => {
const variants = [
fixture.markdown.replace(/\n/g, "\r\n"),
fixture.markdown.split("\n").map((line: string, index: number) => index % 2 ? `${line}\r` : line).join("\n"),
];
for (const markdown of variants) {
const output = ordinaryReportDownloadMarkdown(markdown);
assert.equal((output.match(/```jyotish-chart/g) ?? []).length, 0);
assert.equal((output.match(/<svg\b/gi) ?? []).length, 22);
assert.deepEqual(ordinaryOutputLeaks(output), []);
}
});
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));
}
});