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| { const projected = projectOrdinaryReportMarkdown(fixture.markdown); const svgs = [...projected.matchAll(/]*>[\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, / { 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(/ 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(/]*>[\s\S]*?<\/svg>/g)].map(match => match[0]), [...projected.matchAll(/]*>[\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)); } });