@@ -43,6 +43,65 @@ test("converts a negative nanosecond epoch exactly", async ({ page }) => {
|
||||
expect(external).toEqual([]);
|
||||
});
|
||||
|
||||
test("inspects imported recurrence and keeps calendar URLs inert", async ({
|
||||
page,
|
||||
}) => {
|
||||
const external = await localOnly(page);
|
||||
await page.goto("/deep/nested/time/");
|
||||
await page.getByRole("button", { name: "iCalendar" }).click();
|
||||
await page
|
||||
.getByLabel("Paste iCalendar source")
|
||||
.fill(
|
||||
[
|
||||
"BEGIN:VCALENDAR",
|
||||
"VERSION:2.0",
|
||||
"PRODID:-//Browser fixture//EN",
|
||||
"BEGIN:VEVENT",
|
||||
"UID:fixture@example",
|
||||
"DTSTART:20260901T090000Z",
|
||||
"RRULE:FREQ=DAILY;COUNT=2",
|
||||
"SUMMARY:Fixture",
|
||||
"URL:https://example.invalid/never-fetch",
|
||||
"END:VEVENT",
|
||||
"END:VCALENDAR",
|
||||
].join("\r\n"),
|
||||
);
|
||||
await page
|
||||
.getByRole("button", { name: "Validate and inspect calendar" })
|
||||
.click();
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "iCalendar inspection" }),
|
||||
).toBeVisible();
|
||||
await expect(page.locator(".result > pre")).toContainText('"inert": true');
|
||||
await expect(page.locator(".result > pre")).toContainText('"occurrences": [');
|
||||
expect(external).toEqual([]);
|
||||
});
|
||||
|
||||
test("plans cross-zone meetings and previews an explicit cron dialect", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/deep/nested/time/");
|
||||
await page.getByRole("button", { name: "Meeting planner" }).click();
|
||||
await page.getByLabel("Search from instant").fill("2026-09-01T00:00:00Z");
|
||||
await page
|
||||
.getByRole("button", { name: "Find shared work-hour slots" })
|
||||
.click();
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Meeting candidates" }),
|
||||
).toBeVisible();
|
||||
await expect(page.locator(".result > pre")).toContainText("Europe/Berlin");
|
||||
await expect(page.locator(".result > pre")).toContainText("America/New_York");
|
||||
|
||||
await page.getByRole("button", { name: "Cron & RRULE" }).click();
|
||||
await page.getByLabel("Cron dialect").selectOption("github-actions");
|
||||
await page.getByLabel("Time zone").fill("UTC");
|
||||
await page.getByRole("button", { name: "Preview cron" }).click();
|
||||
await expect(page.locator(".result > pre")).toContainText("github-actions");
|
||||
await expect(page.locator(".result > pre")).toContainText(
|
||||
"normalizedPattern",
|
||||
);
|
||||
});
|
||||
|
||||
test("serves the release identity and hardened headers", async ({
|
||||
request,
|
||||
}) => {
|
||||
@@ -55,7 +114,7 @@ test("serves the release identity and hardened headers", async ({
|
||||
const manifest = await request.get("/deep/nested/time/toolbox-app.json");
|
||||
await expect(manifest.json()).resolves.toMatchObject({
|
||||
id: "de.add-ideas.time-tools",
|
||||
version: "0.1.0",
|
||||
version: "0.2.0",
|
||||
entry: "./",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("keeps the primary workspace inside a narrow viewport", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/deep/nested/time/");
|
||||
await expect(page.locator("main").first()).toBeVisible();
|
||||
await expect(
|
||||
page.locator("main .loading, main .workbench-loading"),
|
||||
).toHaveCount(0);
|
||||
|
||||
const widths = await page.evaluate(() => ({
|
||||
content: document.documentElement.scrollWidth,
|
||||
viewport: document.documentElement.clientWidth,
|
||||
}));
|
||||
expect(widths.viewport).toBeLessThanOrEqual(430);
|
||||
expect(widths.content).toBeLessThanOrEqual(widths.viewport + 1);
|
||||
});
|
||||
+225
-1
@@ -2,7 +2,13 @@ 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 { previewCron, previewRRule } from "../../src/time/recurrence";
|
||||
import { inspectIcs } from "../../src/time/ics-inspect";
|
||||
import { planMeetings } from "../../src/time/meeting";
|
||||
import {
|
||||
previewCron,
|
||||
previewCronDialect,
|
||||
previewRRule,
|
||||
} from "../../src/time/recurrence";
|
||||
import {
|
||||
compareTimeZones,
|
||||
nextTransitions,
|
||||
@@ -102,6 +108,100 @@ describe("bounded recurrence previews", () => {
|
||||
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", () => {
|
||||
@@ -129,3 +229,127 @@ describe("iCalendar output", () => {
|
||||
).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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user