返回 oh-my-ppt
workspace-ribbon-controller.test.ts
根目录 / tests / unit / session / workspace-ribbon-controller.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 { useWorkspaceRibbonController } from '../../../src/renderer/src/components/session-detail/hooks/useWorkspaceRibbonController'
8 import {
9 useEditHistoryStore,
10 useEditSessionStore,
11 useGenerateStore,
12 useSessionDetailUiStore,
13 useSessionStore
14 } from '@renderer/store'
15
16 const toastInfo = vi.hoisted(() => vi.fn())
17
18 vi.mock('@renderer/i18n', () => ({
19 useT: () => (key: string) => key
20 }))
21
22 vi.mock('@renderer/store', async () => {
23 const actual = await vi.importActual<typeof import('@renderer/store')>('@renderer/store')
24 return {
25 ...actual,
26 useToastStore: Object.assign((selector: unknown) => {
27 if (typeof selector === 'function') {
28 return selector({
29 info: toastInfo
30 })
31 }
32 return actual.useToastStore.getState()
33 }, actual.useToastStore)
34 }
35 })
36
37 type Controller = ReturnType<typeof useWorkspaceRibbonController>
38
39 let latest: Controller | null = null
40
41 function Harness(): null {
42 latest = useWorkspaceRibbonController(false)
43 return null
44 }
45
46 async function renderHarness(): Promise<{ root: Root; container: HTMLDivElement }> {
47 const container = document.createElement('div')
48 document.body.appendChild(container)
49 const root = createRoot(container)
50 await act(async () => {
51 root.render(React.createElement(Harness))
52 })
53 return { root, container }
54 }
55
56 async function cleanup(root: Root, container: HTMLDivElement): Promise<void> {
57 await act(async () => {
58 root.unmount()
59 })
60 container.remove()
61 }
62
63 describe('useWorkspaceRibbonController', () => {
64 beforeEach(() => {
65 latest = null
66 toastInfo.mockReset()
67 useEditHistoryStore.getState().clear()
68 useEditSessionStore.getState().reset()
69 useGenerateStore.getState().reset()
70 useGenerateStore.getState().setPages([
71 {
72 id: 'page-record-1',
73 pageId: 'page-1',
74 pageNumber: 1,
75 title: 'Page 1',
76 html: '<div>Page</div>',
77 htmlPath: '/tmp/page-1.html'
78 }
79 ])
80 useSessionStore.setState({ currentSession: { id: 'session-1' } as never })
81 useSessionDetailUiStore.getState().resetForSessionChange()
82 useSessionDetailUiStore.setState({
83 selectedPageId: 'page-record-1',
84 workspaceTab: 'preview',
85 interactionMode: 'preview'
86 })
87 })
88
89 afterEach(() => {
90 document.body.innerHTML = ''
91 })
92
93 it('shows a guidance toast when switching to animation mode', async () => {
94 const { root, container } = await renderHarness()
95
96 try {
97 await act(async () => {
98 latest?.activateTab('animation')
99 })
100
101 expect(useSessionDetailUiStore.getState()).toMatchObject({
102 workspaceTab: 'animation',
103 interactionMode: 'animation-select'
104 })
105 expect(toastInfo).toHaveBeenCalledWith('sessionDetail.animationModeToast')
106 } finally {
107 await cleanup(root, container)
108 }
109 })
110
111 it('locks editing tools only while the selected page has an active AI edit job', async () => {
112 const { root, container } = await renderHarness()
113
114 try {
115 await act(async () => {
116 useGenerateStore.getState().startPageEdit('session-1', { pageId: 'page-1', pageNumber: 1 })
117 })
118 expect(latest?.state.isPageEditing).toBe(true)
119 expect(latest?.state.isGenerating).toBe(false)
120
121 await act(async () => {
122 useGenerateStore.getState().setPages([
123 ...useGenerateStore.getState().currentPages,
124 {
125 id: 'page-record-2',
126 pageId: 'page-2',
127 pageNumber: 2,
128 title: 'Page 2',
129 html: '<div>Page</div>',
130 htmlPath: '/tmp/page-2.html'
131 }
132 ])
133 useSessionDetailUiStore.getState().setSelectedPageId('page-record-2')
134 })
135 expect(latest?.state.isPageEditing).toBe(false)
136 expect(latest?.state.isGenerating).toBe(false)
137 } finally {
138 await cleanup(root, container)
139 }
140 })
141
142 it('keeps other pages editable while a generated page is running', async () => {
143 const { root, container } = await renderHarness()
144
145 try {
146 await act(async () => {
147 useGenerateStore.getState().setPages([
148 ...useGenerateStore.getState().currentPages,
149 {
150 id: 'page-record-2',
151 pageId: 'page-2',
152 pageNumber: 2,
153 title: 'Page 2',
154 html: '<div>Page</div>',
155 htmlPath: '/tmp/page-2.html'
156 }
157 ])
158 useGenerateStore.setState({ isGenerating: true, status: 'running' })
159 useSessionDetailUiStore.setState({
160 isAddingPage: true,
161 addingPageId: 'page-record-2'
162 })
163 })
164
165 expect(latest?.state.isGenerating).toBe(false)
166
167 await act(async () => {
168 useSessionDetailUiStore.getState().setSelectedPageId('page-record-2')
169 })
170
171 expect(latest?.state.isGenerating).toBe(true)
172 } finally {
173 await cleanup(root, container)
174 }
175 })
176
177 it('locks the workspace for a deck edit in the current session', async () => {
178 const { root, container } = await renderHarness()
179
180 try {
181 await act(async () => {
182 useGenerateStore.getState().startDeckEdit('session-1', { totalPages: 2 })
183 })
184 expect(latest?.state.isDeckEditing).toBe(true)
185
186 await act(async () => {
187 useGenerateStore.getState().startDeckEdit('session-2', { totalPages: 2 })
188 useGenerateStore.getState().finishDeckEdit('session-1')
189 })
190 expect(latest?.state.isDeckEditing).toBe(false)
191 } finally {
192 await cleanup(root, container)
193 }
194 })
195
196 it('discards the current page history before switching to a pending workspace tab', async () => {
197 const discardAll = vi.fn(() => useEditHistoryStore.getState().clearPage('page-1'))
198 const originalDiscardAll = useEditSessionStore.getState().discardAll
199 useEditSessionStore.setState({ discardAll })
200 const { root, container } = await renderHarness()
201
202 try {
203 await act(async () => {
204 useEditHistoryStore.getState().upsertTextEdit({
205 pageId: 'page-1',
206 htmlPath: '/tmp/page-1.html',
207 selector: '[data-block-id="title"]',
208 patch: { text: 'Updated title', style: {} }
209 })
210 })
211 await act(async () => {
212 latest?.activateTab('ai')
213 })
214 expect(latest?.pendingTab).toBe('ai')
215
216 await act(async () => {
217 latest?.discardPendingTab()
218 })
219
220 expect(discardAll).toHaveBeenCalledOnce()
221 expect(useEditHistoryStore.getState().hasPendingEdits('page-1')).toBe(false)
222 expect(useSessionDetailUiStore.getState()).toMatchObject({
223 workspaceTab: 'ai',
224 interactionMode: 'ai-inspect'
225 })
226 } finally {
227 useEditSessionStore.setState({ discardAll: originalDiscardAll })
228 await cleanup(root, container)
229 }
230 })
231 })
232
232 lines TYPESCRIPT