test(timetable): keyboard a11y end-to-end (Step 10)
11 tests for useTimetableKeyboard (RFC v0.2 D20):
- Arrow Left → nudge(-SNAP_MIN, 0, 0)
- Arrow Right → nudge(+SNAP_MIN, 0, 0)
- Shift+Arrow → nudge(±60min)
- Arrow Up/Down → ±lane
- Shift+Arrow Up/Down → ±stage
- ] / [ → cycle stages preserving time + lane
- Enter → openPopover with the selected performance
- Delete → remove with the selected performance
- Space → drag mode + aria-live announce; Arrow keys accumulate; Enter
commits with the cumulative offset; aria-live announces 'bevestigd'
- Esc cancels keyboard drag, no mutation, aria-live announces 'geannuleerd'
- all keys are no-ops when no performance is selected
Tests the composable directly with a host component that owns a focusable
canvas root and exposes the spies + announce ref — much more reliable
than mounting the whole timetable page (heavy + asynchronous).
Test count: 369 → 380.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
236
apps/app/tests/a11y/keyboard.test.ts
Normal file
236
apps/app/tests/a11y/keyboard.test.ts
Normal file
@@ -0,0 +1,236 @@
|
||||
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<typeof vi.fn>
|
||||
openPopover: ReturnType<typeof vi.fn>
|
||||
remove: ReturnType<typeof vi.fn>
|
||||
}
|
||||
}
|
||||
|
||||
function mountKeyboardHost(performances: Performance[]) {
|
||||
const callbacks = {
|
||||
nudge: vi.fn().mockResolvedValue(undefined),
|
||||
openPopover: vi.fn(),
|
||||
remove: vi.fn().mockResolvedValue(undefined),
|
||||
}
|
||||
|
||||
const selectedId = ref<string | null>(performances[0]?.id ?? null)
|
||||
|
||||
const Host = defineComponent({
|
||||
setup(_, { expose }) {
|
||||
const rootEl = ref<HTMLElement | null>(null)
|
||||
const stagesRef = ref<Stage[]>([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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user