import { parseIcs, selectUpcoming } from '../src/features/calendar/icsParser'; describe('parseIcs', () => { test('returns empty array on invalid / empty input', () => { expect(parseIcs('')).toEqual([]); expect(parseIcs(null as any)).toEqual([]); expect(parseIcs(undefined as any)).toEqual([]); expect(parseIcs('random text no VEVENT')).toEqual([]); }); test('parses single timed event', () => { const ics = [ 'BEGIN:VCALENDAR', 'BEGIN:VEVENT', 'SUMMARY:팀 미팅', 'DTSTART:20260520T130000Z', 'DTEND:20260520T140000Z', 'LOCATION:회의실 A', 'END:VEVENT', 'END:VCALENDAR', ].join('\r\n'); const events = parseIcs(ics); expect(events).toHaveLength(1); expect(events[0].summary).toBe('팀 미팅'); expect(events[0].location).toBe('회의실 A'); expect(events[0].allDay).toBe(false); // 2026-05-20 13:00 UTC expect(events[0].start.getUTCFullYear()).toBe(2026); expect(events[0].start.getUTCMonth()).toBe(4); expect(events[0].start.getUTCDate()).toBe(20); expect(events[0].start.getUTCHours()).toBe(13); expect(events[0].end?.getUTCHours()).toBe(14); }); test('parses all-day event via VALUE=DATE', () => { const ics = [ 'BEGIN:VEVENT', 'SUMMARY:생일', 'DTSTART;VALUE=DATE:20260615', 'DTEND;VALUE=DATE:20260616', 'END:VEVENT', ].join('\n'); const events = parseIcs(ics); expect(events).toHaveLength(1); expect(events[0].allDay).toBe(true); expect(events[0].start.getFullYear()).toBe(2026); expect(events[0].start.getMonth()).toBe(5); expect(events[0].start.getDate()).toBe(15); }); test('unfolds line continuations (RFC 5545)', () => { // ICS 75자 wrap — 다음 줄이 공백/탭으로 시작하면 같은 필드의 연속. const ics = [ 'BEGIN:VEVENT', 'SUMMARY:긴 제목 첫 부분', ' 두번째 줄 이어짐', 'DTSTART:20260520T130000Z', 'END:VEVENT', ].join('\r\n'); const events = parseIcs(ics); expect(events[0].summary).toBe('긴 제목 첫 부분두번째 줄 이어짐'); }); test('unescapes ICS escape sequences in summary / location', () => { const ics = [ 'BEGIN:VEVENT', 'SUMMARY:1\\, 2\\, 3 — 회의\\nFollow-up', 'LOCATION:Zoom\\, 본관 3층', 'DTSTART:20260520T130000Z', 'END:VEVENT', ].join('\n'); const events = parseIcs(ics); expect(events[0].summary).toBe('1, 2, 3 — 회의 Follow-up'); expect(events[0].location).toBe('Zoom, 본관 3층'); }); test('parses multiple VEVENTs and gives default summary when missing', () => { const ics = [ 'BEGIN:VEVENT', 'DTSTART:20260520T130000Z', 'END:VEVENT', 'BEGIN:VEVENT', 'SUMMARY:두번째', 'DTSTART:20260521T130000Z', 'END:VEVENT', ].join('\n'); const events = parseIcs(ics); expect(events).toHaveLength(2); expect(events[0].summary).toBe('(제목 없음)'); expect(events[1].summary).toBe('두번째'); }); test('drops VEVENT without DTSTART', () => { const ics = [ 'BEGIN:VEVENT', 'SUMMARY:DTSTART 누락', 'END:VEVENT', ].join('\n'); expect(parseIcs(ics)).toEqual([]); }); }); describe('selectUpcoming', () => { const now = new Date('2026-05-20T12:00:00Z'); const mkEv = (offsetMin: number, label = 'evt'): any => ({ start: new Date(now.getTime() + offsetMin * 60 * 1000), end: undefined, summary: label, location: '', description: '', allDay: false, }); test('filters past events older than 1 hour', () => { const events = [ mkEv(-120, 'old'), // 2 hours ago — dropped mkEv(-30, 'recent'), // 30 min ago — kept (within 1h window) mkEv(60, 'soon'), // 1 hour future — kept ]; const upcoming = selectUpcoming(events, 14, now); expect(upcoming.map((e) => e.summary)).toEqual(['recent', 'soon']); }); test('filters events beyond cutoff days', () => { const events = [ mkEv(60, 'soon'), mkEv(60 * 24 * 15, 'too-far'), // 15 days — beyond 14-day cutoff ]; const upcoming = selectUpcoming(events, 14, now); expect(upcoming.map((e) => e.summary)).toEqual(['soon']); }); test('sorts upcoming events by start time', () => { const events = [mkEv(180, 'c'), mkEv(60, 'a'), mkEv(120, 'b')]; const upcoming = selectUpcoming(events, 14, now); expect(upcoming.map((e) => e.summary)).toEqual(['a', 'b', 'c']); }); });