@@ -1,13 +1,21 @@
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 ` ;
@@ -24,6 +32,26 @@ describe("calendar model", () => {
) ;
} ) ;
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 ) ;
@@ -76,6 +104,69 @@ describe("calendar model", () => {
) ;
} ) ;
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 – 5 0 0 / u ,