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:
24
apps/app/tests/unit/lib/idempotencyKey.test.ts
Normal file
24
apps/app/tests/unit/lib/idempotencyKey.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
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)
|
||||
})
|
||||
})
|
||||
76
apps/app/tests/unit/lib/timetable/capacity.test.ts
Normal file
76
apps/app/tests/unit/lib/timetable/capacity.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
116
apps/app/tests/unit/lib/timetable/conflict.test.ts
Normal file
116
apps/app/tests/unit/lib/timetable/conflict.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
111
apps/app/tests/unit/lib/timetable/lane.test.ts
Normal file
111
apps/app/tests/unit/lib/timetable/lane.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
41
apps/app/tests/unit/lib/timetable/snap.test.ts
Normal file
41
apps/app/tests/unit/lib/timetable/snap.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
47
apps/app/tests/unit/lib/timetable/time-grid.test.ts
Normal file
47
apps/app/tests/unit/lib/timetable/time-grid.test.ts
Normal 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])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user