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