import { describe, it, expect } from 'vitest'; import { parseIcs, mapPriorityToUrgency, eventToTimer, detectConflicts, importCalendar, } from './calendar-import'; import type { Timer } from './timer-engine'; // ── Fixtures ────────────────────────────────────────────────── const SIMPLE_ICS = `BEGIN:VCALENDAR VERSION:2.0 PRODID:-//Test//Test//EN BEGIN:VEVENT UID:test-1@example.com SUMMARY:Team Standup DTSTART:20260315T090000 DTEND:20260315T093000 DESCRIPTION:Daily standup meeting LOCATION:Room 42 PRIORITY:3 END:VEVENT END:VCALENDAR`; const MULTI_EVENT_ICS = `BEGIN:VCALENDAR VERSION:2.0 BEGIN:VEVENT UID:evt-1 SUMMARY:Morning Meeting DTSTART:20260315T090000 DTEND:20260315T100000 END:VEVENT BEGIN:VEVENT UID:evt-2 SUMMARY:Lunch DTSTART:20260315T120000 DTEND:20260315T130000 END:VEVENT BEGIN:VEVENT UID:evt-3 SUMMARY:Code Review DTSTART:20260315T140000 DTEND:20260315T150000 PRIORITY:2 END:VEVENT END:VCALENDAR`; const UTC_ICS = `BEGIN:VCALENDAR BEGIN:VEVENT UID:utc-1 SUMMARY:UTC Event DTSTART:20260315T140000Z DTEND:20260315T150000Z END:VEVENT END:VCALENDAR`; const DATE_ONLY_ICS = `BEGIN:VCALENDAR BEGIN:VEVENT UID:date-1 SUMMARY:All Day Event DTSTART:20260315 END:VEVENT END:VCALENDAR`; const ESCAPED_ICS = `BEGIN:VCALENDAR BEGIN:VEVENT UID:esc-1 SUMMARY:Meeting\\, Important DESCRIPTION:Details:\\nLine 2\\nLine 3 DTSTART:20260315T090000 END:VEVENT END:VCALENDAR`; const FOLDED_ICS = `BEGIN:VCALENDAR\r BEGIN:VEVENT\r UID:fold-1\r SUMMARY:A very long summary that gets\r folded across multiple lines\r DTSTART:20260315T090000\r END:VEVENT\r END:VCALENDAR`; const INVALID_ICS = `BEGIN:VCALENDAR BEGIN:VEVENT UID:bad-1 END:VEVENT END:VCALENDAR`; // ── Tests ───────────────────────────────────────────────────── describe('parseIcs', () => { it('parses a simple .ics file', () => { const { events, errors } = parseIcs(SIMPLE_ICS); expect(errors).toHaveLength(0); expect(events).toHaveLength(1); const evt = events[0]; expect(evt.uid).toBe('test-1@example.com'); expect(evt.summary).toBe('Team Standup'); expect(evt.description).toBe('Daily standup meeting'); expect(evt.location).toBe('Room 42'); expect(evt.priority).toBe(3); expect(evt.dtstart.getFullYear()).toBe(2026); expect(evt.dtstart.getMonth()).toBe(2); // March = 2 (0-indexed) expect(evt.dtstart.getDate()).toBe(15); expect(evt.dtstart.getHours()).toBe(9); }); it('parses multiple events', () => { const { events, errors } = parseIcs(MULTI_EVENT_ICS); expect(errors).toHaveLength(0); expect(events).toHaveLength(3); expect(events[0].summary).toBe('Morning Meeting'); expect(events[1].summary).toBe('Lunch'); expect(events[2].summary).toBe('Code Review'); }); it('parses UTC datetime', () => { const { events } = parseIcs(UTC_ICS); expect(events).toHaveLength(1); // UTC time: should be converted properly expect(events[0].dtstart.getUTCHours()).toBe(14); }); it('parses date-only events', () => { const { events } = parseIcs(DATE_ONLY_ICS); expect(events).toHaveLength(1); expect(events[0].dtstart.getFullYear()).toBe(2026); expect(events[0].dtstart.getMonth()).toBe(2); expect(events[0].dtstart.getDate()).toBe(15); }); it('unescapes special characters', () => { const { events } = parseIcs(ESCAPED_ICS); expect(events[0].summary).toBe('Meeting, Important'); expect(events[0].description).toBe('Details:\nLine 2\nLine 3'); }); it('unfolds continuation lines', () => { const { events } = parseIcs(FOLDED_ICS); expect(events).toHaveLength(1); expect(events[0].summary).toBe('A very long summary that gets folded across multiple lines'); }); it('reports errors for events missing required fields', () => { const { events, errors } = parseIcs(INVALID_ICS); expect(events).toHaveLength(0); expect(errors).toHaveLength(1); expect(errors[0]).toContain('missing required fields'); }); it('handles empty content', () => { const { events, errors } = parseIcs(''); expect(events).toHaveLength(0); expect(errors).toHaveLength(0); }); }); describe('mapPriorityToUrgency', () => { it('maps priority 1 to critical', () => { expect(mapPriorityToUrgency(1)).toBe('critical'); }); it('maps priority 2 to critical', () => { expect(mapPriorityToUrgency(2)).toBe('critical'); }); it('maps priority 3 to important', () => { expect(mapPriorityToUrgency(3)).toBe('important'); }); it('maps priority 5 to standard', () => { expect(mapPriorityToUrgency(5)).toBe('standard'); }); it('maps priority 7 to gentle', () => { expect(mapPriorityToUrgency(7)).toBe('gentle'); }); it('maps priority 9 to passive', () => { expect(mapPriorityToUrgency(9)).toBe('passive'); }); it('defaults to standard for undefined priority', () => { expect(mapPriorityToUrgency(undefined)).toBe('standard'); }); it('defaults to standard for out-of-range priority', () => { expect(mapPriorityToUrgency(0)).toBe('standard'); expect(mapPriorityToUrgency(10)).toBe('standard'); }); }); describe('eventToTimer', () => { it('converts an event to a timer', () => { const event = { uid: 'test-1', summary: 'Meeting', dtstart: new Date(Date.now() + 3_600_000), description: 'Important meeting', priority: 3, }; const timer = eventToTimer(event); expect(timer.type).toBe('alarm'); expect(timer.label).toBe('Meeting'); expect(timer.urgency).toBe('important'); expect(timer.state).toBe('active'); expect(timer.cascade.preset).toBe('standard'); }); it('marks past events as dismissed', () => { const event = { uid: 'past-1', summary: 'Past Event', dtstart: new Date(Date.now() - 3_600_000), }; const timer = eventToTimer(event); expect(timer.state).toBe('dismissed'); }); it('includes location in description', () => { const event = { uid: 'loc-1', summary: 'Meeting', dtstart: new Date(Date.now() + 3_600_000), location: 'Room 42', description: 'Notes here', }; const timer = eventToTimer(event); expect(timer.description).toContain('Room 42'); expect(timer.description).toContain('Notes here'); }); }); describe('detectConflicts', () => { const now = Date.now(); function makeExistingTimer(targetOffset: number): Timer { return { id: `existing-${Math.random()}`, type: 'alarm', label: 'Existing', urgency: 'standard', state: 'active', targetTime: now + targetOffset, duration: null, createdAt: now, startedAt: now, pausedAt: null, firedAt: null, dismissedAt: null, completedAt: null, elapsedBeforePause: 0, cascade: { preset: 'none', intervals: [] }, warnings: [], snoozeCount: 0, snoozedUntil: null, }; } it('detects overlapping timers', () => { const event = { uid: 'conflict-1', summary: 'New Event', dtstart: new Date(now + 3_600_000), dtend: new Date(now + 7_200_000), }; const existing = [ makeExistingTimer(3_600_000 + 5 * 60_000), // 5 min after event start ]; const conflicts = detectConflicts(event, existing); expect(conflicts).toHaveLength(1); }); it('does not flag non-overlapping timers', () => { const event = { uid: 'no-conflict', summary: 'New Event', dtstart: new Date(now + 3_600_000), dtend: new Date(now + 7_200_000), }; const existing = [ makeExistingTimer(24 * 3_600_000), // 24 hours later ]; const conflicts = detectConflicts(event, existing); expect(conflicts).toHaveLength(0); }); it('ignores dismissed timers', () => { const event = { uid: 'dismissed-test', summary: 'New Event', dtstart: new Date(now + 3_600_000), dtend: new Date(now + 7_200_000), }; const dismissed = makeExistingTimer(3_600_000 + 60_000); dismissed.state = 'dismissed'; const conflicts = detectConflicts(event, [dismissed]); expect(conflicts).toHaveLength(0); }); }); describe('importCalendar', () => { it('imports a full .ics file', () => { const result = importCalendar(SIMPLE_ICS, [], Date.now()); expect(result.totalParsed).toBe(1); expect(result.events).toHaveLength(1); expect(result.events[0].timer.label).toBe('Team Standup'); }); it('imports multiple events', () => { const result = importCalendar(MULTI_EVENT_ICS, []); expect(result.totalParsed).toBe(3); expect(result.events).toHaveLength(3); }); it('reports parse errors', () => { const result = importCalendar(INVALID_ICS, []); expect(result.errors.length).toBeGreaterThan(0); }); it('detects conflicts with existing timers', () => { const now = Date.now(); // Create an existing timer at March 15, 2026 9:15 AM const target = new Date(2026, 2, 15, 9, 15, 0).getTime(); const existing: Timer[] = [{ id: 'conflict-timer', type: 'alarm', label: 'Conflicting', urgency: 'standard', state: 'active', targetTime: target, duration: null, createdAt: now, startedAt: now, pausedAt: null, firedAt: null, dismissedAt: null, completedAt: null, elapsedBeforePause: 0, cascade: { preset: 'none', intervals: [] }, warnings: [], snoozeCount: 0, snoozedUntil: null, }]; const result = importCalendar(SIMPLE_ICS, existing, now); // The "Team Standup" event is at 9:00 AM March 15 — the existing timer at 9:15 is within window const standup = result.events[0]; expect(standup.conflicts.length).toBeGreaterThan(0); }); });