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>
159 lines
9.5 KiB
TypeScript
159 lines
9.5 KiB
TypeScript
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));
|
||
}
|
||
});
|