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