import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import test from "node:test"; import React from "react"; import { toast } from "sonner"; import { ReportExportDrawer } from "../src/components/personal-report/report-export-drawer"; import { ReportActions } from "../src/components/personal-report/report-actions"; import { buildReportExportBlocks, exportByteSize, exportFilename, formatExportSize, joinExportBlocks } from "../src/lib/report-export-blocks"; import { assembleReportFactTables } from "../src/lib/report-fact-tables"; import { createClientLifecycleHarness } from "./react-client-lifecycle-test-support"; Object.assign(globalThis, { React }); const reader = 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 blocks = buildReportExportBlocks({ markdown: reader.markdown, factTables: assembleReportFactTables(engine) }); const editions = { zh: blocks }; const source = readFileSync(new URL("../src/components/personal-report/report-export-drawer.tsx", import.meta.url), "utf8"); const styles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8"); type Harness = ReturnType; const button = (h: Harness, text: string) => { const node = h.elements().find(node => node.tagName === "BUTTON" && node.text.startsWith(text)); assert.ok(node, text); return node; }; const checks = (h: Harness) => h.elements().filter(node => node.getAttribute("role") === "checkbox"); const radio = (h: Harness, text: string) => { const node = h.elements().find(node => node.getAttribute("role") === "radio" && node.text.startsWith(text)); assert.ok(node, text); return node; }; const exportButton = (h: Harness) => h.elements().filter(node => node.tagName === "BUTTON").find(node => /^导出|^先勾选/.test(node.text))!; // 2026-09-30 redesign (product: 「分块导出太丑,需要重新设计」, chose 「两步:整篇 / 自选」). // Test names are kept for suite continuity; the presets 常用 / 全部分盘 / 仅核对表 and // 全选 are retired. Each rewritten test states 原值 / 新值 / 原因 at its head. test("drawer mounts all golden blocks, presets replace selection and manual choices clear highlight", async () => { // 原值:打开即列出全部块(默认勾正文),套餐按钮替换选择,手动勾选清除套餐高亮 // 新值:打开是「整篇报告」一步导出(全部块);选「自选章节」才出现清单,正文默认全勾、分组复选框可整组开关,分盘/核对表默认收起 // 原因:2026-09-30 产品重设计;主语(能拿到全部块、能按组与逐块选择)不变 const h = createClientLifecycleHarness(); try { await h.render( {}} />); assert.deepEqual(h.errors, []); assert.ok(h.elements().find(node => node.tagName === "DIALOG")?.open); assert.equal(checks(h).length, 0, "no checklist until 自选章节"); assert.equal(radio(h, "整篇报告").getAttribute("aria-checked"), "true"); assert.equal(exportButton(h).text, `导出整篇 · ${formatExportSize(exportByteSize(blocks))}`); await h.event(radio(h, "自选章节")); const body = blocks.filter(block => block.group === "body"); // Group rows (3) + the expanded 正文 rows. assert.equal(checks(h).length, 3 + body.length); assert.equal(checks(h).filter(node => node.getAttribute("aria-checked") === "true").length, 1 + body.length); const chartGroup = checks(h).find(node => node.text.startsWith("分盘"))!; assert.equal(chartGroup.getAttribute("aria-checked"), "false"); await h.event(chartGroup); assert.equal(checks(h).find(node => node.text.startsWith("分盘"))!.getAttribute("aria-checked"), "true"); await h.event(checks(h).find(node => node.text.startsWith(body[0]!.title))!); assert.equal(checks(h).find(node => node.text.startsWith("正文"))!.getAttribute("aria-checked"), "mixed"); for (const group of ["正文", "分盘", "核对表"]) { const row = checks(h).find(node => node.text.startsWith(group))!; if (row.getAttribute("aria-checked") === "true") await h.event(row); if (checks(h).find(node => node.text.startsWith(group))!.getAttribute("aria-checked") === "mixed") { await h.event(checks(h).find(node => node.text.startsWith(group))!); await h.event(checks(h).find(node => node.text.startsWith(group))!); } } assert.equal(exportButton(h).text, "先勾选要导出的部分"); assert.equal(exportButton(h).disabled, true); } finally { await h.close(); } }); test("drawer download uses the existing Blob downloader exactly once and only then closes", async () => { // 原值:选「仅核对表」后按「导出选中 (8)」,toast「已导出 8 块」,文件名为节选 // 新值:自选里只开「核对表」组、关掉正文组后按「导出 8 项 · …」,toast「已导出」,文件名为节选;内容仍是这些块逐字节拼接 // 原因:2026-09-30 重设计去掉套餐 const h = createClientLifecycleHarness(); const oldCreate = URL.createObjectURL; const oldRevoke = URL.revokeObjectURL; let blob: Blob | undefined; let clicked = 0; let closed = 0; const successBefore = toast.success; const successes: string[] = []; toast.success = message => { assert.equal(closed, 1); successes.push(String(message)); return 1; }; let filename = ""; const document = globalThis.document; const originalCreate = document.createElement.bind(document); URL.createObjectURL = value => { blob = value as Blob; return "blob:test"; }; URL.revokeObjectURL = () => {}; Object.assign(document, { body: { append() {} } }); document.createElement = ((tag: string) => { if (tag !== "a") return originalCreate(tag); return { set download(value: string) { filename = value; }, click() { clicked += 1; }, remove() {} } as unknown as HTMLAnchorElement; }) as typeof document.createElement; try { await h.render( { closed += 1; }} />); await h.event(radio(h, "自选章节")); await h.event(checks(h).find(node => node.text.startsWith("正文"))!); await h.event(checks(h).find(node => node.text.startsWith("核对表"))!); const tables = blocks.filter(block => block.group === "table"); await h.event(button(h, `导出 ${tables.length} 项`)); assert.equal(clicked, 1); assert.equal(closed, 1); assert.deepEqual(successes, ["已导出"]); assert.equal(filename, exportFilename("2026-09-24", false) + ".md"); assert.equal(await blob?.text(), joinExportBlocks(tables)); } finally { toast.success = successBefore; URL.createObjectURL = oldCreate; URL.revokeObjectURL = oldRevoke; await h.close(); } }); test("download failure and WeChat restrictions leave drawer open without a success close", async () => { // 原值:按「导出选中 (正文块数)」;新值:按「导出整篇 · …」;原因:2026-09-30 重设计,失败与微信限制的行为断言不变 const h = createClientLifecycleHarness(); const oldCreate = URL.createObjectURL; const navigatorDescriptor = Object.getOwnPropertyDescriptor(globalThis, "navigator"); const successBefore = toast.success; const errorBefore = toast.error; let successes = 0; const errors: string[] = []; toast.success = () => { successes += 1; return 1; }; toast.error = (message) => { errors.push(String(message)); return 1; }; let closed = 0; try { await h.render( { closed += 1; }} />); URL.createObjectURL = () => { throw new Error("下载失败"); }; await h.event(button(h, "导出整篇")); // 原值: h.container.text 含「下载失败」/「系统浏览器」(抽屉底部 role="alert" 一行) // 新值: 两句都是 error toast;抽屉仍开着、没有成功提示(主语不变) // 原因: 2026-09-30 产品「报错和提示不要占页面布局」 assert.equal(errors.at(-1), "下载失败"); Object.defineProperty(globalThis, "navigator", { configurable: true, value: { userAgent: "MicroMessenger" } }); await h.event(button(h, "导出整篇")); assert.match(errors.at(-1) ?? "", /系统浏览器/); assert.equal(closed, 0); assert.equal(successes, 0); assert.ok(h.elements().find(node => node.tagName === "DIALOG")?.open); } finally { toast.success = successBefore; toast.error = errorBefore; URL.createObjectURL = oldCreate; if (navigatorDescriptor) Object.defineProperty(globalThis, "navigator", navigatorDescriptor); else Reflect.deleteProperty(globalThis, "navigator"); await h.close(); } }); test("reader actions keep only the accessible export icon", async () => { const h = createClientLifecycleHarness(); try { Object.assign(window, { location: { hash: "#export" }, history: { replaceState() { assert.fail("retired export hash must not be consumed"); } } }); await h.render(); assert.ok(!h.elements().some(node => node.tagName === "DIALOG"), "old export hash does not auto-open the dialog"); assert.equal(h.elements().filter(node => node.tagName === "BUTTON").length, 1); assert.ok(h.elements().some(node => node.tagName === "A" && node.getAttribute("href") === "/reports")); assert.deepEqual(h.errors, []); const exportButton = h.elements().find(node => node.tagName === "BUTTON" && node.getAttribute("aria-label") === "导出报告"); assert.ok(exportButton); // 原值:`title` 属性等于「导出」(只有图标的按钮靠原生提示说明用途) // 新值:按钮自己的可见文字就是「导出」,不再挂 title // 原因:TASK-report-reader-polish-20260929 §3-3——产品反馈图标按钮找不到,改为带文字的描边按钮 assert.equal(exportButton?.text, "导出"); assert.equal(exportButton?.getAttribute("title"), null); assert.ok(!h.container.text.includes("下载原始附录")); assert.ok(!h.container.text.includes("打印")); await h.event(exportButton!); assert.ok(h.elements().some(node => node.tagName === "DIALOG")); // 原值:按可见文字「关闭」找按钮;新值:按读屏名「关闭导出」找(2026-09-30 改成 × 图标,标题改「导出报告」) await h.event(h.elements().find(node => node.tagName === "BUTTON" && node.getAttribute("aria-label") === "关闭导出")!); await h.idle(); assert.ok(!h.elements().some(node => node.tagName === "DIALOG")); } finally { await h.close(); } }); test("mounted drawer handles Escape and backdrop but not inner clicks and restores focus", async () => { // 原值:内部点击目标是「常用」套餐按钮;新值:「整篇报告」单选;原因:套餐已删除 const h = createClientLifecycleHarness(); let closes = 0; const previous = document.createElement("button"); previous.focus(); try { await h.render( { closes += 1; }} />); const dialog = h.elements().find(node => node.tagName === "DIALOG")!; await h.event(dialog, "onClick", { target: radio(h, "整篇报告") }); assert.equal(closes, 0); await h.event(dialog, "onCancel"); assert.equal(closes, 1); await h.event(dialog); assert.equal(closes, 2); await h.render(null); assert.equal(document.activeElement, previous); } finally { await h.close(); } }); test("drawer contract uses native dialog, Escape/backdrop close and only allowed responsive chrome", () => { assert.match(source, / { const body = blocks.filter(block => block.group === "body"); const charts = blocks.filter(block => block.group === "chart"); assert.ok(charts.length > 0, "golden carries chart blocks"); // Exact bytes of what downloadMarkdownReport would receive. assert.equal(exportByteSize(body), new TextEncoder().encode(joinExportBlocks(body)).length); assert.equal(exportByteSize([]), 0); // Chinese is three bytes a character, so bytes run past the character count. const bodyChars = body.reduce((sum, block) => sum + block.chars, 0); assert.ok(exportByteSize(body) > bodyChars); assert.equal(formatExportSize(186 * 1024), "约 186 KB"); assert.equal(formatExportSize(300), "约 1 KB"); assert.equal(formatExportSize(Math.round(1.44 * 1024 * 1024)), "约 1.4 MB"); // 原值:统计行同时显示全篇与已选大小;新值:大小只在导出按钮上出现一次(整篇 = 全部块,自选 = 所选块) // 原因:2026-09-30 重设计,统计行删除 const h = createClientLifecycleHarness(); try { await h.render( {}} />); assert.equal(exportButton(h).text, `导出整篇 · ${formatExportSize(exportByteSize(blocks))}`); await h.event(radio(h, "自选章节")); assert.equal(exportButton(h).text, `导出 ${body.length} 项 · ${formatExportSize(exportByteSize(body))}`); } finally { await h.close(); } });