import { mount } from '@vue/test-utils' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { defineComponent, h, ref } from 'vue' import { useTimetableKeyboard } from '@/composables/timetable/useTimetableKeyboard' import { ArtistEngagementStatus, type Performance, type Stage } from '@/types/timetable' /** * Keyboard a11y end-to-end (RFC v0.2 D20). Tests the composable directly * with a host component that owns a focusable canvas root, mirrors the * page wiring, and exposes the callback spies + announce ref so we can * assert without driving the whole timetable page. */ const stage: Stage = { id: 's1', event_id: 'ev1', name: 'Hardstyle District', color: '#e85d75', capacity: 1000, sort_order: 0, created_at: null, updated_at: null, } function perf(id: string): Performance { return { id, engagement_id: `e_${id}`, event_id: 'ev1', stage_id: 's1', lane: 0, lane_resolved: 0, start_at: '2026-07-10T18:00:00.000Z', end_at: '2026-07-10T19:00:00.000Z', version: 1, notes: null, warnings: [], engagement: { booking_status: { value: ArtistEngagementStatus.CONFIRMED, label: 'Bevestigd' }, } as Performance['engagement'], created_at: null, updated_at: null, deleted_at: null, } } interface HostExposed { announce: () => string setSelected: (id: string | null) => void rootEl: () => HTMLElement | null callbacks: { nudge: ReturnType openPopover: ReturnType remove: ReturnType } } function mountKeyboardHost(performances: Performance[]) { const callbacks = { nudge: vi.fn().mockResolvedValue(undefined), openPopover: vi.fn(), remove: vi.fn().mockResolvedValue(undefined), } const selectedId = ref(performances[0]?.id ?? null) const Host = defineComponent({ setup(_, { expose }) { const rootEl = ref(null) const stagesRef = ref([stage]) const { announce } = useTimetableKeyboard({ rootEl, selectedId, resolvePerformance: (id: string) => performances.find(p => p.id === id) ?? null, stages: stagesRef, callbacks, }) expose({ announce: () => announce.value, setSelected: (id: string | null) => { selectedId.value = id }, rootEl: () => rootEl.value, callbacks, }) return () => h('div', { 'ref': rootEl, 'tabindex': '0', 'data-test': 'canvas' }, [ ...performances.map(p => h('div', { 'data-perf-id': p.id, 'tabindex': '0' }, p.id), ), ]) }, }) const wrapper = mount(Host, { attachTo: document.body }) return { wrapper, callbacks, selectedId } } function getExposed(wrapper: { vm: object }): HostExposed { return (wrapper.vm as { $: { exposed: HostExposed } }).$.exposed } function dispatch(rootEl: HTMLElement | null, key: string, opts: KeyboardEventInit = {}): void { if (!rootEl) throw new Error('rootEl is null') rootEl.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true, ...opts })) } describe('useTimetableKeyboard — RFC D20 a11y model', () => { beforeEach(() => vi.clearAllMocks()) afterEach(() => vi.useRealTimers()) it('Arrow Left nudges the selected performance by -SNAP_MIN', () => { const { wrapper, callbacks } = mountKeyboardHost([perf('p1')]) const exposed = getExposed(wrapper) dispatch(exposed.rootEl(), 'ArrowLeft') expect(callbacks.nudge).toHaveBeenCalledTimes(1) expect(callbacks.nudge.mock.calls[0][1]).toBe(-5) // -SNAP_MIN }) it('Arrow Right nudges by +SNAP_MIN', () => { const { wrapper, callbacks } = mountKeyboardHost([perf('p1')]) const exposed = getExposed(wrapper) dispatch(exposed.rootEl(), 'ArrowRight') expect(callbacks.nudge.mock.calls[0][1]).toBe(5) }) it('Shift+Arrow Right nudges by +60 min (12 × SNAP_MIN)', () => { const { wrapper, callbacks } = mountKeyboardHost([perf('p1')]) const exposed = getExposed(wrapper) dispatch(exposed.rootEl(), 'ArrowRight', { shiftKey: true }) expect(callbacks.nudge.mock.calls[0][1]).toBe(60) }) it('Arrow Down shifts lane (+1)', () => { const { wrapper, callbacks } = mountKeyboardHost([perf('p1')]) const exposed = getExposed(wrapper) dispatch(exposed.rootEl(), 'ArrowDown') expect(callbacks.nudge.mock.calls[0][2]).toBe(1) // deltaLane }) it('Shift+Arrow Down shifts stage (+1)', () => { const { wrapper, callbacks } = mountKeyboardHost([perf('p1')]) const exposed = getExposed(wrapper) dispatch(exposed.rootEl(), 'ArrowDown', { shiftKey: true }) expect(callbacks.nudge.mock.calls[0][3]).toBe(1) // deltaStageIdx }) it('] cycles to next stage preserving time + lane', () => { const { wrapper, callbacks } = mountKeyboardHost([perf('p1')]) const exposed = getExposed(wrapper) dispatch(exposed.rootEl(), ']') expect(callbacks.nudge.mock.calls[0][1]).toBe(0) // deltaMin expect(callbacks.nudge.mock.calls[0][2]).toBe(0) // deltaLane expect(callbacks.nudge.mock.calls[0][3]).toBe(1) // deltaStageIdx }) it('Enter on a focused block opens the popover', () => { const { wrapper, callbacks } = mountKeyboardHost([perf('p1')]) const exposed = getExposed(wrapper) dispatch(exposed.rootEl(), 'Enter') expect(callbacks.openPopover).toHaveBeenCalledTimes(1) expect(callbacks.openPopover.mock.calls[0][0]).toMatchObject({ id: 'p1' }) }) it('Delete invokes the remove callback', () => { const { wrapper, callbacks } = mountKeyboardHost([perf('p1')]) const exposed = getExposed(wrapper) dispatch(exposed.rootEl(), 'Delete') expect(callbacks.remove).toHaveBeenCalledTimes(1) expect(callbacks.remove.mock.calls[0][0]).toMatchObject({ id: 'p1' }) }) it('Space enters drag mode + announces; Arrow accumulates; Enter commits', () => { const { wrapper, callbacks } = mountKeyboardHost([perf('p1')]) const exposed = getExposed(wrapper) dispatch(exposed.rootEl(), ' ') expect(exposed.announce()).toMatch(/Toetsenbord-verplaatsing/i) expect(callbacks.nudge).not.toHaveBeenCalled() dispatch(exposed.rootEl(), 'ArrowRight') dispatch(exposed.rootEl(), 'ArrowRight') expect(exposed.announce()).toMatch(/Voorlopig.*\+10/) // 2 × SNAP_MIN // Commit dispatch(exposed.rootEl(), 'Enter') expect(callbacks.nudge).toHaveBeenCalledTimes(1) expect(callbacks.nudge.mock.calls[0][1]).toBe(10) expect(exposed.announce()).toMatch(/bevestigd/i) }) it('Esc cancels keyboard drag without invoking the mutation', () => { const { wrapper, callbacks } = mountKeyboardHost([perf('p1')]) const exposed = getExposed(wrapper) dispatch(exposed.rootEl(), ' ') dispatch(exposed.rootEl(), 'ArrowRight') dispatch(exposed.rootEl(), 'Escape') expect(callbacks.nudge).not.toHaveBeenCalled() expect(exposed.announce()).toMatch(/geannuleerd/i) }) it('keys do nothing when no performance is selected', () => { const { wrapper, callbacks, selectedId } = mountKeyboardHost([perf('p1')]) const exposed = getExposed(wrapper) selectedId.value = null dispatch(exposed.rootEl(), 'ArrowLeft') dispatch(exposed.rootEl(), 'Enter') dispatch(exposed.rootEl(), 'Delete') expect(callbacks.nudge).not.toHaveBeenCalled() expect(callbacks.openPopover).not.toHaveBeenCalled() expect(callbacks.remove).not.toHaveBeenCalled() }) })