import { describe, expect, it } from 'vitest' import { axe } from 'vitest-axe' import { defineComponent, h, ref } from 'vue' import { mountWithVuexy } from '../utils/mountWithVuexy' import AddPerformanceDialog from '@/components/timetable/AddPerformanceDialog.vue' import PerformanceBlock from '@/components/timetable/PerformanceBlock.vue' import PerformancePopover from '@/components/timetable/PerformancePopover.vue' import { type ArtistEngagement, ArtistEngagementStatus, type Performance, type Stage } from '@/types/timetable' /** * jsdom-based axe scans pick up structural a11y issues (missing roles, * orphan labels, color-contrast metadata, ARIA mismatches) but cannot * resolve actual rendered colors — that needs a real browser. Visual * a11y is on TEST-INFRA-001's Playwright migration list. * * For now: zero violations on (1) PerformanceBlock with focus, * (2) PerformancePopover open, (3) AddPerformanceDialog open. */ const stage: Stage = { id: 's1', event_id: 'ev1', name: 'Hardstyle District', color: '#e85d75', capacity: 1000, sort_order: 0, created_at: null, updated_at: null, } const engagement: Partial = { id: 'e1', artist: { id: 'a1', name: 'Devin Wild' } as ArtistEngagement['artist'], booking_status: { value: ArtistEngagementStatus.CONFIRMED, label: 'Bevestigd' }, computed: { buma_amount: 70, vat_grondslag: 1070, vat_amount: 224.7, breakdown_total: 0, total_cost: 1294.7 }, fee_amount: '1000.00', advancing_completed_count: 3, advancing_total_count: 5, } function performance(): Performance { return { id: 'p1', engagement_id: 'e1', 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: engagement as ArtistEngagement, stage, created_at: null, updated_at: null, deleted_at: null, } } // Stub VDialog so the popover/dialog renders inline (axe needs to see the // content in the wrapper, not in a teleport target). const VDialogStub = defineComponent({ name: 'VDialog', props: ['modelValue'], setup(_, { slots }) { return () => h('div', { class: 'v-dialog-stub' }, slots.default?.()) }, }) // Component fragments are not full pages — skip page-level landmark // rules (`region`, `page-has-heading-one`, `landmark-one-main`) that // only make sense at document root. Visual contrast resolution is also // jsdom-blind; TEST-INFRA-001's Playwright migration covers that. const fragmentAxeOptions = { rules: { 'region': { enabled: false }, 'page-has-heading-one': { enabled: false }, 'landmark-one-main': { enabled: false }, 'color-contrast': { enabled: false }, }, } describe('axe-core a11y enforcement (RFC D20 + D21)', () => { it('PerformanceBlock has zero violations when rendered + focusable', async () => { const { wrapper } = mountWithVuexy(PerformanceBlock, { props: { performance: performance(), leftPx: 0, widthPx: 200, topPx: 0, heightPx: 44, }, }) const results = await axe(wrapper.element, fragmentAxeOptions) expect(results).toHaveNoViolations() }) it('PerformancePopover (open) has zero violations', async () => { const orgIdRef = ref('org_1') const { wrapper } = mountWithVuexy(PerformancePopover, { props: { modelValue: true, anchorRect: { top: 100, left: 100, right: 200, bottom: 150, width: 100, height: 50, x: 100, y: 100, toJSON: () => ({}) } as DOMRect, performance: performance(), orgId: orgIdRef.value, }, }) await wrapper.vm.$nextTick() const popoverEl = document.querySelector('.tt-popover') expect(popoverEl).toBeTruthy() const results = await axe(popoverEl as HTMLElement, fragmentAxeOptions) expect(results).toHaveNoViolations() }) it('AddPerformanceDialog (open) has zero violations', async () => { const fieldStub = defineComponent({ name: 'FieldStub', props: ['modelValue', 'label', 'errorMessages'], setup(props) { return () => h('div', { class: 'field-stub' }, [ h('label', {}, String(props.label ?? '')), ]) }, }) const { wrapper } = mountWithVuexy(AddPerformanceDialog, { props: { modelValue: true, orgId: 'org_1', eventId: 'ev_1', dayId: 'day_1', stages: [stage], engagements: [engagement as ArtistEngagement], }, stubs: { VDialog: VDialogStub, AppTextField: fieldStub, AppTextarea: fieldStub, AppSelect: fieldStub, AppAutocomplete: fieldStub, AppDateTimePicker: fieldStub, }, }) await wrapper.vm.$nextTick() const results = await axe(wrapper.element, fragmentAxeOptions) expect(results).toHaveNoViolations() }) })