返回 oh-my-ppt
chat-panel-page-scope.test.ts
根目录 / tests / unit / session / chat-panel-page-scope.test.ts
1 /**
2 * @vitest-environment happy-dom
3 */
4 import React, { act } from 'react'
5 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
6 import { createRoot, type Root } from 'react-dom/client'
7 import { useChatPanelController } from '../../../src/renderer/src/components/session-detail/hooks/useChatPanelController'
8 import { useGenerateStore } from '../../../src/renderer/src/store/generateStore'
9 import { useSessionDetailUiStore } from '../../../src/renderer/src/store/sessionDetailStore'
10 import { useSessionStore } from '../../../src/renderer/src/store/sessionStore'
11 import { useToastStore } from '../../../src/renderer/src/store/toastStore'
12 import type { ChatPanelController } from '../../../src/renderer/src/types/session-detail'
13
14 const {
15 assessPageEditMock,
16 cancelDeckEditMock,
17 cancelGenerateMock,
18 cancelPageEditMock,
19 cancelStyleSwitchMock,
20 getDeckEditStateMock,
21 getGenerateStateMock,
22 getPageEditStateMock,
23 getStyleSwitchStateMock,
24 startDeckEditMock,
25 startGenerateMock,
26 startPageEditMock,
27 toastErrorMock,
28 toastWarningMock
29 } = vi.hoisted(() => ({
30 assessPageEditMock: vi.fn(),
31 cancelDeckEditMock: vi.fn().mockResolvedValue({ success: true }),
32 cancelGenerateMock: vi.fn().mockResolvedValue({ success: true }),
33 cancelPageEditMock: vi.fn().mockResolvedValue({ success: true }),
34 cancelStyleSwitchMock: vi.fn().mockResolvedValue({ success: true }),
35 getDeckEditStateMock: vi.fn(),
36 getGenerateStateMock: vi.fn(),
37 getPageEditStateMock: vi.fn(),
38 getStyleSwitchStateMock: vi.fn(),
39 startDeckEditMock: vi.fn(),
40 startGenerateMock: vi.fn(),
41 startPageEditMock: vi.fn(),
42 toastErrorMock: vi.fn(),
43 toastWarningMock: vi.fn()
44 }))
45
46 vi.mock('../../../src/renderer/src/i18n', () => ({
47 useT: () => (key: string) => key
48 }))
49
50 vi.mock('../../../src/renderer/src/lib/ipc', () => ({
51 ipc: {
52 assessPageEdit: assessPageEditMock,
53 cancelDeckEdit: cancelDeckEditMock,
54 cancelPageEdit: cancelPageEditMock,
55 cancelStyleSwitch: cancelStyleSwitchMock,
56 cancelGenerate: cancelGenerateMock,
57 getDeckEditState: getDeckEditStateMock,
58 getGenerateState: getGenerateStateMock,
59 getPageEditState: getPageEditStateMock,
60 getStyleSwitchState: getStyleSwitchStateMock,
61 startDeckEdit: startDeckEditMock,
62 startGenerate: startGenerateMock,
63 startPageEdit: startPageEditMock
64 }
65 }))
66
67 type Controller = ReturnType<typeof useChatPanelController>
68
69 let latest: Controller | null = null
70
71 function Harness(): null {
72 latest = useChatPanelController('session-1')
73 return null
74 }
75
76 async function renderHarness(): Promise<{ root: Root; container: HTMLDivElement }> {
77 const container = document.createElement('div')
78 document.body.appendChild(container)
79 const root = createRoot(container)
80 await act(async () => {
81 root.render(React.createElement(Harness))
82 })
83 return { root, container }
84 }
85
86 async function cleanup(root: Root, container: HTMLDivElement): Promise<void> {
87 await act(async () => {
88 root.unmount()
89 })
90 container.remove()
91 }
92
93 describe('useChatPanelController page edit scope', () => {
94 beforeEach(() => {
95 latest = null
96 assessPageEditMock.mockReset()
97 cancelDeckEditMock.mockReset().mockResolvedValue({ success: true })
98 cancelGenerateMock.mockReset().mockResolvedValue({ success: true })
99 cancelPageEditMock.mockReset().mockResolvedValue({ success: true })
100 cancelStyleSwitchMock.mockReset().mockResolvedValue({ success: true })
101 getDeckEditStateMock.mockReset()
102 getGenerateStateMock.mockReset()
103 getPageEditStateMock.mockReset()
104 getStyleSwitchStateMock.mockReset()
105 startDeckEditMock.mockReset()
106 startGenerateMock.mockReset()
107 startPageEditMock.mockReset()
108 toastErrorMock.mockClear()
109 toastWarningMock.mockClear()
110 useToastStore.setState({ error: toastErrorMock, warning: toastWarningMock })
111 useGenerateStore.getState().reset()
112 useGenerateStore.getState().setPages([
113 {
114 id: 'page-record-1',
115 pageId: 'page-1',
116 pageNumber: 1,
117 title: 'Page 1',
118 html: '<div>Page 1</div>',
119 htmlPath: '/tmp/page-1.html'
120 },
121 {
122 id: 'page-record-2',
123 pageId: 'page-2',
124 pageNumber: 2,
125 title: 'Page 2',
126 html: '<div>Page 2</div>',
127 htmlPath: '/tmp/page-2.html'
128 }
129 ])
130 useSessionDetailUiStore.getState().resetForSessionChange()
131 useSessionDetailUiStore.setState({
132 selectedPageId: 'page-record-1',
133 chatType: 'page'
134 })
135 useSessionStore.setState({ currentMessages: [] })
136 })
137
138 afterEach(() => {
139 document.body.innerHTML = ''
140 })
141
142 it('leaves the other page AI panel available after switching away from an active page edit', async () => {
143 const { root, container } = await renderHarness()
144
145 try {
146 await act(async () => {
147 useGenerateStore.getState().startPageEditPlanning('session-1', 'page-1')
148 useGenerateStore.getState().setPendingPageEditPlan('session-1', {
149 targetPageId: 'page-1',
150 targetPageNumber: 1,
151 payload: {
152 sessionId: 'session-1',
153 userMessage: 'Improve the page title',
154 type: 'page',
155 chatType: 'page',
156 selectedPageId: 'page-1'
157 },
158 plan: {
159 intent: 'content',
160 target: 'Page title',
161 summary: 'Make the title more concise.',
162 changes: ['Shorten the title'],
163 confirmationQuestion: 'Apply this change?'
164 }
165 })
166 useGenerateStore.getState().finishPageEditPlanning('session-1')
167 useGenerateStore.getState().startPageEdit('session-1', { pageId: 'page-1', pageNumber: 1 })
168 useSessionDetailUiStore.getState().setSelectedPageId('page-record-2')
169 })
170
171 expect(latest).toMatchObject<Partial<ChatPanelController>>({
172 isGenerating: false,
173 isPageEditing: false,
174 isPlanningPageEdit: false,
175 pendingPageEditPlan: null,
176 progress: null
177 })
178 } finally {
179 await cleanup(root, container)
180 }
181 })
182
183 it('clears a pending plan without cancelling an unrelated generation', async () => {
184 const { root, container } = await renderHarness()
185
186 try {
187 await act(async () => {
188 useGenerateStore.getState().setPendingPageEditPlan('session-1', {
189 targetPageId: 'page-1',
190 targetPageNumber: 1,
191 payload: {
192 sessionId: 'session-1',
193 userMessage: 'Update the title',
194 type: 'page',
195 chatType: 'page',
196 selectedPageId: 'page-1'
197 },
198 plan: {
199 intent: 'content',
200 target: 'Title',
201 summary: 'Update the title copy.',
202 changes: ['Replace the title'],
203 confirmationQuestion: 'Apply this change?'
204 }
205 })
206 })
207
208 await act(async () => {
209 await latest?.cancel()
210 })
211
212 expect(useGenerateStore.getState().pageEditPlanning['session-1']?.pendingPlan).toBeNull()
213 expect(cancelGenerateMock).not.toHaveBeenCalled()
214 expect(cancelPageEditMock).not.toHaveBeenCalled()
215 } finally {
216 await cleanup(root, container)
217 }
218 })
219
220 it('keeps the other page input intact and explains why its AI edit cannot start yet', async () => {
221 const { root, container } = await renderHarness()
222
223 try {
224 await act(async () => {
225 useGenerateStore.getState().startPageEdit('session-1', { pageId: 'page-1', pageNumber: 1 })
226 useSessionDetailUiStore.setState({
227 selectedPageId: 'page-record-2',
228 chatType: 'page',
229 input: 'Update the second page title'
230 })
231 })
232
233 await expect(latest?.send('model-1')).resolves.toBe(false)
234 expect(assessPageEditMock).not.toHaveBeenCalled()
235 expect(useSessionDetailUiStore.getState().input).toBe('Update the second page title')
236 expect(toastWarningMock).toHaveBeenCalledWith('sessionDetail.pageEditOtherPageBusy')
237 } finally {
238 await cleanup(root, container)
239 }
240 })
241
242 it('does not clear a request when another job starts while page intent is being assessed', async () => {
243 const { root, container } = await renderHarness()
244 let resolveAssessment: ((value: Record<string, unknown>) => void) | undefined
245
246 assessPageEditMock.mockImplementationOnce(
247 () =>
248 new Promise((resolve) => {
249 resolveAssessment = resolve
250 })
251 )
252
253 try {
254 await act(async () => {
255 useSessionDetailUiStore.setState({ input: 'Make this title concise' })
256 })
257 const sendPromise = latest?.send('model-1')
258 await act(async () => {
259 await Promise.resolve()
260 useGenerateStore.setState({ isGenerating: true })
261 resolveAssessment?.({
262 requiresConfirmation: false,
263 plan: {
264 intent: 'content',
265 target: 'Title',
266 summary: 'Shorten the title',
267 changes: ['Shorten title copy'],
268 confirmationQuestion: 'Apply this edit?'
269 },
270 reply: 'Ready',
271 targetPageId: 'page-1',
272 targetPageNumber: 1
273 })
274 })
275
276 await expect(sendPromise).resolves.toBe(false)
277 expect(startPageEditMock).not.toHaveBeenCalled()
278 expect(useSessionDetailUiStore.getState().input).toBe('Make this title concise')
279 expect(toastWarningMock).toHaveBeenCalledWith('sessionDetail.pageEditPlanWaitForJob')
280 } finally {
281 await cleanup(root, container)
282 }
283 })
284
285 it('shows the current-page user message while its edit intent is being assessed', async () => {
286 const { root, container } = await renderHarness()
287 let resolveAssessment: ((value: Record<string, unknown>) => void) | undefined
288 assessPageEditMock.mockImplementationOnce(
289 () =>
290 new Promise((resolve) => {
291 resolveAssessment = resolve
292 })
293 )
294
295 try {
296 useSessionDetailUiStore.setState({ input: 'Make this title concise' })
297 const sendPromise = latest?.send('model-1')
298
299 await act(async () => {
300 await Promise.resolve()
301 })
302
303 expect(useSessionStore.getState().currentMessages).toMatchObject([
304 {
305 session_id: 'session-1',
306 chat_scope: 'page',
307 page_id: 'page-record-1',
308 role: 'user',
309 content: 'Make this title concise'
310 }
311 ])
312
313 resolveAssessment?.({
314 requiresConfirmation: true,
315 plan: {
316 intent: 'content',
317 target: 'Title',
318 summary: 'Shorten the title',
319 changes: ['Shorten title copy'],
320 confirmationQuestion: 'Apply this edit?'
321 },
322 reply: 'Ready',
323 targetPageId: 'page-1',
324 targetPageNumber: 1
325 })
326 await expect(sendPromise).resolves.toBe(true)
327 } finally {
328 await cleanup(root, container)
329 }
330 })
331
332 it('binds the first main-session edit to its run and infers an explicit page target', async () => {
333 startDeckEditMock.mockResolvedValue({ success: true, runId: 'deck-run-1' })
334 useSessionDetailUiStore.setState({ chatType: 'main', input: '只修改第 2 页标题' })
335 const { root, container } = await renderHarness()
336
337 try {
338 let started = false
339 await act(async () => {
340 started = (await latest?.send('model-1')) ?? false
341 })
342
343 expect(started).toBe(true)
344 expect(startDeckEditMock).toHaveBeenCalledWith(
345 expect.objectContaining({
346 chatType: 'main',
347 type: 'page',
348 selectPageIds: ['page-2']
349 })
350 )
351 expect(useGenerateStore.getState().deckEditJobs['session-1']).toMatchObject({
352 runId: 'deck-run-1',
353 status: 'running',
354 totalPages: 1
355 })
356 } finally {
357 await cleanup(root, container)
358 }
359 })
360
361 it('does not send unsupported page structure requests as deck edits', async () => {
362 useSessionDetailUiStore.setState({ chatType: 'main', input: '删除第 2 页' })
363 const { root, container } = await renderHarness()
364
365 try {
366 await expect(latest?.send('model-1')).resolves.toBe(false)
367 expect(startDeckEditMock).not.toHaveBeenCalled()
368 expect(useSessionDetailUiStore.getState().input).toBe('删除第 2 页')
369 expect(toastWarningMock).toHaveBeenCalledWith('sessionDetail.mainPageStructureUnsupported')
370 } finally {
371 await cleanup(root, container)
372 }
373 })
374
375 it('keeps a deck edit cancelling when its start response arrives late', async () => {
376 let resolveStart: ((value: { success: boolean; runId: string }) => void) | undefined
377 startDeckEditMock.mockImplementationOnce(
378 () =>
379 new Promise((resolve) => {
380 resolveStart = resolve
381 })
382 )
383 getDeckEditStateMock.mockResolvedValue({
384 hasActiveRun: true,
385 runId: 'deck-run-late',
386 status: 'running',
387 progress: 5,
388 totalPages: 2
389 })
390 useSessionDetailUiStore.setState({ chatType: 'main', input: '统一全部页面标题' })
391 const { root, container } = await renderHarness()
392
393 try {
394 let sendPromise: Promise<boolean> | undefined
395 await act(async () => {
396 sendPromise = latest?.send('model-1')
397 await Promise.resolve()
398 })
399 await act(async () => {
400 await latest?.cancel()
401 })
402 expect(useGenerateStore.getState().deckEditJobs['session-1']?.status).toBe('cancelling')
403
404 resolveStart?.({ success: true, runId: 'deck-run-late' })
405 await act(async () => {
406 await sendPromise
407 })
408
409 expect(useGenerateStore.getState().deckEditJobs['session-1']).toMatchObject({
410 runId: 'deck-run-late',
411 status: 'cancelling'
412 })
413 } finally {
414 await cleanup(root, container)
415 }
416 })
417
418 it('clears a stale deck job when cancellation is rejected and no run is active', async () => {
419 cancelDeckEditMock.mockResolvedValueOnce({ success: false })
420 getDeckEditStateMock.mockResolvedValueOnce({ hasActiveRun: false })
421 useGenerateStore.getState().startDeckEdit('session-1', { totalPages: 2 })
422 const { root, container } = await renderHarness()
423
424 try {
425 await act(async () => {
426 await latest?.cancel()
427 })
428 expect(useGenerateStore.getState().deckEditJobs['session-1']).toBeUndefined()
429 } finally {
430 await cleanup(root, container)
431 }
432 })
433
434 it('does not leave a deck lock when cancel and reconciliation both fail', async () => {
435 cancelDeckEditMock.mockRejectedValueOnce(new Error('cancel transport failed'))
436 getDeckEditStateMock.mockRejectedValueOnce(new Error('state transport failed'))
437 useGenerateStore.getState().startDeckEdit('session-1', { totalPages: 2 })
438 const { root, container } = await renderHarness()
439
440 try {
441 await act(async () => {
442 await latest?.cancel()
443 })
444 expect(useGenerateStore.getState().deckEditJobs['session-1']).toBeUndefined()
445 expect(toastErrorMock).toHaveBeenCalledWith('cancel transport failed')
446 } finally {
447 await cleanup(root, container)
448 }
449 })
450
451 it('routes the AI-panel stop action to the active style-switch job', async () => {
452 useGenerateStore.getState().startStyleSwitch('session-1', {
453 styleId: 'style-2',
454 styleName: 'Style 2',
455 totalPages: 1,
456 pages: [
457 {
458 pageId: 'page-1',
459 pageNumber: 1,
460 title: 'Page 1',
461 status: 'running',
462 error: null,
463 retryCount: 0
464 }
465 ]
466 })
467 const { root, container } = await renderHarness()
468
469 try {
470 await act(async () => {
471 await latest?.cancel()
472 })
473 expect(cancelStyleSwitchMock).toHaveBeenCalledWith('session-1')
474 expect(cancelGenerateMock).not.toHaveBeenCalled()
475 expect(useGenerateStore.getState().styleSwitchJobs['session-1']?.status).toBe('cancelling')
476 } finally {
477 await cleanup(root, container)
478 }
479 })
480
481 it('clears an optimistic deck job when startup is cancelled', async () => {
482 startDeckEditMock.mockRejectedValueOnce(new Error('生成已取消'))
483 useSessionDetailUiStore.setState({ chatType: 'main', input: '统一全部页面标题' })
484 const { root, container } = await renderHarness()
485
486 try {
487 await expect(latest?.send('model-1')).resolves.toBe(false)
488 expect(useGenerateStore.getState().deckEditJobs['session-1']).toBeUndefined()
489 expect(toastErrorMock).not.toHaveBeenCalled()
490 } finally {
491 await cleanup(root, container)
492 }
493 })
494 })
495
495 lines TYPESCRIPT