From fe75bc30dec0028f9ca151f6945eeaf6a8787873 Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Sat, 28 Feb 2026 19:29:39 -0800 Subject: [PATCH] test(web): add 21 tests for exportAll/importRoutines/parseImportData --- web/src/lib/export.test.ts | 258 +++++++++++++++++++++++++++++++++++++ 1 file changed, 258 insertions(+) create mode 100644 web/src/lib/export.test.ts diff --git a/web/src/lib/export.test.ts b/web/src/lib/export.test.ts new file mode 100644 index 0000000..0dde5d4 --- /dev/null +++ b/web/src/lib/export.test.ts @@ -0,0 +1,258 @@ +// ── Export / Import Tests ──────────────────────────────────── +import { describe, it, expect } from 'vitest'; +import { + exportTimers, + exportAll, + parseImportData, + importTimers, + importRoutines, +} from './export'; +import type { Timer } from './timer-engine'; +import type { Routine } from './routines'; +import type { ExportData } from './export'; + +// ── Fixtures ───────────────────────────────────────────────── + +function makeTimer(overrides: Partial = {}): Timer { + return { + id: 'timer-1', + type: 'countdown', + label: 'Test Timer', + targetTime: Date.now() + 60_000, + createdAt: Date.now(), + state: 'running', + urgency: 'standard', + cascadePreset: 'standard', + cascadeIntervals: [5, 1], + firedCascades: [], + snoozedUntil: null, + snoozeCount: 0, + ...overrides, + } as Timer; +} + +function makeRoutine(overrides: Partial = {}): Routine { + return { + id: 'routine-1', + name: 'Morning Routine', + steps: [ + { id: 'step-1', label: 'Wake up', durationMs: 60_000, status: 'pending' }, + { id: 'step-2', label: 'Shower', durationMs: 300_000, status: 'pending' }, + ], + status: 'idle', + currentStepIndex: 0, + createdAt: Date.now(), + isTemplate: false, + ...overrides, + } as Routine; +} + +// ── exportTimers ───────────────────────────────────────────── + +describe('exportTimers', () => { + it('returns correct structure with timers only', () => { + const timers = [makeTimer()]; + const result = exportTimers(timers); + expect(result.version).toBe(1); + expect(result.app).toBe('chronomind'); + expect(result.timers).toHaveLength(1); + expect(result.timers[0].id).toBe('timer-1'); + expect(result.routines).toBeUndefined(); + }); + + it('shallow-clones timers (not same reference)', () => { + const timers = [makeTimer()]; + const result = exportTimers(timers); + expect(result.timers[0]).not.toBe(timers[0]); + expect(result.timers[0]).toEqual(timers[0]); + }); + + it('handles empty timer array', () => { + const result = exportTimers([]); + expect(result.timers).toHaveLength(0); + }); +}); + +// ── exportAll ──────────────────────────────────────────────── + +describe('exportAll', () => { + it('includes both timers and routines', () => { + const timers = [makeTimer()]; + const routines = [makeRoutine()]; + const result = exportAll(timers, routines); + expect(result.version).toBe(1); + expect(result.app).toBe('chronomind'); + expect(result.timers).toHaveLength(1); + expect(result.routines).toHaveLength(1); + expect(result.routines![0].name).toBe('Morning Routine'); + }); + + it('deep-clones routine steps', () => { + const routines = [makeRoutine()]; + const result = exportAll([], routines); + expect(result.routines![0].steps[0]).not.toBe(routines[0].steps[0]); + expect(result.routines![0].steps[0]).toEqual(routines[0].steps[0]); + }); + + it('handles empty arrays', () => { + const result = exportAll([], []); + expect(result.timers).toHaveLength(0); + expect(result.routines).toHaveLength(0); + }); +}); + +// ── parseImportData ────────────────────────────────────────── + +describe('parseImportData', () => { + it('parses valid export JSON', () => { + const data: ExportData = { + version: 1, + exportedAt: Date.now(), + app: 'chronomind', + timers: [makeTimer()], + routines: [makeRoutine()], + }; + const result = parseImportData(JSON.stringify(data)); + expect(result).not.toBeNull(); + expect(result!.timers).toHaveLength(1); + expect(result!.routines).toHaveLength(1); + }); + + it('returns null for invalid JSON', () => { + expect(parseImportData('not json')).toBeNull(); + }); + + it('returns null for wrong app', () => { + expect(parseImportData(JSON.stringify({ app: 'other', timers: [] }))).toBeNull(); + }); + + it('returns null for missing timers array', () => { + expect(parseImportData(JSON.stringify({ app: 'chronomind' }))).toBeNull(); + }); + + it('accepts data without routines', () => { + const data = { version: 1, exportedAt: Date.now(), app: 'chronomind', timers: [] }; + const result = parseImportData(JSON.stringify(data)); + expect(result).not.toBeNull(); + expect(result!.routines).toBeUndefined(); + }); +}); + +// ── importTimers ───────────────────────────────────────────── + +describe('importTimers', () => { + it('imports new timers', () => { + const data: ExportData = { + version: 1, exportedAt: Date.now(), app: 'chronomind', + timers: [makeTimer({ id: 'new-1' })], + }; + const result = importTimers(data, []); + expect(result.imported).toBe(1); + expect(result.skipped).toBe(0); + expect(result.success).toBe(true); + }); + + it('skips duplicate timers by ID', () => { + const existing = [makeTimer({ id: 'dup-1' })]; + const data: ExportData = { + version: 1, exportedAt: Date.now(), app: 'chronomind', + timers: [makeTimer({ id: 'dup-1' }), makeTimer({ id: 'new-2' })], + }; + const result = importTimers(data, existing); + expect(result.imported).toBe(1); + expect(result.skipped).toBe(1); + }); + + it('rejects timers missing required fields', () => { + const data: ExportData = { + version: 1, exportedAt: Date.now(), app: 'chronomind', + timers: [{ id: '', type: 'countdown', label: '' } as unknown as Timer], + }; + const result = importTimers(data, []); + expect(result.imported).toBe(0); + expect(result.skipped).toBe(1); + expect(result.errors).toHaveLength(1); + }); + + it('rejects timers with invalid timestamps', () => { + const data: ExportData = { + version: 1, exportedAt: Date.now(), app: 'chronomind', + timers: [makeTimer({ createdAt: 'bad' as unknown as number })], + }; + const result = importTimers(data, []); + expect(result.imported).toBe(0); + expect(result.skipped).toBe(1); + }); +}); + +// ── importRoutines ─────────────────────────────────────────── + +describe('importRoutines', () => { + it('imports new routines', () => { + const data: ExportData = { + version: 1, exportedAt: Date.now(), app: 'chronomind', + timers: [], + routines: [makeRoutine({ id: 'r-1' })], + }; + const result = importRoutines(data, []); + expect(result.imported).toBe(1); + expect(result.skipped).toBe(0); + expect(result.success).toBe(true); + }); + + it('skips duplicate routines by ID', () => { + const existing = [makeRoutine({ id: 'r-dup' })]; + const data: ExportData = { + version: 1, exportedAt: Date.now(), app: 'chronomind', + timers: [], + routines: [makeRoutine({ id: 'r-dup' }), makeRoutine({ id: 'r-new' })], + }; + const result = importRoutines(data, existing); + expect(result.imported).toBe(1); + expect(result.skipped).toBe(1); + }); + + it('returns zero imported when no routines in data', () => { + const data: ExportData = { + version: 1, exportedAt: Date.now(), app: 'chronomind', + timers: [], + }; + const result = importRoutines(data, []); + expect(result.imported).toBe(0); + expect(result.success).toBe(true); + }); + + it('returns zero imported for empty routines array', () => { + const data: ExportData = { + version: 1, exportedAt: Date.now(), app: 'chronomind', + timers: [], + routines: [], + }; + const result = importRoutines(data, []); + expect(result.imported).toBe(0); + expect(result.success).toBe(true); + }); + + it('rejects routines missing required fields', () => { + const data: ExportData = { + version: 1, exportedAt: Date.now(), app: 'chronomind', + timers: [], + routines: [{ id: '', name: '', steps: [] } as unknown as Routine], + }; + const result = importRoutines(data, []); + expect(result.imported).toBe(0); + expect(result.skipped).toBe(1); + expect(result.errors).toHaveLength(1); + }); + + it('rejects routines with non-array steps', () => { + const data: ExportData = { + version: 1, exportedAt: Date.now(), app: 'chronomind', + timers: [], + routines: [{ id: 'r-bad', name: 'Bad', steps: 'not-array' } as unknown as Routine], + }; + const result = importRoutines(data, []); + expect(result.imported).toBe(0); + expect(result.skipped).toBe(1); + }); +});