import { describe, it, expect } from 'vitest'; import { toIcs } from './ics'; const utc = (y: number, mo: number, d: number, h = 0, mi = 0) => Date.UTC(y, mo, d, h, mi, 0, 0); describe('toIcs', () => { it('produces a valid empty calendar for []', () => { const ics = toIcs([]); expect(ics).toContain('BEGIN:VCALENDAR'); expect(ics).toContain('VERSION:2.0'); expect(ics).toContain('END:VCALENDAR'); expect(ics).not.toContain('BEGIN:VEVENT'); }); it('uses CRLF line endings', () => { const ics = toIcs([]); expect(ics).toContain('\r\n'); expect(ics.endsWith('\r\n')).toBe(true); // No bare LF without a preceding CR. expect(/[^\r]\n/.test(ics)).toBe(false); }); it('emits an all-day VEVENT with VALUE=DATE', () => { const ics = toIcs([ { title: 'Final exam', dateMs: utc(2026, 5, 20), allDay: true }, ]); expect(ics).toContain('BEGIN:VEVENT'); expect(ics).toContain('DTSTART;VALUE=DATE:20260620'); expect(ics).toContain('SUMMARY:Final exam'); }); it('emits a timed VEVENT with a UTC DTSTART', () => { const ics = toIcs([ { title: 'Meeting', dateMs: utc(2026, 5, 20, 14, 30), allDay: false }, ]); expect(ics).toContain('DTSTART:20260620T143000Z'); }); it('derives DTSTAMP from the event dateMs', () => { const ics = toIcs([ { title: 'X', dateMs: utc(2026, 5, 20, 9, 0), allDay: true }, ]); expect(ics).toContain('DTSTAMP:20260620T090000Z'); }); it('UID is deterministic (no randomness) and stable across calls', () => { const ev = { title: 'Exam', dateMs: utc(2026, 5, 20), allDay: true }; const a = toIcs([ev]); const b = toIcs([ev]); expect(a).toBe(b); const uid = a.split('\r\n').find((l) => l.startsWith('UID:')); expect(uid).toBeDefined(); expect(uid).toContain('@wisp'); }); it('changes UID when title or date changes', () => { const a = toIcs([{ title: 'A', dateMs: utc(2026, 5, 20), allDay: true }]); const b = toIcs([{ title: 'B', dateMs: utc(2026, 5, 20), allDay: true }]); const uidA = a.split('\r\n').find((l) => l.startsWith('UID:')); const uidB = b.split('\r\n').find((l) => l.startsWith('UID:')); expect(uidA).not.toBe(uidB); }); it('escapes comma, semicolon, backslash and newline in SUMMARY', () => { const ics = toIcs([ { title: 'a, b; c \\ d\ne', dateMs: utc(2026, 5, 20), allDay: true, }, ]); const line = ics .replace(/\r\n /g, '') // unfold first .split('\r\n') .find((l) => l.startsWith('SUMMARY:')); expect(line).toBe('SUMMARY:a\\, b\\; c \\\\ d\\ne'); }); it('folds lines longer than 75 octets with a leading space', () => { const long = 'X'.repeat(200); const ics = toIcs([{ title: long, dateMs: utc(2026, 5, 20), allDay: true }]); for (const line of ics.split('\r\n')) { // Each physical line must be <=75 octets. expect(Buffer.byteLength(line, 'utf8')).toBeLessThanOrEqual(75); } // Unfolding restores the SUMMARY content. const unfolded = ics.replace(/\r\n /g, ''); expect(unfolded).toContain(`SUMMARY:${long}`); }); it('emits one VEVENT per event', () => { const ics = toIcs([ { title: 'A', dateMs: utc(2026, 5, 20), allDay: true }, { title: 'B', dateMs: utc(2026, 5, 21), allDay: true }, ]); const count = ics.split('BEGIN:VEVENT').length - 1; expect(count).toBe(2); }); });