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();
});
});