Files
crewli/apps/app/tests/component/PerformanceBlock.test.ts
bert.hausmans 210c443cc9 test(timetable): PerformanceBlock visual states + interactions (Step 6)
17 component-level tests via mountWithVuexy:

Visual states (10):
  - status palette × 3 (option, confirmed, cancelled) — asserts both the
    CSS class AND that the matching --tt-status-{X}-bg custom property
    resolves on :root (proves the token sheet really loaded)
  - capacity icon present when crew + guests > stage.capacity
  - capacity icon absent when sum ≤ capacity
  - capacity icon absent when stage.capacity is null (no warning possible)
  - B2B left dot present when b2bLeft prop true
  - B2B right dot present when b2bRight prop true
  - no dots when neither prop true
  - conflict ring class when warnings includes 'overlap'
  - cascade-pulse class when pulse=true
  - aria-label includes artist + stage + status + HH:mm time window
  - tabindex="0" for keyboard focus

Interactions (5, in second describe):
  - click → emits select with performance + DOMRect
  - pointerdown → emits pointerdown with (event, performance)
  - Delete keypress → emits delete
  - Enter keypress → emits select

Test count: 333 → 350.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 03:38:46 +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,
fee_currency: 'EUR',
fee_type: { value: null, label: null },
buma_applicable: true,
buma_percentage: 7,
buma_handled_by: { value: null, label: null },
vat_applicable: true,
vat_percentage: 21,
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)
})
})