Files
Jyotisha/frontend/tests/report-export-blocks.test.ts
T
jesse-uxandClaude Code 4d801e53c3
Independent Staging Quality Gate / validate (push) Successful in 16m28s
Independent Staging Quality Gate / publish (push) Successful in 3m33s
fix: integrate people archive, report reader and western chart corrections
Validate on Linux Node 22 and PostgreSQL 17: 3894 frontend tests and 64 database tests pass, with no removed test names or new failures. Preserve static Home, bounded gzip, assertion-change records and manual acceptance gaps.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-25 20:41:16 +08:00

159 lines
9.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);
// 原值 3c52e8bfcfcb3ce3852bd5323867a69bc47d0f9aa821a34692172e7d61c6bcb8
// 新值 b7f6947f6ae6f82e4a972be3c1f2bc50260ed0c5f029847264f40a5ee6ba7b88
// 原因:原 sha 锁的是同一份泄漏正文的普通下载。BUG-1026 起内部词整行删除,不再截成半句。
// 原值 b7f6947f6ae6f82e4a972be3c1f2bc50260ed0c5f029847264f40a5ee6ba7b88
// 新值 4295165c4ed6e13b03e8c3c25fd54ad3ef5a2ca1732e9f0783d0ada0c2bbfef2
// 原因:BUG-1026 修复单 D3 恢复标题,普通下载跟随同一投影;围栏删除与安全 SVG 合同未改。
assert.equal(createHash("sha256").update(output).digest("hex"), "4295165c4ed6e13b03e8c3c25fd54ad3ef5a2ca1732e9f0783d0ada0c2bbfef2");
});
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), []);
const selected = buildReportExportBlocks({ markdown });
const bodyOutput = joinExportBlocks(selected.filter(block => block.group === "body"));
assert.equal(bodyOutput, output);
assert.equal(selected.filter(block => block.group === "chart").length, 22);
const projected = projectOrdinaryReportMarkdown(markdown);
assert.deepEqual(
[...output.matchAll(/<svg\b[^>]*>[\s\S]*?<\/svg>/g)].map(match => match[0]),
[...projected.matchAll(/<svg\b[^>]*>[\s\S]*?<\/svg>/g)].map(match => match[0]),
);
// Independent line scanner: only fence lines and their adjacent boundary
// whitespace may change; the retired stripper's LF blank-run rule remains.
const lines = projected.match(/[^\n]*\n|[^\n]+$/g) ?? [];
let expected = "";
let insideFence = false;
for (let index = 0; index < lines.length; index += 1) {
const line = lines[index];
if (!insideFence && line.trim() === "```jyotish-chart") {
expected = expected.replace(/\r?\n[ \t]*$/, "");
insideFence = true;
} else if (insideFence && line.trim() === "```") {
expected += "\n\n";
if (index + 1 < lines.length) lines[index + 1] = lines[index + 1].replace(/^[ \t]*/, "");
insideFence = false;
} else if (!insideFence) {
expected += line;
}
}
assert.equal(insideFence, false);
assert.equal(output, expected.replace(/\n{3,}/g, "\n\n"), "D14 permits no other prose or newline changes");
}
});
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));
}
});