import { describe, expect, it, vi } from 'vitest'; import { DEFAULT_ANIMATION_SHORTCUTS, buildAriaKeyShortcuts, buildShortcutHelpRows, clampAnimationControlValue, createAnimationShortcutHandler, createAnimationStatusAnnouncement, createReducedMotionAnimationPlan, findMatchingShortcut, formatAriaKeyShortcut, isEditableShortcutTarget, type KeyboardShortcutEventLike, } from './animationAccessibility'; function keyboardEvent(overrides: Partial): KeyboardShortcutEventLike { return { altKey: false, ctrlKey: false, metaKey: false, shiftKey: false, repeat: false, ...overrides, }; } describe('animation accessibility keyboard shortcuts', () => { it('maps primary playback shortcuts to the same command', () => { expect( findMatchingShortcut(keyboardEvent({ key: ' ', code: 'Space' }))?.command, ).toBe('togglePlayback'); expect(findMatchingShortcut(keyboardEvent({ key: 'k' }))?.command).toBe( 'togglePlayback', ); expect(findMatchingShortcut(keyboardEvent({ key: 'K' }))?.command).toBe( 'togglePlayback', ); }); it('requires exact modifiers for conflicting seek and speed shortcuts', () => { expect(findMatchingShortcut(keyboardEvent({ key: 'ArrowRight' }))?.command).toBe( 'seekForwardSmall', ); expect( findMatchingShortcut(keyboardEvent({ key: 'ArrowRight', shiftKey: true }))?.command, ).toBe('seekForwardLarge'); expect(findMatchingShortcut(keyboardEvent({ key: 'ArrowUp' }))).toBeUndefined(); expect( findMatchingShortcut(keyboardEvent({ key: 'ArrowUp', altKey: true }))?.command, ).toBe('increaseRpm'); }); it('prevents repeated toggles while allowing repeated stepping controls', () => { expect(findMatchingShortcut(keyboardEvent({ key: ' ', repeat: true }))).toBeUndefined(); expect( findMatchingShortcut(keyboardEvent({ key: 'ArrowRight', repeat: true }))?.command, ).toBe('seekForwardSmall'); }); it('does not hijack typing, text editing, or slider input targets by default', () => { expect(isEditableShortcutTarget({ tagName: 'INPUT' })).toBe(true); expect(isEditableShortcutTarget({ isContentEditable: true })).toBe(true); expect( isEditableShortcutTarget({ getAttribute: (name: string) => (name === 'role' ? 'textbox' : null), }), ).toBe(true); expect( findMatchingShortcut( keyboardEvent({ key: ' ', target: { tagName: 'INPUT' }, }), ), ).toBeUndefined(); expect( findMatchingShortcut( keyboardEvent({ key: ' ', target: { tagName: 'INPUT' }, }), DEFAULT_ANIMATION_SHORTCUTS, { allowInEditable: true }, )?.command, ).toBe('togglePlayback'); }); it('runs registered shortcut actions and prevents browser defaults only when handled', () => { const preventDefault = vi.fn(); const togglePlayback = vi.fn(); const handler = createAnimationShortcutHandler({ togglePlayback, }); const command = handler( keyboardEvent({ key: ' ', preventDefault, }), ); expect(command).toBe('togglePlayback'); expect(togglePlayback).toHaveBeenCalledTimes(1); expect(preventDefault).toHaveBeenCalledTimes(1); const preventDefaultWithoutAction = vi.fn(); const unhandled = createAnimationShortcutHandler({})( keyboardEvent({ key: ' ', preventDefault: preventDefaultWithoutAction, }), ); expect(unhandled).toBeUndefined(); expect(preventDefaultWithoutAction).not.toHaveBeenCalled(); }); it('builds ordered shortcut help rows and ARIA keyboard shortcut strings', () => { const helpRows = buildShortcutHelpRows(DEFAULT_ANIMATION_SHORTCUTS); expect(helpRows[0]).toMatchObject({ command: 'togglePlayback', chord: 'Space', group: 'playback', }); expect( helpRows.some((row) => row.command === 'toggleHelp' && row.chord === '?'), ).toBe(true); const largeSeekShortcut = DEFAULT_ANIMATION_SHORTCUTS.find( (shortcut) => shortcut.command === 'seekForwardLarge', ); expect(largeSeekShortcut).toBeDefined(); expect(formatAriaKeyShortcut(largeSeekShortcut!)).toBe('Shift+ArrowRight'); expect(buildAriaKeyShortcuts(DEFAULT_ANIMATION_SHORTCUTS)).toContain('Space'); }); }); describe('reduced motion animation planning', () => { it('keeps normal playback unchanged when reduced motion is not requested', () => { const plan = createReducedMotionAnimationPlan({ prefersReducedMotion: false, requestedTimeScale: 1.5, }); expect(plan).toMatchObject({ mode: 'normal', shouldAutoPlay: true, allowContinuousPlayback: true, timeScale: 1.5, rpmMultiplier: 1, }); }); it('uses a static snapshot for non-user-initiated non-essential motion', () => { const plan = createReducedMotionAnimationPlan({ prefersReducedMotion: true, userInitiated: false, }); expect(plan).toMatchObject({ mode: 'static-snapshot', shouldAutoPlay: false, allowContinuousPlayback: false, timeScale: 0, rpmMultiplier: 0, }); }); it('falls back to step-through mode for user-initiated motion unless reduced continuous playback is allowed', () => { const plan = createReducedMotionAnimationPlan({ prefersReducedMotion: true, userInitiated: true, allowContinuousReducedMotion: false, }); expect(plan).toMatchObject({ mode: 'step-through', shouldAutoPlay: false, allowContinuousPlayback: false, }); }); it('caps reduced continuous playback speed and rpm multiplier', () => { const plan = createReducedMotionAnimationPlan({ prefersReducedMotion: true, userInitiated: true, allowContinuousReducedMotion: true, requestedTimeScale: 2, }); expect(plan).toMatchObject({ mode: 'reduced-continuous', shouldAutoPlay: true, allowContinuousPlayback: true, timeScale: 0.35, rpmMultiplier: 0.25, }); }); it('never auto-starts essential explanatory motion unless the user initiates it', () => { const plan = createReducedMotionAnimationPlan({ prefersReducedMotion: true, essentialMotion: true, requestedTimeScale: 0.8, }); expect(plan.mode).toBe('reduced-continuous'); expect(plan.shouldAutoPlay).toBe(false); expect(plan.allowContinuousPlayback).toBe(true); expect(plan.timeScale).toBe(0.35); }); }); describe('animation status announcements and control value clamping', () => { it('formats concise live-region announcements for playback and tour state', () => { const announcement = createAnimationStatusAnnouncement({ machineName: 'Geneva drive', playbackState: 'playing', rpm: 60, timeScale: 0.5, progress: 0.375, tourTitle: 'Indexing cycle', tourStepTitle: 'Drive pin enters the slot', currentStep: 2, totalSteps: 5, highlightedPartName: 'drive pin', }); expect(announcement).toContain('Geneva drive animation playing'); expect(announcement).toContain('60 RPM'); expect(announcement).toContain('0.5× speed'); expect(announcement).toContain('38% through cycle'); expect(announcement).toContain('Tour: Indexing cycle, step 2 of 5'); expect(announcement).toContain('Highlighted part: drive pin'); expect(announcement.endsWith('.')).toBe(true); }); it('clamps and snaps slider-style animation control values', () => { expect( clampAnimationControlValue(122, { min: 0, max: 200, step: 25, fallback: 50, }), ).toBe(125); expect( clampAnimationControlValue(Number.NaN, { min: 0, max: 200, step: 25, fallback: 50, }), ).toBe(50); expect( clampAnimationControlValue(999, { min: 200, max: 0, step: 25, fallback: 50, }), ).toBe(200); }); });