import { render, act, cleanup } from '@testing-library/react'; import { StrictMode } from 'react'; import { describe, it, expect, vi, beforeEach } from 'vitest'; const mockApi = vi.hoisted(() => ({ initialize: vi.fn().mockResolvedValue(undefined), destroy: vi.fn(), sync: vi.fn(), isReady: vi.fn().mockReturnValue(true), on: vi.fn(), off: vi.fn(), configure: vi.fn(), })); const RevealConstructor = vi.hoisted(() => vi.fn(function () { return mockApi; }) ); vi.mock('reveal.js', () => ({ default: RevealConstructor })); import { Deck, Slide, Markdown, useReveal } from '../index'; beforeEach(() => { vi.clearAllMocks(); mockApi.initialize.mockResolvedValue(undefined); mockApi.isReady.mockReturnValue(true); }); describe('Deck', () => { it('renders .reveal > .slides structure', async () => { let container: HTMLElement; await act(async () => { ({ container } = render( Hello )); }); const reveal = container!.querySelector('.reveal'); expect(reveal).toBeInTheDocument(); const slides = reveal?.querySelector('.slides'); expect(slides).toBeInTheDocument(); const section = slides?.querySelector('section'); expect(section).toHaveTextContent('Hello'); }); it('calls new Reveal() and initialize() on mount', async () => { await act(async () => { render( Test ); }); expect(RevealConstructor).toHaveBeenCalledWith( expect.any(HTMLElement), expect.objectContaining({ transition: 'slide', hash: true, plugins: [], }) ); expect(mockApi.initialize).toHaveBeenCalledTimes(1); }); it('calls destroy() on unmount', async () => { await act(async () => { render( Test ); }); await act(async () => { cleanup(); }); expect(mockApi.destroy).toHaveBeenCalledTimes(1); }); it('calls sync() after render when ready', async () => { await act(async () => { render( Test ); }); expect(mockApi.sync).toHaveBeenCalled(); }); it('does not call sync() when slide content changes without structural changes', async () => { let rerender: ReturnType['rerender']; await act(async () => { ({ rerender } = render(

Tick 1

)); }); mockApi.sync.mockClear(); await act(async () => { rerender(

Tick 2

); }); expect(mockApi.sync).not.toHaveBeenCalled(); }); it('calls sync() when Markdown content changes', async () => { let rerender: ReturnType['rerender']; await act(async () => { ({ rerender } = render( )); }); mockApi.sync.mockClear(); await act(async () => { rerender( ); }); expect(mockApi.sync).toHaveBeenCalledTimes(1); }); it('calls sync() when Markdown slide attributes change', async () => { let rerender: ReturnType['rerender']; await act(async () => { ({ rerender } = render( )); }); mockApi.sync.mockClear(); await act(async () => { rerender( ); }); expect(mockApi.sync).toHaveBeenCalledTimes(1); }); it('does not call sync() twice when configure() already syncs', async () => { mockApi.configure.mockImplementation(() => { mockApi.sync(); }); let rerender: ReturnType['rerender']; await act(async () => { ({ rerender } = render( Test )); }); mockApi.configure.mockClear(); mockApi.sync.mockClear(); await act(async () => { rerender( Test ); }); expect(mockApi.configure).toHaveBeenCalledTimes(1); expect(mockApi.sync).toHaveBeenCalledTimes(1); }); it('calls sync() when keyed slides are reordered', async () => { let rerender: ReturnType['rerender']; await act(async () => { ({ rerender } = render( {['first', 'second'].map((label) => ( {label} ))} )); }); mockApi.sync.mockClear(); await act(async () => { rerender( {['second', 'first'].map((label) => ( {label} ))} ); }); expect(mockApi.sync).toHaveBeenCalledTimes(1); }); it('calls sync() after children change even when isReady() was false during the config-change render', async () => { let rerender: ReturnType['rerender']; await act(async () => { ({ rerender } = render( Slide 1 )); }); // Make isReady() return true for the configure effect but false for the sync effect, // simulating Reveal briefly being non-ready during configuration. Without the fix the // skip flag would never be reset and the subsequent sync() call would be wrongly skipped. mockApi.isReady.mockReturnValueOnce(true).mockReturnValueOnce(false); await act(async () => { rerender( Slide 1 ); }); mockApi.isReady.mockReturnValue(true); mockApi.sync.mockClear(); await act(async () => { rerender( Slide 1 Slide 2 ); }); expect(mockApi.sync).toHaveBeenCalledTimes(1); }); it('does not reconfigure when config object is recreated with same values', async () => { let rerender: ReturnType['rerender']; await act(async () => { ({ rerender } = render( Test )); }); mockApi.configure.mockClear(); await act(async () => { rerender( Test ); }); expect(mockApi.configure).not.toHaveBeenCalled(); }); it('fires onReady callback after initialization', async () => { const onReady = vi.fn(); await act(async () => { render( Test ); }); expect(onReady).toHaveBeenCalledWith(mockApi); }); it('wires onSlideChange to the slidechanged event', async () => { const onSlideChange = vi.fn(); await act(async () => { render( Test ); }); expect(mockApi.on).toHaveBeenCalledWith('slidechanged', onSlideChange); }); it('wires onSlideSync to the slidesync event', async () => { const onSlideSync = vi.fn(); await act(async () => { render( Test ); }); expect(mockApi.on).toHaveBeenCalledWith('slidesync', onSlideSync); }); it('cleans up event listeners on unmount', async () => { const onSlideChange = vi.fn(); await act(async () => { render( Test ); }); await act(async () => { cleanup(); }); expect(mockApi.off).toHaveBeenCalledWith('slidechanged', onSlideChange); }); it('passes plugins to the Reveal constructor', async () => { const fakePlugin = () => ({ id: 'fake', init: () => {} }); await act(async () => { render( Test ); }); expect(RevealConstructor).toHaveBeenCalledWith( expect.any(HTMLElement), expect.objectContaining({ plugins: [fakePlugin] }) ); }); it('registers plugins only on initialization', async () => { const pluginA = () => ({ id: 'a', init: () => {} }); const pluginB = () => ({ id: 'b', init: () => {} }); let rerender: ReturnType['rerender']; await act(async () => { ({ rerender } = render( Test )); }); mockApi.configure.mockClear(); await act(async () => { rerender( Test ); }); expect(RevealConstructor).toHaveBeenCalledTimes(1); expect(RevealConstructor).toHaveBeenCalledWith( expect.any(HTMLElement), expect.objectContaining({ plugins: [pluginA] }) ); expect(mockApi.configure).not.toHaveBeenCalled(); }); it('applies className and style to the .reveal div', async () => { let container: HTMLElement; await act(async () => { ({ container } = render( Test )); }); const reveal = container!.querySelector('.reveal'); expect(reveal).toHaveClass('reveal', 'custom'); expect(reveal).toHaveStyle({ height: '400px' }); }); it('does not initialize twice in StrictMode', async () => { await act(async () => { render( Test ); }); expect(RevealConstructor).toHaveBeenCalledTimes(1); expect(mockApi.initialize).toHaveBeenCalledTimes(1); }); it('updates deckRef when the ref prop changes', async () => { const refA = vi.fn(); const refB = vi.fn(); let rerender: ReturnType['rerender']; let unmount: ReturnType['unmount']; await act(async () => { ({ rerender, unmount } = render( Test )); }); expect(refA).toHaveBeenCalledWith(mockApi); await act(async () => { rerender( Test ); }); expect(refA).toHaveBeenCalledWith(null); expect(refB).toHaveBeenCalledWith(mockApi); await act(async () => { unmount(); }); expect(refB).toHaveBeenCalledWith(null); }); }); describe('useReveal', () => { it('returns the RevealApi instance inside a Deck', async () => { let hookResult: any = undefined; function Inspector() { hookResult = useReveal(); return null; } await act(async () => { render( ); }); expect(hookResult).toBe(mockApi); }); it('returns null outside of a Deck', () => { let hookResult: any = 'not-null'; function Inspector() { hookResult = useReveal(); return null; } render(); expect(hookResult).toBeNull(); }); });