learning_ai_clock/web/src/lib/routines.test.ts
saravanakumardb1 8fe5e8e787 feat(web): add routine engine, NL parser, and context messages
- 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)
2026-02-27 21:42:29 -08:00

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