|
| 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