Files
zemion 5e462f881d
Verify / verify (push) Canceled after 0s
Release Time Tools 0.2.0
2026-09-02 05:05:59 +02:00

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);
});
});