import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { useTimerStore } from '@/store/timerStore'; describe('timerStore', () => { beforeEach(() => { vi.useFakeTimers(); useTimerStore.setState({ currentRate: 50, running: false, runStartedAt: null, accumulatedMs: 0, splits: [], lastHeartbeat: Date.now(), elapsedMs: 0, crashRecovery: null, }); }); afterEach(() => { vi.useRealTimers(); }); it('starts and tracks elapsed time', () => { const s = useTimerStore.getState(); s.start(); expect(useTimerStore.getState().running).toBe(true); vi.advanceTimersByTime(5000); useTimerStore.getState()._tick(); expect(useTimerStore.getState().elapsedMs).toBeGreaterThanOrEqual(5000); }); it('pause freezes elapsed and stops ticking', () => { useTimerStore.getState().start(); vi.advanceTimersByTime(3000); useTimerStore.getState().pause(); const frozen = useTimerStore.getState().elapsedMs; expect(useTimerStore.getState().running).toBe(false); vi.advanceTimersByTime(10000); expect(useTimerStore.getState().elapsedMs).toBe(frozen); }); it('resume after pause accumulates correctly', () => { useTimerStore.getState().start(); vi.advanceTimersByTime(2000); useTimerStore.getState().pause(); vi.advanceTimersByTime(5000); // paused time doesn't count useTimerStore.getState().start(); vi.advanceTimersByTime(3000); useTimerStore.getState()._tick(); const elapsed = useTimerStore.getState().elapsedMs; expect(elapsed).toBeGreaterThanOrEqual(5000); expect(elapsed).toBeLessThan(7000); // not 10000 }); it('split records current segment and resets clock', () => { useTimerStore.getState().start(); vi.advanceTimersByTime(4000); const split = useTimerStore.getState().split('task A'); expect(split.elapsedMs).toBeGreaterThanOrEqual(4000); expect(split.rate).toBe(50); expect(split.label).toBe('task A'); expect(split.recorded).toBe(false); expect(useTimerStore.getState().splits).toHaveLength(1); // Clock resets but keeps running expect(useTimerStore.getState().accumulatedMs).toBe(0); expect(useTimerStore.getState().running).toBe(true); }); it('split preserves rate at time of split (rate change after doesn\'t affect)', () => { useTimerStore.getState().start(); vi.advanceTimersByTime(1000); const split = useTimerStore.getState().split(); expect(split.rate).toBe(50); useTimerStore.getState().setRate(100); expect(useTimerStore.getState().splits[0].rate).toBe(50); expect(useTimerStore.getState().currentRate).toBe(100); }); it('setRate only affects live clock, not existing splits', () => { useTimerStore.getState().start(); vi.advanceTimersByTime(1000); useTimerStore.getState().split(); useTimerStore.getState().split(); useTimerStore.getState().setRate(999); expect(useTimerStore.getState().splits.every((s) => s.rate === 50)).toBe(true); }); it('markRecorded sets flag and stores work entry id', () => { useTimerStore.getState().start(); vi.advanceTimersByTime(1000); const split = useTimerStore.getState().split(); useTimerStore.getState().markRecorded(split.id, 'we-123'); const updated = useTimerStore.getState().splits.find((s) => s.id === split.id)!; expect(updated.recorded).toBe(true); expect(updated.recordedWorkEntryId).toBe('we-123'); }); it('updateSplit modifies fields', () => { useTimerStore.getState().start(); vi.advanceTimersByTime(1000); const split = useTimerStore.getState().split(); useTimerStore.getState().updateSplit(split.id, { label: 'new label', elapsedMs: 9999 }); const updated = useTimerStore.getState().splits.find((s) => s.id === split.id)!; expect(updated.label).toBe('new label'); expect(updated.elapsedMs).toBe(9999); }); it('deleteSplit removes from table', () => { useTimerStore.getState().start(); vi.advanceTimersByTime(1000); const split = useTimerStore.getState().split(); useTimerStore.getState().deleteSplit(split.id); expect(useTimerStore.getState().splits).toHaveLength(0); }); it('reset clears everything', () => { useTimerStore.getState().start(); vi.advanceTimersByTime(1000); useTimerStore.getState().split(); useTimerStore.getState().reset(); expect(useTimerStore.getState().running).toBe(false); expect(useTimerStore.getState().elapsedMs).toBe(0); expect(useTimerStore.getState().splits).toHaveLength(0); }); it('persists state to cookie on tick', () => { useTimerStore.getState().start(); vi.advanceTimersByTime(1500); useTimerStore.getState()._tick(); expect(document.cookie).toContain('t99_timer'); }); }); describe('timerStore crash recovery', () => { beforeEach(() => { vi.useRealTimers(); }); it('detects crash when heartbeat stale + running', () => { const now = Date.now(); const staleSnapshot = { currentRate: 75, running: true, runStartedAt: now - 60000, // started 1 min ago accumulatedMs: 0, splits: [], lastHeartbeat: now - 30000, // last seen 30s ago (> 5s threshold) }; document.cookie = `t99_timer=${encodeURIComponent(JSON.stringify(staleSnapshot))}; path=/`; useTimerStore.setState({ crashRecovery: null }); useTimerStore.getState()._restoreFromCookie(); const cr = useTimerStore.getState().crashRecovery; expect(cr).not.toBeNull(); expect(cr!.gapMs).toBeGreaterThan(25000); expect(cr!.crashTime).toBe(now - 30000); }); it('does NOT flag crash when paused', () => { const now = Date.now(); const snapshot = { currentRate: 50, running: false, runStartedAt: null, accumulatedMs: 120000, splits: [], lastHeartbeat: now - 60000, }; document.cookie = `t99_timer=${encodeURIComponent(JSON.stringify(snapshot))}; path=/`; useTimerStore.setState({ crashRecovery: null }); useTimerStore.getState()._restoreFromCookie(); expect(useTimerStore.getState().crashRecovery).toBeNull(); }); it('restores elapsed time from original start (includes gap)', () => { const now = Date.now(); const snapshot = { currentRate: 50, running: true, runStartedAt: now - 120000, // 2 min ago accumulatedMs: 0, splits: [], lastHeartbeat: now - 60000, }; document.cookie = `t99_timer=${encodeURIComponent(JSON.stringify(snapshot))}; path=/`; useTimerStore.getState()._restoreFromCookie(); // Clock should show ~2 minutes (original start was preserved) expect(useTimerStore.getState().elapsedMs).toBeGreaterThanOrEqual(120000); }); it('subtractCrashGap removes gap from accumulated time', () => { useTimerStore.setState({ running: false, runStartedAt: null, accumulatedMs: 100000, crashRecovery: { crashTime: 0, reloadTime: 30000, gapMs: 30000 }, elapsedMs: 100000, }); useTimerStore.getState().subtractCrashGap(); expect(useTimerStore.getState().accumulatedMs).toBe(70000); expect(useTimerStore.getState().crashRecovery).toBeNull(); }); it('subtractCrashGap clamps to zero', () => { useTimerStore.setState({ accumulatedMs: 5000, crashRecovery: { crashTime: 0, reloadTime: 0, gapMs: 99999 }, }); useTimerStore.getState().subtractCrashGap(); expect(useTimerStore.getState().accumulatedMs).toBe(0); }); it('dismissCrashBanner keeps time but removes banner', () => { useTimerStore.setState({ accumulatedMs: 100000, crashRecovery: { crashTime: 0, reloadTime: 0, gapMs: 30000 }, }); useTimerStore.getState().dismissCrashBanner(); expect(useTimerStore.getState().crashRecovery).toBeNull(); expect(useTimerStore.getState().accumulatedMs).toBe(100000); // unchanged }); it('restores splits from cookie', () => { const snapshot = { currentRate: 50, running: false, runStartedAt: null, accumulatedMs: 0, splits: [ { id: 's1', startedAt: 0, elapsedMs: 60000, rate: 50, recorded: false }, { id: 's2', startedAt: 0, elapsedMs: 30000, rate: 75, recorded: true, recordedWorkEntryId: 'we-1' }, ], lastHeartbeat: Date.now(), }; document.cookie = `t99_timer=${encodeURIComponent(JSON.stringify(snapshot))}; path=/`; useTimerStore.getState()._restoreFromCookie(); expect(useTimerStore.getState().splits).toHaveLength(2); expect(useTimerStore.getState().splits[1].recorded).toBe(true); }); });