Files
crewli/apps/app/tests/unit/lib/timetable/lane.test.ts
bert.hausmans 39fdc0fa3d 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>
2026-05-09 02:04:10 +02:00

112 lines
3.2 KiB
TypeScript

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