Files
zemion 1a741d6802
Verify / verify (push) Canceled after 0s
Release Calendar Tools 0.2.0
2026-09-02 11:20:48 +02:00

179 lines
7.7 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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(/(?<!\r)\n/u);
});
it("merges event identity using sequence then stamp", () => {
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(
/1500/u,
);
expect(() => parseCalendar("not a calendar")).toThrow(
/parse failed|root component/u,
);
});
});