报告语言改成分段页签,切换后不留下上一种语言的正文。导出从右侧或底部滑出,不再显示文件大小和说明句。星历用箭头按钮按方向翻页,卡片边框不被裁掉。登录的输入、按钮和六位验证码换成组件库。星星汇聚不改。 自测:tsc 退出码 0;lint 0 error、122 个警告;相关测试 185 条里 184 通过,另 1 条是既有的 Windows 管理路径问题;英文版 10 条和删除 5 条重跑通过;build 后首页仍是 Static;首页 JS gzip-9 为 642174,相对 639609 多 0.40%。隐私标记测试通过。 Co-Authored-By: Grok (agent loop)
138 lines
9.0 KiB
TypeScript
138 lines
9.0 KiB
TypeScript
import "./install-test-document";
|
||
import assert from "node:assert/strict";
|
||
import test from "node:test";
|
||
import { toast } from "sonner";
|
||
import React from "react";
|
||
import { AppRouterContext, type AppRouterInstance } from "next/dist/shared/lib/app-router-context.shared-runtime";
|
||
import { SidebarProvider } from "../src/components/ui/sidebar";
|
||
import { ReportRowDelete } from "../src/components/personal-report/report-row-delete";
|
||
import { PersonalReportCenter, rowMeta } from "../src/components/personal-report/personal-report-center";
|
||
import { readReports, invalidateReportsPage, peekReportsPage, refreshReportsPage, writeReportsPage, type ReportListItem } from "../src/lib/secondary-page-data";
|
||
import { createClientLifecycleHarness } from "./react-client-lifecycle-test-support";
|
||
|
||
Object.assign(globalThis, { React });
|
||
type Harness = ReturnType<typeof createClientLifecycleHarness>;
|
||
type Node = ReturnType<Harness["elements"]>[number];
|
||
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 textOf = (h: Harness) => `${h.container.text}\n${h.document.body.text}`;
|
||
const buttons = (h: Harness, text: string) => allNodes(h).filter(node => node.tagName === "BUTTON" && node.text === text);
|
||
const trigger = (h: Harness) => allNodes(h).find(node => node.getAttribute("aria-label") === "报告更多操作")!;
|
||
// 原值:删除项一直在 details 里,是第一枚文字为「删除」的按钮;取消后焦点回到 SUMMARY
|
||
// 新值:先点「报告更多操作」,再点菜单里的「删除」;确认框里的「删除」是按钮;取消后焦点回到菜单按钮
|
||
// 原因:K5 行内 details 换成 DropdownMenu 和 AlertDialog
|
||
async function askDelete(h: Harness) {
|
||
// 原值:点一下就打开 details
|
||
// 新值:菜单在按下主按钮时打开,打开排到下一帧,所以要等一拍再找「删除」
|
||
// 原因:组件库菜单听的是 mousedown,内容在传送门里
|
||
await h.event(trigger(h), "onMouseDown", { button: 0 });
|
||
await h.idle();
|
||
const item = allNodes(h).find(node => node.text === "删除" && node.getAttribute("role") === "menuitem")
|
||
?? buttons(h, "删除")[0];
|
||
assert.ok(item, "删除");
|
||
await h.event(item);
|
||
await h.idle();
|
||
}
|
||
function deferred<T>() { let resolve!: (value: T) => void; const promise = new Promise<T>(done => { resolve = done; }); return { promise, resolve }; }
|
||
// Explicitly fictional list metadata, not an engine-contract fixture.
|
||
const row: ReportListItem = { id: "fictional-report", reportType: "personal_full", presentationMode: "default", depth: "standard", themes: ["career"], status: "ready", failureCode: null, createdAt: "2026-09-24T12:00:00Z", completedAt: null };
|
||
|
||
test("report metadata localizes depth and themes while preserving unknown identifiers", () => {
|
||
assert.equal(rowMeta(row), "2026年9月24日 · 标准深度 · 事业");
|
||
assert.equal(rowMeta({ ...row, depth: "future-depth", themes: ["future-theme"], createdAt: "invalid" }), "时间未知 · future-depth · future-theme");
|
||
assert.match(rowMeta({ ...row, themes: [] }), /综合主题$/);
|
||
assert.match(rowMeta(readReports({ reports: [{ ...row, depth: "future-depth" }] })[0]), /future-depth/);
|
||
});
|
||
|
||
test("row delete is confirmed inline, cancelled without request and retains failure for retry", async () => {
|
||
const h = createClientLifecycleHarness(); const fetchBefore = globalThis.fetch;
|
||
let calls = 0; let removed = 0;
|
||
const errorBefore = toast.error; const errors: string[] = [];
|
||
toast.error = (message) => { errors.push(String(message)); return 1; };
|
||
globalThis.fetch = async () => { calls += 1; return new Response(null, { status: 500 }); };
|
||
try {
|
||
await h.render(<ReportRowDelete reportId={row.id} onDeleted={() => { removed += 1; }} />);
|
||
await askDelete(h);
|
||
assert.match(textOf(h), /确定删除这份报告?/);
|
||
await h.event(buttons(h, "取消")[0]);
|
||
assert.equal(calls, 0);
|
||
assert.equal(document.activeElement, trigger(h));
|
||
await askDelete(h);
|
||
await h.event(buttons(h, "删除").at(-1)!);
|
||
await h.idle();
|
||
// 原值: h.container.text 含「报告没能删除,请稍后重试。」(行内确认条里一行红字,两次失败各一次)
|
||
// 新值: 同一句是 error toast,两次失败各一条;确认条仍保留供重试
|
||
// 原因: 2026-09-30 产品「报错和提示不要占页面布局」
|
||
assert.deepEqual(errors, ["报告没能删除,请稍后重试。"]);
|
||
assert.match(textOf(h), /确定删除这份报告?/);
|
||
assert.equal(removed, 0); assert.equal(calls, 1);
|
||
globalThis.fetch = async () => { throw new Error("offline"); };
|
||
// 原值:`buttons(h, "删除")[1]`,details 里的「删除」和确认按钮同时在,下标 1 是确认
|
||
// 新值:确认按钮是当前文字为「删除」的最后一枚。菜单关掉后那一项不留在文档里
|
||
// 原因:K5 用下拉菜单。第二次失败仍点确认,仍要再报一次错且不删行
|
||
await h.event(buttons(h, "删除").at(-1)!); await h.idle();
|
||
assert.deepEqual(errors, ["报告没能删除,请稍后重试。", "报告没能删除,请稍后重试。"]);
|
||
assert.equal(removed, 0);
|
||
} finally { toast.error = errorBefore; globalThis.fetch = fetchBefore; await h.close(); }
|
||
});
|
||
|
||
test("row delete sends one same-origin DELETE despite repeated confirmation clicks", async () => {
|
||
const h = createClientLifecycleHarness(); const fetchBefore = globalThis.fetch;
|
||
const response = deferred<Response>(); let calls = 0; const removed: string[] = [];
|
||
globalThis.fetch = async (url, options) => { assert.equal(url, `/api/reports/${row.id}`); assert.equal(options?.method, "DELETE"); assert.equal(options?.credentials, "same-origin"); calls += 1; return response.promise; };
|
||
try {
|
||
await h.render(<ReportRowDelete reportId={row.id} onDeleted={id => removed.push(id)} />);
|
||
await askDelete(h);
|
||
const confirm = buttons(h, "删除").at(-1)!;
|
||
await h.event(confirm); await h.event(confirm);
|
||
assert.equal(calls, 1); assert.match(textOf(h), /删除中/);
|
||
response.resolve(new Response(null, { status: 204 })); await h.idle();
|
||
assert.deepEqual(removed, [row.id]);
|
||
} finally { globalThis.fetch = fetchBefore; await h.close(); }
|
||
});
|
||
|
||
test("report cache invalidation rejects stale GET writes and stale finally keeps newer flight", async () => {
|
||
const fetchBefore = globalThis.fetch; const first = deferred<Response>(); const second = deferred<Response>(); let calls = 0;
|
||
invalidateReportsPage();
|
||
globalThis.fetch = () => (++calls === 1 ? first.promise : second.promise);
|
||
try {
|
||
const stale = refreshReportsPage(); invalidateReportsPage(); const fresh = refreshReportsPage();
|
||
first.resolve(Response.json({ reports: [row] })); await stale;
|
||
assert.equal(peekReportsPage(), null);
|
||
assert.equal(refreshReportsPage(), fresh); assert.equal(calls, 2);
|
||
second.resolve(Response.json({ reports: [] })); await fresh;
|
||
// 原值: { kind: "ready", reports: [] }
|
||
// 新值: { kind: "ready", reports: [], nextCursor: null }
|
||
// 原因: 报告列表按 10 份分页(BUG-1124),快照带上「加载更多」的游标;无更多时为 null
|
||
assert.deepEqual(peekReportsPage(), { kind: "ready", reports: [], nextCursor: null });
|
||
} finally { globalThis.fetch = fetchBefore; invalidateReportsPage(); }
|
||
});
|
||
|
||
test("mounted report center removes deleted row and an older GET cannot resurrect it", async () => {
|
||
const { bindCurrentSubjectAccount } = await import("../src/lib/current-subject.ts");
|
||
bindCurrentSubjectAccount("fictional-report-owner", []);
|
||
const h = createClientLifecycleHarness(); const fetchBefore = globalThis.fetch;
|
||
const stale = deferred<Response>(); let gets = 0;
|
||
writeReportsPage({ kind: "ready", reports: [row], nextCursor: null });
|
||
globalThis.fetch = async (url, options) => {
|
||
if (options?.method === "DELETE") return new Response(null, { status: 204 });
|
||
if (String(url).startsWith("/api/reports")) { gets += 1; return stale.promise; }
|
||
return Response.json({}, { status: 401 });
|
||
};
|
||
try {
|
||
const router = { bfcacheId: "test", push() {}, replace() {}, refresh() {}, prefetch() {}, back() {}, forward() {} } as AppRouterInstance;
|
||
await h.render(<AppRouterContext.Provider value={router}><SidebarProvider open><PersonalReportCenter /></SidebarProvider></AppRouterContext.Provider>); await h.idle();
|
||
assert.equal(gets, 1); assert.match(h.container.text, /标准深度/);
|
||
await askDelete(h); await h.event(buttons(h, "删除").at(-1)!); await h.idle();
|
||
assert.doesNotMatch(h.container.text, /标准深度/); assert.equal(peekReportsPage(), null);
|
||
stale.resolve(Response.json({ reports: [row] })); await h.idle();
|
||
assert.doesNotMatch(h.container.text, /标准深度/); assert.equal(peekReportsPage(), null);
|
||
assert.deepEqual(h.errors, []);
|
||
} finally { globalThis.fetch = fetchBefore; invalidateReportsPage(); await h.close(); }
|
||
});
|