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:
2026-05-09 03:49:58 +02:00
parent fbfe72d090
commit b65969c459

View 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()
})
})