Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N4f2nya58RoRu4yEmJgRGE
237 lines
16 KiB
TypeScript
237 lines
16 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");
|
|
|
|
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("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(); }
|
|
});
|