Files
Jyotisha/frontend/tests/report-export-drawer.test.tsx
T
jesse-uxGrok (agent loop)Cursor
a2f65842e6 fix(report): toast a missing English edition and tint export selection
Opening the export sheet now says once, at the top, why English is unavailable. Selected borders, radios and checks use the theme color while fills stay neutral.

Co-Authored-By: Grok (agent loop)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-21 23:13:15 +08:00

285 lines
18 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, 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<typeof createClientLifecycleHarness>;
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(<ReportExportDrawer editions={editions} onClose={() => {}} />);
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(<ReportExportDrawer editions={editions} reportDate="2026-09-24" onClose={() => { 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(<ReportExportDrawer editions={editions} onClose={() => { 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(<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);
// 原值:`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(<ReportExportDrawer editions={editions} onClose={() => { 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, /<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/);
// 原值:窄屏抽屉 `max-height: 70vh` / `70dvh`
// 新值:`85vh` / `85dvh`
// 原因:2026-09-30 产品真机:手机上头部占掉大半,分块列表看不见;头部压缩后再给列表更多高度(桌面 80vh 不变)
assert.match(narrow, /max-height: 85vh/);
assert.match(narrow, /@supports \(height: 1dvh\)\s*\{\s*\.report-export-drawer, \.report-export-panel \{ max-height: 85dvh;/);
const desktopStart = css.indexOf("@media (min-width: 860px)");
const desktop = css.slice(desktopStart);
// 原值:桌面 `width: min(720px, 92vw)`;新值:`min(520px, 92vw)`;原因:2026-09-30 重设计后是单列的紧凑面板,720 宽只剩空白
assert.match(desktop, /\.report-export-drawer\s*\{\s*inset: 0;\s*width: min\(520px, 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\); \}/);
// 原值 70(d)vh → 新值 85(d)vh:窄屏上限改为 85 后,桌面仍不得继承窄屏上限(原因同上)
assert.doesNotMatch(desktop, /max-height: 85(?:d)?vh|inset:.*--report-export-sidebar/);
// 原值:`.report-export-list` 负责滚动、头尾 `flex-shrink: 0`
// 新值:滚动区是 `.report-export-body`(语言、范围与清单在同一滚动区),头尾仍 `flex-shrink: 0`
// 原因:2026-09-30 重设计,清单只在「自选」时出现
assert.match(css, /\.report-export-body \{[^}]*min-height: 0;[^}]*overflow-y: auto;/);
assert.match(css, /\.report-export-header \{[^}]*flex-shrink: 0;/);
assert.match(css, /\.report-export-footer \{[^}]*flex-shrink: 0;/);
});
test("an unavailable English edition is one info toast per open, not a caption", async () => {
const legacy = "这份报告生成于英文版上线前,重新生成即可获得中英两版。";
const failed = "这份报告的英文版没有生成成功,只能导出中文版。";
const infos: { message: string; id?: string | number }[] = [];
const previous = toast.info;
const h = createClientLifecycleHarness();
toast.info = (message, options) => {
assert.equal(h.elements().find(node => node.tagName === "DIALOG")?.open, true, "notify after showModal");
infos.push({ message: String(message), id: options?.id });
return infos.length;
};
try {
await h.render(<ReportExportDrawer editions={editions} onClose={() => {}} 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(radio(h, "自选章节"));
const chapter = blocks.find(block => block.group === "body")!;
await h.event(checks(h).find(node => node.text.startsWith(chapter.title))!);
await h.event(checks(h).find(node => node.text.startsWith(chapter.title))!);
const expand = h.elements().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(<ReportExportDrawer editions={editions} onClose={() => {}} 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(<ReportExportDrawer editions={editions} onClose={() => {}} 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(<ReportExportDrawer editions={{ zh: blocks, en: blocks }} onClose={() => {}} englishEdition="ready" />);
assert.equal(infos.length, 3, "a ready English edition does not notify");
assert.ok(h.container.text.includes("建议选 English"));
} 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");
// 原值:统计行同时显示全篇与已选大小;新值:大小只在导出按钮上出现一次(整篇 = 全部块,自选 = 所选块)
// 原因:2026-09-30 重设计,统计行删除
const h = createClientLifecycleHarness();
try {
await h.render(<ReportExportDrawer editions={editions} onClose={() => {}} />);
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(); }
});