返回 oh-my-ppt
element-animation-picker.test.ts
根目录 / tests / unit / session / element-animation-picker.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 { ElementAnimationPicker } from '../../../src/renderer/src/components/session-detail/workspace/toolbar/tool-rows/ElementAnimationPicker'
8 import { useGenerateStore } from '../../../src/renderer/src/store/generateStore'
9 import { useSessionDetailUiStore } from '../../../src/renderer/src/store/sessionDetailStore'
10
11 (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT =
12 true
13
14 const ipcMocks = vi.hoisted(() => ({
15 getElementAnimation: vi.fn(),
16 setElementAnimation: vi.fn()
17 }))
18 const refreshCurrentPreview = vi.hoisted(() => vi.fn())
19 const translate = vi.hoisted(() => vi.fn((key: string) => key))
20
21 vi.mock('@renderer/lib/ipc', () => ({ ipc: ipcMocks }))
22 vi.mock('@renderer/i18n', () => ({
23 useT: () => translate
24 }))
25 vi.mock('@renderer/store', async () => {
26 const actual = await vi.importActual<typeof import('@renderer/store')>('@renderer/store')
27 return {
28 ...actual,
29 useSessionDetailRuntimeStore: Object.assign(
30 (selector: unknown) => {
31 if (typeof selector === 'function') {
32 return selector({
33 refreshCurrentPreview
34 })
35 }
36 return actual.useSessionDetailRuntimeStore.getState()
37 },
38 actual.useSessionDetailRuntimeStore
39 )
40 }
41 })
42 vi.mock('react-router-dom', async () => {
43 const actual = await vi.importActual<typeof import('react-router-dom')>('react-router-dom')
44 return { ...actual, useParams: () => ({ id: 'session-1' }) }
45 })
46 vi.mock('sonner', () => {
47 const fn = (() => '') as unknown as {
48 success: () => string
49 error: () => string
50 info: () => string
51 warning: () => string
52 loading: () => string
53 promise: () => void
54 dismiss: () => void
55 }
56 fn.success = fn
57 fn.error = fn
58 fn.info = fn
59 fn.warning = fn
60 fn.loading = fn
61 fn.promise = () => {}
62 fn.dismiss = () => {}
63 return { toast: fn }
64 })
65
66 async function renderPicker(): Promise<{ root: Root; container: HTMLDivElement }> {
67 const container = document.createElement('div')
68 document.body.appendChild(container)
69 const root = createRoot(container)
70 await act(async () => {
71 root.render(React.createElement(ElementAnimationPicker))
72 await Promise.resolve()
73 })
74 return { root, container }
75 }
76
77 describe('ElementAnimationPicker', () => {
78 beforeEach(() => {
79 ipcMocks.getElementAnimation.mockReset()
80 ipcMocks.setElementAnimation.mockReset()
81 refreshCurrentPreview.mockReset()
82 useGenerateStore.getState().reset()
83 useGenerateStore.getState().setPages([
84 {
85 id: 'page-record-1',
86 pageId: 'page-1',
87 pageNumber: 1,
88 title: 'Page 1',
89 html: '<div>Page</div>',
90 htmlPath: '/tmp/page-1.html'
91 }
92 ])
93 useSessionDetailUiStore.getState().resetForSessionChange()
94 useSessionDetailUiStore.setState({
95 workspaceTab: 'animation',
96 selectedPageId: 'page-record-1',
97 interactionMode: 'preview'
98 })
99 })
100
101 afterEach(() => {
102 document.body.innerHTML = ''
103 })
104
105 it('owns animation selection mode and loads the selected element config', async () => {
106 ipcMocks.getElementAnimation.mockResolvedValue({
107 animation: {
108 type: 'fade-up',
109 trigger: 'load',
110 durationMs: 450
111 }
112 })
113 const { root, container } = await renderPicker()
114
115 try {
116 expect(useSessionDetailUiStore.getState().interactionMode).toBe('animation-select')
117 expect(container.textContent).toContain('sessionDetail.elementAnimationSelectTarget')
118
119 await act(async () => {
120 useSessionDetailUiStore
121 .getState()
122 .setSelectedElement('[data-block-id="metric"]', 'metric', 'div', 'Revenue')
123 await Promise.resolve()
124 await Promise.resolve()
125 })
126
127 expect(ipcMocks.getElementAnimation).toHaveBeenCalledWith({
128 sessionId: 'session-1',
129 htmlPath: '/tmp/page-1.html',
130 pageId: 'page-1',
131 selector: '[data-block-id="metric"]'
132 })
133 expect(container.textContent).toContain('home.animationPreferenceOptions.fade-up')
134 } finally {
135 await act(async () => root.unmount())
136 container.remove()
137 }
138 })
139
140 it('disables the trigger until an element is selected', async () => {
141 const { root, container } = await renderPicker()
142 try {
143 expect(container.querySelector('button')?.disabled).toBe(true)
144
145 ipcMocks.getElementAnimation.mockResolvedValue({ animation: null })
146 await act(async () => {
147 useSessionDetailUiStore
148 .getState()
149 .setSelectedElement('[data-block-id="metric"]', 'metric', 'div', 'Revenue')
150 await Promise.resolve()
151 await Promise.resolve()
152 })
153
154 expect(container.querySelector('button')?.disabled).toBe(false)
155 } finally {
156 await act(async () => root.unmount())
157 container.remove()
158 }
159 })
160
161 it('locks type cards until the existing animation is cleared', async () => {
162 ipcMocks.getElementAnimation.mockResolvedValue({
163 animation: { type: 'pulse', trigger: 'load', durationMs: 600 }
164 })
165 const { root, container } = await renderPicker()
166 try {
167 await act(async () => {
168 useSessionDetailUiStore
169 .getState()
170 .setSelectedElement('[data-block-id="metric"]', 'metric', 'div', 'Revenue')
171 await Promise.resolve()
172 await Promise.resolve()
173 })
174
175 await act(async () => {
176 container.querySelector('button')?.click()
177 await Promise.resolve()
178 await Promise.resolve()
179 })
180
181 // Popover content is portaled to document.body.
182 const cards = Array.from(document.querySelectorAll('[data-element-animation]'))
183 expect(cards.length).toBeGreaterThan(0)
184 expect(cards.every((card) => (card as HTMLButtonElement).disabled)).toBe(true)
185 expect(document.body.textContent).toContain('sessionDetail.elementAnimationClearFirstHint')
186
187 ipcMocks.setElementAnimation.mockResolvedValue({ animation: null, changed: true })
188 const noneButton = Array.from(document.querySelectorAll('button')).find((btn) =>
189 btn.textContent?.includes('sessionDetail.indexTransitionNone')
190 )
191 expect(noneButton).toBeTruthy()
192 await act(async () => {
193 noneButton!.click()
194 await Promise.resolve()
195 await Promise.resolve()
196 })
197
198 await act(async () => {
199 container.querySelector('button')?.click()
200 await Promise.resolve()
201 await Promise.resolve()
202 })
203
204 const cardsAfterClear = Array.from(document.querySelectorAll('[data-element-animation]'))
205 expect(cardsAfterClear.length).toBeGreaterThan(0)
206 expect(
207 cardsAfterClear.every((card) => (card as HTMLButtonElement).disabled)
208 ).toBe(false)
209 } finally {
210 await act(async () => root.unmount())
211 container.remove()
212 }
213 })
214
215 it('refreshes the page preview after a successful save', async () => {
216 ipcMocks.getElementAnimation.mockResolvedValue({ animation: null })
217 const { root, container } = await renderPicker()
218 try {
219 const previewKeyBefore = useSessionDetailUiStore.getState().previewKey
220
221 await act(async () => {
222 useSessionDetailUiStore
223 .getState()
224 .setSelectedElement('[data-block-id="metric"]', 'metric', 'div', 'Revenue')
225 await Promise.resolve()
226 await Promise.resolve()
227 })
228 await act(async () => {
229 container.querySelector('button')?.click()
230 await Promise.resolve()
231 await Promise.resolve()
232 })
233
234 ipcMocks.setElementAnimation.mockResolvedValue({
235 animation: { type: 'fade-up', trigger: 'load', durationMs: 600 },
236 changed: true
237 })
238 const card = document.querySelector('[data-element-animation="fade-up"]') as HTMLButtonElement
239 expect(card).toBeTruthy()
240 await act(async () => {
241 card.click()
242 await Promise.resolve()
243 await Promise.resolve()
244 })
245
246 expect(useSessionDetailUiStore.getState().previewKey).toBe(previewKeyBefore)
247 expect(refreshCurrentPreview).toHaveBeenCalledTimes(1)
248 expect(document.querySelector('[data-element-animation="fade-up"]')).toBeNull()
249 } finally {
250 await act(async () => root.unmount())
251 container.remove()
252 }
253 })
254
255 it('does not show selected element text inside the animation popover', async () => {
256 ipcMocks.getElementAnimation.mockResolvedValue({ animation: null })
257 const { root, container } = await renderPicker()
258 try {
259 await act(async () => {
260 useSessionDetailUiStore
261 .getState()
262 .setSelectedElement('[data-block-id="metric"]', 'metric', 'div', 'Revenue')
263 await Promise.resolve()
264 await Promise.resolve()
265 })
266
267 await act(async () => {
268 container.querySelector('button')?.click()
269 await Promise.resolve()
270 await Promise.resolve()
271 })
272
273 expect(document.querySelector('[data-element-animation="fade"]')).toBeTruthy()
274 expect(document.body.textContent).not.toContain('Revenue')
275 expect(document.body.textContent).not.toContain('[data-block-id="metric"]')
276 } finally {
277 await act(async () => root.unmount())
278 container.remove()
279 }
280 })
281
282 it('keeps animation mode active and preserves the selected element after clearing animation', async () => {
283 ipcMocks.getElementAnimation.mockResolvedValue({
284 animation: { type: 'pulse', trigger: 'load', durationMs: 600 }
285 })
286 const { root, container } = await renderPicker()
287 try {
288 await act(async () => {
289 useSessionDetailUiStore
290 .getState()
291 .setSelectedElement('[data-block-id="metric"]', 'metric', 'div', 'Revenue')
292 await Promise.resolve()
293 await Promise.resolve()
294 })
295
296 await act(async () => {
297 container.querySelector('button')?.click()
298 await Promise.resolve()
299 await Promise.resolve()
300 })
301
302 const noneButton = Array.from(document.querySelectorAll('button')).find((btn) =>
303 btn.textContent?.includes('sessionDetail.indexTransitionNone')
304 ) as HTMLButtonElement | undefined
305 expect(noneButton).toBeTruthy()
306
307 ipcMocks.setElementAnimation.mockResolvedValue({
308 animation: null,
309 changed: true
310 })
311 await act(async () => {
312 noneButton!.click()
313 await Promise.resolve()
314 await Promise.resolve()
315 })
316
317 expect(useSessionDetailUiStore.getState().workspaceTab).toBe('animation')
318 expect(useSessionDetailUiStore.getState().interactionMode).toBe('animation-select')
319 expect(useSessionDetailUiStore.getState().selectedSelector).toBe(
320 '[data-block-id="metric"]'
321 )
322 } finally {
323 await act(async () => root.unmount())
324 container.remove()
325 }
326 })
327 })
328
328 lines TYPESCRIPT