import "./install-test-document"; 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"); // Opening a sheet without English notifies. Keep the real toaster out of these mounts. toast.info = () => 1; type Harness = ReturnType; type Node = ReturnType[number]; // Sheet, tabs and menus portal onto document.body. The harness container walk misses them. function allNodes(h: Harness): Node[] { const walk = (node: Node): Node[] => { const children = node?.childNodes ?? []; return [node, ...[...children].flatMap((child) => walk(child as Node))]; }; return [...walk(h.container as unknown as Node), ...[...(h.document.body.childNodes ?? [])].flatMap((child) => walk(child as Node))]; } const button = (h: Harness, text: string) => { const node = allNodes(h).find(node => node.tagName === "BUTTON" && node.text.startsWith(text)); assert.ok(node, text); return node; }; const checks = (h: Harness) => allNodes(h).filter(node => node.getAttribute("role") === "checkbox"); const namedCheck = (h: Harness, label: string) => { const node = checks(h).find(node => (node.getAttribute("aria-label") ?? "").startsWith(label)); assert.ok(node, label); return node; }; // 原值:单选是 role=radio + aria-checked // 新值:分段页签 role=tab。选中看 aria-selected // 原因:K5 / D9,导出语言和导出范围改用和星盘页盘型条一样的 Tabs const tab = (h: Harness, text: string) => { const node = allNodes(h).find(node => node.getAttribute("role") === "tab" && node.text.startsWith(text)); assert.ok(node, text); return node; }; const exportButton = (h: Harness) => allNodes(h).filter(node => node.tagName === "BUTTON").find(node => /^导出|^先勾选/.test(node.text))!; const sheetOpen = (h: Harness) => allNodes(h).some(node => node.getAttribute("role") === "dialog" || node.getAttribute("data-slot") === "sheet-content"); const sheetText = (h: Harness) => `${h.container.text}\n${h.document.body.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, []); // 原值:原生 DIALOG 的 open 为真,按钮写「导出整篇 · 约 N KB」 // 新值:Sheet 在文档里;按钮只写「导出整篇」,不带文件大小 // 原因:K5 导出改用 Sheet,D8 去掉文件大小 assert.equal(sheetOpen(h), true); assert.equal(checks(h).length, 0, "no checklist until 自选章节"); assert.equal(tab(h, "整篇报告").getAttribute("aria-selected"), "true"); assert.equal(exportButton(h).text, "导出整篇"); assert.doesNotMatch(exportButton(h).text, /KB|MB|·/); await h.event(tab(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 = namedCheck(h, "分盘"); assert.equal(chartGroup.getAttribute("aria-checked"), "false"); await h.event(chartGroup); assert.equal(namedCheck(h, "分盘").getAttribute("aria-checked"), "true"); await h.event(namedCheck(h, body[0]!.title)); assert.equal(namedCheck(h, "正文").getAttribute("aria-checked"), "mixed"); for (const group of ["正文", "分盘", "核对表"]) { const row = namedCheck(h, group); if (row.getAttribute("aria-checked") === "true") await h.event(row); if (namedCheck(h, group).getAttribute("aria-checked") === "mixed") { await h.event(namedCheck(h, group)); await h.event(namedCheck(h, 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 = () => {}; // 原值:把 document.body 换成只有 append 的空对象,挡住原生下载 // 新值:留下测试文档的 body。锚点仍是假的,body.append 对它不做事 // 原因:K5 的 Sheet 要挂到 body 上。换掉 body 后传送门和下载都看不见 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(tab(h, "自选章节")); await h.event(namedCheck(h, "正文")); await h.event(namedCheck(h, "核对表")); 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 () => { // 原值:按「导出整篇 · …」;新值:按「导出整篇」;原因:K5 / D8 去掉文件大小。失败与微信限制的行为不变 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.equal(sheetOpen(h), true); } 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.equal(sheetOpen(h), false, "old export hash does not auto-open the sheet"); 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.equal(sheetOpen(h), true); // 原值:按可见文字「关闭」找按钮;新值:按读屏名「关闭导出」找(2026-09-30 改成 × 图标,标题改「导出报告」) await h.event(allNodes(h).find(node => node.tagName === "BUTTON" && node.getAttribute("aria-label") === "关闭导出")!); await h.idle(); assert.equal(sheetOpen(h), false); } finally { await h.close(); } }); type DomListener = { type: string; fn: (event: Record) => void; capture: boolean }; function captureFlag(options: unknown): boolean { return options === true || (typeof options === "object" && options !== null && Boolean((options as { capture?: boolean }).capture)); } function trackDocumentListeners(target: object): DomListener[] { const bag: DomListener[] = []; const host = target as { addEventListener: (type: string, fn: DomListener["fn"], options?: unknown) => void; removeEventListener: (type: string, fn: DomListener["fn"]) => void; }; host.addEventListener = (type, fn, options) => { bag.push({ type, fn, capture: captureFlag(options) }); }; host.removeEventListener = (type, fn) => { const index = bag.findIndex((entry) => entry.type === type && entry.fn === fn); if (index >= 0) bag.splice(index, 1); }; return bag; } type DomListenerHandle = (type: string, fn: DomListener["fn"], options?: unknown) => void; function trackElementListeners(proto: { addEventListener: DomListenerHandle; removeEventListener: DomListenerHandle }) { const bags = new WeakMap(); const originalAdd = proto.addEventListener; const originalRemove = proto.removeEventListener; proto.addEventListener = function (this: object, type: string, fn: DomListener["fn"], options?: unknown) { const bag = bags.get(this) ?? []; if (!bags.has(this)) bags.set(this, bag); bag.push({ type, fn, capture: captureFlag(options) }); }; proto.removeEventListener = function (this: object, type: string, fn: DomListener["fn"]) { const bag = bags.get(this); if (!bag) return; const index = bag.findIndex((entry) => entry.type === type && entry.fn === fn); if (index >= 0) bag.splice(index, 1); }; return { bag(target: object) { return bags.get(target) ?? []; }, restore() { proto.addEventListener = originalAdd; proto.removeEventListener = originalRemove; }, }; } function fireListeners(bag: readonly DomListener[], type: string, event: Record, capture: boolean) { for (const entry of bag.filter((item) => item.type === type && item.capture === capture)) entry.fn(event); } test("mounted drawer closes from Escape and the overlay, and restores focus", async () => { // 原值:原生 dialog 的 onCancel(Esc)和点遮罩(target === currentTarget)各关一次;内部点「整篇报告」不关闭 // 新值:点「整篇报告」不关闭;按 Esc 关一次;点遮罩再关一次;卸载后焦点回到打开前的按钮 // 原因:K5 导出改用 Sheet。Esc 走浮层自己的按键处理,点遮罩走文档上的捕获监听,不再用源码正则代替 const h = createClientLifecycleHarness(); let closes = 0; const previous = document.createElement("button"); previous.focus(); const documentListeners = trackDocumentListeners(h.document); const elementListeners = trackElementListeners(Object.getPrototypeOf(h.document.body) as { addEventListener: DomListenerHandle; removeEventListener: DomListenerHandle }); try { await h.render( { closes += 1; }} />); await h.event(tab(h, "整篇报告")); assert.equal(closes, 0); await h.idle(); const sheet = allNodes(h).find(node => node.getAttribute("data-slot") === "sheet-content"); assert.ok(sheet); await h.event(sheet, "onKeyDown", { key: "Escape" }); assert.equal(closes, 1); const overlay = allNodes(h).find(node => node.getAttribute("data-slot") === "sheet-overlay"); assert.ok(overlay); const click = { type: "click", target: overlay, currentTarget: h.document, button: 0, composedPath: () => [overlay], preventDefault() {}, stopPropagation() {}, }; await h.update(() => { fireListeners(documentListeners, "click", click, true); fireListeners(elementListeners.bag(overlay), "click", click, false); }); assert.equal(closes, 2); // 原值(365a623fc):删掉了点「关闭导出」关一次的断言 // 新值:补回,点「关闭导出」再关一次 // 原因:按钮还在页面上,是用户最常用的关法;只测 Esc 和遮罩会漏掉它(第 2 轮验收第 4 条) await h.event(allNodes(h).find(node => node.getAttribute("aria-label") === "关闭导出")!); assert.equal(closes, 3); await h.render(null); assert.equal(document.activeElement, previous); } finally { elementListeners.restore(); await h.close(); } }); test("drawer contract uses a sheet from the right or the bottom, and the list still scrolls", () => { // 原值:原生 ``、onCancel、点遮罩 target === currentTarget;窄屏 bottom inset 与桌面居中 min(520px) // 新值:` \{ if \(!open\) onClose\(\); \}\}/); assert.match(source, /side=\{side\}/); assert.match(source, /max-width: 767px/); assert.doesNotMatch(source, / { const legacy = "这份报告生成于英文版上线前,重新生成即可获得中英两版。"; const failed = "这份报告的英文版没有生成成功,只能导出中文版。"; const infos: { message: string; id?: string | number }[] = []; const previous = toast.info; const h = createClientLifecycleHarness(); toast.info = (message, options) => { assert.equal(sheetOpen(h), true, "notify after the sheet is open"); infos.push({ message: String(message), id: options?.id }); return infos.length; }; try { await h.render( {}} englishEdition="legacy" />); assert.deepEqual(infos, [{ message: legacy, id: "report-export-english" }]); assert.equal(h.container.text.includes(legacy), false); assert.equal(h.container.text.includes(failed), false); await h.event(tab(h, "自选章节")); const chapter = blocks.find(block => block.group === "body")!; await h.event(namedCheck(h, chapter.title)); await h.event(namedCheck(h, chapter.title)); const expand = allNodes(h).find(node => node.getAttribute("aria-label") === "展开分盘"); assert.ok(expand, "展开分盘"); await h.event(expand); assert.equal(infos.length, 1, "picking chapters does not notify again"); await h.render(null); await h.render( {}} englishEdition="legacy" />); assert.equal(infos.length, 2, "opening again notifies once more"); assert.equal(infos[1]?.id, "report-export-english"); await h.render(null); await h.render( {}} englishEdition="unavailable" />); assert.equal(infos.length, 3); assert.deepEqual(infos[2], { message: failed, id: "report-export-english" }); assert.equal(h.container.text.includes(failed), false); assert.equal(h.container.text.includes(legacy), false); await h.render(null); await h.render( {}} englishEdition="ready" />); assert.equal(infos.length, 3, "a ready English edition does not notify"); // 原值:英文可用时抽屉正文含「建议选 English」 // 新值:没有这句说明 // 原因:K5 / D8 删掉说明句 assert.equal(sheetText(h).includes("建议选 English"), false); } finally { toast.info = previous; await h.close(); } }); test("the drawer shows the real download size, not a character count (BUG-1094)", async () => { 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"); // 原值:大小只在导出按钮上出现一次(整篇 = 全部块,自选 = 所选块) // 新值:按钮只写「导出整篇」或「导出 N 项」,页面上看不到「约 N KB」 // 原因:K5 / D8 去掉文件大小。字节计算仍由上面的断言锁住 const h = createClientLifecycleHarness(); try { await h.render( {}} />); assert.equal(exportButton(h).text, "导出整篇"); assert.doesNotMatch(sheetText(h), /约 \d| KB| MB/); await h.event(tab(h, "自选章节")); assert.equal(exportButton(h).text, `导出 ${body.length} 项`); assert.doesNotMatch(sheetText(h), /约 \d| KB| MB/); } finally { await h.close(); } });