Phase A diagnosed the "Kon timetable niet laden" browser symptom as Zod
schema drift. The prompt's hypothesis (enum {value, label} mismatch) was
incorrect — the schema already uses the enumLabel() wrapper for every
enum field. The actual drift is decimal-cast columns: Laravel serialises
`decimal(N,M)` columns as strings to preserve precision, but the schema
expected numbers, so the very first response triggered a ZodError.
Affected fields, all on `artist_engagements`:
fee_amount decimal(10,2) → wire `"11503.58"`, schema was z.number()
buma_percentage decimal(5,2) → wire `"7.00"`, schema was z.number()
vat_percentage decimal(5,2) → wire `"21.00"`, schema was z.number()
deposit_percentage decimal(5,2) → wire `"…"`, schema was z.number()
Backend has no explicit `decimal:N` cast on these columns
(api/app/Models/ArtistEngagement.php:64-85 — the `casts()` method covers
the enums + booleans + dates + integers, but skips decimals).
Per the strategic decision (frontend adapts, backend stays):
- schemas/timetable.ts: four fields → z.string().nullable()
- types/timetable.ts: matching ArtistEngagement interface fields →
`string | null`
- PerformancePopover.vue:129: only consumer doing arithmetic on a
decimal field; coerce at the use site via Number(...).toFixed(2).
Single line.
- tests/component/PerformanceBlock.test.ts + tests/a11y/axe.test.ts:
spot-checked mocks; the two with hand-built engagement payloads
flipped fee_amount/buma_percentage/vat_percentage from numbers to
strings to match the new schema. No other mocks needed updating.
The {value, label} enum wrapper claim in the prompt was specifically
debunked in Phase A — every consumer (Wachtrij, PerformanceBlock,
WachtrijCard, PerformancePopover, AddPerformanceDialog, page entry)
already uses .value/.label access against an enumLabel-wrapped schema.
B6 will lock the wire-format contract with a real-API fixture
regression test.
All 397 tests still pass; typecheck clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
162 lines
4.8 KiB
TypeScript
162 lines
4.8 KiB
TypeScript
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<ArtistEngagement> = {
|
|
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()
|
|
})
|
|
})
|