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__/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..0c21fea0b --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-presenter/__tests__/useSlides.spec.ts @@ -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' }); + }); +});