Skip to content

Commit 4fe09e0

Browse files
committed
fix(session): 会话列表全选口径改可见行并加批量 pending 防护
批4 审查 P3-1:全选框口径原为 filtered——折叠组时把不可见行也纳入全选 (随后被裁剪 effect 清掉,计数误导且折叠内容被误操作);改与区间/自动 裁剪同基准(可见行序 visibleOrder,含 data-testid 供回归)。 批4 审查 P3-2:批量删除/批量转按钮加 pending disabled(state=视觉, ref=同 tick 拦截,与行内改名 busyRef 同模式)——连点二次提交报 「已删除 0 个」;onBatchConvert 允许返回 Promise(父层 invoke 完成前 保持禁用)。 P2-9 组件测试补强(随本域收口,参照 GroupSidebar mock 惯例): SessionListPanel.test 覆盖选择模式进出/单击勾选/Ctrl 加减/Shift 区间/ 折叠后全选不含隐藏行/列表变化自动裁剪/批量删除成功清选集与 pending 防连点/失败保留选集/批量转 eligible 过滤;SessionListRow.test 覆盖 行内改名 Enter/Esc/失焦提交与连点失焦竞态防抖。
1 parent aa7add5 commit 4fe09e0

4 files changed

Lines changed: 548 additions & 17 deletions

File tree

Lines changed: 316 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,316 @@
1+
// @vitest-environment jsdom
2+
/**
3+
* SessionListPanel.test.tsx — 会话列表面板交互测试(批 4;P2-9 组件覆盖 + P3-1/P3-2 回归)。
4+
*
5+
* @ai-context: 覆盖批 4 选择/批量交互矩阵——选择模式进出(按钮+Esc)、单击勾选、
6+
* Ctrl 加/减、Shift 区间、折叠组全选**不含隐藏行**(P3-1 修复:
7+
* 全选口径=可见行序 visibleOrder 而非 filtered)、列表变化自动
8+
* 裁剪选集、批量删除 pending 防连点与成功清选集(P3-2)、批量转
9+
* eligible 过滤。invoke 全 mock;数据为本地 props 零后端往返
10+
* (面板数据驱动经 props——父层 invoke 不在本组件)。
11+
*/
12+
import { beforeEach, afterEach, describe, expect, it, vi } from "vitest";
13+
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
14+
import type { CourseGroup, SessionListItem } from "../types";
15+
import SessionListPanel from "./SessionListPanel";
16+
17+
const { invokeMock } = vi.hoisted(() => ({ invokeMock: vi.fn() }));
18+
vi.mock("@tauri-apps/api/core", () => ({ invoke: invokeMock }));
19+
20+
beforeEach(() => {
21+
// 调用历史跨测试隔离(面板不 invoke——但搜索路径留 mock 完备性)
22+
invokeMock.mockReset();
23+
});
24+
25+
afterEach(() => {
26+
cleanup();
27+
vi.restoreAllMocks();
28+
});
29+
30+
function item(id: number, title: string, over: Partial<SessionListItem["session"]> & { hasNote?: boolean; status?: string } = {}): SessionListItem {
31+
const { hasNote, status, ...rest } = over;
32+
return {
33+
session: { id, title, source_window: null, started_at: id * 1000, ended_at: id * 1000 + 500, status: status ?? "finished", kind: null, ...rest },
34+
hasContent: true,
35+
hasNote: hasNote ?? false,
36+
noteId: null,
37+
noteTitle: null,
38+
displayNo: id,
39+
};
40+
}
41+
42+
function groupOf(course: string, ...rows: SessionListItem[]): CourseGroup {
43+
return { course, sessions: rows };
44+
}
45+
46+
/** 可控 Promise(busy 窗口期模拟——invoke 未返回时连点) */
47+
function deferred<T>() {
48+
let resolve!: (v: T) => void;
49+
const promise = new Promise<T>((r) => { resolve = r; });
50+
return { promise, resolve };
51+
}
52+
53+
interface Harness {
54+
onBatchConvert: ReturnType<typeof vi.fn>;
55+
onBatchDelete: ReturnType<typeof vi.fn>;
56+
onOpenDetail: ReturnType<typeof vi.fn>;
57+
showToast: ReturnType<typeof vi.fn>;
58+
rerender: (items: SessionListItem[], groups?: CourseGroup[] | null, grouped?: boolean) => void;
59+
}
60+
61+
const DEFAULTS = {
62+
width: 320,
63+
groups: null,
64+
grouped: false,
65+
onToggleGrouped: () => {},
66+
loading: false,
67+
justFinished: 0,
68+
onDismissJustFinished: () => {},
69+
openSessionId: null,
70+
onOpenDetail: () => {},
71+
onConvert: () => {},
72+
onOpenNote: () => {},
73+
onBatchConvert: async () => {},
74+
onBatchDelete: async () => true,
75+
onDeleteOne: () => {},
76+
onSessionRenamed: () => {},
77+
showToast: () => {},
78+
onCollapse: () => {},
79+
};
80+
81+
function renderPanel(items: SessionListItem[], groups: CourseGroup[] | null = null, grouped = false): Harness {
82+
const spies = {
83+
onBatchConvert: vi.fn(async () => {}),
84+
onBatchDelete: vi.fn(async () => true),
85+
onOpenDetail: vi.fn(),
86+
showToast: vi.fn(),
87+
};
88+
const view = render(
89+
<SessionListPanel
90+
{...DEFAULTS}
91+
items={items}
92+
groups={groups}
93+
grouped={grouped}
94+
onOpenDetail={spies.onOpenDetail}
95+
onBatchConvert={spies.onBatchConvert}
96+
onBatchDelete={spies.onBatchDelete}
97+
showToast={spies.showToast}
98+
/>,
99+
);
100+
return {
101+
...spies,
102+
rerender: (next, g2 = groups, grouped2 = grouped) =>
103+
view.rerender(
104+
<SessionListPanel
105+
{...DEFAULTS}
106+
items={next}
107+
groups={g2}
108+
grouped={grouped2}
109+
onOpenDetail={spies.onOpenDetail}
110+
onBatchConvert={spies.onBatchConvert}
111+
onBatchDelete={spies.onBatchDelete}
112+
showToast={spies.showToast}
113+
/>,
114+
),
115+
};
116+
}
117+
118+
function selectedCountText(): string | null {
119+
// 批量栏固定文案「已选 N 个」——不存在=栏未渲染(空选集)
120+
const span = screen.queryByText(/^已选 \d+ 个$/);
121+
return span ? (span.textContent ?? null) : null;
122+
}
123+
124+
describe("选择模式进出与行勾选", () => {
125+
it("进入/退出选择模式(按钮与 Esc 双路径):退出后单击行=打开详情", () => {
126+
const h = renderPanel([item(1, "会话一"), item(2, "会话二"), item(3, "会话三")]);
127+
// 进入(按钮)→ 模式 chip 出现;单击行=勾选不打开详情
128+
fireEvent.click(screen.getByTestId("session-select-mode-btn"));
129+
expect(screen.getByTestId("session-select-mode-chip").textContent).toContain("选择模式");
130+
fireEvent.click(screen.getByTestId("session-row-1"));
131+
expect(selectedCountText()).toBe("已选 1 个");
132+
expect(h.onOpenDetail).not.toHaveBeenCalled();
133+
// 再点按钮退出:chip 消失、选集清空
134+
fireEvent.click(screen.getByTestId("session-select-mode-btn"));
135+
expect(screen.queryByTestId("session-select-mode-chip")).toBeNull();
136+
expect(selectedCountText()).toBeNull();
137+
// 普通单击=打开详情(无目标段参数——单参调用)
138+
fireEvent.click(screen.getByTestId("session-row-2"));
139+
expect(h.onOpenDetail).toHaveBeenCalledWith(2);
140+
// Esc 退出:重新进入 → 勾两行 → Esc 全部清空退出
141+
fireEvent.click(screen.getByTestId("session-select-mode-btn"));
142+
fireEvent.click(screen.getByTestId("session-row-3"));
143+
expect(selectedCountText()).toBe("已选 1 个");
144+
fireEvent.keyDown(window, { key: "Escape" });
145+
expect(screen.queryByTestId("session-select-mode-chip")).toBeNull();
146+
expect(selectedCountText()).toBeNull();
147+
});
148+
149+
it("选择模式单击勾选/再点取消(toggle 语义)", () => {
150+
const h = renderPanel([item(1, "会话一"), item(2, "会话二")]);
151+
fireEvent.click(screen.getByTestId("session-select-mode-btn"));
152+
fireEvent.click(screen.getByTestId("session-row-1"));
153+
fireEvent.click(screen.getByTestId("session-row-2"));
154+
expect(selectedCountText()).toBe("已选 2 个");
155+
// 再点行 1 = 取消勾选
156+
fireEvent.click(screen.getByTestId("session-row-1"));
157+
expect(selectedCountText()).toBe("已选 1 个");
158+
expect(h.onOpenDetail).not.toHaveBeenCalled();
159+
});
160+
});
161+
162+
describe("修饰键多选(Ctrl 加/减、Shift 区间)", () => {
163+
it("Ctrl/⌘+单击=加/减单行,不打开详情", () => {
164+
const h = renderPanel([item(1, "a"), item(2, "b"), item(3, "c")]);
165+
fireEvent.click(screen.getByTestId("session-row-1"), { ctrlKey: true });
166+
expect(selectedCountText()).toBe("已选 1 个");
167+
fireEvent.click(screen.getByTestId("session-row-2"), { ctrlKey: true });
168+
expect(selectedCountText()).toBe("已选 2 个");
169+
fireEvent.click(screen.getByTestId("session-row-1"), { ctrlKey: true });
170+
expect(selectedCountText()).toBe("已选 1 个");
171+
expect(h.onOpenDetail).not.toHaveBeenCalled();
172+
// Esc 清多选(无选择模式也生效)
173+
fireEvent.keyDown(window, { key: "Escape" });
174+
expect(selectedCountText()).toBeNull();
175+
});
176+
177+
it("Shift 区间=可见列表位置首尾并集(锚迁移)", () => {
178+
const h = renderPanel([1, 2, 3, 4, 5].map((i) => item(i, `会话${i}`)));
179+
// 首次 Shift=单选并设锚(行 5)
180+
fireEvent.click(screen.getByTestId("session-row-5"), { shiftKey: true });
181+
expect(selectedCountText()).toBe("已选 1 个");
182+
// 锚 5 → Shift 点 2:区间 [2..5] 并集
183+
fireEvent.click(screen.getByTestId("session-row-2"), { shiftKey: true });
184+
expect(selectedCountText()).toBe("已选 4 个");
185+
// 经批量删除参数断言精确成员(顺序无关)
186+
fireEvent.click(screen.getByText("批量删除"));
187+
expect(h.onBatchDelete).toHaveBeenCalledTimes(1);
188+
expect(new Set(h.onBatchDelete.mock.calls[0][0] as number[])).toEqual(new Set([2, 3, 4, 5]));
189+
expect(h.onOpenDetail).not.toHaveBeenCalled();
190+
});
191+
});
192+
193+
describe("全选口径(P3-1:折叠组全选不含隐藏行)", () => {
194+
function groupedFixture() {
195+
const gA = groupOf("课程甲", item(1, "甲-1"), item(2, "甲-2"));
196+
const gB = groupOf("课程乙", item(3, "乙-1"), item(4, "乙-2"));
197+
const gC = groupOf("课程丙", item(5, "丙-1"), item(6, "丙-2"));
198+
const items = [...gA.sessions, ...gB.sessions, ...gC.sessions];
199+
return { gA, gB, gC, items };
200+
}
201+
202+
it("折叠组后全选只含可见行(原 filtered 口径把隐藏行一并纳入的回归)", async () => {
203+
const { gA, gB, gC, items } = groupedFixture();
204+
const h = renderPanel(items, [gA, gB, gC], true);
205+
fireEvent.click(screen.getByTestId("session-select-mode-btn"));
206+
// 先勾一行使批量栏(含全选框)出现
207+
fireEvent.click(screen.getByTestId("session-row-1"));
208+
expect(selectedCountText()).toBe("已选 1 个");
209+
// 折叠课程丙 → 丙行不可见(不渲染)
210+
fireEvent.click(screen.getByText(/课程丙(2)/));
211+
expect(screen.queryByTestId("session-row-5")).toBeNull();
212+
// Act:全选(基准=可见行序:甲 1-2 + 乙 3-4;丙行不可见不得纳入)
213+
fireEvent.click(screen.getByTestId("session-select-all"));
214+
expect(selectedCountText()).toBe("已选 4 个");
215+
const all = screen.getByTestId("session-select-all") as HTMLInputElement;
216+
expect(all.checked).toBe(true);
217+
// 批量删除参数=精确 4 个可见行(旧实现会把隐藏的 5/6 也送来)
218+
fireEvent.click(screen.getByText("批量删除"));
219+
expect(h.onBatchDelete).toHaveBeenCalledTimes(1);
220+
expect(new Set(h.onBatchDelete.mock.calls[0][0] as number[])).toEqual(new Set([1, 2, 3, 4]));
221+
// 成功 → 清选集:批量栏消失
222+
await waitFor(() => expect(selectedCountText()).toBeNull());
223+
// 全选后再点=清空(checkbox 满选态反转)
224+
fireEvent.click(screen.getByTestId("session-row-1"));
225+
fireEvent.click(screen.getByTestId("session-select-all"));
226+
expect(selectedCountText()).toBe("已选 4 个");
227+
fireEvent.click(screen.getByTestId("session-select-all"));
228+
expect(selectedCountText()).toBeNull();
229+
});
230+
});
231+
232+
describe("选集自动裁剪与批量操作", () => {
233+
it("列表变化自动裁剪选集(行消失即从选集剔除;全空则栏消失)", async () => {
234+
const h = renderPanel([item(1, "a"), item(2, "b"), item(3, "c")]);
235+
fireEvent.click(screen.getByTestId("session-select-mode-btn"));
236+
fireEvent.click(screen.getByTestId("session-row-1"));
237+
fireEvent.click(screen.getByTestId("session-row-2"));
238+
expect(selectedCountText()).toBe("已选 2 个");
239+
// 刷新后行 2 不存在(删除/筛选)→ 幽灵勾选被裁剪
240+
h.rerender([item(1, "a")]);
241+
await waitFor(() => expect(selectedCountText()).toBe("已选 1 个"));
242+
// 列表清空 → 选集空、批量栏消失(模式 chip 仍在——模式与选集解耦)
243+
h.rerender([]);
244+
await waitFor(() => expect(selectedCountText()).toBeNull());
245+
expect(screen.getByTestId("session-select-mode-chip")).toBeTruthy();
246+
// Esc 退出模式
247+
fireEvent.keyDown(window, { key: "Escape" });
248+
expect(screen.queryByTestId("session-select-mode-chip")).toBeNull();
249+
});
250+
251+
it("批量删除 pending 禁用防连点(P3-2)且成功后清选集", async () => {
252+
const d = deferred<boolean>();
253+
const h = renderPanel([item(1, "a"), item(2, "b")]);
254+
h.onBatchDelete.mockImplementation(async () => { await d.promise; return true; });
255+
fireEvent.click(screen.getByTestId("session-select-mode-btn"));
256+
fireEvent.click(screen.getByTestId("session-row-1"));
257+
fireEvent.click(screen.getByTestId("session-row-2"));
258+
expect(selectedCountText()).toBe("已选 2 个");
259+
// Act:点击批量删除(pending——confirm/invoke 未返回)
260+
fireEvent.click(screen.getByText("批量删除"));
261+
expect(h.onBatchDelete).toHaveBeenCalledTimes(1);
262+
const delBtn = screen.getByText("批量删除") as HTMLButtonElement;
263+
expect(delBtn.disabled).toBe(true);
264+
// 连点(disabled 期间浏览器不派发;即便派发 ref 同步拦截)→ 仍单次
265+
fireEvent.click(delBtn);
266+
expect(h.onBatchDelete).toHaveBeenCalledTimes(1);
267+
// pending 期间不清选集
268+
expect(selectedCountText()).toBe("已选 2 个");
269+
// 完成(成功)→ 清选集、批量栏消失
270+
await act(async () => { d.resolve(true); });
271+
await waitFor(() => expect(selectedCountText()).toBeNull());
272+
});
273+
274+
it("批量删除失败保留选集(resolve=false 不清——父层报错后可重试)", async () => {
275+
const h = renderPanel([item(1, "a")]);
276+
h.onBatchDelete.mockResolvedValue(false);
277+
fireEvent.click(screen.getByTestId("session-select-mode-btn"));
278+
fireEvent.click(screen.getByTestId("session-row-1"));
279+
fireEvent.click(screen.getByText("批量删除"));
280+
await waitFor(() => expect(h.onBatchDelete).toHaveBeenCalledTimes(1));
281+
expect(selectedCountText()).toBe("已选 1 个");
282+
});
283+
284+
it("批量转笔记:过滤不可转(已转/进行中)只交可转 id", async () => {
285+
const rows = [
286+
item(1, "可转会话"),
287+
item(2, "已转会话", { hasNote: true }),
288+
item(3, "录制中", { status: "recording" }),
289+
];
290+
const h = renderPanel(rows);
291+
fireEvent.click(screen.getByTestId("session-select-mode-btn"));
292+
for (const id of [1, 2, 3]) fireEvent.click(screen.getByTestId(`session-row-${id}`));
293+
expect(selectedCountText()).toBe("已选 3 个");
294+
// Act:批量转(选中集含不可转——只发 eligible)
295+
fireEvent.click(screen.getByText("批量转笔记"));
296+
await waitFor(() => expect(h.onBatchConvert).toHaveBeenCalledTimes(1));
297+
expect(h.onBatchConvert.mock.calls[0][0]).toEqual([1]);
298+
// 选集已清(转前 clearSelection——批量栏消失)
299+
await waitFor(() => expect(selectedCountText()).toBeNull());
300+
});
301+
302+
it("批量转:全不可转不 invoke 走 toast", () => {
303+
const h = renderPanel([
304+
item(2, "已转会话", { hasNote: true }),
305+
item(3, "录制中", { status: "recording" }),
306+
]);
307+
fireEvent.click(screen.getByTestId("session-select-mode-btn"));
308+
fireEvent.click(screen.getByTestId("session-row-2"));
309+
fireEvent.click(screen.getByTestId("session-row-3"));
310+
fireEvent.click(screen.getByText("批量转笔记"));
311+
expect(h.onBatchConvert).not.toHaveBeenCalled();
312+
expect(h.showToast).toHaveBeenCalledWith(expect.stringContaining("均不可转换"), "err");
313+
// 转换未发生——选集保留
314+
expect(selectedCountText()).toBe("已选 2 个");
315+
});
316+
});

0 commit comments

Comments
 (0)