Files
crewli/apps/app/tests/component/PerformanceBlock.test.ts
bert.hausmans 1eee1f9415 fix(timetable): align Zod decimal fields with backend wire format (decimal-as-string per Laravel cast) (B5)
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>
2026-05-10 00:33:13 +02:00

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