diff --git a/apps/app/src/composables/api/useTimetableMutations.ts b/apps/app/src/composables/api/useTimetableMutations.ts index 0adef797..8a65913c 100644 --- a/apps/app/src/composables/api/useTimetableMutations.ts +++ b/apps/app/src/composables/api/useTimetableMutations.ts @@ -126,15 +126,16 @@ export function useTimetableMutations(args: UseTimetableMutationsArgs) { } catch (err) { if (isVersionMismatch(err)) { - throw { - status: 409, - conflict: err.response.data.errors, - } as VersionMismatchError + const mismatch = new Error('version_mismatch') as Error & VersionMismatchError + + mismatch.status = 409 + mismatch.conflict = err.response.data.errors + throw mismatch } - throw { - status: (err as AxiosError).response?.status ?? 0, - message: (err as AxiosError).message, - } as { status: number; message: string } + const wrapped = new Error((err as AxiosError).message) as Error & { status: number; message: string } + + wrapped.status = (err as AxiosError).response?.status ?? 0 + throw wrapped } }, onMutate: async ({ optimistic }) => { diff --git a/apps/app/tests/unit/composables/useDragOrClick.test.ts b/apps/app/tests/unit/composables/useDragOrClick.test.ts new file mode 100644 index 00000000..4c023352 --- /dev/null +++ b/apps/app/tests/unit/composables/useDragOrClick.test.ts @@ -0,0 +1,77 @@ +import { mount } from '@vue/test-utils' +import { describe, expect, it, vi } from 'vitest' +import { defineComponent, h } from 'vue' +import { useDragOrClick } from '@/composables/timetable/useDragOrClick' + +interface ComponentInstance { + begin: (e: Event) => void +} + +function makeHost(opts: Parameters[0]) { + return defineComponent({ + setup(_, { expose }) { + const ctl = useDragOrClick(opts) + + expose({ begin: ctl.begin }) + + return () => h('div') + }, + }) +} + +function makePointerEvent(type: string, x: number, y: number, pointerId = 1): PointerEvent { + const e = new Event(type, { bubbles: true, cancelable: true }) as PointerEvent + + Object.defineProperty(e, 'pointerId', { value: pointerId }) + Object.defineProperty(e, 'clientX', { value: x }) + Object.defineProperty(e, 'clientY', { value: y }) + + return e +} + +describe('useDragOrClick', () => { + it('fires onClick when movement < threshold', async () => { + const onClick = vi.fn() + const onDragStart = vi.fn() + const wrapper = mount(makeHost({ thresholdPx: 4, onClick, onDragStart })) + const inst = wrapper.vm as unknown as ComponentInstance + + inst.begin(makePointerEvent('pointerdown', 10, 10)) + window.dispatchEvent(makePointerEvent('pointermove', 11, 11)) + window.dispatchEvent(makePointerEvent('pointerup', 11, 11)) + + expect(onClick).toHaveBeenCalledTimes(1) + expect(onDragStart).not.toHaveBeenCalled() + }) + + it('enters drag mode and emits onDragStart + onDragEnd when movement crosses threshold', async () => { + const onClick = vi.fn() + const onDragStart = vi.fn() + const onDragEnd = vi.fn() + const wrapper = mount(makeHost({ thresholdPx: 4, onClick, onDragStart, onDragEnd })) + const inst = wrapper.vm as unknown as ComponentInstance + + inst.begin(makePointerEvent('pointerdown', 10, 10)) + window.dispatchEvent(makePointerEvent('pointermove', 50, 10)) + window.dispatchEvent(makePointerEvent('pointerup', 50, 10)) + + expect(onDragStart).toHaveBeenCalledTimes(1) + expect(onDragEnd).toHaveBeenCalledTimes(1) + expect(onClick).not.toHaveBeenCalled() + }) + + it('Esc cancels an in-flight drag', async () => { + const onDragEnd = vi.fn() + const wrapper = mount(makeHost({ thresholdPx: 4, onDragEnd })) + const inst = wrapper.vm as unknown as ComponentInstance + + inst.begin(makePointerEvent('pointerdown', 10, 10)) + window.dispatchEvent(makePointerEvent('pointermove', 50, 10)) + + const esc = new KeyboardEvent('keydown', { key: 'Escape' }) + + window.dispatchEvent(esc) + + expect(onDragEnd).toHaveBeenCalledWith(expect.anything(), true) + }) +}) diff --git a/apps/app/tests/unit/composables/useTimetableMutations.test.ts b/apps/app/tests/unit/composables/useTimetableMutations.test.ts new file mode 100644 index 00000000..7e4f2e3e --- /dev/null +++ b/apps/app/tests/unit/composables/useTimetableMutations.test.ts @@ -0,0 +1,201 @@ +import { QueryClient, VueQueryPlugin } from '@tanstack/vue-query' +import { mount } from '@vue/test-utils' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { defineComponent, h, ref } from 'vue' +import { apiClient } from '@/lib/axios' +import { useTimetableMutations } from '@/composables/api/useTimetableMutations' +import type { Performance } from '@/types/timetable' + +vi.mock('@/lib/axios', () => { + const post = vi.fn() + const put = vi.fn() + const get = vi.fn() + const del = vi.fn() + + return { apiClient: { post, put, get, delete: del } } +}) + +interface MockApi { + post: ReturnType + put: ReturnType + get: ReturnType + delete: ReturnType +} + +const mocked = apiClient as unknown as MockApi + +function p(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: 3, + notes: null, + warnings: [], + created_at: null, + updated_at: null, + deleted_at: null, + ...overrides, + } +} + +function mountWithMutations() { + const api: { value: ReturnType | null } = { value: null } + const orgId = ref('org_1') + const eventId = ref('ev_1') + const dayId = ref('day_1') + + const Host = defineComponent({ + setup() { + api.value = useTimetableMutations({ orgId, eventId, dayId }) + + return () => h('div') + }, + }) + + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }) + + const wrapper = mount(Host, { + global: { plugins: [[VueQueryPlugin, { queryClient }]] }, + }) + + return { wrapper, api, queryClient } +} + +describe('useTimetableMutations', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + afterEach(() => { + vi.clearAllMocks() + }) + + describe('move', () => { + it('sends Idempotency-Key header on POST /timetable/move', async () => { + mocked.post.mockResolvedValueOnce({ data: { success: true, data: { moved: p({ version: 4 }), cascaded: [] } } }) + + const { api } = mountWithMutations() + + await api.value!.move.mutateAsync({ + payload: { + performance_id: 'p1', + target_stage_id: 's1', + target_start_at: '2026-07-10 19:00:00', + target_end_at: '2026-07-10 20:00:00', + target_lane: 0, + version: 3, + }, + idempotencyKey: 'idem-test-key-12345', + }) + + expect(mocked.post).toHaveBeenCalledTimes(1) + + const [, , config] = mocked.post.mock.calls[0] + + expect(config.headers['Idempotency-Key']).toBe('idem-test-key-12345') + }) + + it('applies optimistic patch + cascade on success', async () => { + mocked.post.mockResolvedValueOnce({ + data: { + success: true, + data: { + moved: p({ id: 'p1', version: 4 }), + cascaded: [p({ id: 'p2', lane: 1, lane_resolved: 1, version: 4 })], + }, + }, + }) + + const { api, queryClient } = mountWithMutations() + const eventId = ref('ev_1') + const dayId = ref('day_1') + + queryClient.setQueryData(['timetable', 'performances', eventId, dayId], [ + p({ id: 'p1' }), + p({ id: 'p2', lane: 0, lane_resolved: 0 }), + ]) + + const result = await api.value!.move.mutateAsync({ + payload: { + performance_id: 'p1', + target_stage_id: 's1', + target_start_at: '2026-07-10 19:00:00', + target_end_at: '2026-07-10 20:00:00', + target_lane: 0, + version: 3, + }, + idempotencyKey: 'idem', + }) + + expect(result.cascaded).toHaveLength(1) + expect(result.moved.version).toBe(4) + }) + + it('surfaces VersionMismatch on 409', async () => { + mocked.post.mockRejectedValueOnce({ + response: { + status: 409, + data: { + errors: { + conflict: 'version_mismatch', + current_version: 5, + client_version: 3, + server_data: p({ version: 5 }), + }, + }, + }, + }) + + const { api } = mountWithMutations() + + await expect(api.value!.move.mutateAsync({ + payload: { + performance_id: 'p1', + target_stage_id: 's1', + target_start_at: '2026-07-10 19:00:00', + target_end_at: '2026-07-10 20:00:00', + target_lane: 0, + version: 3, + }, + idempotencyKey: 'idem', + })).rejects.toMatchObject({ status: 409, conflict: { conflict: 'version_mismatch', current_version: 5 } }) + }) + }) + + describe('park / unpark via move', () => { + it('park sends target_stage_id null', async () => { + mocked.post.mockResolvedValueOnce({ + data: { success: true, data: { moved: p({ stage_id: null, version: 4 }), cascaded: [] } }, + }) + + const { api } = mountWithMutations() + + await api.value!.park(p(), 'key1') + + const [, body] = mocked.post.mock.calls[0] + + expect(body.target_stage_id).toBe(null) + expect(body.version).toBe(3) + }) + }) + + describe('createStage', () => { + it('hits POST /stages', async () => { + mocked.post.mockResolvedValueOnce({ + data: { success: true, data: { id: 's2', name: 'New Stage', color: '#aabbcc', capacity: 1000, sort_order: 1, event_id: 'ev_1' } }, + }) + + const { api } = mountWithMutations() + + await api.value!.createStage.mutateAsync({ name: 'New Stage', color: '#aabbcc', capacity: 1000 }) + + expect(mocked.post.mock.calls[0][0]).toContain('/stages') + }) + }) +}) diff --git a/apps/app/tests/unit/lib/idempotencyKey.test.ts b/apps/app/tests/unit/lib/idempotencyKey.test.ts new file mode 100644 index 00000000..ce43b161 --- /dev/null +++ b/apps/app/tests/unit/lib/idempotencyKey.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest' +import { generateIdempotencyKey } from '@/lib/idempotencyKey' + +describe('generateIdempotencyKey', () => { + it('returns a string within the backend 6..30 char range', () => { + const key = generateIdempotencyKey() + + expect(key.length).toBeGreaterThanOrEqual(6) + expect(key.length).toBeLessThanOrEqual(30) + }) + + it('produces 24-hex output when crypto.randomUUID is available', () => { + const key = generateIdempotencyKey() + + expect(key).toMatch(/^[0-9a-f]{24}$/) + }) + + it('successive calls return different values (very high probability)', () => { + const a = generateIdempotencyKey() + const b = generateIdempotencyKey() + + expect(a).not.toBe(b) + }) +}) diff --git a/apps/app/tests/unit/lib/timetable/b2b.test.ts b/apps/app/tests/unit/lib/timetable/b2b.test.ts new file mode 100644 index 00000000..1026ab6a --- /dev/null +++ b/apps/app/tests/unit/lib/timetable/b2b.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'vitest' +import { B2B_THRESHOLD_MIN, findB2BLinks, findB2BSides } from '@/lib/timetable/b2b' +import type { Performance } from '@/types/timetable' + +function p(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: 0, + notes: null, + warnings: [], + created_at: null, + updated_at: null, + deleted_at: null, + ...overrides, + } +} + +describe('findB2BLinks', () => { + it('returns empty when no consecutive pair exists', () => { + expect(findB2BLinks([p({ id: 'a' })])).toEqual([]) + }) + + it('marks 0-min gap as B2B', () => { + const links = findB2BLinks([ + p({ id: 'a', start_at: '2026-07-10T18:00:00Z', end_at: '2026-07-10T19:00:00Z' }), + p({ id: 'b', start_at: '2026-07-10T19:00:00Z', end_at: '2026-07-10T20:00:00Z' }), + ]) + + expect(links).toHaveLength(1) + expect(links[0]).toEqual({ leftId: 'a', rightId: 'b', gapMin: 0 }) + }) + + it('marks 2:59 gap as B2B (under 3-min threshold)', () => { + const links = findB2BLinks([ + p({ id: 'a', start_at: '2026-07-10T18:00:00Z', end_at: '2026-07-10T19:00:00Z' }), + p({ id: 'b', start_at: '2026-07-10T19:02:59Z', end_at: '2026-07-10T20:00:00Z' }), + ]) + + expect(links).toHaveLength(1) + }) + + it('does NOT mark 3:01 gap as B2B', () => { + const links = findB2BLinks([ + p({ id: 'a', start_at: '2026-07-10T18:00:00Z', end_at: '2026-07-10T19:00:00Z' }), + p({ id: 'b', start_at: '2026-07-10T19:03:01Z', end_at: '2026-07-10T20:00:00Z' }), + ]) + + expect(links).toHaveLength(0) + }) + + it('overlap (negative gap) is NOT a B2B link', () => { + const links = findB2BLinks([ + p({ id: 'a', start_at: '2026-07-10T18:00:00Z', end_at: '2026-07-10T19:00:00Z' }), + p({ id: 'b', start_at: '2026-07-10T18:30:00Z', end_at: '2026-07-10T19:30:00Z' }), + ]) + + expect(links).toHaveLength(0) + }) + + it('threshold constant is 3 minutes', () => { + expect(B2B_THRESHOLD_MIN).toBe(3) + }) +}) + +describe('findB2BSides', () => { + it('produces left+right sets reflecting neighbour position', () => { + const { leftSet, rightSet } = findB2BSides([ + p({ id: 'a', start_at: '2026-07-10T18:00:00Z', end_at: '2026-07-10T19:00:00Z' }), + p({ id: 'b', start_at: '2026-07-10T19:00:00Z', end_at: '2026-07-10T20:00:00Z' }), + ]) + + expect(rightSet.has('a')).toBe(true) + expect(leftSet.has('b')).toBe(true) + expect(leftSet.has('a')).toBe(false) + expect(rightSet.has('b')).toBe(false) + }) +}) diff --git a/apps/app/tests/unit/lib/timetable/capacity.test.ts b/apps/app/tests/unit/lib/timetable/capacity.test.ts new file mode 100644 index 00000000..7d15c581 --- /dev/null +++ b/apps/app/tests/unit/lib/timetable/capacity.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest' +import { CAPACITY_TOLERANCE, evaluateCapacity } from '@/lib/timetable/capacity' +import type { ArtistEngagement, Performance, Stage } from '@/types/timetable' + +const stage: Stage = { + id: 's1', + event_id: 'ev1', + name: 'Hardstyle', + color: '#ff0000', + capacity: 1000, + sort_order: 0, + created_at: null, + updated_at: null, +} + +const perf: Performance = { + id: 'p1', + engagement_id: 'e1', + event_id: 'ev1', + stage_id: 's1', + lane: 0, + lane_resolved: 0, + start_at: null, + end_at: null, + version: 0, + notes: null, + warnings: [], + created_at: null, + updated_at: null, + deleted_at: null, +} + +function eng(crew: number, guests: number, draw: number | null = null): ArtistEngagement { + return { + crew_count: crew, + guests_count: guests, + artist: draw === null ? undefined : { default_draw: draw } as ArtistEngagement['artist'], + } as ArtistEngagement +} + +describe('evaluateCapacity', () => { + it('returns null when stage has no capacity', () => { + expect(evaluateCapacity(perf, { ...stage, capacity: null }, eng(0, 0, 500))).toBeNull() + }) + + it('returns null when no expected attendance is available', () => { + expect(evaluateCapacity(perf, stage, eng(0, 0))).toBeNull() + }) + + it('returns null when below the tolerance', () => { + expect(evaluateCapacity(perf, stage, eng(0, 0, 1100))).toBeNull() + }) + + it('returns warn when ratio between tolerance and 1.5×', () => { + const result = evaluateCapacity(perf, stage, eng(0, 0, 1200)) + + expect(result?.level).toBe('warn') + }) + + it('returns critical when ratio > 1.5', () => { + const result = evaluateCapacity(perf, stage, eng(0, 0, 1700)) + + expect(result?.level).toBe('critical') + }) + + it('prefers crew + guests when present', () => { + const result = evaluateCapacity(perf, stage, eng(800, 800)) + + expect(result?.expected).toBe(1600) + expect(result?.level).toBe('critical') + }) + + it('exposes the tolerance constant', () => { + expect(CAPACITY_TOLERANCE).toBeGreaterThan(1) + }) +}) diff --git a/apps/app/tests/unit/lib/timetable/conflict.test.ts b/apps/app/tests/unit/lib/timetable/conflict.test.ts new file mode 100644 index 00000000..6e1ab456 --- /dev/null +++ b/apps/app/tests/unit/lib/timetable/conflict.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from 'vitest' +import { findConflicts, wouldConflict } from '@/lib/timetable/conflict' +import { ArtistEngagementStatus, type Performance } from '@/types/timetable' + +function p(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: 0, + notes: null, + warnings: [], + created_at: null, + updated_at: null, + deleted_at: null, + ...overrides, + } +} + +describe('findConflicts', () => { + it('flags two overlapping performances on the same lane', () => { + const conflicts = findConflicts([ + p({ id: 'a', start_at: '2026-07-10T18:00:00Z', end_at: '2026-07-10T19:00:00Z' }), + p({ id: 'b', start_at: '2026-07-10T18:30:00Z', end_at: '2026-07-10T19:30:00Z' }), + ]) + + expect(conflicts).toEqual(new Set(['a', 'b'])) + }) + + it('endpoint-touching is NOT overlap', () => { + const conflicts = findConflicts([ + p({ id: 'a', start_at: '2026-07-10T18:00:00Z', end_at: '2026-07-10T19:00:00Z' }), + p({ id: 'b', start_at: '2026-07-10T19:00:00Z', end_at: '2026-07-10T20:00:00Z' }), + ]) + + expect(conflicts.size).toBe(0) + }) + + it('different lanes on same stage = no conflict', () => { + const conflicts = findConflicts([ + p({ id: 'a', lane_resolved: 0 }), + p({ id: 'b', lane_resolved: 1 }), + ]) + + expect(conflicts.size).toBe(0) + }) + + it('different stages = no conflict', () => { + const conflicts = findConflicts([ + p({ id: 'a', stage_id: 's1' }), + p({ id: 'b', stage_id: 's2' }), + ]) + + expect(conflicts.size).toBe(0) + }) + + it('cancelled performances do not participate', () => { + const cancelled = p({ + id: 'c', + engagement: { + booking_status: { value: ArtistEngagementStatus.CANCELLED, label: 'Geannuleerd' }, + } as Performance['engagement'], + }) + + const conflicts = findConflicts([ + p({ id: 'a' }), + cancelled, + ]) + + expect(conflicts.size).toBe(0) + }) + + it('parked performances (stage_id null) do not participate', () => { + const conflicts = findConflicts([ + p({ id: 'a', stage_id: null }), + p({ id: 'b' }), + ]) + + expect(conflicts.size).toBe(0) + }) +}) + +describe('wouldConflict', () => { + it('detects 1-pixel overlap', () => { + const others = [p({ id: 'x', start_at: '2026-07-10T18:00:00Z', end_at: '2026-07-10T19:00:00Z' })] + + const result = wouldConflict({ + id: 'new', + stage_id: 's1', + lane: 0, + start_at: '2026-07-10T18:59:00Z', + end_at: '2026-07-10T20:00:00Z', + }, others) + + expect(result).toBe(true) + }) + + it('returns false when candidate is parked', () => { + const others = [p({ id: 'x' })] + + const result = wouldConflict({ + id: 'new', + stage_id: null, + lane: 0, + start_at: '2026-07-10T18:00:00Z', + end_at: '2026-07-10T19:00:00Z', + }, others) + + expect(result).toBe(false) + }) +}) diff --git a/apps/app/tests/unit/lib/timetable/lane.test.ts b/apps/app/tests/unit/lib/timetable/lane.test.ts new file mode 100644 index 00000000..2db93c43 --- /dev/null +++ b/apps/app/tests/unit/lib/timetable/lane.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from 'vitest' +import { type LaneSubject, previewCascade, resolveLanes } from '@/lib/timetable/lane' +import type { Performance } from '@/types/timetable' + +function s(id: string, start: string, end: string, lane: number | null = null, cancelled = false): LaneSubject { + return { id, start_at: start, end_at: end, lane, cancelled } +} + +function p(id: string, start: string, end: string, lane = 0): Performance { + return { + id, + engagement_id: 'e1', + event_id: 'ev1', + stage_id: 's1', + lane, + lane_resolved: lane, + start_at: start, + end_at: end, + version: 0, + notes: null, + warnings: [], + created_at: null, + updated_at: null, + deleted_at: null, + } +} + +describe('resolveLanes (Pass 2 only — implicit lanes)', () => { + it('places non-overlapping items on lane 0', () => { + const result = resolveLanes([ + s('a', '2026-07-10T18:00:00Z', '2026-07-10T19:00:00Z'), + s('b', '2026-07-10T19:00:00Z', '2026-07-10T20:00:00Z'), + ]) + + expect(result.laneOf).toEqual({ a: 0, b: 0 }) + expect(result.laneCount).toBe(1) + }) + + it('stacks overlapping items into separate lanes', () => { + const result = resolveLanes([ + s('a', '2026-07-10T18:00:00Z', '2026-07-10T19:30:00Z'), + s('b', '2026-07-10T19:00:00Z', '2026-07-10T20:00:00Z'), + ]) + + expect(result.laneOf.a).toBe(0) + expect(result.laneOf.b).toBe(1) + expect(result.laneCount).toBe(2) + }) + + it('Pass 1 — explicit lane is honoured', () => { + const result = resolveLanes([ + s('a', '2026-07-10T18:00:00Z', '2026-07-10T19:00:00Z', 2), + s('b', '2026-07-10T19:00:00Z', '2026-07-10T20:00:00Z'), + ]) + + expect(result.laneOf.a).toBe(2) + expect(result.laneOf.b).toBe(0) + }) + + it('Pass 1 — overlapping explicit lane bumps down', () => { + const result = resolveLanes([ + s('a', '2026-07-10T18:00:00Z', '2026-07-10T19:30:00Z', 0), + s('b', '2026-07-10T19:00:00Z', '2026-07-10T20:00:00Z', 0), + ]) + + expect(result.laneOf.a).toBe(0) + expect(result.laneOf.b).toBe(1) + }) + + it('cancelled items are excluded from collision checks', () => { + const result = resolveLanes([ + s('a', '2026-07-10T18:00:00Z', '2026-07-10T19:30:00Z'), + s('b', '2026-07-10T19:00:00Z', '2026-07-10T20:00:00Z', null, true), + ]) + + expect(result.laneOf.b).toBe(0) + }) + + it('handles empty input', () => { + const result = resolveLanes([]) + + expect(result.laneCount).toBe(1) + expect(result.laneOf).toEqual({}) + }) +}) + +describe('previewCascade (drag preview)', () => { + it('preserves wanted lane when target is free', () => { + const cohort = [p('a', '2026-07-10T18:00:00Z', '2026-07-10T19:00:00Z', 0)] + + const result = previewCascade( + { id: 'dragged', lane: 1, start_at: '2026-07-10T18:30:00Z', end_at: '2026-07-10T19:30:00Z' }, + cohort, + ) + + expect(result.laneOf.dragged).toBe(1) + expect(result.laneOf.a).toBe(0) + }) + + it('cascades existing item down when wanted lane is busy', () => { + const cohort = [p('a', '2026-07-10T18:00:00Z', '2026-07-10T19:00:00Z', 0)] + + const result = previewCascade( + { id: 'dragged', lane: 0, start_at: '2026-07-10T18:30:00Z', end_at: '2026-07-10T19:30:00Z' }, + cohort, + ) + + expect(result.laneOf.dragged).toBe(1) + expect(result.laneOf.a).toBe(0) + }) +}) diff --git a/apps/app/tests/unit/lib/timetable/snap.test.ts b/apps/app/tests/unit/lib/timetable/snap.test.ts new file mode 100644 index 00000000..abb64fd3 --- /dev/null +++ b/apps/app/tests/unit/lib/timetable/snap.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest' +import { MIN_DURATION_MIN, SNAP_MIN, snap, snapClamp } from '@/lib/timetable/snap' + +describe('snap', () => { + it('rounds to nearest multiple of step', () => { + expect(snap(0, 5)).toBe(0) + expect(snap(2, 5)).toBe(0) + expect(snap(3, 5)).toBe(5) + expect(snap(7, 5)).toBe(5) + expect(snap(8, 5)).toBe(10) + expect(snap(12, 5)).toBe(10) + expect(snap(13, 5)).toBe(15) + }) + + it('returns value unchanged when step <= 0', () => { + expect(snap(7.3, 0)).toBe(7.3) + expect(snap(7.3, -1)).toBe(7.3) + }) + + it('handles exact-multiple inputs', () => { + expect(snap(15, 5)).toBe(15) + expect(snap(60, 15)).toBe(60) + }) + + it('exposes the SNAP_MIN constant', () => { + expect(SNAP_MIN).toBeGreaterThan(0) + expect(SNAP_MIN).toBeLessThanOrEqual(15) + }) + + it('exposes MIN_DURATION_MIN', () => { + expect(MIN_DURATION_MIN).toBe(15) + }) +}) + +describe('snapClamp', () => { + it('snaps then clamps inside [min, max]', () => { + expect(snapClamp(7, 5, 0, 100)).toBe(5) + expect(snapClamp(-5, 5, 0, 100)).toBe(0) + expect(snapClamp(150, 5, 0, 100)).toBe(100) + }) +}) diff --git a/apps/app/tests/unit/lib/timetable/time-grid.test.ts b/apps/app/tests/unit/lib/timetable/time-grid.test.ts new file mode 100644 index 00000000..496207ea --- /dev/null +++ b/apps/app/tests/unit/lib/timetable/time-grid.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest' +import { + formatTickLabel, + generateTicks, + isoToMinutes, + minutesToIso, + minutesToPx, + pxToMinutes, +} from '@/lib/timetable/time-grid' + +describe('time-grid coordinate conversions', () => { + const gridStart = '2026-07-10T14:00:00.000Z' + + it('isoToMinutes returns 0 at the anchor', () => { + expect(isoToMinutes(gridStart, gridStart)).toBe(0) + }) + + it('isoToMinutes computes minute offsets', () => { + expect(isoToMinutes('2026-07-10T15:00:00.000Z', gridStart)).toBe(60) + expect(isoToMinutes('2026-07-10T14:30:00.000Z', gridStart)).toBe(30) + expect(isoToMinutes('2026-07-10T13:30:00.000Z', gridStart)).toBe(-30) + }) + + it('roundtrip isoToMinutes ↔ minutesToIso preserves the value', () => { + const back = minutesToIso(isoToMinutes('2026-07-10T18:45:00.000Z', gridStart), gridStart) + + expect(back).toBe('2026-07-10T18:45:00.000Z') + }) + + it('minutesToPx and pxToMinutes are inverses', () => { + expect(minutesToPx(30, 2)).toBe(60) + expect(pxToMinutes(60, 2)).toBe(30) + expect(pxToMinutes(60, 0)).toBe(0) + }) + + it('formatTickLabel returns nl-NL HH:MM', () => { + const label = formatTickLabel(0, gridStart) + + expect(label).toMatch(/^\d{2}:\d{2}$/) + }) + + it('generateTicks produces inclusive endpoints', () => { + const ticks = generateTicks(120, 30) + + expect(ticks).toEqual([0, 30, 60, 90, 120]) + }) +}) diff --git a/apps/app/tests/unit/schemas/timetable.test.ts b/apps/app/tests/unit/schemas/timetable.test.ts new file mode 100644 index 00000000..6203d449 --- /dev/null +++ b/apps/app/tests/unit/schemas/timetable.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it } from 'vitest' +import { + createPerformancePayloadSchema, + createStagePayloadSchema, + performanceSchema, +} from '@/schemas/timetable' + +describe('createPerformancePayloadSchema', () => { + it('accepts a complete payload', () => { + const result = createPerformancePayloadSchema.safeParse({ + engagement_id: 'e1', + event_id: 'ev1', + stage_id: 's1', + start_at: '2026-07-10 18:00:00', + end_at: '2026-07-10 19:00:00', + lane: 0, + notes: null, + }) + + expect(result.success).toBe(true) + }) + + it('rejects missing engagement_id', () => { + const result = createPerformancePayloadSchema.safeParse({ + engagement_id: '', + event_id: 'ev1', + stage_id: null, + start_at: '2026-07-10 18:00:00', + end_at: '2026-07-10 19:00:00', + }) + + expect(result.success).toBe(false) + }) + + it('rejects end <= start', () => { + const result = createPerformancePayloadSchema.safeParse({ + engagement_id: 'e1', + event_id: 'ev1', + stage_id: null, + start_at: '2026-07-10 19:00:00', + end_at: '2026-07-10 18:00:00', + }) + + expect(result.success).toBe(false) + }) + + it('rejects lane > 9', () => { + const result = createPerformancePayloadSchema.safeParse({ + engagement_id: 'e1', + event_id: 'ev1', + stage_id: null, + start_at: '2026-07-10 18:00:00', + end_at: '2026-07-10 19:00:00', + lane: 99, + }) + + expect(result.success).toBe(false) + }) +}) + +describe('createStagePayloadSchema', () => { + it('accepts uppercase + lowercase hex', () => { + expect(createStagePayloadSchema.safeParse({ name: 'A', color: '#aabbcc' }).success).toBe(true) + expect(createStagePayloadSchema.safeParse({ name: 'A', color: '#AABBCC' }).success).toBe(true) + }) + + it('rejects shorthand hex', () => { + expect(createStagePayloadSchema.safeParse({ name: 'A', color: '#abc' }).success).toBe(false) + }) + + it('rejects empty name', () => { + expect(createStagePayloadSchema.safeParse({ name: '', color: '#aabbcc' }).success).toBe(false) + }) +}) + +describe('performanceSchema', () => { + it('parses a minimal performance', () => { + const result = performanceSchema.safeParse({ + id: 'p1', + engagement_id: 'e1', + event_id: 'ev1', + stage_id: null, + lane: 0, + lane_resolved: 0, + start_at: null, + end_at: null, + version: 0, + notes: null, + warnings: [], + created_at: null, + updated_at: null, + deleted_at: null, + }) + + expect(result.success).toBe(true) + }) + + it('rejects unknown warning value', () => { + const result = performanceSchema.safeParse({ + id: 'p1', + engagement_id: 'e1', + event_id: 'ev1', + stage_id: null, + lane: 0, + lane_resolved: 0, + start_at: null, + end_at: null, + version: 0, + notes: null, + warnings: ['nonsense'], + created_at: null, + updated_at: null, + deleted_at: null, + }) + + expect(result.success).toBe(false) + }) +}) diff --git a/apps/app/tests/unit/stores/useTimetableStore.test.ts b/apps/app/tests/unit/stores/useTimetableStore.test.ts new file mode 100644 index 00000000..06f22ced --- /dev/null +++ b/apps/app/tests/unit/stores/useTimetableStore.test.ts @@ -0,0 +1,77 @@ +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it } from 'vitest' +import { useTimetableStore } from '@/stores/useTimetableStore' +import { ArtistEngagementStatus, type Performance } from '@/types/timetable' + +function p(): 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: [], + created_at: null, + updated_at: null, + deleted_at: null, + } +} + +describe('useTimetableStore', () => { + beforeEach(() => setActivePinia(createPinia())) + + it('initialises with cancelled OFF in status filter', () => { + const store = useTimetableStore() + + expect(store.isStatusVisible(ArtistEngagementStatus.CONFIRMED)).toBe(true) + expect(store.isStatusVisible(ArtistEngagementStatus.CANCELLED)).toBe(false) + }) + + it('toggleStatus flips a single status', () => { + const store = useTimetableStore() + + store.toggleStatus(ArtistEngagementStatus.CONFIRMED) + expect(store.isStatusVisible(ArtistEngagementStatus.CONFIRMED)).toBe(false) + store.toggleStatus(ArtistEngagementStatus.CONFIRMED) + expect(store.isStatusVisible(ArtistEngagementStatus.CONFIRMED)).toBe(true) + }) + + it('setActiveDay updates and selectPerformance maps to id', () => { + const store = useTimetableStore() + + store.setActiveDay('day_1') + expect(store.activeDayId).toBe('day_1') + store.selectPerformance('p1') + expect(store.selectedPerformanceId).toBe('p1') + store.selectPerformance(null) + expect(store.selectedPerformanceId).toBeNull() + }) + + it('startDrag/endDrag manages snapshot + ghost', () => { + const store = useTimetableStore() + + expect(store.isDragging).toBe(false) + store.startDrag(p()) + expect(store.isDragging).toBe(true) + expect(store.dragOriginSnapshot?.id).toBe('p1') + + store.updateDragGhost({ stageId: 's1', startAt: '2026-07-10T18:30:00Z', endAt: '2026-07-10T19:30:00Z', lane: 1 }) + expect(store.dragGhost?.lane).toBe(1) + + store.endDrag() + expect(store.isDragging).toBe(false) + expect(store.dragGhost).toBeNull() + }) + + it('isStatusVisible handles null gracefully', () => { + const store = useTimetableStore() + + expect(store.isStatusVisible(null)).toBe(false) + expect(store.isStatusVisible(undefined)).toBe(false) + }) +})