Files
Jyotisha/frontend/tests/report-row-delete.test.tsx
T
jesse-uxandGrok (agent loop) 1516ad56ec
Independent Staging Quality Gate / validate (push) Failing after 9m11s
Independent Staging Quality Gate / publish (push) Skipped
报告、星历和登录页换成组件库(#109)。
报告语言改成分段页签,切换后不留下上一种语言的正文。导出从右侧或底部滑出,不再显示文件大小和说明句。星历用箭头按钮按方向翻页,卡片边框不被裁掉。登录的输入、按钮和六位验证码换成组件库。星星汇聚不改。

自测: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)
2026-10-25 05:56:25 +08:00

138 lines
9.0 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 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(); }
});