Self-hosted multi-platform broadcast newsroom for activist collectives: publishing pipeline with group approval and DST-proof scheduling, TweetDeck-style column wall with hostile-content-safe rendering, live control room with Auto Director, bridged chat governance with polls and quorum, distributed sandboxed transcode workers, encrypted content-addressed archive, monthly analytics. MIT licensed. See README.md and docs/.
111 lines
5.2 KiB
TypeScript
111 lines
5.2 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { materializeOccurrence } from "./materialize-api.js";
|
|
import { compareWall } from "./nominal.js";
|
|
import { InvalidNominalLocalError } from "./errors.js";
|
|
import { wallOfInstant } from "./timezone.js";
|
|
|
|
const ISO = (ms: number) => new Date(ms).toISOString().replace(".000Z", "Z");
|
|
|
|
describe("materializeOccurrence — normal (unambiguous) times", () => {
|
|
it("resolves a plain New York evening slot", () => {
|
|
expect(ISO(materializeOccurrence("2024-03-09T21:00", "America/New_York")))
|
|
.toBe("2024-03-10T02:00:00Z");
|
|
});
|
|
it("passes UTC straight through", () => {
|
|
expect(ISO(materializeOccurrence("2024-03-10T02:30", "UTC")))
|
|
.toBe("2024-03-10T02:30:00Z");
|
|
});
|
|
it("resolves Kolkata (no DST, half-hour offset)", () => {
|
|
expect(ISO(materializeOccurrence("2024-06-01T09:00", "Asia/Kolkata")))
|
|
.toBe("2024-06-01T03:30:00Z");
|
|
});
|
|
});
|
|
|
|
describe("DST gap — nonexistent local time fires at the first valid instant after", () => {
|
|
it("2:30 on New York spring-forward (2024-03-10) shifts to 3:00 local / 07:00Z", () => {
|
|
const fire = materializeOccurrence("2024-03-10T02:30", "America/New_York");
|
|
expect(ISO(fire)).toBe("2024-03-10T07:00:00Z");
|
|
const wall = wallOfInstant(fire, "America/New_York");
|
|
expect(`${wall.hour}:${String(wall.minute).padStart(2, "0")}`).toBe("3:00");
|
|
});
|
|
it("2:45 shifts to 3:00 as well", () => {
|
|
expect(ISO(materializeOccurrence("2024-03-10T02:45", "America/New_York")))
|
|
.toBe("2024-03-10T07:00:00Z");
|
|
});
|
|
it("2:00 (whole gap hour) shifts to 3:00", () => {
|
|
expect(ISO(materializeOccurrence("2024-03-10T02:00", "America/New_York")))
|
|
.toBe("2024-03-10T07:00:00Z");
|
|
});
|
|
it("1:59 — last pre-gap minute — is untouched", () => {
|
|
expect(ISO(materializeOccurrence("2024-03-10T01:59", "America/New_York")))
|
|
.toBe("2024-03-10T06:59:00Z");
|
|
});
|
|
it("Berlin 02:30 spring-forward shifts to 03:00 local", () => {
|
|
// EU transitions at 01:00 UTC: 2024-03-31 02:00->03:00 local
|
|
expect(ISO(materializeOccurrence("2024-03-31T02:30", "Europe/Berlin")))
|
|
.toBe("2024-03-31T01:00:00Z");
|
|
});
|
|
it("whole-day gap: Pacific/Apia skipped 2011-12-30; a slot on that day fires at the first valid instant after the gap", () => {
|
|
// The zone jumped 2011-12-29T23:59:59-10:00 -> 2011-12-31T00:00:00+14:00,
|
|
// so 2011-12-30 never existed. First valid instant after the gap is
|
|
// 2011-12-31T00:00 local == 2011-12-30T10:00Z.
|
|
const fire = materializeOccurrence("2011-12-30T09:00", "Pacific/Apia");
|
|
expect(ISO(fire)).toBe("2011-12-30T10:00:00Z");
|
|
const wall = wallOfInstant(fire, "Pacific/Apia");
|
|
expect(`${wall.year}-${wall.month}-${String(wall.day).padStart(2, "0")}T${wall.hour}:${wall.minute}`).toBe("2011-12-31T0:0");
|
|
});
|
|
it("a slot just before the Apia skip (23:00 on 12-29) is untouched", () => {
|
|
expect(ISO(materializeOccurrence("2011-12-29T23:00", "Pacific/Apia")))
|
|
.toBe("2011-12-30T09:00:00Z");
|
|
});
|
|
});
|
|
|
|
describe("DST fold — ambiguous local time fires once, at the earlier instant", () => {
|
|
it("1:30 on New York fall-back (2024-11-03) fires at EDT (earlier offset)", () => {
|
|
// 01:30 EDT == 05:30Z; the later reading 01:30 EST == 06:30Z is NOT used.
|
|
const fire = materializeOccurrence("2024-11-03T01:30", "America/New_York");
|
|
expect(ISO(fire)).toBe("2024-11-03T05:30:00Z");
|
|
expect(fire).not.toBe(Date.parse("2024-11-03T06:30:00Z"));
|
|
});
|
|
it("1:30 fires exactly once at 05:30Z (not 06:30Z)", () => {
|
|
expect(ISO(materializeOccurrence("2024-11-03T01:30", "America/New_York")))
|
|
.toBe("2024-11-03T05:30:00Z");
|
|
});
|
|
it("1:00 fold hour fires at 05:00Z (earlier)", () => {
|
|
expect(ISO(materializeOccurrence("2024-11-03T01:00", "America/New_York")))
|
|
.toBe("2024-11-03T05:00:00Z");
|
|
});
|
|
it("2:00 after the fold is unambiguous again", () => {
|
|
expect(ISO(materializeOccurrence("2024-11-03T02:00", "America/New_York")))
|
|
.toBe("2024-11-03T07:00:00Z");
|
|
});
|
|
});
|
|
|
|
describe("validation", () => {
|
|
it("rejects malformed nominal strings", () => {
|
|
for (const bad of ["2024-3-10T02:30", "2024-03-10", "2024-03-10T2:30", "2024-13-01T00:00", "2024-03-32T00:00", "2024-03-10T25:00", "nonsense"]) {
|
|
expect(() => materializeOccurrence(bad, "UTC")).toThrow(InvalidNominalLocalError);
|
|
}
|
|
});
|
|
it("rejects invalid zones", () => {
|
|
expect(() => materializeOccurrence("2024-03-10T02:30", "America/Nowhere")).toThrow();
|
|
expect(() => materializeOccurrence("2024-03-10T02:30", "local")).toThrow();
|
|
});
|
|
it("is pure — repeated calls agree", () => {
|
|
const a = materializeOccurrence("2024-03-10T02:30", "America/New_York");
|
|
const b = materializeOccurrence("2024-03-10T02:30", "America/New_York");
|
|
const c = materializeOccurrence("2024-03-10T02:30", "America/New_York");
|
|
expect(a).toBe(b);
|
|
expect(b).toBe(c);
|
|
});
|
|
it("compareWall orders wall clocks chronologically", () => {
|
|
const w = (s: string) => ({
|
|
year: +s.slice(0, 4), month: +s.slice(5, 7), day: +s.slice(8, 10),
|
|
hour: +s.slice(11, 13), minute: +s.slice(14, 16), second: 0,
|
|
});
|
|
expect(compareWall(w("2024-03-10T01:59"), w("2024-03-10T03:00"))).toBeLessThan(0);
|
|
expect(compareWall(w("2024-11-03T01:30"), w("2024-11-03T01:30"))).toBe(0);
|
|
});
|
|
});
|
|
|
|
export {};
|