import { describe, it, expect } from 'vitest'; import { buildChain, appendToChain, removeFromChain, getNextInChain, findChainForTimer, getChainPosition, hasDownstreamTimers, getDownstreamTimerIds, getChainTimers, CHAIN_PRESETS, } from './linked-timers'; import type { Timer } from './timer-engine'; function makeTimer(id: string): Timer { const now = Date.now(); return { id, type: 'countdown', label: `Timer ${id}`, urgency: 'standard', state: 'active', targetTime: now + 60_000, duration: 60_000, createdAt: now, startedAt: now, pausedAt: null, firedAt: null, dismissedAt: null, completedAt: null, elapsedBeforePause: 0, cascade: { preset: 'none', intervals: [] }, warnings: [], snoozeCount: 0, snoozedUntil: null, }; } describe('buildChain', () => { it('creates a chain with correct links', () => { const chain = buildChain('Test', ['a', 'b', 'c']); expect(chain.name).toBe('Test'); expect(chain.timerIds).toEqual(['a', 'b', 'c']); expect(chain.links).toHaveLength(2); expect(chain.links[0]).toEqual({ fromId: 'a', toId: 'b', delay: 0 }); expect(chain.links[1]).toEqual({ fromId: 'b', toId: 'c', delay: 0 }); }); it('creates a chain with custom delay', () => { const chain = buildChain('Delayed', ['x', 'y'], 5000); expect(chain.links[0].delay).toBe(5000); }); it('handles single timer (no links)', () => { const chain = buildChain('Solo', ['a']); expect(chain.timerIds).toEqual(['a']); expect(chain.links).toHaveLength(0); }); it('handles empty timer list', () => { const chain = buildChain('Empty', []); expect(chain.timerIds).toEqual([]); expect(chain.links).toHaveLength(0); }); }); describe('appendToChain', () => { it('appends a timer to end of chain', () => { const chain = buildChain('Base', ['a', 'b']); const extended = appendToChain(chain, 'c', 1000); expect(extended.timerIds).toEqual(['a', 'b', 'c']); expect(extended.links).toHaveLength(2); expect(extended.links[1]).toEqual({ fromId: 'b', toId: 'c', delay: 1000 }); }); it('preserves existing links', () => { const chain = buildChain('Base', ['a', 'b'], 500); const extended = appendToChain(chain, 'c'); expect(extended.links[0].delay).toBe(500); expect(extended.links[1].delay).toBe(0); }); }); describe('removeFromChain', () => { it('removes a middle timer and relinks', () => { const chain = buildChain('Test', ['a', 'b', 'c']); const reduced = removeFromChain(chain, 'b'); expect(reduced.timerIds).toEqual(['a', 'c']); expect(reduced.links).toHaveLength(1); expect(reduced.links[0].fromId).toBe('a'); expect(reduced.links[0].toId).toBe('c'); }); it('removes first timer', () => { const chain = buildChain('Test', ['a', 'b', 'c']); const reduced = removeFromChain(chain, 'a'); expect(reduced.timerIds).toEqual(['b', 'c']); expect(reduced.links).toHaveLength(1); }); it('removes last timer', () => { const chain = buildChain('Test', ['a', 'b', 'c']); const reduced = removeFromChain(chain, 'c'); expect(reduced.timerIds).toEqual(['a', 'b']); expect(reduced.links).toHaveLength(1); }); it('returns unchanged if timer not in chain', () => { const chain = buildChain('Test', ['a', 'b']); const same = removeFromChain(chain, 'z'); expect(same.timerIds).toEqual(['a', 'b']); }); }); describe('getNextInChain', () => { it('finds the next link after a completed timer', () => { const chain = buildChain('Test', ['a', 'b', 'c']); const link = getNextInChain([chain], 'a'); expect(link).not.toBeNull(); expect(link!.toId).toBe('b'); }); it('returns null for last timer in chain', () => { const chain = buildChain('Test', ['a', 'b']); const link = getNextInChain([chain], 'b'); expect(link).toBeNull(); }); it('returns null for unknown timer', () => { const chain = buildChain('Test', ['a', 'b']); const link = getNextInChain([chain], 'z'); expect(link).toBeNull(); }); it('searches across multiple chains', () => { const c1 = buildChain('Chain1', ['a', 'b']); const c2 = buildChain('Chain2', ['x', 'y']); const link = getNextInChain([c1, c2], 'x'); expect(link!.toId).toBe('y'); }); }); describe('findChainForTimer', () => { it('finds the chain containing a timer', () => { const c1 = buildChain('C1', ['a', 'b']); const c2 = buildChain('C2', ['x', 'y']); const found = findChainForTimer([c1, c2], 'x'); expect(found!.name).toBe('C2'); }); it('returns null if not found', () => { const chain = buildChain('C1', ['a', 'b']); expect(findChainForTimer([chain], 'z')).toBeNull(); }); }); describe('getChainPosition', () => { it('returns correct position', () => { const chain = buildChain('Test', ['a', 'b', 'c']); const pos = getChainPosition([chain], 'b'); expect(pos!.index).toBe(1); expect(pos!.total).toBe(3); }); it('returns null for unknown timer', () => { const chain = buildChain('Test', ['a']); expect(getChainPosition([chain], 'z')).toBeNull(); }); }); describe('hasDownstreamTimers', () => { it('returns true for non-last timer', () => { const chain = buildChain('Test', ['a', 'b', 'c']); expect(hasDownstreamTimers([chain], 'a')).toBe(true); expect(hasDownstreamTimers([chain], 'b')).toBe(true); }); it('returns false for last timer', () => { const chain = buildChain('Test', ['a', 'b']); expect(hasDownstreamTimers([chain], 'b')).toBe(false); }); it('returns false for unknown timer', () => { const chain = buildChain('Test', ['a']); expect(hasDownstreamTimers([chain], 'z')).toBe(false); }); }); describe('getDownstreamTimerIds', () => { it('returns downstream IDs', () => { const chain = buildChain('Test', ['a', 'b', 'c', 'd']); expect(getDownstreamTimerIds([chain], 'b')).toEqual(['c', 'd']); }); it('returns empty for last timer', () => { const chain = buildChain('Test', ['a', 'b']); expect(getDownstreamTimerIds([chain], 'b')).toEqual([]); }); }); describe('getChainTimers', () => { it('resolves timer objects', () => { const timers = [makeTimer('a'), makeTimer('b'), makeTimer('c')]; const chain = buildChain('Test', ['a', 'b', 'c']); const resolved = getChainTimers(chain, timers); expect(resolved).toHaveLength(3); expect(resolved[0]!.id).toBe('a'); expect(resolved[2]!.id).toBe('c'); }); it('returns null for missing timers', () => { const timers = [makeTimer('a')]; const chain = buildChain('Test', ['a', 'missing']); const resolved = getChainTimers(chain, timers); expect(resolved[0]!.id).toBe('a'); expect(resolved[1]).toBeNull(); }); }); describe('CHAIN_PRESETS', () => { it('has at least 3 presets', () => { expect(CHAIN_PRESETS.length).toBeGreaterThanOrEqual(3); }); it('each preset has steps with durations', () => { for (const preset of CHAIN_PRESETS) { expect(preset.name).toBeTruthy(); expect(preset.steps.length).toBeGreaterThan(0); for (const step of preset.steps) { expect(step.label).toBeTruthy(); expect(step.durationMs).toBeGreaterThan(0); } } }); });