Files
Jyotisha/frontend/tests/report-export-drawer.test.tsx
T
Jesse_ChenandClaude Opus 5.5 de9d4826c1 #111 第 3 轮:建档不退回加载、右栏能拖宽、清单不迟到、月亮问句与别人的上升句(BUG-1455~1459)
- 建档重读人物名单时保留旧名单,失败也不卡在加载画面(BUG-1455)
- 对话右栏改用自己的拖宽把手,侧栏与右栏都能用方向键(BUG-1456/1457)
- 启程清单胶囊和首页一起出来,读状态时用上次的样子占位(BUG-1458)
- 起步第一问与别人的天空过场读一次已算好的星盘取星座,上升只在不会换座时用(BUG-1459,产品 10-27 同意)
- 补回删减的断言与三栏说明;登录代码改回原样,替身放进测试;删旧校正画面的无用样式
- 首屏 +7.54% 产品已接受,写进 DESIGN

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LutgzyJqNhPpZy1nFsJtN3
2026-10-27 10:24:57 +08:00

383 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<typeof createClientLifecycleHarness>;
type Node = ReturnType<Harness["elements"]>[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(<ReportExportDrawer editions={editions} onClose={() => {}} />);
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(<ReportExportDrawer editions={editions} reportDate="2026-09-24" onClose={() => { 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(<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.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(<ReportActions markdown={reader.markdown} />);
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<string, unknown>) => 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<object, DomListener[]>();
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<string, unknown>, 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(<ReportExportDrawer editions={editions} onClose={() => { 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", () => {
// 原值:原生 `<dialog>`、onCancel、点遮罩 target === currentTarget;窄屏 bottom inset 与桌面居中 min(520px)
// 新值:`<Sheet`,onOpenChange 在 open 为 false 时 onClose;桌面 data-side=right 宽 min(520px, 92vw),窄屏 data-side=bottom 最高 85dvh
// 原因:K5 导出从右侧滑出,手机从底部滑出。滚动仍在正文,头尾不滚走
assert.match(source, /<Sheet open onOpenChange=\{\(open\) => \{ if \(!open\) onClose\(\); \}\}/);
assert.match(source, /side=\{side\}/);
assert.match(source, /max-width: 767px/);
assert.doesNotMatch(source, /<dialog|onCancel=|InlineSpinner|navigator\.clipboard|fetch\(/);
const css = styles.slice(styles.indexOf(".report-export-drawer {"), styles.indexOf("personal-report: unique block"));
assert.doesNotMatch(css, /var\(--report-(?:paper|rule|accent)\)/);
assert.match(css, /min-height: 44px/);
// 原值:窄屏在 @media 之前用 bottom inset、85vh 和 85dvh;桌面 @media (min-width: 860px) 居中,宽 min(520px, 92vw),高 80vh
// 新值:窄屏是 data-side=bottom,高 85dvh,上圆角;桌面是 data-side=right,宽 min(520px, 92vw),最宽 520px
// 原因:K5 用 Sheet 的 side 区分桌面和手机,不再用原生 dialog 的媒体查询。vh 和 dvh 不再写在同一条规则里
assert.match(css, /\.report-export-drawer\[data-side="right"\] \{[^}]*width: min\(520px, 92vw\);/);
assert.match(css, /\.report-export-drawer\[data-side="right"\] \{[^}]*max-width: 520px;/);
assert.match(css, /\.report-export-drawer\[data-side="bottom"\] \{[^}]*max-height: 85dvh;/);
assert.match(css, /\.report-export-drawer\[data-side="bottom"\] \{[^}]*border-radius: var\(--radius-lg\) var\(--radius-lg\) 0 0;/);
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(sheetOpen(h), true, "notify after the sheet is open");
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(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(<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");
// 原值:英文可用时抽屉正文含「建议选 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(<ReportExportDrawer editions={editions} onClose={() => {}} />);
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(); }
});