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, exportFilename, 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 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 === text); assert.ok(node, text); return node; }; const checks = (h: Harness) => h.elements().filter(node => node.getAttribute("role") === "checkbox"); test("drawer mounts all golden blocks, presets replace selection and manual choices clear highlight", async () => { 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, blocks.length); assert.equal(checks(h).filter(node => node.getAttribute("aria-checked") === "true").length, blocks.filter(block => block.group === "body").length); await h.event(button(h, "全部分盘")); assert.equal(checks(h).filter(node => node.getAttribute("aria-checked") === "true").length, blocks.filter(block => block.group === "chart").length); await h.event(button(h, "仅核对表")); assert.equal(checks(h).filter(node => node.getAttribute("aria-checked") === "true").length, 8); await h.event(checks(h)[0]); assert.equal(button(h, "仅核对表").getAttribute("aria-pressed"), "false"); await h.event(button(h, "全选")); await h.event(button(h, "取消全选")); assert.equal(button(h, "导出选中 (0)").disabled, true); } finally { await h.close(); } }); test("drawer download uses the existing Blob downloader exactly once and only then closes", async () => { 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(button(h, "仅核对表")); await h.event(button(h, "导出选中 (8)")); assert.equal(clicked, 1); assert.equal(closed, 1); assert.deepEqual(successes, ["已导出 8 块"]); assert.equal(filename, exportFilename("2026-09-24", false) + ".md"); assert.equal(await blob?.text(), joinExportBlocks(blocks.filter(block => block.group === "table"))); } 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 () => { const h = createClientLifecycleHarness(); const oldCreate = URL.createObjectURL; const navigatorDescriptor = Object.getOwnPropertyDescriptor(globalThis, "navigator"); const successBefore = toast.success; let successes = 0; toast.success = () => { successes += 1; return 1; }; let closed = 0; try { await h.render( { closed += 1; }} />); URL.createObjectURL = () => { throw new Error("下载失败"); }; await h.event(button(h, `导出选中 (${blocks.filter(block => block.group === "body").length})`)); assert.ok(h.container.text.includes("下载失败")); Object.defineProperty(globalThis, "navigator", { configurable: true, value: { userAgent: "MicroMessenger" } }); await h.event(button(h, `导出选中 (${blocks.filter(block => block.group === "body").length})`)); assert.ok(h.container.text.includes("系统浏览器")); assert.equal(closed, 0); assert.equal(successes, 0); assert.ok(h.elements().find(node => node.tagName === "DIALOG")?.open); } finally { toast.success = successBefore; 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); assert.equal(exportButton?.getAttribute("title"), "导出"); 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")); await h.event(button(h, "关闭")); 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: button(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, /