learning_ai_clock/web/src/lib/linked-timers.test.ts
saravanakumardb1 fc05ea12ba feat(web): routine editor/runner UI, linked timers, routines page
- RoutineEditor component: add/remove/reorder steps, transition types, notes, save-as-template toggle, total duration display
- RoutineRunner component: countdown ring, step dots, progress bar, next step preview, pause/resume/skip/done/cancel, completion celebration
- Routine store (lib/routine-store.ts): Zustand + localStorage persistence, template management, auto-advance via tickRoutines()
- Routines page (app/routines/): template browser, active runner, ready queue, past routines
- Linked timers engine (lib/linked-timers.ts): chain building/append/remove with relinking, 3 presets (Pasta, Laundry, Meeting Prep), downstream queries
- Linked timers tests (27 tests)
- Dashboard: added Routines + nav link
- Updated roadmap.md Week 3 items
- 329 tests passing (14 test files), tsc clean
2026-02-27 22:07:16 -08:00

237 lines
7.1 KiB
TypeScript

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