mirror of
https://github.com/suitenumerique/docs.git
synced 2026-05-09 16:42:18 +02:00
✅(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:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user