diff --git a/apps/app/tests/component/PerformanceBlock.test.ts b/apps/app/tests/component/PerformanceBlock.test.ts new file mode 100644 index 00000000..02754202 --- /dev/null +++ b/apps/app/tests/component/PerformanceBlock.test.ts @@ -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 { + 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) + }) +})