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>
This commit is contained in:
252
apps/app/tests/component/PerformanceBlock.test.ts
Normal file
252
apps/app/tests/component/PerformanceBlock.test.ts
Normal file
@@ -0,0 +1,252 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user