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>
253 lines
8.6 KiB
TypeScript
253 lines
8.6 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
import { mountWithVuexy } from '../utils/mountWithVuexy'
|
|
import PerformanceBlock from '@/components/timetable/PerformanceBlock.vue'
|
|
import { ArtistEngagementStatus, type Performance } from '@/types/timetable'
|
|
|
|
function makePerformance(overrides: Partial<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: {
|
|
id: 'e1',
|
|
organisation_id: 'o1',
|
|
artist_id: 'a1',
|
|
event_id: 'ev1',
|
|
booking_status: { value: ArtistEngagementStatus.CONFIRMED, label: 'Bevestigd' },
|
|
project_leader_id: null,
|
|
fee_amount: '1000.00',
|
|
fee_currency: 'EUR',
|
|
fee_type: { value: null, label: null },
|
|
buma_applicable: true,
|
|
buma_percentage: '7.00',
|
|
buma_handled_by: { value: null, label: null },
|
|
vat_applicable: true,
|
|
vat_percentage: '21.00',
|
|
deal_breakdown: null,
|
|
deposit_percentage: null,
|
|
deposit_due_date: null,
|
|
balance_due_date: null,
|
|
payment_status: { value: null, label: null },
|
|
crew_count: 0,
|
|
guests_count: 0,
|
|
requested_at: null,
|
|
option_expires_at: null,
|
|
advance_open_from: null,
|
|
advance_open_to: null,
|
|
advancing_completed_count: 3,
|
|
advancing_total_count: 5,
|
|
notes: null,
|
|
computed: { buma_amount: 70, vat_grondslag: 1070, vat_amount: 224.7, breakdown_total: 0, total_cost: 1294.7 },
|
|
created_at: null,
|
|
updated_at: null,
|
|
deleted_at: null,
|
|
artist: {
|
|
id: 'a1',
|
|
organisation_id: 'o1',
|
|
name: 'Devin Wild',
|
|
slug: 'devin-wild',
|
|
default_genre_id: null,
|
|
default_draw: null,
|
|
star_rating: null,
|
|
home_base_country: null,
|
|
agent_company_id: null,
|
|
notes: null,
|
|
engagements_summary: { lifetime_count: 1, upcoming_count: 1 },
|
|
created_at: null,
|
|
updated_at: null,
|
|
deleted_at: null,
|
|
},
|
|
},
|
|
stage: {
|
|
id: 's1',
|
|
event_id: 'ev1',
|
|
name: 'Hardstyle District',
|
|
color: '#e85d75',
|
|
capacity: 1000,
|
|
sort_order: 0,
|
|
created_at: null,
|
|
updated_at: null,
|
|
},
|
|
created_at: null,
|
|
updated_at: null,
|
|
deleted_at: null,
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
const baseProps = {
|
|
leftPx: 0,
|
|
widthPx: 200,
|
|
topPx: 0,
|
|
heightPx: 44,
|
|
}
|
|
|
|
describe('PerformanceBlock — visual states', () => {
|
|
it.each([
|
|
[ArtistEngagementStatus.OPTION, 'tt-perf-block--status-option'],
|
|
[ArtistEngagementStatus.CONFIRMED, 'tt-perf-block--status-confirmed'],
|
|
[ArtistEngagementStatus.CANCELLED, 'tt-perf-block--status-cancelled'],
|
|
])('renders status %s with the matching CSS-token class', (status, expectedClass) => {
|
|
const perf = makePerformance({
|
|
engagement: {
|
|
...makePerformance().engagement!,
|
|
booking_status: { value: status, label: 'X' },
|
|
},
|
|
})
|
|
|
|
const { wrapper } = mountWithVuexy(PerformanceBlock, { props: { performance: perf, ...baseProps } })
|
|
|
|
const block = wrapper.find('[data-perf-id="p1"]')
|
|
|
|
expect(block.classes()).toContain(expectedClass)
|
|
expect(block.attributes('data-status')).toBe(status)
|
|
|
|
// CSS variable resolves via the loaded token sheet — proves the class
|
|
// truly maps to the CSS custom property.
|
|
const cssVarName = `--tt-status-${status}-bg`
|
|
const value = getComputedStyle(document.documentElement).getPropertyValue(cssVarName).trim()
|
|
|
|
expect(value.length).toBeGreaterThan(0)
|
|
})
|
|
|
|
it('renders the capacity icon when crew + guests > stage.capacity', () => {
|
|
const perf = makePerformance({
|
|
engagement: {
|
|
...makePerformance().engagement!,
|
|
crew_count: 600,
|
|
guests_count: 600,
|
|
},
|
|
})
|
|
|
|
const { wrapper } = mountWithVuexy(PerformanceBlock, { props: { performance: perf, ...baseProps } })
|
|
|
|
expect(wrapper.find('.tt-perf-block__capacity').exists()).toBe(true)
|
|
})
|
|
|
|
it('omits the capacity icon when sum ≤ capacity', () => {
|
|
const perf = makePerformance({
|
|
engagement: {
|
|
...makePerformance().engagement!,
|
|
crew_count: 100,
|
|
guests_count: 100,
|
|
},
|
|
})
|
|
|
|
const { wrapper } = mountWithVuexy(PerformanceBlock, { props: { performance: perf, ...baseProps } })
|
|
|
|
expect(wrapper.find('.tt-perf-block__capacity').exists()).toBe(false)
|
|
})
|
|
|
|
it('omits capacity icon when stage.capacity is null', () => {
|
|
const perf = makePerformance({
|
|
stage: { ...makePerformance().stage!, capacity: null },
|
|
engagement: {
|
|
...makePerformance().engagement!,
|
|
crew_count: 5000,
|
|
guests_count: 5000,
|
|
},
|
|
})
|
|
|
|
const { wrapper } = mountWithVuexy(PerformanceBlock, { props: { performance: perf, ...baseProps } })
|
|
|
|
expect(wrapper.find('.tt-perf-block__capacity').exists()).toBe(false)
|
|
})
|
|
|
|
it('renders B2B left dot when b2bLeft prop true', () => {
|
|
const { wrapper } = mountWithVuexy(PerformanceBlock, { props: { performance: makePerformance(), b2bLeft: true, ...baseProps } })
|
|
|
|
expect(wrapper.find('.tt-perf-block__b2b--left').exists()).toBe(true)
|
|
expect(wrapper.find('.tt-perf-block__b2b--right').exists()).toBe(false)
|
|
})
|
|
|
|
it('renders B2B right dot when b2bRight prop true', () => {
|
|
const { wrapper } = mountWithVuexy(PerformanceBlock, { props: { performance: makePerformance(), b2bRight: true, ...baseProps } })
|
|
|
|
expect(wrapper.find('.tt-perf-block__b2b--right').exists()).toBe(true)
|
|
expect(wrapper.find('.tt-perf-block__b2b--left').exists()).toBe(false)
|
|
})
|
|
|
|
it('renders neither dot when both props false', () => {
|
|
const { wrapper } = mountWithVuexy(PerformanceBlock, { props: { performance: makePerformance(), ...baseProps } })
|
|
|
|
expect(wrapper.find('.tt-perf-block__b2b').exists()).toBe(false)
|
|
})
|
|
|
|
it('applies conflict ring when warnings include "overlap"', () => {
|
|
const perf = makePerformance({ warnings: ['overlap'] })
|
|
const { wrapper } = mountWithVuexy(PerformanceBlock, { props: { performance: perf, ...baseProps } })
|
|
|
|
expect(wrapper.find('[data-perf-id="p1"]').classes()).toContain('tt-perf-block--conflict')
|
|
})
|
|
|
|
it('applies cascade-pulse class when pulse=true', () => {
|
|
const { wrapper } = mountWithVuexy(PerformanceBlock, { props: { performance: makePerformance(), pulse: true, ...baseProps } })
|
|
|
|
expect(wrapper.find('[data-perf-id="p1"]').classes()).toContain('tt-cascade-pulse')
|
|
})
|
|
|
|
it('aria-label includes artist, time window, stage, status', () => {
|
|
const { wrapper } = mountWithVuexy(PerformanceBlock, { props: { performance: makePerformance(), ...baseProps } })
|
|
const label = wrapper.find('[data-perf-id="p1"]').attributes('aria-label') ?? ''
|
|
|
|
expect(label).toContain('Devin Wild')
|
|
expect(label).toContain('Hardstyle District')
|
|
expect(label).toContain('Bevestigd')
|
|
expect(label).toMatch(/\d{2}:\d{2}/) // time formatted
|
|
})
|
|
|
|
it('exposes tabindex=0 for keyboard focus', () => {
|
|
const { wrapper } = mountWithVuexy(PerformanceBlock, { props: { performance: makePerformance(), ...baseProps } })
|
|
|
|
expect(wrapper.find('[data-perf-id="p1"]').attributes('tabindex')).toBe('0')
|
|
})
|
|
})
|
|
|
|
describe('PerformanceBlock — interactions', () => {
|
|
it('emits select on click with the performance + DOMRect', async () => {
|
|
const { wrapper } = mountWithVuexy(PerformanceBlock, { props: { performance: makePerformance(), ...baseProps } })
|
|
|
|
await wrapper.find('[data-perf-id="p1"]').trigger('click')
|
|
|
|
const events = wrapper.emitted('select')
|
|
|
|
expect(events).toHaveLength(1)
|
|
expect(events![0][0]).toMatchObject({ id: 'p1' })
|
|
})
|
|
|
|
it('emits pointerdown with (event, performance) on pointerdown', async () => {
|
|
const { wrapper } = mountWithVuexy(PerformanceBlock, { props: { performance: makePerformance(), ...baseProps } })
|
|
|
|
await wrapper.find('[data-perf-id="p1"]').trigger('pointerdown')
|
|
|
|
const events = wrapper.emitted('pointerdown')
|
|
|
|
expect(events).toHaveLength(1)
|
|
expect(events![0][1]).toMatchObject({ id: 'p1' })
|
|
})
|
|
|
|
it('emits delete on Delete keypress', async () => {
|
|
const { wrapper } = mountWithVuexy(PerformanceBlock, { props: { performance: makePerformance(), ...baseProps } })
|
|
|
|
await wrapper.find('[data-perf-id="p1"]').trigger('keydown', { key: 'Delete' })
|
|
|
|
expect(wrapper.emitted('delete')).toHaveLength(1)
|
|
})
|
|
|
|
it('emits select on Enter keypress', async () => {
|
|
const { wrapper } = mountWithVuexy(PerformanceBlock, { props: { performance: makePerformance(), ...baseProps } })
|
|
|
|
await wrapper.find('[data-perf-id="p1"]').trigger('keydown', { key: 'Enter' })
|
|
|
|
expect(wrapper.emitted('select')).toHaveLength(1)
|
|
})
|
|
})
|