(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.
This commit is contained in:
Nathan Panchout
2026-06-02 16:25:56 +02:00
parent 5f7e59a8dd
commit 39390eff0e
4 changed files with 545 additions and 0 deletions
@@ -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,220 @@
import { act, renderHook } from '@testing-library/react';
import { RefObject } from 'react';
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { computeFitScale, useFitScale } from '../hooks/useFitScale';
// Live constants the pure function closes over (see constants.ts):
// designWidth=900, minScale=0.7, maxScale=1.5, paddingX=64, paddingY=64.
// So availW = frameWidth - 128 and availH = frameHeight - 128.
describe('computeFitScale', () => {
// Short content: scaleW/scaleH both large, clamped down to MAX.
test('short content clamps to MAX scale', () => {
// availW=1792, availH=952; scaleW=1.99, scaleH=4.76 -> min 1.99 -> clamp 1.5
const fit = computeFitScale(200, 1920, 1080);
expect(fit).not.toBeNull();
expect(fit?.scale).toBeCloseTo(1.5, 5);
expect(fit?.outerWidth).toBeCloseTo(900 * 1.5, 5);
expect(fit?.stageHeight).toBeCloseTo(300, 5);
});
// Tall content: scaleH tiny, clamped up to MIN. The slide then overflows and
// scrolls — handled purely in CSS (PresenterSlide's outer is
// overflow-y:auto, the stage uses margin:auto so the top is never clipped).
test('tall content floors at MIN scale', () => {
// availH=952, naturalH=3000 -> scaleH=0.317 -> clamp 0.7; 3000 × 0.7 = 2100
const fit = computeFitScale(3000, 1920, 1080);
expect(fit?.scale).toBeCloseTo(0.7, 5);
expect(fit?.stageHeight).toBeCloseTo(2100, 5);
});
// Height-limited exact fit: naturalH equals availH, scaleH=1.0 wins.
test('content that exactly fits picks scaleH', () => {
// availH=952, naturalH=952 -> scaleH=1.0, scaleW=1.99 -> min 1.0
const fit = computeFitScale(952, 1920, 1080);
expect(fit?.scale).toBeCloseTo(1.0, 5);
expect(fit?.stageHeight).toBeCloseTo(952, 5);
});
test('width-limited frame below MIN clamps up to MIN', () => {
// availW=572 -> scaleW=0.635 < 0.7 -> clamp up to 0.7
const fit = computeFitScale(400, 700, 1080);
expect(fit?.scale).toBeCloseTo(0.7, 5);
});
test('width-limited frame within range picks scaleW', () => {
// availW=872 -> scaleW=872/900=0.969; availH=1372 -> scaleH=3.43 -> min 0.969
const fit = computeFitScale(400, 1000, 1500);
expect(fit?.scale).toBeCloseTo(872 / 900, 5);
});
test('returns null on non-positive dimensions', () => {
expect(computeFitScale(0, 1920, 1080)).toBeNull(); // no content height yet
expect(computeFitScale(500, 100, 1080)).toBeNull(); // availW = 100 - 128 <= 0
expect(computeFitScale(500, 1920, 100)).toBeNull(); // availH = 100 - 128 <= 0
});
});
type Callback = ResizeObserverCallback;
class FakeResizeObserver {
static instances: FakeResizeObserver[] = [];
callback: Callback;
targets: Element[] = [];
constructor(callback: Callback) {
this.callback = callback;
FakeResizeObserver.instances.push(this);
}
observe(target: Element): void {
this.targets.push(target);
}
unobserve(target: Element): void {
this.targets = this.targets.filter((t) => t !== target);
}
disconnect(): void {
this.targets = [];
}
trigger(): void {
this.callback([], this);
}
}
const triggerAll = () => {
for (const o of FakeResizeObserver.instances) {
o.trigger();
}
};
const makeRefs = (
innerScrollHeight: number,
frameClientWidth: number,
frameClientHeight: number,
) => {
const inner = document.createElement('div');
const frame = document.createElement('div');
Object.defineProperty(inner, 'scrollHeight', {
configurable: true,
get: () => innerScrollHeight,
});
Object.defineProperty(frame, 'clientWidth', {
configurable: true,
get: () => frameClientWidth,
});
Object.defineProperty(frame, 'clientHeight', {
configurable: true,
get: () => frameClientHeight,
});
document.body.appendChild(inner);
document.body.appendChild(frame);
return {
inner,
frame,
innerRef: { current: inner } as RefObject<HTMLDivElement | null>,
frameRef: { current: frame } as RefObject<HTMLDivElement | null>,
setInnerScrollHeight: (h: number) => {
Object.defineProperty(inner, 'scrollHeight', {
configurable: true,
get: () => h,
});
},
setFrameSize: (w: number, h: number) => {
Object.defineProperty(frame, 'clientWidth', {
configurable: true,
get: () => w,
});
Object.defineProperty(frame, 'clientHeight', {
configurable: true,
get: () => h,
});
},
};
};
// Thin glue tests: the formula is covered above, so these only assert the
// wiring (initial measure, re-measure on either resize, SSR safety, cleanup).
// No rAF: measure() runs synchronously inside the effect and the observer
// callback, so there is nothing async to flush.
describe('useFitScale', () => {
beforeEach(() => {
FakeResizeObserver.instances = [];
vi.stubGlobal('ResizeObserver', FakeResizeObserver);
});
afterEach(() => {
vi.unstubAllGlobals();
document.body.innerHTML = '';
});
test('measures synchronously on mount', () => {
const refs = makeRefs(500, 1920, 1080);
const { result } = renderHook(() =>
useFitScale(refs.innerRef, refs.frameRef),
);
expect(result.current).not.toBeNull();
expect(result.current?.scale).toBeCloseTo(
computeFitScale(500, 1920, 1080)!.scale,
5,
);
});
test('frame resize triggers a new computation', () => {
const refs = makeRefs(500, 1920, 1080);
const { result } = renderHook(() =>
useFitScale(refs.innerRef, refs.frameRef),
);
const initialScale = result.current?.scale;
expect(initialScale).not.toBeUndefined();
refs.setFrameSize(1280, 720);
act(() => triggerAll());
expect(result.current?.scale).not.toBe(initialScale);
});
test('inner resize (content grew) triggers a new computation', () => {
const refs = makeRefs(500, 1920, 1080);
const { result } = renderHook(() =>
useFitScale(refs.innerRef, refs.frameRef),
);
const initialScale = result.current?.scale;
refs.setInnerScrollHeight(2500);
act(() => triggerAll());
expect(result.current?.scale).not.toBe(initialScale);
expect(result.current?.scale).toBeCloseTo(0.7, 5);
});
test('stays null and does not throw without ResizeObserver (SSR)', () => {
vi.stubGlobal('ResizeObserver', undefined);
const refs = makeRefs(500, 1920, 1080);
const { result } = renderHook(() =>
useFitScale(refs.innerRef, refs.frameRef),
);
expect(result.current).toBeNull();
});
test('cleanup on unmount disconnects observers', () => {
const refs = makeRefs(500, 1920, 1080);
const { unmount } = renderHook(() =>
useFitScale(refs.innerRef, refs.frameRef),
);
expect(FakeResizeObserver.instances).toHaveLength(1);
const observer = FakeResizeObserver.instances[0];
expect(observer.targets).toHaveLength(2);
unmount();
expect(observer.targets).toHaveLength(0);
});
});
@@ -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,86 @@
import { describe, expect, test } from 'vitest';
import { splitBlocksIntoSlides } from '../hooks/useSlides';
const para = (text = 'hello') => ({
type: 'paragraph',
content: text === '' ? [] : [{ type: 'text', text }],
});
const divider = () => ({ type: 'divider' });
const image = () => ({ type: 'image', props: { url: 'x' } });
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('empty paragraphs are preserved as intentional spacing', () => {
const result = splitBlocksIntoSlides([
para('a'),
divider(),
para(''),
para(' '),
divider(),
para('b'),
]);
expect(result).toHaveLength(3);
expect(result[0][0]).toMatchObject({ content: [{ text: 'a' }] });
expect(result[1]).toHaveLength(2);
expect(result[2][0]).toMatchObject({ content: [{ text: 'b' }] });
});
test('blocks within a group are kept verbatim, empty or not', () => {
const result = splitBlocksIntoSlides([para(''), para('hi'), para(' ')]);
expect(result).toHaveLength(1);
expect(result[0]).toHaveLength(3);
expect(result[0][1]).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);
});
});