From 39390eff0e1a32ff6062f848d6f995d616b9250b Mon Sep 17 00:00:00 2001 From: Nathan Panchout Date: Mon, 4 May 2026 16:18:11 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=85(frontend)=20add=20unit=20tests=20for?= =?UTF-8?q?=20presenter=20hooks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../__tests__/useBrowserFullscreen.spec.ts | 130 +++++++++++ .../__tests__/useFitScale.spec.ts | 220 ++++++++++++++++++ .../__tests__/usePresenterShortcuts.spec.ts | 109 +++++++++ .../doc-presenter/__tests__/useSlides.spec.ts | 86 +++++++ 4 files changed, 545 insertions(+) create mode 100644 src/frontend/apps/impress/src/features/docs/doc-presenter/__tests__/useBrowserFullscreen.spec.ts create mode 100644 src/frontend/apps/impress/src/features/docs/doc-presenter/__tests__/useFitScale.spec.ts create mode 100644 src/frontend/apps/impress/src/features/docs/doc-presenter/__tests__/usePresenterShortcuts.spec.ts create mode 100644 src/frontend/apps/impress/src/features/docs/doc-presenter/__tests__/useSlides.spec.ts diff --git a/src/frontend/apps/impress/src/features/docs/doc-presenter/__tests__/useBrowserFullscreen.spec.ts b/src/frontend/apps/impress/src/features/docs/doc-presenter/__tests__/useBrowserFullscreen.spec.ts new file mode 100644 index 000000000..274b584d0 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-presenter/__tests__/useBrowserFullscreen.spec.ts @@ -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(); + }); +}); diff --git a/src/frontend/apps/impress/src/features/docs/doc-presenter/__tests__/useFitScale.spec.ts b/src/frontend/apps/impress/src/features/docs/doc-presenter/__tests__/useFitScale.spec.ts new file mode 100644 index 000000000..a2684b5da --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-presenter/__tests__/useFitScale.spec.ts @@ -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, + frameRef: { current: frame } as RefObject, + 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); + }); +}); diff --git a/src/frontend/apps/impress/src/features/docs/doc-presenter/__tests__/usePresenterShortcuts.spec.ts b/src/frontend/apps/impress/src/features/docs/doc-presenter/__tests__/usePresenterShortcuts.spec.ts new file mode 100644 index 000000000..4d6369549 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-presenter/__tests__/usePresenterShortcuts.spec.ts @@ -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[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); + }); +}); diff --git a/src/frontend/apps/impress/src/features/docs/doc-presenter/__tests__/useSlides.spec.ts b/src/frontend/apps/impress/src/features/docs/doc-presenter/__tests__/useSlides.spec.ts new file mode 100644 index 000000000..3de86f529 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-presenter/__tests__/useSlides.spec.ts @@ -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); + }); +});