返回 oh-my-ppt
styles-page-rendering.test.ts
根目录 / tests / unit / styles / styles-page-rendering.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 { MemoryRouter } from 'react-router-dom'
8 import { StylesPage } from '../../../src/renderer/src/pages/styles'
9 import { useStylePreviewStore } from '../../../src/renderer/src/store/stylePreviewStore'
10
11 const ipcMocks = vi.hoisted(() => ({
12 listStyles: vi.fn(),
13 generateStylePreview: vi.fn(),
14 setStyleFavorite: vi.fn(),
15 exportStylePackageZip: vi.fn(),
16 deleteStyle: vi.fn(),
17 importStylePackageDirectory: vi.fn(),
18 importStylePackageZip: vi.fn(),
19 onHtmlThumbnailChanged: vi.fn(() => () => undefined)
20 }))
21 let thumbnailListener: ((task: {
22 resourceType: string
23 resourceId: string
24 variant: string
25 status: 'completed'
26 thumbnailPath: string
27 }) => void) | null = null
28 const translate = vi.hoisted(() => vi.fn((key: string) => key))
29
30 type ObserverEntry = Pick<IntersectionObserverEntry, 'target' | 'isIntersecting'>
31
32 class MockIntersectionObserver {
33 static instances: MockIntersectionObserver[] = []
34 readonly observed = new Set<Element>()
35
36 constructor(private readonly callback: IntersectionObserverCallback) {
37 MockIntersectionObserver.instances.push(this)
38 }
39
40 observe = (element: Element): void => {
41 this.observed.add(element)
42 }
43
44 unobserve = (element: Element): void => {
45 this.observed.delete(element)
46 }
47
48 disconnect = (): void => {
49 this.observed.clear()
50 }
51
52 emit(entries: ObserverEntry[]): void {
53 this.callback(entries as IntersectionObserverEntry[], this as unknown as IntersectionObserver)
54 }
55 }
56
57 function getObservedCard(observer: MockIntersectionObserver, styleId: string): Element {
58 const card = Array.from(observer.observed).find(
59 (element) => (element as HTMLElement).dataset.styleCardId === styleId
60 )
61 if (!card) throw new Error(`Expected observed style card ${styleId}`)
62 return card
63 }
64
65 vi.mock('@renderer/lib/ipc', () => ({ ipc: ipcMocks }))
66 vi.mock('@renderer/i18n', () => ({ useT: () => translate }))
67
68 async function renderStylesPage(): Promise<{ container: HTMLDivElement; root: Root }> {
69 const container = document.createElement('div')
70 document.body.appendChild(container)
71 const root = createRoot(container)
72 await act(async () => {
73 root.render(React.createElement(MemoryRouter, null, React.createElement(StylesPage)))
74 })
75 await act(async () => {
76 await new Promise((resolve) => window.setTimeout(resolve, 5))
77 })
78 return { container, root }
79 }
80
81 describe('StylesPage rendering', () => {
82 beforeEach(() => {
83 vi.spyOn(console, 'error').mockImplementation(() => undefined)
84 MockIntersectionObserver.instances = []
85 vi.stubGlobal('IntersectionObserver', MockIntersectionObserver)
86 useStylePreviewStore.setState({ generatingStyleId: '', completionVersion: 0 })
87 ipcMocks.listStyles.mockResolvedValue({
88 items: [
89 {
90 id: 'style-with-preview',
91 label: 'Preview Style',
92 description: 'Has a generated preview',
93 category: 'deck',
94 source: 'custom',
95 styleCase: 'Pitch, Report',
96 previewPath: '/styles/preview/preview.html',
97 thumbnailPath: '/thumbnail-cache/style-with-preview.png',
98 favoriteAt: 10,
99 createdAt: 1,
100 updatedAt: 2
101 },
102 {
103 id: 'style-pending-thumbnail',
104 label: 'Pending Thumbnail',
105 description: 'Uses iframe while visible',
106 category: 'deck',
107 source: 'custom',
108 previewPath: '/styles/pending/preview.html',
109 createdAt: 1,
110 updatedAt: 1
111 },
112 {
113 id: 'style-without-preview',
114 label: 'Fresh Style',
115 description: 'Needs a generated preview',
116 category: 'deck',
117 source: 'builtin',
118 createdAt: 1,
119 updatedAt: 0
120 }
121 ]
122 })
123 ipcMocks.generateStylePreview.mockResolvedValue({ previewPath: '/styles/fresh/preview.html' })
124 ipcMocks.setStyleFavorite.mockResolvedValue({
125 success: true,
126 styleId: 'style-with-preview',
127 favoriteAt: null
128 })
129 ipcMocks.importStylePackageDirectory.mockResolvedValue({
130 success: true,
131 id: 'folder-style',
132 source: 'custom'
133 })
134 ipcMocks.onHtmlThumbnailChanged.mockImplementation((listener) => {
135 thumbnailListener = listener
136 return () => {
137 thumbnailListener = null
138 }
139 })
140 })
141
142 afterEach(() => {
143 vi.unstubAllGlobals()
144 vi.restoreAllMocks()
145 document.body.innerHTML = ''
146 })
147
148 it('uses a visible iframe until the PNG thumbnail arrives', async () => {
149 const { container, root } = await renderStylesPage()
150 try {
151 expect(container.querySelectorAll('[data-style-card-id]')).toHaveLength(3)
152 const importMenuButton = Array.from(container.querySelectorAll('button')).find(
153 (button) => button.textContent?.includes('styles.importMenu')
154 ) as HTMLButtonElement | undefined
155 const openMenu = async (): Promise<void> => {
156 await act(async () => {
157 importMenuButton?.dispatchEvent(
158 new PointerEvent('pointerdown', { bubbles: true, cancelable: true })
159 )
160 importMenuButton?.dispatchEvent(
161 new MouseEvent('mousedown', { bubbles: true, cancelable: true })
162 )
163 await new Promise((resolve) => window.setTimeout(resolve, 5))
164 })
165 }
166 await openMenu()
167 const officialSkillLink = document.body.querySelector(
168 'a[href="https://github.com/arcsin1/style-generate-skill"]'
169 )
170 expect(officialSkillLink?.getAttribute('target')).toBe('_blank')
171 expect(officialSkillLink?.getAttribute('rel')).toBe('noopener noreferrer')
172 await act(async () => {
173 document.body.dispatchEvent(
174 new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })
175 )
176 await new Promise((resolve) => window.setTimeout(resolve, 5))
177 })
178 expect(container.querySelectorAll('img')).toHaveLength(1)
179 expect(container.querySelectorAll('iframe')).toHaveLength(0)
180 expect(container.textContent).toContain('Preview Style')
181 expect(container.textContent).toContain('Pitch')
182
183 const observer = MockIntersectionObserver.instances[0]
184 await act(async () => {
185 observer.emit([
186 {
187 target: getObservedCard(observer, 'style-pending-thumbnail'),
188 isIntersecting: true
189 }
190 ])
191 })
192 const previewIframe = container.querySelector('[data-testid="style-preview-iframe"]')
193 expect(previewIframe).not.toBeNull()
194 expect(previewIframe?.getAttribute('sandbox')).toBe('')
195
196 await act(async () => {
197 thumbnailListener?.({
198 resourceType: 'style',
199 resourceId: 'style-pending-thumbnail',
200 variant: 'default',
201 status: 'completed',
202 thumbnailPath: '/thumbnail-cache/style-pending-thumbnail.png'
203 })
204 })
205 expect(container.querySelectorAll('img')).toHaveLength(2)
206 expect(container.querySelectorAll('iframe')).toHaveLength(0)
207 expect(ipcMocks.listStyles).toHaveBeenCalledTimes(1)
208
209 const generateButton = container.querySelector(
210 'button[aria-label="styles.generatePreview"]'
211 ) as HTMLButtonElement | null
212 await act(async () => {
213 generateButton?.click()
214 await Promise.resolve()
215 })
216 expect(ipcMocks.generateStylePreview).toHaveBeenCalledWith({
217 styleId: 'style-without-preview'
218 })
219
220 await openMenu()
221 const importFolderItem = Array.from(document.body.querySelectorAll('[role="menuitem"]')).find(
222 (item) => item.textContent?.includes('styles.importPackageDirectory')
223 )
224 expect(importFolderItem).toBeTruthy()
225 await act(async () => {
226 importFolderItem?.dispatchEvent(
227 new PointerEvent('pointerup', { bubbles: true, cancelable: true })
228 )
229 importFolderItem?.click()
230 await new Promise((resolve) => window.setTimeout(resolve, 10))
231 })
232 expect(ipcMocks.importStylePackageDirectory).toHaveBeenCalledTimes(1)
233 } finally {
234 await act(async () => root.unmount())
235 container.remove()
236 }
237 })
238
239 it('filters by keyword and favorite chip while toggling favorite state', async () => {
240 const { container, root } = await renderStylesPage()
241 try {
242 expect(container.querySelectorAll('[data-style-card-id]')).toHaveLength(3)
243 const searchInput = container.querySelector(
244 'input[placeholder="styles.searchPlaceholder"]'
245 ) as HTMLInputElement | null
246 await act(async () => {
247 if (!searchInput) throw new Error('Expected style search input')
248 const valueSetter = Object.getOwnPropertyDescriptor(
249 HTMLInputElement.prototype,
250 'value'
251 )?.set
252 valueSetter?.call(searchInput, 'Fresh')
253 searchInput.dispatchEvent(new Event('input', { bubbles: true }))
254 searchInput.dispatchEvent(new Event('change', { bubbles: true }))
255 })
256 expect(container.querySelectorAll('[data-style-card-id]')).toHaveLength(1)
257 expect(container.textContent).toContain('Fresh Style')
258 expect(container.textContent).not.toContain('Preview Style')
259
260 const clearSearch = container.querySelector(
261 'button[aria-label="styles.clearSearch"]'
262 ) as HTMLButtonElement | null
263 await act(async () => {
264 clearSearch?.click()
265 })
266
267 const favoriteChip = Array.from(container.querySelectorAll('button')).find((button) =>
268 button.textContent?.includes('styles.favoriteStyles')
269 ) as HTMLButtonElement | undefined
270 await act(async () => {
271 favoriteChip?.click()
272 })
273 expect(container.querySelectorAll('[data-style-card-id]')).toHaveLength(1)
274 expect(container.textContent).toContain('Preview Style')
275 expect(container.querySelector('button[aria-label="styles.unfavoriteStyle"]')).not.toBeNull()
276
277 const unfavoriteButton = container.querySelector(
278 'button[aria-label="styles.unfavoriteStyle"]'
279 ) as HTMLButtonElement | null
280 await act(async () => {
281 unfavoriteButton?.click()
282 await Promise.resolve()
283 })
284 expect(ipcMocks.setStyleFavorite).toHaveBeenCalledWith({
285 styleId: 'style-with-preview',
286 favorite: false
287 })
288 expect(container.textContent).toContain('styles.noFavoriteStyles')
289 } finally {
290 await act(async () => root.unmount())
291 container.remove()
292 }
293 })
294 })
295
295 lines TYPESCRIPT