Release Time Tools 0.1.0
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
const ORIGIN = "http://127.0.0.1:4173";
|
||||
async function localOnly(page: Page) {
|
||||
const external: string[] = [];
|
||||
await page.route("**/*", async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
if (url.origin !== ORIGIN) {
|
||||
external.push(url.href);
|
||||
await route.abort();
|
||||
} else await route.continue();
|
||||
});
|
||||
return external;
|
||||
}
|
||||
|
||||
test("runs from a nested path without external requests", async ({ page }) => {
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", (error) => errors.push(error.message));
|
||||
page.on("console", (message) => {
|
||||
if (message.type() === "error") errors.push(message.text());
|
||||
});
|
||||
const external = await localOnly(page);
|
||||
await page.goto("/deep/nested/time/");
|
||||
await expect(page.getByRole("heading", { name: "Time Tools" })).toBeVisible();
|
||||
expect(external).toEqual([]);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("converts a negative nanosecond epoch exactly", async ({ page }) => {
|
||||
const external = await localOnly(page);
|
||||
await page.goto("/deep/nested/time/");
|
||||
await page.getByLabel("Epoch value").fill("-0.000000001");
|
||||
await page.getByRole("button", { name: "Convert exactly" }).click();
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Exact timestamp conversion" }),
|
||||
).toBeVisible();
|
||||
await expect(page.locator(".result > pre")).toContainText(
|
||||
"1969-12-31T23:59:59.999999999Z",
|
||||
);
|
||||
await expect(page.locator(".result > pre")).toContainText(
|
||||
'"epochNanoseconds": "-1"',
|
||||
);
|
||||
expect(external).toEqual([]);
|
||||
});
|
||||
|
||||
test("serves the release identity and hardened headers", async ({
|
||||
request,
|
||||
}) => {
|
||||
const index = await request.get("/deep/nested/time/");
|
||||
expect(index.ok()).toBe(true);
|
||||
expect(index.headers()["content-security-policy"]).toContain(
|
||||
"default-src 'self'",
|
||||
);
|
||||
expect(await index.text()).not.toMatch(/\b(?:src|href)=["']\//u);
|
||||
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",
|
||||
entry: "./",
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { App } from "../../src/App";
|
||||
|
||||
describe("Time Tools", () => {
|
||||
it("renders the local workbench and standard shell", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => new Response("Not found", { status: 404 })),
|
||||
);
|
||||
render(<App />);
|
||||
expect(
|
||||
await screen.findByRole("heading", { name: "Time Tools" }),
|
||||
).toBeVisible();
|
||||
expect(await screen.findByText("No network requests")).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
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 {
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user