返回 reveal.js
deck.test.tsx
根目录 / react / src / components / deck.test.tsx
1 import { render, act, cleanup } from '@testing-library/react';
2 import { StrictMode } from 'react';
3 import { describe, it, expect, vi, beforeEach } from 'vitest';
4
5 const mockApi = vi.hoisted(() => ({
6 initialize: vi.fn().mockResolvedValue(undefined),
7 destroy: vi.fn(),
8 sync: vi.fn(),
9 isReady: vi.fn().mockReturnValue(true),
10 on: vi.fn(),
11 off: vi.fn(),
12 configure: vi.fn(),
13 }));
14
15 const RevealConstructor = vi.hoisted(() =>
16 vi.fn(function () {
17 return mockApi;
18 })
19 );
20
21 vi.mock('reveal.js', () => ({ default: RevealConstructor }));
22
23 import { Deck, Slide, Markdown, useReveal } from '../index';
24
25 beforeEach(() => {
26 vi.clearAllMocks();
27 mockApi.initialize.mockResolvedValue(undefined);
28 mockApi.isReady.mockReturnValue(true);
29 });
30
31 describe('Deck', () => {
32 it('renders .reveal > .slides structure', async () => {
33 let container: HTMLElement;
34 await act(async () => {
35 ({ container } = render(
36 <Deck>
37 <Slide>Hello</Slide>
38 </Deck>
39 ));
40 });
41
42 const reveal = container!.querySelector('.reveal');
43 expect(reveal).toBeInTheDocument();
44
45 const slides = reveal?.querySelector('.slides');
46 expect(slides).toBeInTheDocument();
47
48 const section = slides?.querySelector('section');
49 expect(section).toHaveTextContent('Hello');
50 });
51
52 it('calls new Reveal() and initialize() on mount', async () => {
53 await act(async () => {
54 render(
55 <Deck config={{ transition: 'slide', hash: true }}>
56 <Slide>Test</Slide>
57 </Deck>
58 );
59 });
60
61 expect(RevealConstructor).toHaveBeenCalledWith(
62 expect.any(HTMLElement),
63 expect.objectContaining({
64 transition: 'slide',
65 hash: true,
66 plugins: [],
67 })
68 );
69 expect(mockApi.initialize).toHaveBeenCalledTimes(1);
70 });
71
72 it('calls destroy() on unmount', async () => {
73 await act(async () => {
74 render(
75 <Deck>
76 <Slide>Test</Slide>
77 </Deck>
78 );
79 });
80
81 await act(async () => {
82 cleanup();
83 });
84
85 expect(mockApi.destroy).toHaveBeenCalledTimes(1);
86 });
87
88 it('calls sync() after render when ready', async () => {
89 await act(async () => {
90 render(
91 <Deck>
92 <Slide>Test</Slide>
93 </Deck>
94 );
95 });
96
97 expect(mockApi.sync).toHaveBeenCalled();
98 });
99
100 it('does not call sync() when slide content changes without structural changes', async () => {
101 let rerender: ReturnType<typeof render>['rerender'];
102 await act(async () => {
103 ({ rerender } = render(
104 <Deck>
105 <Slide>
106 <p>Tick 1</p>
107 </Slide>
108 </Deck>
109 ));
110 });
111
112 mockApi.sync.mockClear();
113
114 await act(async () => {
115 rerender(
116 <Deck>
117 <Slide>
118 <p>Tick 2</p>
119 </Slide>
120 </Deck>
121 );
122 });
123
124 expect(mockApi.sync).not.toHaveBeenCalled();
125 });
126
127 it('calls sync() when Markdown content changes', async () => {
128 let rerender: ReturnType<typeof render>['rerender'];
129 await act(async () => {
130 ({ rerender } = render(
131 <Deck>
132 <Markdown markdown="## Tick 1" />
133 </Deck>
134 ));
135 });
136
137 mockApi.sync.mockClear();
138
139 await act(async () => {
140 rerender(
141 <Deck>
142 <Markdown markdown="## Tick 2" />
143 </Deck>
144 );
145 });
146
147 expect(mockApi.sync).toHaveBeenCalledTimes(1);
148 });
149
150 it('calls sync() when Markdown slide attributes change', async () => {
151 let rerender: ReturnType<typeof render>['rerender'];
152 await act(async () => {
153 ({ rerender } = render(
154 <Deck>
155 <Markdown markdown="## Tick" backgroundColor="#111111" />
156 </Deck>
157 ));
158 });
159
160 mockApi.sync.mockClear();
161
162 await act(async () => {
163 rerender(
164 <Deck>
165 <Markdown markdown="## Tick" backgroundColor="#222222" />
166 </Deck>
167 );
168 });
169
170 expect(mockApi.sync).toHaveBeenCalledTimes(1);
171 });
172
173 it('does not call sync() twice when configure() already syncs', async () => {
174 mockApi.configure.mockImplementation(() => {
175 mockApi.sync();
176 });
177
178 let rerender: ReturnType<typeof render>['rerender'];
179 await act(async () => {
180 ({ rerender } = render(
181 <Deck config={{ transition: 'slide' }}>
182 <Slide>Test</Slide>
183 </Deck>
184 ));
185 });
186
187 mockApi.configure.mockClear();
188 mockApi.sync.mockClear();
189
190 await act(async () => {
191 rerender(
192 <Deck config={{ transition: 'convex' }}>
193 <Slide>Test</Slide>
194 </Deck>
195 );
196 });
197
198 expect(mockApi.configure).toHaveBeenCalledTimes(1);
199 expect(mockApi.sync).toHaveBeenCalledTimes(1);
200 });
201
202 it('calls sync() when keyed slides are reordered', async () => {
203 let rerender: ReturnType<typeof render>['rerender'];
204 await act(async () => {
205 ({ rerender } = render(
206 <Deck>
207 {['first', 'second'].map((label) => (
208 <Slide key={label}>{label}</Slide>
209 ))}
210 </Deck>
211 ));
212 });
213
214 mockApi.sync.mockClear();
215
216 await act(async () => {
217 rerender(
218 <Deck>
219 {['second', 'first'].map((label) => (
220 <Slide key={label}>{label}</Slide>
221 ))}
222 </Deck>
223 );
224 });
225
226 expect(mockApi.sync).toHaveBeenCalledTimes(1);
227 });
228
229 it('calls sync() after children change even when isReady() was false during the config-change render', async () => {
230 let rerender: ReturnType<typeof render>['rerender'];
231 await act(async () => {
232 ({ rerender } = render(
233 <Deck config={{ transition: 'slide' }}>
234 <Slide>Slide 1</Slide>
235 </Deck>
236 ));
237 });
238
239 // Make isReady() return true for the configure effect but false for the sync effect,
240 // simulating Reveal briefly being non-ready during configuration. Without the fix the
241 // skip flag would never be reset and the subsequent sync() call would be wrongly skipped.
242 mockApi.isReady.mockReturnValueOnce(true).mockReturnValueOnce(false);
243
244 await act(async () => {
245 rerender(
246 <Deck config={{ transition: 'convex' }}>
247 <Slide>Slide 1</Slide>
248 </Deck>
249 );
250 });
251
252 mockApi.isReady.mockReturnValue(true);
253 mockApi.sync.mockClear();
254
255 await act(async () => {
256 rerender(
257 <Deck config={{ transition: 'convex' }}>
258 <Slide>Slide 1</Slide>
259 <Slide>Slide 2</Slide>
260 </Deck>
261 );
262 });
263
264 expect(mockApi.sync).toHaveBeenCalledTimes(1);
265 });
266
267 it('does not reconfigure when config object is recreated with same values', async () => {
268 let rerender: ReturnType<typeof render>['rerender'];
269 await act(async () => {
270 ({ rerender } = render(
271 <Deck config={{ transition: 'slide', hash: true }}>
272 <Slide>Test</Slide>
273 </Deck>
274 ));
275 });
276
277 mockApi.configure.mockClear();
278
279 await act(async () => {
280 rerender(
281 <Deck config={{ transition: 'slide', hash: true }}>
282 <Slide>Test</Slide>
283 </Deck>
284 );
285 });
286
287 expect(mockApi.configure).not.toHaveBeenCalled();
288 });
289
290 it('fires onReady callback after initialization', async () => {
291 const onReady = vi.fn();
292
293 await act(async () => {
294 render(
295 <Deck onReady={onReady}>
296 <Slide>Test</Slide>
297 </Deck>
298 );
299 });
300
301 expect(onReady).toHaveBeenCalledWith(mockApi);
302 });
303
304 it('wires onSlideChange to the slidechanged event', async () => {
305 const onSlideChange = vi.fn();
306
307 await act(async () => {
308 render(
309 <Deck onSlideChange={onSlideChange}>
310 <Slide>Test</Slide>
311 </Deck>
312 );
313 });
314
315 expect(mockApi.on).toHaveBeenCalledWith('slidechanged', onSlideChange);
316 });
317
318 it('wires onSlideSync to the slidesync event', async () => {
319 const onSlideSync = vi.fn();
320
321 await act(async () => {
322 render(
323 <Deck onSlideSync={onSlideSync}>
324 <Slide>Test</Slide>
325 </Deck>
326 );
327 });
328
329 expect(mockApi.on).toHaveBeenCalledWith('slidesync', onSlideSync);
330 });
331
332 it('cleans up event listeners on unmount', async () => {
333 const onSlideChange = vi.fn();
334
335 await act(async () => {
336 render(
337 <Deck onSlideChange={onSlideChange}>
338 <Slide>Test</Slide>
339 </Deck>
340 );
341 });
342
343 await act(async () => {
344 cleanup();
345 });
346
347 expect(mockApi.off).toHaveBeenCalledWith('slidechanged', onSlideChange);
348 });
349
350 it('passes plugins to the Reveal constructor', async () => {
351 const fakePlugin = () => ({ id: 'fake', init: () => {} });
352
353 await act(async () => {
354 render(
355 <Deck plugins={[fakePlugin]}>
356 <Slide>Test</Slide>
357 </Deck>
358 );
359 });
360
361 expect(RevealConstructor).toHaveBeenCalledWith(
362 expect.any(HTMLElement),
363 expect.objectContaining({ plugins: [fakePlugin] })
364 );
365 });
366
367 it('registers plugins only on initialization', async () => {
368 const pluginA = () => ({ id: 'a', init: () => {} });
369 const pluginB = () => ({ id: 'b', init: () => {} });
370
371 let rerender: ReturnType<typeof render>['rerender'];
372 await act(async () => {
373 ({ rerender } = render(
374 <Deck plugins={[pluginA]}>
375 <Slide>Test</Slide>
376 </Deck>
377 ));
378 });
379
380 mockApi.configure.mockClear();
381
382 await act(async () => {
383 rerender(
384 <Deck plugins={[pluginB]}>
385 <Slide>Test</Slide>
386 </Deck>
387 );
388 });
389
390 expect(RevealConstructor).toHaveBeenCalledTimes(1);
391 expect(RevealConstructor).toHaveBeenCalledWith(
392 expect.any(HTMLElement),
393 expect.objectContaining({ plugins: [pluginA] })
394 );
395 expect(mockApi.configure).not.toHaveBeenCalled();
396 });
397
398 it('applies className and style to the .reveal div', async () => {
399 let container: HTMLElement;
400 await act(async () => {
401 ({ container } = render(
402 <Deck className="custom" style={{ height: '400px' }}>
403 <Slide>Test</Slide>
404 </Deck>
405 ));
406 });
407
408 const reveal = container!.querySelector('.reveal');
409 expect(reveal).toHaveClass('reveal', 'custom');
410 expect(reveal).toHaveStyle({ height: '400px' });
411 });
412
413 it('does not initialize twice in StrictMode', async () => {
414 await act(async () => {
415 render(
416 <StrictMode>
417 <Deck>
418 <Slide>Test</Slide>
419 </Deck>
420 </StrictMode>
421 );
422 });
423
424 expect(RevealConstructor).toHaveBeenCalledTimes(1);
425 expect(mockApi.initialize).toHaveBeenCalledTimes(1);
426 });
427
428 it('updates deckRef when the ref prop changes', async () => {
429 const refA = vi.fn();
430 const refB = vi.fn();
431
432 let rerender: ReturnType<typeof render>['rerender'];
433 let unmount: ReturnType<typeof render>['unmount'];
434 await act(async () => {
435 ({ rerender, unmount } = render(
436 <Deck deckRef={refA}>
437 <Slide>Test</Slide>
438 </Deck>
439 ));
440 });
441
442 expect(refA).toHaveBeenCalledWith(mockApi);
443
444 await act(async () => {
445 rerender(
446 <Deck deckRef={refB}>
447 <Slide>Test</Slide>
448 </Deck>
449 );
450 });
451
452 expect(refA).toHaveBeenCalledWith(null);
453 expect(refB).toHaveBeenCalledWith(mockApi);
454
455 await act(async () => {
456 unmount();
457 });
458
459 expect(refB).toHaveBeenCalledWith(null);
460 });
461 });
462
463 describe('useReveal', () => {
464 it('returns the RevealApi instance inside a Deck', async () => {
465 let hookResult: any = undefined;
466
467 function Inspector() {
468 hookResult = useReveal();
469 return null;
470 }
471
472 await act(async () => {
473 render(
474 <Deck>
475 <Slide>
476 <Inspector />
477 </Slide>
478 </Deck>
479 );
480 });
481
482 expect(hookResult).toBe(mockApi);
483 });
484
485 it('returns null outside of a Deck', () => {
486 let hookResult: any = 'not-null';
487
488 function Inspector() {
489 hookResult = useReveal();
490 return null;
491 }
492
493 render(<Inspector />);
494 expect(hookResult).toBeNull();
495 });
496 });
497
497 lines Plain Text