356 lines
10 KiB
TypeScript
356 lines
10 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { addBusinessDays, compareArithmetic } from "../../src/time/arithmetic";
|
|
import { inspectEpoch } from "../../src/time/epoch";
|
|
import { createUtcEvent, foldIcsLine } from "../../src/time/ics";
|
|
import { inspectIcs } from "../../src/time/ics-inspect";
|
|
import { planMeetings } from "../../src/time/meeting";
|
|
import {
|
|
previewCron,
|
|
previewCronDialect,
|
|
previewRRule,
|
|
} from "../../src/time/recurrence";
|
|
import {
|
|
compareTimeZones,
|
|
nextTransitions,
|
|
resolveLocalDateTime,
|
|
} from "../../src/time/zones";
|
|
|
|
describe("exact epoch conversion", () => {
|
|
it("preserves negative nanoseconds", () => {
|
|
const result = inspectEpoch("-0.000000001", "seconds");
|
|
expect(result.epochNanoseconds).toBe(-1n);
|
|
expect(result.iso).toBe("1969-12-31T23:59:59.999999999Z");
|
|
});
|
|
|
|
it("rejects sub-nanosecond precision", () => {
|
|
expect(() => inspectEpoch("0.0000000001", "seconds")).toThrow(
|
|
/smaller than one nanosecond/u,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("time-zone handling", () => {
|
|
it("classifies a spring gap and autumn overlap", () => {
|
|
expect(
|
|
resolveLocalDateTime("2026-03-29T02:30", "Europe/Berlin").status,
|
|
).toBe("skipped");
|
|
const overlap = resolveLocalDateTime("2026-10-25T02:30", "Europe/Berlin");
|
|
expect(overlap.status).toBe("ambiguous");
|
|
expect(overlap.choices.map((choice) => choice.offset)).toEqual([
|
|
"+02:00",
|
|
"+01:00",
|
|
]);
|
|
});
|
|
|
|
it("compares the same instant without changing its epoch", () => {
|
|
const values = compareTimeZones("2026-01-01T00:00:00Z", [
|
|
"UTC",
|
|
"Asia/Tokyo",
|
|
]);
|
|
expect(values[0]?.epochNanoseconds).toBe(values[1]?.epochNanoseconds);
|
|
expect(values[1]?.local).toBe("2026-01-01T09:00:00");
|
|
});
|
|
|
|
it("rejects a non-integer transition count", () => {
|
|
expect(() =>
|
|
nextTransitions("2026-01-01T00:00:00Z", "Europe/Berlin", 1.5),
|
|
).toThrow(/between 1 and 32/u);
|
|
});
|
|
});
|
|
|
|
describe("calendar arithmetic", () => {
|
|
it("shows wall-clock and elapsed days diverging across DST", () => {
|
|
const result = compareArithmetic(
|
|
"2026-03-28T12:00:00Z",
|
|
"Europe/Berlin",
|
|
"P1D",
|
|
);
|
|
expect(result.differenceSeconds).toBe("-3600");
|
|
});
|
|
|
|
it("adds business days with explicit holidays", () => {
|
|
expect(addBusinessDays("2026-12-24", 2, ["2026-12-25"]).result).toBe(
|
|
"2026-12-29",
|
|
);
|
|
});
|
|
|
|
it("bounds explicit holiday allocation", () => {
|
|
expect(() =>
|
|
addBusinessDays("2026-01-01", 1, Array(10_001).fill("2026-01-02")),
|
|
).toThrow(/10,000/u);
|
|
});
|
|
|
|
it("does not silently truncate a fractional business-day count", () => {
|
|
expect(() => addBusinessDays("2026-01-01", 1.5)).toThrow(/±100,000/u);
|
|
});
|
|
});
|
|
|
|
describe("bounded recurrence previews", () => {
|
|
it("previews five-field cron in its named zone", () => {
|
|
const result = previewCron(
|
|
"0 9 * * MON-FRI",
|
|
"2026-08-31T08:00:00Z",
|
|
"Europe/Berlin",
|
|
2,
|
|
"5-part",
|
|
);
|
|
expect(result).toHaveLength(2);
|
|
expect(result[0]?.zoned).toContain("T09:00:00");
|
|
});
|
|
|
|
it("previews a strict RRULE", () => {
|
|
const result = previewRRule(
|
|
"FREQ=DAILY;COUNT=3",
|
|
"2026-09-01T09:00",
|
|
"Europe/Berlin",
|
|
10,
|
|
);
|
|
expect(result).toHaveLength(3);
|
|
expect(result[0]?.zoned).toContain("2026-09-01T09:00:00");
|
|
});
|
|
|
|
it("makes cron dialect field and timezone semantics explicit", () => {
|
|
const unix = previewCronDialect(
|
|
"0 9 * * MON-FRI",
|
|
"2026-09-01T00:00:00Z",
|
|
"Europe/Berlin",
|
|
2,
|
|
"unix-vixie",
|
|
);
|
|
expect(unix).toMatchObject({
|
|
dialect: "unix-vixie",
|
|
dayCombination: "or",
|
|
});
|
|
expect(unix.occurrences[0]?.zoned).toContain("T09:00:00");
|
|
expect(() =>
|
|
previewCronDialect(
|
|
"0 9 * * *",
|
|
"2026-09-01T00:00:00Z",
|
|
"Europe/Berlin",
|
|
1,
|
|
"github-actions",
|
|
),
|
|
).toThrow(/UTC only/u);
|
|
expect(
|
|
previewCronDialect(
|
|
"* * * * *",
|
|
"2026-09-01T00:00:00Z",
|
|
"UTC",
|
|
2,
|
|
"github-actions",
|
|
).warnings.join(" "),
|
|
).toMatch(/five-minute minimum/u);
|
|
const aws = previewCronDialect(
|
|
"0 9 ? * MON-FRI 2026",
|
|
"2026-09-01T00:00:00Z",
|
|
"UTC",
|
|
1,
|
|
"aws-eventbridge",
|
|
);
|
|
expect(aws.normalizedPattern).toBe("0 0 9 ? * MON-FRI 2026");
|
|
expect(aws.dayCombination).toBe("exclusive-question-mark");
|
|
});
|
|
});
|
|
|
|
describe("time-zone meeting planning", () => {
|
|
it("finds only shared recurring local work-hour windows", () => {
|
|
const result = planMeetings({
|
|
start: "2026-09-01T00:00:00Z",
|
|
days: 2,
|
|
durationMinutes: 60,
|
|
stepMinutes: 30,
|
|
maxResults: 2,
|
|
participants: [
|
|
{
|
|
name: "Berlin",
|
|
timeZone: "Europe/Berlin",
|
|
workStart: "09:00",
|
|
workEnd: "17:00",
|
|
},
|
|
{
|
|
name: "New York",
|
|
timeZone: "America/New_York",
|
|
workStart: "09:00",
|
|
workEnd: "17:00",
|
|
},
|
|
],
|
|
});
|
|
expect(result.candidates).toHaveLength(2);
|
|
expect(
|
|
result.candidates[0]?.participants.map((item) => item.startLocal),
|
|
).toEqual([
|
|
expect.stringContaining("T15:00:00"),
|
|
expect.stringContaining("T09:00:00"),
|
|
]);
|
|
});
|
|
|
|
it("bounds participants and rejects overnight work-window ambiguity", () => {
|
|
expect(() =>
|
|
planMeetings({
|
|
start: "2026-09-01T00:00:00Z",
|
|
days: 1,
|
|
durationMinutes: 60,
|
|
stepMinutes: 30,
|
|
participants: [
|
|
{
|
|
name: "Night",
|
|
timeZone: "UTC",
|
|
workStart: "22:00",
|
|
workEnd: "06:00",
|
|
},
|
|
],
|
|
}),
|
|
).toThrow(/end after/u);
|
|
});
|
|
});
|
|
|
|
describe("iCalendar output", () => {
|
|
it("creates CRLF UTC event lines", () => {
|
|
const result = createUtcEvent({
|
|
startLocal: "2026-09-01T10:00",
|
|
timeZone: "Europe/Berlin",
|
|
duration: "PT1H",
|
|
summary: "Review, plan",
|
|
});
|
|
expect(result.ics).toContain("DTSTART:20260901T080000Z\r\n");
|
|
expect(result.ics).toContain("SUMMARY:Review\\, plan");
|
|
expect(result.ics.endsWith("\r\n")).toBe(true);
|
|
});
|
|
|
|
it("folds Unicode lines by UTF-8 octets", () => {
|
|
const folded = foldIcsLine(`DESCRIPTION:${"ä".repeat(50)}`);
|
|
expect(folded).toContain("\r\n ");
|
|
expect(
|
|
Math.max(
|
|
...folded
|
|
.split("\r\n")
|
|
.map((line) => new TextEncoder().encode(line).length),
|
|
),
|
|
).toBeLessThanOrEqual(75);
|
|
});
|
|
});
|
|
|
|
describe("iCalendar import and inspection", () => {
|
|
it("imports multiple events and applies exclusions and moved/cancelled overrides", () => {
|
|
const result = inspectIcs(
|
|
[
|
|
"BEGIN:VCALENDAR",
|
|
"VERSION:2.0",
|
|
"PRODID:-//Fixture//EN",
|
|
"BEGIN:VEVENT",
|
|
"UID:series@example",
|
|
"DTSTART;TZID=Europe/Berlin:20260901T090000",
|
|
"RRULE:FREQ=DAILY;COUNT=4",
|
|
"EXDATE;TZID=Europe/Berlin:20260902T090000",
|
|
"SUMMARY:Master",
|
|
"END:VEVENT",
|
|
"BEGIN:VEVENT",
|
|
"UID:series@example",
|
|
"RECURRENCE-ID;TZID=Europe/Berlin:20260903T090000",
|
|
"DTSTART;TZID=Europe/Berlin:20260903T110000",
|
|
"SUMMARY:Moved",
|
|
"END:VEVENT",
|
|
"BEGIN:VEVENT",
|
|
"UID:series@example",
|
|
"RECURRENCE-ID;TZID=Europe/Berlin:20260904T090000",
|
|
"DTSTART;TZID=Europe/Berlin:20260904T090000",
|
|
"STATUS:CANCELLED",
|
|
"SUMMARY:Cancelled",
|
|
"END:VEVENT",
|
|
"BEGIN:VEVENT",
|
|
"UID:single@example",
|
|
"DTSTART:20260905T120000Z",
|
|
"SUMMARY:Single",
|
|
"URL:https://example.invalid/private",
|
|
"ATTACH;FMTTYPE=application/pdf:https://example.invalid/file.pdf",
|
|
"END:VEVENT",
|
|
"END:VCALENDAR",
|
|
"",
|
|
].join("\r\n"),
|
|
20,
|
|
);
|
|
expect(result.events).toHaveLength(4);
|
|
expect(result.occurrences.map((item) => item.summary)).toEqual([
|
|
"Master",
|
|
"Moved",
|
|
"Single",
|
|
]);
|
|
expect(result.occurrences[1]).toMatchObject({ source: "override" });
|
|
expect(result.references).toHaveLength(2);
|
|
expect(result.references.every((reference) => reference.inert)).toBe(true);
|
|
});
|
|
|
|
it("inspects VTIMEZONE while withholding unknown custom-zone expansion", () => {
|
|
const result = inspectIcs(
|
|
[
|
|
"BEGIN:VCALENDAR",
|
|
"VERSION:2.0",
|
|
"PRODID:-//Fixture//EN",
|
|
"BEGIN:VTIMEZONE",
|
|
"TZID:Custom/Office",
|
|
"BEGIN:STANDARD",
|
|
"DTSTART:19700101T000000",
|
|
"TZOFFSETFROM:+0100",
|
|
"TZOFFSETTO:+0100",
|
|
"END:STANDARD",
|
|
"END:VTIMEZONE",
|
|
"BEGIN:VEVENT",
|
|
"UID:custom@example",
|
|
"DTSTART;TZID=Custom/Office:20260901T090000",
|
|
"RRULE:FREQ=DAILY;COUNT=2",
|
|
"SUMMARY:Custom zone",
|
|
"ATTACH;VALUE=BINARY;ENCODING=BASE64;FMTTYPE=image/png:AAAA",
|
|
"END:VEVENT",
|
|
"END:VCALENDAR",
|
|
].join("\r\n"),
|
|
);
|
|
expect(result.timeZones[0]).toMatchObject({
|
|
tzid: "Custom/Office",
|
|
usableByBrowser: false,
|
|
});
|
|
expect(result.references[0]).toMatchObject({
|
|
kind: "embedded-binary",
|
|
encodedCharacters: 4,
|
|
});
|
|
expect(result.diagnostics.map((item) => item.code)).toContain(
|
|
"custom-vtimezone",
|
|
);
|
|
expect(result.occurrences).toEqual([]);
|
|
});
|
|
|
|
it("expands all-day recurrences without pretending they are instants", () => {
|
|
const result = inspectIcs(
|
|
[
|
|
"BEGIN:VCALENDAR",
|
|
"VERSION:2.0",
|
|
"PRODID:-//Fixture//EN",
|
|
"BEGIN:VEVENT",
|
|
"UID:days@example",
|
|
"DTSTART;VALUE=DATE:20260901",
|
|
"RRULE:FREQ=DAILY;COUNT=3",
|
|
"EXDATE;VALUE=DATE:20260902",
|
|
"SUMMARY:All day",
|
|
"END:VEVENT",
|
|
"END:VCALENDAR",
|
|
].join("\r\n"),
|
|
);
|
|
expect(result.occurrences.map((item) => item.start)).toEqual([
|
|
"2026-09-01 (all-day)",
|
|
"2026-09-03 (all-day)",
|
|
]);
|
|
});
|
|
|
|
it("rejects mismatched and excessively nested components", () => {
|
|
expect(() => inspectIcs("BEGIN:VCALENDAR\r\nEND:VEVENT\r\n")).toThrow(
|
|
/Mismatched/u,
|
|
);
|
|
const nested = [
|
|
"BEGIN:VCALENDAR",
|
|
...Array(17).fill("BEGIN:X"),
|
|
...Array(17).fill("END:X"),
|
|
"END:VCALENDAR",
|
|
].join("\r\n");
|
|
expect(() => inspectIcs(nested)).toThrow(/nesting limit/u);
|
|
});
|
|
});
|