- Routine engine: data model, state machine (start/pause/resume/complete/skip/cancel), 4 built-in templates (Morning, Workout, Cooking, Wind-Down), template instantiation - NL parser: regex-based parsing for relative times, absolute times, durations, pomodoro, urgency detection, label extraction (no LLM dependency) - Context messages: keyword→message rules for 20+ categories (meetings, flights, cooking, health, etc.), warning message formatting - 105 unit tests (45 routines + 37 NL parser + 23 context messages)
394 lines
15 KiB
TypeScript
394 lines
15 KiB
TypeScript
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');
|
|
});
|
|
});
|
|
});
|