test(timetable): Phase C — 67 new tests (pure logic + composables + store + schemas)

apps/app/tests/unit/lib/timetable/:
  - snap.test.ts (5)            — rounding, clamp, edge cases
  - time-grid.test.ts (6)       — px↔min↔ISO roundtrips, formatTickLabel
  - conflict.test.ts (8)        — overlap, endpoint-touching, lane/stage scoping, cancelled exclusion
  - b2b.test.ts (6)             — 0min, 2:59, 3:01, overlap, side-set mapping, threshold constant
  - capacity.test.ts (7)        — null capacity, missing data, warn/critical, crew+guests preference
  - lane.test.ts (8)            — Pass 1 + Pass 2, cascade-bump preview, cancelled exclusion

apps/app/tests/unit/composables/:
  - useTimetableMutations.test.ts (5) — Idempotency-Key header, optimistic + cascade,
                                         409 VersionMismatch surfaced, park sends null,
                                         createStage POST path
  - useDragOrClick.test.ts (3)        — onClick fires under threshold, onDragStart+End
                                         above threshold, Esc cancels mid-flight

apps/app/tests/unit/schemas/timetable.test.ts (8) — payload + response zod parsers
apps/app/tests/unit/lib/idempotencyKey.test.ts (3) — 6-30 char range, 24-hex, uniqueness
apps/app/tests/unit/stores/useTimetableStore.test.ts (5) — defaults, toggleStatus, drag state, null guard

Refactor: useTimetableMutations.move now throws Error instances (no-throw-literal)
so AxiosError.message and the VersionMismatchError shape both bubble through .catch().

Test count: 252 → 319 (+67). All 42 files pass.

Out of scope this session (added to BACKLOG):
- ART-PERFORMANCEBLOCK-COMPONENT-TESTS — Vuetify intentionally not loaded in
  vitest.config.ts; a Vuexy-stub setup for component-mount tests is one PR of
  its own. Pure rendering logic (capacity, B2B, conflict) is fully covered at
  the lib/ layer.
- ART-AXE-CORE-A11Y-TESTS — axe-core not yet installed in the repo. The
  aria-label structure on PerformanceBlock + aria-live on the page entry are
  authored to pass an axe scan when added.
- ART-INTEGRATION-FLOW-TEST — full add → drag → resize → park flow needs
  Vuetify + router + msw setup; defer with the component tests above.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-09 02:04:10 +02:00
parent 43572a7812
commit 39fdc0fa3d
12 changed files with 981 additions and 8 deletions

View File

@@ -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<typeof useDragOrClick>[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)
})
})

View File

@@ -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<typeof vi.fn>
put: ReturnType<typeof vi.fn>
get: ReturnType<typeof vi.fn>
delete: ReturnType<typeof vi.fn>
}
const mocked = apiClient as unknown as MockApi
function p(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: 3,
notes: null,
warnings: [],
created_at: null,
updated_at: null,
deleted_at: null,
...overrides,
}
}
function mountWithMutations() {
const api: { value: ReturnType<typeof useTimetableMutations> | null } = { value: null }
const orgId = ref('org_1')
const eventId = ref('ev_1')
const dayId = ref<string | null>('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<string | null>('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')
})
})
})

View File

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

View File

@@ -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> = {}): 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)
})
})

View File

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

View File

@@ -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> = {}): 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)
})
})

View File

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

View File

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

View File

@@ -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])
})
})

View File

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

View File

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