Files
Jyotisha/frontend/tests/report-export-drawer.test.tsx
T
jesse-uxandClaude Code f125fae0a0
Independent Staging Quality Gate / validate (push) Failing after 11m16s
Independent Staging Quality Gate / publish (push) Skipped
feat(reports): streamline reader actions and center export dialog
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-24 20:43:35 +08:00

161 lines
9.5 KiB
TypeScript

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<typeof createClientLifecycleHarness>;
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(<ReportExportDrawer blocks={blocks} onClose={() => {}} />);
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(<ReportExportDrawer blocks={blocks} reportDate="2026-09-24" onClose={() => { 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(<ReportExportDrawer blocks={blocks} onClose={() => { 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(<ReportActions markdown={reader.markdown} />);
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(<ReportExportDrawer blocks={blocks} onClose={() => { 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, /<dialog/);
assert.match(source, /onCancel=/);
assert.match(source, /event.target === event.currentTarget/);
assert.doesNotMatch(source, /InlineSpinner|navigator\.clipboard|fetch\(/);
const css = styles.slice(styles.indexOf(".report-export-drawer {"), styles.indexOf("personal-report: unique block"));
assert.match(css, /min-width: 860px/);
assert.doesNotMatch(css, /var\(--report-(?:paper|rule|accent)\)/);
assert.match(css, /min-height: 44px/);
const narrow = css.slice(0, css.indexOf("@media (min-width: 860px)"));
assert.match(narrow, /inset: auto 0 0 var\(--report-export-sidebar\)/);
assert.match(narrow, /width: min\(760px, calc\(\(100% - var\(--report-export-sidebar\)\) \* \.92\)\)/);
assert.match(narrow, /margin: 0 auto/);
assert.match(narrow, /border-radius: var\(--radius-lg\) var\(--radius-lg\) 0 0/);
assert.match(narrow, /max-height: 70vh/);
assert.match(narrow, /@supports \(height: 1dvh\)\s*\{\s*\.report-export-drawer, \.report-export-panel \{ max-height: 70dvh;/);
const desktopStart = css.indexOf("@media (min-width: 860px)");
const desktop = css.slice(desktopStart);
assert.match(desktop, /\.report-export-drawer\s*\{\s*inset: 0;\s*width: min\(720px, 92vw\);\s*max-height: 80vh;\s*margin: auto;\s*border-radius: var\(--radius-lg\);/);
assert.match(desktop, /\.report-export-panel \{ max-height: calc\(80vh - 2px\); \}/);
assert.doesNotMatch(desktop, /max-height: 70(?:d)?vh|inset:.*--report-export-sidebar/);
assert.match(css, /\.report-export-list \{[^}]*overflow-y: auto;[^}]*min-height: 0;/);
assert.match(css, /\.report-export-header, \.report-export-footer \{[^}]*flex-shrink: 0;/);
});