返回 oh-my-ppt
browse-view-rendering.test.ts
根目录 / tests / unit / session / browse-view-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 { BrowseView } from '../../../src/renderer/src/components/session-detail/browse/BrowseView'
8 import { TooltipProvider } from '../../../src/renderer/src/components/ui/Tooltip'
9 import { useGenerateStore } from '../../../src/renderer/src/store/generateStore'
10 import { useSessionDetailUiStore } from '../../../src/renderer/src/store/sessionDetailStore'
11 import { useSessionStore } from '../../../src/renderer/src/store/sessionStore'
12
13 vi.mock('../../../src/renderer/src/i18n', () => ({
14 useT: () => (key: string) => key
15 }))
16
17 vi.mock('../../../src/renderer/src/components/preview/PreviewIframe', () => ({
18 PreviewIframe: ({ title }: { title: string }) =>
19 React.createElement('div', {
20 'data-testid': 'preview-iframe',
21 'data-title': title
22 })
23 }))
24
25 type ObserverEntry = Pick<IntersectionObserverEntry, 'target' | 'isIntersecting'>
26
27 class MockIntersectionObserver {
28 static instances: MockIntersectionObserver[] = []
29
30 readonly observed = new Set<Element>()
31
32 constructor(
33 private readonly callback: IntersectionObserverCallback,
34 readonly options?: IntersectionObserverInit
35 ) {
36 MockIntersectionObserver.instances.push(this)
37 }
38
39 observe = (element: Element): void => {
40 this.observed.add(element)
41 }
42
43 unobserve = (element: Element): void => {
44 this.observed.delete(element)
45 }
46
47 disconnect = (): void => {
48 this.observed.clear()
49 }
50
51 takeRecords = (): IntersectionObserverEntry[] => []
52
53 emit(entries: ObserverEntry[]): void {
54 this.callback(entries as IntersectionObserverEntry[], this as unknown as IntersectionObserver)
55 }
56 }
57
58 function makePages(count: number) {
59 return Array.from({ length: count }, (_, index) => {
60 const pageNumber = index + 1
61 return {
62 id: `page-${pageNumber}`,
63 pageId: `page-${pageNumber}`,
64 pageNumber,
65 title: `Page ${pageNumber}`,
66 html: '<html></html>',
67 sourceUrl: `session://page-${pageNumber}.html`
68 }
69 })
70 }
71
72 function getObservedCard(observer: MockIntersectionObserver, pageId: string): Element {
73 const element = Array.from(observer.observed).find(
74 (node) => (node as HTMLElement).dataset.browseCardId === pageId
75 )
76 if (!element) throw new Error(`Expected observed card ${pageId}`)
77 return element
78 }
79
80 async function renderBrowseView(): Promise<{ container: HTMLDivElement; root: Root }> {
81 const container = document.createElement('div')
82 document.body.appendChild(container)
83 const root = createRoot(container)
84
85 await act(async () => {
86 root.render(
87 React.createElement(
88 TooltipProvider,
89 { delayDuration: 0 },
90 React.createElement(BrowseView, { sessionId: 'session-1' })
91 )
92 )
93 })
94
95 return { container, root }
96 }
97
98 async function cleanupRoot(root: Root, container: HTMLDivElement): Promise<void> {
99 await act(async () => {
100 root.unmount()
101 })
102 container.remove()
103 }
104
105 describe('BrowseView preview rendering', () => {
106 beforeEach(() => {
107 MockIntersectionObserver.instances = []
108 vi.stubGlobal('IntersectionObserver', MockIntersectionObserver)
109 useGenerateStore.getState().reset()
110 useSessionDetailUiStore.getState().resetForSessionChange()
111 useSessionStore.setState({
112 currentSession: {
113 slideSizeId: 'wide-16-9',
114 slideWidth: 1600,
115 slideHeight: 900
116 } as never
117 })
118 })
119
120 afterEach(() => {
121 vi.unstubAllGlobals()
122 document.body.innerHTML = ''
123 })
124
125 it('renders every intersecting grid card instead of only the nearest eight', async () => {
126 useGenerateStore.getState().setPages(makePages(12))
127 const { container, root } = await renderBrowseView()
128
129 try {
130 const observer = MockIntersectionObserver.instances[0]
131 expect(observer.options?.rootMargin).toBe('200px 100px')
132 expect(observer.options?.root).toBeTruthy()
133 expect(observer.observed.size).toBe(12)
134
135 await act(async () => {
136 observer.emit(
137 Array.from({ length: 12 }, (_, index) => ({
138 target: getObservedCard(observer, `page-${index + 1}`),
139 isIntersecting: true
140 }))
141 )
142 })
143
144 expect(container.querySelectorAll('[data-testid="preview-iframe"]')).toHaveLength(12)
145
146 await act(async () => {
147 observer.emit([{ target: getObservedCard(observer, 'page-2'), isIntersecting: false }])
148 })
149
150 const titles = Array.from(container.querySelectorAll('[data-testid="preview-iframe"]')).map(
151 (node) => (node as HTMLElement).dataset.title
152 )
153 expect(titles).toHaveLength(11)
154 expect(titles).not.toContain('browse-page-2')
155 } finally {
156 await cleanupRoot(root, container)
157 }
158 })
159
160 it('reserves two lines for every card title so short titles do not shrink the card', async () => {
161 const pages = [
162 {
163 id: 'page-1',
164 pageId: 'page-1',
165 pageNumber: 1,
166 title: 'Short one-line title',
167 html: '<html></html>',
168 sourceUrl: 'session://page-1.html'
169 },
170 {
171 id: 'page-2',
172 pageId: 'page-2',
173 pageNumber: 2,
174 title: 'A '.repeat(80).trim(),
175 html: '<html></html>',
176 sourceUrl: 'session://page-2.html'
177 }
178 ]
179 useGenerateStore.getState().setPages(pages)
180 const { container, root } = await renderBrowseView()
181
182 try {
183 const titles = container.querySelectorAll('p')
184 expect(titles).toHaveLength(2)
185 // Every title reserves 2 lines (leading-4 = 1rem/line), so a 1-line
186 // title cannot shrink its card below a 2-line title's card.
187 for (const title of titles) {
188 expect((title as HTMLElement).style.minHeight).toBe('calc(2 * 1rem)')
189 }
190 } finally {
191 await cleanupRoot(root, container)
192 }
193 })
194
195 it('renders a drag handle for each browse card', async () => {
196 useGenerateStore.getState().setPages(makePages(3))
197 const { container, root } = await renderBrowseView()
198
199 try {
200 const handles = container.querySelectorAll('button[aria-label="pageManagement.dragHandle"]')
201 expect(handles).toHaveLength(3)
202 } finally {
203 await cleanupRoot(root, container)
204 }
205 })
206
207 it('renders browse card hover actions for export, rename, and delete', async () => {
208 useGenerateStore.getState().setPages(makePages(3))
209 const { container, root } = await renderBrowseView()
210
211 try {
212 expect(
213 container.querySelectorAll('button[aria-label="sessionDetail.exportSinglePagePptx"]')
214 ).toHaveLength(3)
215 expect(container.querySelectorAll('button[aria-label="pageManagement.editPageTitle"]'))
216 .toHaveLength(3)
217 expect(container.querySelectorAll('button[aria-label="pageManagement.deletePage"]'))
218 .toHaveLength(3)
219 } finally {
220 await cleanupRoot(root, container)
221 }
222 })
223
224 it('hides browse card export actions when the deck is not 16:9', async () => {
225 useSessionStore.setState({
226 currentSession: {
227 slideSizeId: 'standard-4-3',
228 slideWidth: 1600,
229 slideHeight: 1200
230 } as never
231 })
232 useGenerateStore.getState().setPages(makePages(3))
233 const { container, root } = await renderBrowseView()
234
235 try {
236 expect(
237 container.querySelectorAll('button[aria-label="sessionDetail.exportSinglePagePptx"]')
238 ).toHaveLength(0)
239 expect(container.querySelectorAll('button[aria-label="pageManagement.editPageTitle"]'))
240 .toHaveLength(3)
241 expect(container.querySelectorAll('button[aria-label="pageManagement.deletePage"]'))
242 .toHaveLength(3)
243 } finally {
244 await cleanupRoot(root, container)
245 }
246 })
247
248 it('wires browse card rename and delete actions to page management state', async () => {
249 useGenerateStore.getState().setPages(makePages(2))
250 const { container, root } = await renderBrowseView()
251
252 try {
253 const renameButton = container.querySelector<HTMLButtonElement>(
254 'button[aria-label="pageManagement.editPageTitle"]'
255 )
256 const deleteButton = container.querySelector<HTMLButtonElement>(
257 'button[aria-label="pageManagement.deletePage"]'
258 )
259
260 await act(async () => {
261 renameButton?.click()
262 })
263 expect(useSessionDetailUiStore.getState().pageTitleEditPageId).toBe('page-1')
264 expect(useSessionDetailUiStore.getState().pageTitleEditDraft).toBe('Page 1')
265
266 await act(async () => {
267 deleteButton?.click()
268 })
269 expect(useSessionDetailUiStore.getState().deleteConfirmPageId).toBe('page-1')
270 } finally {
271 await cleanupRoot(root, container)
272 }
273 })
274
275 it('keeps browse previews independent from the merge pages dialog state', async () => {
276 useGenerateStore.getState().setPages(makePages(12))
277 useSessionDetailUiStore.getState().setMergeSessionPagesDialogOpen(true)
278 const { container, root } = await renderBrowseView()
279
280 try {
281 const observer = MockIntersectionObserver.instances[0]
282
283 await act(async () => {
284 observer.emit(
285 Array.from({ length: 12 }, (_, index) => ({
286 target: getObservedCard(observer, `page-${index + 1}`),
287 isIntersecting: true
288 }))
289 )
290 })
291
292 expect(container.querySelectorAll('[data-testid="preview-iframe"]')).toHaveLength(12)
293 } finally {
294 await cleanupRoot(root, container)
295 }
296 })
297 })
298
298 lines TYPESCRIPT