import { describe, expect, it } from "vitest"; import { busyIntervals, canonicalRepair, findConflicts, findFreeSlots, localTimeCandidates, mergeCalendars, parseCalendar, previewOccurrences, timezoneDiagnostics, } from "../../src/calendar/model"; import { exportJCal, exportJSCalendar, importJCal, importJSCalendar, } from "../../src/calendar/interchange"; const recurring = `BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//test//EN\r\nBEGIN:VEVENT\r\nUID:a\r\nDTSTAMP:20260101T000000Z\r\nDTSTART:20260901T100000Z\r\nDTEND:20260901T110000Z\r\nRRULE:FREQ=DAILY;COUNT=3\r\nSUMMARY:A\r\nEND:VEVENT\r\nBEGIN:VEVENT\r\nUID:b\r\nDTSTART:20260901T103000Z\r\nDTEND:20260901T120000Z\r\nSUMMARY:B\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n`; describe("calendar model", () => { it("parses and expands recurrence under a bound", () => { const document = parseCalendar(recurring); expect(document.events).toHaveLength(2); const preview = previewOccurrences(document, 10, 30); expect(preview.occurrences.filter((item) => item.uid === "a")).toHaveLength( 3, ); expect(findConflicts(preview.occurrences)).toEqual( expect.arrayContaining([expect.objectContaining({ kind: "overlap" })]), ); }); it("applies moved, cancelled, duration, and THISANDFUTURE exceptions", () => { const source = `BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//test//EN\r\nBEGIN:VEVENT\r\nUID:series\r\nDTSTART:20260901T100000Z\r\nDTEND:20260901T110000Z\r\nRRULE:FREQ=DAILY;COUNT=5\r\nSUMMARY:Original\r\nEND:VEVENT\r\nBEGIN:VEVENT\r\nUID:series\r\nRECURRENCE-ID:20260902T100000Z\r\nDTSTART:20260902T120000Z\r\nDTEND:20260902T133000Z\r\nSUMMARY:Moved\r\nEND:VEVENT\r\nBEGIN:VEVENT\r\nUID:series\r\nRECURRENCE-ID:20260903T100000Z\r\nDTSTART:20260903T100000Z\r\nDTEND:20260903T110000Z\r\nSTATUS:CANCELLED\r\nEND:VEVENT\r\nBEGIN:VEVENT\r\nUID:series\r\nRECURRENCE-ID;RANGE=THISANDFUTURE:20260904T100000Z\r\nDTSTART:20260904T110000Z\r\nDTEND:20260904T130000Z\r\nSUMMARY:Shifted future\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n`; const preview = previewOccurrences(parseCalendar(source), 10, 30); expect(preview.occurrences).toHaveLength(4); expect(preview.occurrences.map((item) => item.startText)).toEqual([ "2026-09-01T10:00:00", "2026-09-02T12:00:00", "2026-09-04T11:00:00", "2026-09-05T11:00:00", ]); expect(preview.occurrences[1]).toMatchObject({ summary: "Moved", endText: "2026-09-02T13:30:00", }); expect(preview.occurrences[3]).toMatchObject({ summary: "Shifted future", endText: "2026-09-05T13:00:00", }); }); it("canonicalizes line endings and supplies required metadata", () => { const input = `BEGIN:VCALENDAR\nBEGIN:VEVENT\nUID:x\nDTSTART:20260901T100000Z\nDTEND:20260901T110000Z\nEND:VEVENT\nEND:VCALENDAR\n`; const repaired = canonicalRepair(input); expect(repaired.source).toContain("VERSION:2.0\r\n"); expect(repaired.source).toContain("PRODID:"); expect(repaired.source).not.toMatch(/(? { const newer = recurring.replace( "SUMMARY:A", "SEQUENCE:2\r\nSUMMARY:Updated A", ); const merged = mergeCalendars([ parseCalendar(recurring), parseCalendar(newer), ]); expect(merged.deduplicated).toBe(2); expect(merged.source).toContain("SUMMARY:Updated A"); expect(parseCalendar(merged.source).events).toHaveLength(2); }); it("never deduplicates separate UID-less events", () => { const uidless = `BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//test//EN\r\nBEGIN:VEVENT\r\nDTSTART:20260901T100000Z\r\nDTEND:20260901T110000Z\r\nSUMMARY:No identity\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n`; const merged = mergeCalendars([ parseCalendar(uidless), parseCalendar(uidless), ]); expect(merged.deduplicated).toBe(0); expect(parseCalendar(merged.source).events).toHaveLength(2); expect(merged.diagnostics.map((item) => item.message).join(" ")).toMatch( /UID-less/u, ); }); it("uses embedded VTIMEZONE observances for custom TZIDs", () => { const custom = `BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//test//EN\r\nBEGIN:VTIMEZONE\r\nTZID:Custom/Test\r\nBEGIN:STANDARD\r\nDTSTART:19700101T000000\r\nTZOFFSETFROM:+0230\r\nTZOFFSETTO:+0230\r\nTZNAME:CST\r\nEND:STANDARD\r\nEND:VTIMEZONE\r\nBEGIN:VEVENT\r\nUID:custom\r\nDTSTART;TZID=Custom/Test:20260901T100000\r\nDTEND;TZID=Custom/Test:20260901T110000\r\nSUMMARY:Custom zone\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n`; const document = parseCalendar(custom); expect(document.timezones[0]?.tzid).toBe("Custom/Test"); expect(document.events[0]?.startMs).toBe( Date.parse("2026-09-01T07:30:00.000Z"), ); }); it("classifies DST overlaps and reports yearly transitions", () => { const candidates = localTimeCandidates("2026-10-25T02:30", "Europe/Berlin"); expect(candidates).toHaveLength(2); expect(timezoneDiagnostics("Europe/Berlin", 2026).transitions).toHaveLength( 2, ); }); it("inspects tasks, journals, free-busy periods, and alarms", () => { const source = `BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//test//EN\r\nBEGIN:VTODO\r\nUID:task-1\r\nDTSTART:20260901T080000Z\r\nDUE:20260901T090000Z\r\nSUMMARY:Write tests\r\nPERCENT-COMPLETE:25\r\nPRIORITY:3\r\nBEGIN:VALARM\r\nACTION:DISPLAY\r\nTRIGGER:-PT15M\r\nDESCRIPTION:Reminder\r\nEND:VALARM\r\nEND:VTODO\r\nBEGIN:VJOURNAL\r\nUID:journal-1\r\nDTSTART;VALUE=DATE:20260901\r\nSUMMARY:Daily note\r\nDESCRIPTION:Done locally\r\nEND:VJOURNAL\r\nBEGIN:VFREEBUSY\r\nUID:busy-1\r\nDTSTART:20260901T080000Z\r\nDTEND:20260901T120000Z\r\nFREEBUSY:20260901T100000Z/20260901T110000Z\r\nEND:VFREEBUSY\r\nEND:VCALENDAR\r\n`; const document = parseCalendar(source); expect(document.tasks[0]).toMatchObject({ uid: "task-1", percentComplete: 25, priority: 3, }); expect(document.alarms[0]).toMatchObject({ ownerUid: "task-1", trigger: "-PT15M", }); expect(document.journals[0]).toMatchObject({ uid: "journal-1", dateText: "2026-09-01", }); expect(document.freeBusy[0]?.intervals[0]).toMatchObject({ startMs: Date.parse("2026-09-01T10:00:00Z"), endMs: Date.parse("2026-09-01T11:00:00Z"), }); const windowStart = Date.parse("2026-09-01T09:00:00Z"); const windowEnd = Date.parse("2026-09-01T12:00:00Z"); expect(busyIntervals(document, windowStart, windowEnd)).toHaveLength(1); expect(findFreeSlots(document, windowStart, windowEnd, 30)).toEqual([ { startMs: windowStart, endMs: Date.parse("2026-09-01T10:00:00Z"), minutes: 60, }, { startMs: Date.parse("2026-09-01T11:00:00Z"), endMs: windowEnd, minutes: 60, }, ]); }); it("round-trips jCal and focused JSCalendar Event/Task groups", () => { const document = parseCalendar(recurring); expect(parseCalendar(importJCal(exportJCal(document))).events).toHaveLength( 2, ); const exported = exportJSCalendar(document); expect(exported.data).toMatchObject({ "@type": "Group" }); const imported = parseCalendar(importJSCalendar(exported.data)); expect(imported.events).toHaveLength(2); const task = parseCalendar( importJSCalendar({ "@type": "Task", uid: "task-json", title: "Imported task", due: "2026-09-02T12:00:00Z", progress: 0.5, }), ); expect(task.tasks[0]).toMatchObject({ uid: "task-json", summary: "Imported task", percentComplete: 50, }); }); it("rejects unbounded and malformed inputs", () => { expect(() => previewOccurrences(parseCalendar(recurring), 501, 30)).toThrow( /1–500/u, ); expect(() => parseCalendar("not a calendar")).toThrow( /parse failed|root component/u, ); }); });