import { describe, it, expect } from 'vitest'; import { createRoutine, createRoutineStep, startRoutine, pauseRoutine, resumeRoutine, completeCurrentStep, skipCurrentStep, cancelRoutine, getCurrentStep, getNextStep, getCompletedStepCount, getSkippedStepCount, getRoutineProgress, getRemainingStepMs, shouldStepComplete, calculateTotalDuration, getTransitionMinutes, instantiateTemplate, getBuiltInTemplates, ROUTINE_TEMPLATES, } from './routines'; describe('routines', () => { const ONE_MIN = 60_000; function makeSteps() { return [ { label: 'Step A', durationMinutes: 5, transition: 'immediate' as const }, { label: 'Step B', durationMinutes: 10, transition: '1m_break' as const }, { label: 'Step C', durationMinutes: 15, transition: 'immediate' as const }, ]; } describe('createRoutine', () => { it('creates a routine with correct fields', () => { const routine = createRoutine({ name: 'Test', steps: makeSteps() }); expect(routine.name).toBe('Test'); expect(routine.status).toBe('ready'); expect(routine.steps).toHaveLength(3); expect(routine.currentStepIndex).toBe(0); expect(routine.isTemplate).toBe(false); }); it('creates a template routine', () => { const routine = createRoutine({ name: 'Template', steps: makeSteps(), isTemplate: true }); expect(routine.status).toBe('template'); expect(routine.isTemplate).toBe(true); }); it('generates unique IDs for steps', () => { const routine = createRoutine({ name: 'Test', steps: makeSteps() }); const ids = routine.steps.map((s) => s.id); expect(new Set(ids).size).toBe(3); }); it('initializes all steps as pending', () => { const routine = createRoutine({ name: 'Test', steps: makeSteps() }); routine.steps.forEach((step) => { expect(step.status).toBe('pending'); expect(step.startedAt).toBeNull(); expect(step.completedAt).toBeNull(); }); }); }); describe('calculateTotalDuration', () => { it('sums step durations and transitions', () => { const routine = createRoutine({ name: 'Test', steps: makeSteps() }); // Step A: 5m, Step B: 10m + 1m transition from A, Step C: 15m + 0 (last has no transition counted) // Actually: A=5 + transition(0), B=10 + transition(1m from B, but only between steps), C=15 // calculateTotalDuration: step.duration + transition if not last // A: 5 + 0(immediate) = 5, B: 10 + 1(1m_break) = 11, C: 15 + 0(last) = 15 => 31 expect(routine.totalDurationMinutes).toBe(31); }); it('returns 0 for empty steps', () => { expect(calculateTotalDuration([])).toBe(0); }); it('handles custom transitions', () => { const steps = [ createRoutineStep({ label: 'A', durationMinutes: 10, transition: 'custom', customTransitionMinutes: 3 }), createRoutineStep({ label: 'B', durationMinutes: 5, transition: 'immediate' }), ]; expect(calculateTotalDuration(steps)).toBe(10 + 3 + 5); }); }); describe('getTransitionMinutes', () => { it('returns 0 for immediate', () => { const step = createRoutineStep({ label: 'A', durationMinutes: 5, transition: 'immediate' }); expect(getTransitionMinutes(step)).toBe(0); }); it('returns 1 for 1m_break', () => { const step = createRoutineStep({ label: 'A', durationMinutes: 5, transition: '1m_break' }); expect(getTransitionMinutes(step)).toBe(1); }); it('returns 5 for 5m_break', () => { const step = createRoutineStep({ label: 'A', durationMinutes: 5, transition: '5m_break' }); expect(getTransitionMinutes(step)).toBe(5); }); it('returns custom value', () => { const step = createRoutineStep({ label: 'A', durationMinutes: 5, transition: 'custom', customTransitionMinutes: 7 }); expect(getTransitionMinutes(step)).toBe(7); }); }); describe('state machine', () => { it('ready -> active via startRoutine', () => { const routine = createRoutine({ name: 'Test', steps: makeSteps() }); const started = startRoutine(routine); expect(started.status).toBe('active'); expect(started.startedAt).not.toBeNull(); expect(started.steps[0].status).toBe('active'); expect(started.steps[0].startedAt).not.toBeNull(); expect(started.steps[1].status).toBe('pending'); }); it('does not start empty routine', () => { const routine = createRoutine({ name: 'Empty', steps: [] }); const result = startRoutine(routine); expect(result.status).toBe('ready'); }); it('active -> paused', () => { const routine = startRoutine(createRoutine({ name: 'Test', steps: makeSteps() })); const paused = pauseRoutine(routine); expect(paused.status).toBe('paused'); expect(paused.pausedAt).not.toBeNull(); expect(paused.elapsedBeforePause).toBeGreaterThanOrEqual(0); }); it('paused -> active via resumeRoutine', () => { let routine = startRoutine(createRoutine({ name: 'Test', steps: makeSteps() })); routine = pauseRoutine(routine); const resumed = resumeRoutine(routine); expect(resumed.status).toBe('active'); expect(resumed.pausedAt).toBeNull(); expect(resumed.steps[0].startedAt).not.toBeNull(); }); it('does not pause a non-active routine', () => { const routine = createRoutine({ name: 'Test', steps: makeSteps() }); expect(pauseRoutine(routine).status).toBe('ready'); }); it('does not resume a non-paused routine', () => { const routine = startRoutine(createRoutine({ name: 'Test', steps: makeSteps() })); expect(resumeRoutine(routine).status).toBe('active'); }); }); describe('step transitions', () => { it('completes current step and advances', () => { const routine = startRoutine(createRoutine({ name: 'Test', steps: makeSteps() })); const next = completeCurrentStep(routine); expect(next.steps[0].status).toBe('completed'); expect(next.steps[0].completedAt).not.toBeNull(); expect(next.steps[1].status).toBe('active'); expect(next.steps[1].startedAt).not.toBeNull(); expect(next.currentStepIndex).toBe(1); }); it('completes routine on last step', () => { let routine = startRoutine(createRoutine({ name: 'Test', steps: makeSteps() })); routine = completeCurrentStep(routine); // A -> B routine = completeCurrentStep(routine); // B -> C routine = completeCurrentStep(routine); // C -> done expect(routine.status).toBe('completed'); expect(routine.completedAt).not.toBeNull(); }); it('skips current step and advances', () => { const routine = startRoutine(createRoutine({ name: 'Test', steps: makeSteps() })); const next = skipCurrentStep(routine); expect(next.steps[0].status).toBe('skipped'); expect(next.steps[1].status).toBe('active'); expect(next.currentStepIndex).toBe(1); }); it('skipping last step completes routine', () => { let routine = startRoutine(createRoutine({ name: 'Single', steps: [{ label: 'Only Step', durationMinutes: 5, transition: 'immediate' as const }], })); routine = skipCurrentStep(routine); expect(routine.status).toBe('completed'); }); it('does not complete step on non-active routine', () => { const routine = createRoutine({ name: 'Test', steps: makeSteps() }); const result = completeCurrentStep(routine); expect(result.currentStepIndex).toBe(0); }); }); describe('cancelRoutine', () => { it('cancels an active routine', () => { const routine = startRoutine(createRoutine({ name: 'Test', steps: makeSteps() })); const cancelled = cancelRoutine(routine); expect(cancelled.status).toBe('cancelled'); expect(cancelled.completedAt).not.toBeNull(); }); it('does not cancel a completed routine', () => { let routine = startRoutine(createRoutine({ name: 'Single', steps: [{ label: 'Step', durationMinutes: 1, transition: 'immediate' as const }], })); routine = completeCurrentStep(routine); expect(cancelRoutine(routine).status).toBe('completed'); }); }); describe('utility functions', () => { it('getCurrentStep returns active step', () => { const routine = startRoutine(createRoutine({ name: 'Test', steps: makeSteps() })); expect(getCurrentStep(routine)?.label).toBe('Step A'); }); it('getNextStep returns the next step', () => { const routine = startRoutine(createRoutine({ name: 'Test', steps: makeSteps() })); expect(getNextStep(routine)?.label).toBe('Step B'); }); it('getNextStep returns null on last step', () => { let routine = startRoutine(createRoutine({ name: 'Test', steps: makeSteps() })); routine = completeCurrentStep(routine); routine = completeCurrentStep(routine); expect(getNextStep(routine)).toBeNull(); }); it('getCompletedStepCount counts completed steps', () => { let routine = startRoutine(createRoutine({ name: 'Test', steps: makeSteps() })); expect(getCompletedStepCount(routine)).toBe(0); routine = completeCurrentStep(routine); expect(getCompletedStepCount(routine)).toBe(1); }); it('getSkippedStepCount counts skipped steps', () => { let routine = startRoutine(createRoutine({ name: 'Test', steps: makeSteps() })); routine = skipCurrentStep(routine); expect(getSkippedStepCount(routine)).toBe(1); }); it('getRoutineProgress returns fraction', () => { let routine = startRoutine(createRoutine({ name: 'Test', steps: makeSteps() })); expect(getRoutineProgress(routine)).toBe(0); routine = completeCurrentStep(routine); expect(getRoutineProgress(routine)).toBeCloseTo(1 / 3); routine = completeCurrentStep(routine); expect(getRoutineProgress(routine)).toBeCloseTo(2 / 3); routine = completeCurrentStep(routine); expect(getRoutineProgress(routine)).toBe(1); }); it('getRoutineProgress returns 0 for empty', () => { const routine = createRoutine({ name: 'Empty', steps: [] }); expect(getRoutineProgress(routine)).toBe(0); }); it('getRemainingStepMs returns remaining time for active step', () => { const routine = startRoutine(createRoutine({ name: 'Test', steps: makeSteps() })); const remaining = getRemainingStepMs(routine); expect(remaining).toBeGreaterThan(4.9 * ONE_MIN); expect(remaining).toBeLessThanOrEqual(5 * ONE_MIN); }); it('getRemainingStepMs returns remaining for paused routine', () => { let routine = startRoutine(createRoutine({ name: 'Test', steps: makeSteps() })); routine = pauseRoutine(routine); const remaining = getRemainingStepMs(routine); expect(remaining).toBeGreaterThan(0); }); it('getRemainingStepMs returns 0 for non-active routine', () => { const routine = createRoutine({ name: 'Test', steps: makeSteps() }); expect(getRemainingStepMs(routine)).toBe(0); }); it('shouldStepComplete returns true when time is up', () => { const routine = startRoutine(createRoutine({ name: 'Test', steps: makeSteps() })); const futureTime = Date.now() + 6 * ONE_MIN; expect(shouldStepComplete(routine, futureTime)).toBe(true); }); it('shouldStepComplete returns false when time remains', () => { const routine = startRoutine(createRoutine({ name: 'Test', steps: makeSteps() })); expect(shouldStepComplete(routine)).toBe(false); }); }); describe('instantiateTemplate', () => { it('creates a ready copy from a template', () => { const template = createRoutine({ name: 'Template', steps: makeSteps(), isTemplate: true }); const instance = instantiateTemplate(template); expect(instance.id).not.toBe(template.id); expect(instance.status).toBe('ready'); expect(instance.isTemplate).toBe(false); expect(instance.steps).toHaveLength(3); expect(instance.steps[0].id).not.toBe(template.steps[0].id); }); it('preserves step labels and durations', () => { const template = createRoutine({ name: 'Template', steps: makeSteps(), isTemplate: true }); const instance = instantiateTemplate(template); expect(instance.steps[0].label).toBe('Step A'); expect(instance.steps[0].durationMinutes).toBe(5); }); }); describe('built-in templates', () => { it('has 4 templates', () => { expect(ROUTINE_TEMPLATES).toHaveLength(4); }); it('getBuiltInTemplates returns valid routines', () => { const templates = getBuiltInTemplates(); expect(templates).toHaveLength(4); templates.forEach((t) => { expect(t.isTemplate).toBe(true); expect(t.status).toBe('template'); expect(t.steps.length).toBeGreaterThan(0); expect(t.totalDurationMinutes).toBeGreaterThan(0); }); }); it('Morning Routine has 5 steps', () => { const templates = getBuiltInTemplates(); const morning = templates.find((t) => t.name === 'Morning Routine'); expect(morning).toBeDefined(); expect(morning!.steps).toHaveLength(5); }); it('templates can be instantiated and started', () => { const templates = getBuiltInTemplates(); const instance = instantiateTemplate(templates[0]); const started = startRoutine(instance); expect(started.status).toBe('active'); expect(started.steps[0].status).toBe('active'); }); }); describe('full routine lifecycle', () => { it('runs through all steps to completion', () => { let routine = startRoutine(createRoutine({ name: 'Full Test', steps: makeSteps() })); expect(routine.status).toBe('active'); expect(getCurrentStep(routine)?.label).toBe('Step A'); routine = completeCurrentStep(routine); expect(getCurrentStep(routine)?.label).toBe('Step B'); expect(getRoutineProgress(routine)).toBeCloseTo(1 / 3); routine = completeCurrentStep(routine); expect(getCurrentStep(routine)?.label).toBe('Step C'); expect(getRoutineProgress(routine)).toBeCloseTo(2 / 3); routine = completeCurrentStep(routine); expect(routine.status).toBe('completed'); expect(getRoutineProgress(routine)).toBe(1); expect(getCompletedStepCount(routine)).toBe(3); expect(getSkippedStepCount(routine)).toBe(0); }); it('handles mixed complete and skip', () => { let routine = startRoutine(createRoutine({ name: 'Mixed', steps: makeSteps() })); routine = completeCurrentStep(routine); // complete A routine = skipCurrentStep(routine); // skip B routine = completeCurrentStep(routine); // complete C expect(routine.status).toBe('completed'); expect(getCompletedStepCount(routine)).toBe(2); expect(getSkippedStepCount(routine)).toBe(1); }); it('pause and resume preserves state', () => { let routine = startRoutine(createRoutine({ name: 'Pause Test', steps: makeSteps() })); routine = completeCurrentStep(routine); // A done, B active routine = pauseRoutine(routine); expect(routine.status).toBe('paused'); expect(routine.currentStepIndex).toBe(1); routine = resumeRoutine(routine); expect(routine.status).toBe('active'); expect(getCurrentStep(routine)?.label).toBe('Step B'); routine = completeCurrentStep(routine); // B done, C active routine = completeCurrentStep(routine); // C done expect(routine.status).toBe('completed'); }); }); });