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:
84
apps/app/tests/unit/lib/timetable/b2b.test.ts
Normal file
84
apps/app/tests/unit/lib/timetable/b2b.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user