mirror of
https://github.com/suitenumerique/docs.git
synced 2026-08-17 21:25:43 +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.
This commit is contained in:
+130
@@ -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();
|
||||
});
|
||||
});
|
||||
+220
@@ -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);
|
||||
});
|
||||
});
|
||||
+109
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user