(frontend) add unit tests for presenter hooks

Cover the three hooks that drive the presenter overlay:
useSlides for divider-based block segmentation,
useBrowserFullscreen for the Fullscreen API wrapper,
and usePresenterShortcuts for the keyboard navigation bindings.

Signed-off-by: Nathan Panchout <nathanpanchout@hotmail.com>
This commit is contained in:
Nathan Panchout
2026-05-04 16:18:11 +02:00
parent 11b44a3925
commit f90da06a79
3 changed files with 367 additions and 0 deletions

View File

@@ -0,0 +1,130 @@
import { act, renderHook } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { useBrowserFullscreen } from '../hooks/useBrowserFullscreen';
describe('useBrowserFullscreen', () => {
let fullscreenElement: Element | null = null;
const requestFullscreen = vi.fn(async () => {
fullscreenElement = document.documentElement;
document.dispatchEvent(new Event('fullscreenchange'));
});
const exitFullscreen = vi.fn(async () => {
fullscreenElement = null;
document.dispatchEvent(new Event('fullscreenchange'));
});
beforeEach(() => {
fullscreenElement = null;
Object.defineProperty(document, 'fullscreenElement', {
configurable: true,
get: () => fullscreenElement,
});
Object.defineProperty(document.documentElement, 'requestFullscreen', {
configurable: true,
value: requestFullscreen,
});
Object.defineProperty(document, 'exitFullscreen', {
configurable: true,
value: exitFullscreen,
});
requestFullscreen.mockClear();
exitFullscreen.mockClear();
});
afterEach(() => {
fullscreenElement = null;
});
test('initial state reflects current fullscreen state', () => {
const { result } = renderHook(() => useBrowserFullscreen());
expect(result.current.isFullscreen).toBe(false);
});
test('enter() requests fullscreen and updates state', async () => {
const { result } = renderHook(() => useBrowserFullscreen());
await act(async () => {
await result.current.enter();
});
expect(requestFullscreen).toHaveBeenCalledTimes(1);
expect(result.current.isFullscreen).toBe(true);
});
test('enter() is a no-op if already fullscreen', async () => {
fullscreenElement = document.documentElement;
const { result } = renderHook(() => useBrowserFullscreen());
await act(async () => {
await result.current.enter();
});
expect(requestFullscreen).not.toHaveBeenCalled();
});
test('exit() leaves fullscreen and updates state', async () => {
const { result } = renderHook(() => useBrowserFullscreen());
await act(async () => {
await result.current.enter();
});
await act(async () => {
await result.current.exit();
});
expect(exitFullscreen).toHaveBeenCalledTimes(1);
expect(result.current.isFullscreen).toBe(false);
});
test('toggle() flips state', async () => {
const { result } = renderHook(() => useBrowserFullscreen());
await act(async () => {
await result.current.toggle();
});
expect(result.current.isFullscreen).toBe(true);
await act(async () => {
await result.current.toggle();
});
expect(result.current.isFullscreen).toBe(false);
});
test('reacts to external fullscreenchange events', () => {
const { result } = renderHook(() => useBrowserFullscreen());
act(() => {
fullscreenElement = document.documentElement;
document.dispatchEvent(new Event('fullscreenchange'));
});
expect(result.current.isFullscreen).toBe(true);
});
test('exitIfOwned() exits when we initiated the fullscreen', async () => {
const { result } = renderHook(() => useBrowserFullscreen());
await act(async () => {
await result.current.enter();
});
await act(async () => {
await result.current.exitIfOwned();
});
expect(exitFullscreen).toHaveBeenCalledTimes(1);
});
test('exitIfOwned() is a no-op when fullscreen pre-exists', async () => {
fullscreenElement = document.documentElement;
const { result } = renderHook(() => useBrowserFullscreen());
await act(async () => {
await result.current.exitIfOwned();
});
expect(exitFullscreen).not.toHaveBeenCalled();
});
test('exitIfOwned() is a no-op after user exits fullscreen externally', async () => {
const { result } = renderHook(() => useBrowserFullscreen());
await act(async () => {
await result.current.enter();
});
// User presses Esc — fullscreen ends outside of our control.
act(() => {
fullscreenElement = null;
document.dispatchEvent(new Event('fullscreenchange'));
});
await act(async () => {
await result.current.exitIfOwned();
});
expect(exitFullscreen).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,109 @@
import { renderHook } from '@testing-library/react';
import { describe, expect, test, vi } from 'vitest';
import { usePresenterShortcuts } from '../hooks/usePresenterShortcuts';
const renderShortcuts = (
overrides: Partial<Parameters<typeof usePresenterShortcuts>[0]> = {},
) => {
const handlers = {
onPrev: vi.fn(),
onNext: vi.fn(),
onFirst: vi.fn(),
onLast: vi.fn(),
onToggleFullscreen: vi.fn(),
onClose: vi.fn(),
isFullscreen: false,
...overrides,
};
renderHook(() => usePresenterShortcuts(handlers));
return handlers;
};
const press = (init: KeyboardEventInit) => {
const event = new KeyboardEvent('keydown', { ...init, cancelable: true });
window.dispatchEvent(event);
return event;
};
describe('usePresenterShortcuts', () => {
test('ArrowLeft and PageUp call onPrev', () => {
const h = renderShortcuts();
press({ code: 'ArrowLeft' });
press({ code: 'PageUp' });
expect(h.onPrev).toHaveBeenCalledTimes(2);
});
test('ArrowRight, PageDown and Space call onNext', () => {
const h = renderShortcuts();
press({ code: 'ArrowRight' });
press({ code: 'PageDown' });
press({ code: 'Space' });
expect(h.onNext).toHaveBeenCalledTimes(3);
});
test('Home calls onFirst, End calls onLast', () => {
const h = renderShortcuts();
press({ code: 'Home' });
press({ code: 'End' });
expect(h.onFirst).toHaveBeenCalledTimes(1);
expect(h.onLast).toHaveBeenCalledTimes(1);
});
test('KeyF toggles fullscreen but ignores modifiers', () => {
const h = renderShortcuts();
press({ code: 'KeyF' });
press({ code: 'KeyF', metaKey: true });
press({ code: 'KeyF', ctrlKey: true });
expect(h.onToggleFullscreen).toHaveBeenCalledTimes(1);
});
test('Escape calls onClose only when not fullscreen', () => {
const h1 = renderShortcuts({ isFullscreen: false });
press({ code: 'Escape' });
expect(h1.onClose).toHaveBeenCalledTimes(1);
const h2 = renderShortcuts({ isFullscreen: true });
press({ code: 'Escape' });
expect(h2.onClose).not.toHaveBeenCalled();
});
test('Space prevents default to avoid page scroll', () => {
renderShortcuts();
const event = press({ code: 'Space' });
expect(event.defaultPrevented).toBe(true);
});
test('Arrow keys prevent default', () => {
renderShortcuts();
expect(press({ code: 'ArrowLeft' }).defaultPrevented).toBe(true);
expect(press({ code: 'ArrowRight' }).defaultPrevented).toBe(true);
});
test('non-arrow repeat events are ignored', () => {
const h = renderShortcuts();
press({ code: 'Space', repeat: true });
expect(h.onNext).not.toHaveBeenCalled();
});
test('arrow repeat events are accepted', () => {
const h = renderShortcuts();
press({ code: 'ArrowRight', repeat: true });
expect(h.onNext).toHaveBeenCalledTimes(1);
});
test('Space on a button is ignored to avoid native click double-trigger', () => {
const h = renderShortcuts();
const button = document.createElement('button');
document.body.appendChild(button);
button.dispatchEvent(
new KeyboardEvent('keydown', {
code: 'Space',
bubbles: true,
cancelable: true,
}),
);
expect(h.onNext).not.toHaveBeenCalled();
document.body.removeChild(button);
});
});

View File

@@ -0,0 +1,128 @@
import { describe, expect, test } from 'vitest';
import { isEmptyBlock, splitBlocksIntoSlides } from '../hooks/useSlides';
const para = (text = 'hello') => ({
type: 'paragraph',
content: text === '' ? [] : [{ type: 'text', text }],
});
const heading = (text = 'Title', level = 1) => ({
type: 'heading',
content: [{ type: 'text', text }],
props: { level },
});
const divider = () => ({ type: 'divider' });
const image = () => ({ type: 'image', props: { url: 'x' } });
describe('isEmptyBlock', () => {
test('empty paragraph (no content array entries) is empty', () => {
expect(isEmptyBlock(para(''))).toBe(true);
});
test('whitespace-only paragraph is empty', () => {
expect(isEmptyBlock(para(' '))).toBe(true);
});
test('paragraph with text is not empty', () => {
expect(isEmptyBlock(para('hi'))).toBe(false);
});
test('heading with whitespace is empty', () => {
expect(isEmptyBlock(heading(' '))).toBe(true);
});
test('image is never empty', () => {
expect(isEmptyBlock(image() as any)).toBe(false);
});
test('divider is not "empty" (it is filtered separately)', () => {
expect(isEmptyBlock(divider() as any)).toBe(false);
});
test('block with children is not empty', () => {
const b = { type: 'paragraph', content: [], children: [para()] };
expect(isEmptyBlock(b as any)).toBe(false);
});
});
describe('splitBlocksIntoSlides', () => {
test('no divider yields one slide', () => {
const result = splitBlocksIntoSlides([para('a'), para('b')]);
expect(result).toHaveLength(1);
expect(result[0]).toHaveLength(2);
});
test('one divider yields two slides', () => {
const result = splitBlocksIntoSlides([para('a'), divider(), para('b')]);
expect(result).toHaveLength(2);
expect(result[0]).toHaveLength(1);
expect(result[1]).toHaveLength(1);
});
test('leading divider does not produce an empty slide', () => {
const result = splitBlocksIntoSlides([divider(), para('a')]);
expect(result).toHaveLength(1);
});
test('trailing divider does not produce an empty slide', () => {
const result = splitBlocksIntoSlides([para('a'), divider()]);
expect(result).toHaveLength(1);
});
test('consecutive dividers do not produce empty slides', () => {
const result = splitBlocksIntoSlides([
para('a'),
divider(),
divider(),
divider(),
para('b'),
]);
expect(result).toHaveLength(2);
});
test('empty doc yields one empty slide', () => {
const result = splitBlocksIntoSlides([]);
expect(result).toHaveLength(1);
expect(result[0]).toHaveLength(0);
});
test('divider-only doc yields one empty slide', () => {
const result = splitBlocksIntoSlides([divider(), divider()]);
expect(result).toHaveLength(1);
expect(result[0]).toHaveLength(0);
});
test('group of only empty paragraphs is dropped', () => {
const result = splitBlocksIntoSlides([
para('a'),
divider(),
para(''),
para(' '),
divider(),
para('b'),
]);
expect(result).toHaveLength(2);
expect(result[0][0]).toMatchObject({ content: [{ text: 'a' }] });
expect(result[1][0]).toMatchObject({ content: [{ text: 'b' }] });
});
test('group with one empty + one non-empty paragraph keeps only the non-empty', () => {
const result = splitBlocksIntoSlides([para(''), para('hi'), para(' ')]);
expect(result).toHaveLength(1);
expect(result[0]).toHaveLength(1);
expect(result[0][0]).toMatchObject({ content: [{ text: 'hi' }] });
});
test('image-only group is kept', () => {
const result = splitBlocksIntoSlides([para('a'), divider(), image()]);
expect(result).toHaveLength(2);
expect(result[1]).toHaveLength(1);
});
test('heading with whitespace is filtered', () => {
const result = splitBlocksIntoSlides([heading(' '), para('body')]);
expect(result).toHaveLength(1);
expect(result[0]).toHaveLength(1);
expect(result[0][0]).toMatchObject({ type: 'paragraph' });
});
});