Files
crewli/apps/app/tests/a11y/keyboard.test.ts
bert.hausmans b65969c459 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>
2026-05-09 03:49:58 +02:00

237 lines
7.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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()
})
})