src.nth.io/

summaryrefslogtreecommitdiff
path: root/test/timezone.test.ts
blob: d4ba6d63bf04b60cfca963d04a8a1a821382e1bf (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
import { describe, expect, it } from "vitest";
import {
  buildDstOffsetList,
  buildTimeZoneList,
  formatUtcOffset,
  isValidTimeZone,
  nextOffsetTransition,
  standardOffsetSeconds,
  utcOffsetSeconds,
} from "../src/timezone.js";

// All builder timestamps are Unix-epoch microseconds (matter.js TlvEpochUs
// convention); "since the beginning of time" is Matter epoch zero in Unix us.
const MATTER_ZERO = 946_684_800_000_000n;
const FALL_BACK_2026 = BigInt(Date.parse("2026-11-01T07:00:00Z")) * 1_000n;
const SPRING_FORWARD_2027 = BigInt(Date.parse("2027-03-14T08:00:00Z")) * 1_000n;

describe("isValidTimeZone", () => {
  it("accepts IANA names", () => {
    expect(isValidTimeZone("America/Chicago")).toBe(true);
    expect(isValidTimeZone("Europe/Berlin")).toBe(true);
    expect(isValidTimeZone("UTC")).toBe(true);
  });

  it("rejects invalid names", () => {
    expect(isValidTimeZone("Central Time")).toBe(false);
    expect(isValidTimeZone("")).toBe(false);
    expect(isValidTimeZone("America/Springfield")).toBe(false);
  });
});

describe("utcOffsetSeconds", () => {
  it("returns CST offset in January", () => {
    expect(utcOffsetSeconds("America/Chicago", new Date("2026-01-15T12:00:00Z"))).toBe(-6 * 3600);
  });

  it("returns CDT offset in July", () => {
    expect(utcOffsetSeconds("America/Chicago", new Date("2026-07-15T12:00:00Z"))).toBe(-5 * 3600);
  });

  it("returns 0 for UTC", () => {
    expect(utcOffsetSeconds("UTC", new Date("2026-07-15T12:00:00Z"))).toBe(0);
  });

  it("handles half-hour offsets", () => {
    expect(utcOffsetSeconds("Asia/Kolkata", new Date("2026-07-15T12:00:00Z"))).toBe(5.5 * 3600);
  });
});

describe("formatUtcOffset", () => {
  it("formats negative, positive, and zero offsets", () => {
    expect(formatUtcOffset(-6 * 3600)).toBe("UTC-06:00");
    expect(formatUtcOffset(5.5 * 3600)).toBe("UTC+05:30");
    expect(formatUtcOffset(0)).toBe("UTC+00:00");
  });
});

describe("nextOffsetTransition", () => {
  it("finds the exact spring-forward instant for Chicago", () => {
    const transition = nextOffsetTransition("America/Chicago", new Date("2026-01-15T00:00:00Z"));
    expect(transition).not.toBeNull();
    // 2026-03-08 02:00 CST (UTC-6) -> 03:00 CDT (UTC-5): 08:00:00 UTC.
    expect(transition!.at.toISOString()).toBe("2026-03-08T08:00:00.000Z");
    expect(transition!.offsetBeforeSeconds).toBe(-6 * 3600);
    expect(transition!.offsetAfterSeconds).toBe(-5 * 3600);
  });

  it("finds the exact fall-back instant for Chicago", () => {
    const transition = nextOffsetTransition("America/Chicago", new Date("2026-07-15T00:00:00Z"));
    expect(transition).not.toBeNull();
    // 2026-11-01 02:00 CDT (UTC-5) -> 01:00 CST (UTC-6): 07:00:00 UTC.
    expect(transition!.at.toISOString()).toBe("2026-11-01T07:00:00.000Z");
    expect(transition!.offsetBeforeSeconds).toBe(-5 * 3600);
    expect(transition!.offsetAfterSeconds).toBe(-6 * 3600);
  });

  it("returns null for fixed-offset zones", () => {
    expect(nextOffsetTransition("UTC", new Date("2026-01-15T00:00:00Z"))).toBeNull();
  });
});

describe("standardOffsetSeconds", () => {
  it("returns the non-DST offset regardless of season", () => {
    expect(standardOffsetSeconds("America/Chicago", new Date("2026-07-15T00:00:00Z"))).toBe(-6 * 3600);
    expect(standardOffsetSeconds("America/Chicago", new Date("2026-01-15T00:00:00Z"))).toBe(-6 * 3600);
  });

  it("handles the southern hemisphere", () => {
    // Sydney: AEST UTC+10 standard, AEDT UTC+11 in southern summer.
    expect(standardOffsetSeconds("Australia/Sydney", new Date("2026-01-15T00:00:00Z"))).toBe(10 * 3600);
  });

  it("handles fixed-offset zones", () => {
    expect(standardOffsetSeconds("UTC", new Date("2026-07-15T00:00:00Z"))).toBe(0);
    expect(standardOffsetSeconds("Asia/Kolkata", new Date("2026-07-15T00:00:00Z"))).toBe(5.5 * 3600);
  });
});

describe("buildTimeZoneList", () => {
  it("builds a single always-valid entry with the standard offset and IANA name", () => {
    const list = buildTimeZoneList("America/Chicago", new Date("2026-07-26T00:00:00Z"));
    expect(list).toEqual([{ offset: -6 * 3600, validAt: MATTER_ZERO, name: "America/Chicago" }]);
  });
});

describe("buildDstOffsetList", () => {
  const from = new Date("2026-07-26T00:00:00Z");

  it("builds the active DST period plus the following one (max 2, as on ALPSTUGA)", () => {
    expect(buildDstOffsetList("America/Chicago", 2, from)).toEqual([
      { offset: 3600, validStarting: MATTER_ZERO, validUntil: FALL_BACK_2026 },
      { offset: 0, validStarting: FALL_BACK_2026, validUntil: SPRING_FORWARD_2027 },
    ]);
  });

  it("caps the list at the device's maximum", () => {
    expect(buildDstOffsetList("America/Chicago", 1, from)).toEqual([
      { offset: 3600, validStarting: MATTER_ZERO, validUntil: FALL_BACK_2026 },
    ]);
  });

  it("emits a single open-ended zero entry for zones without DST", () => {
    expect(buildDstOffsetList("UTC", 2, from)).toEqual([
      { offset: 0, validStarting: MATTER_ZERO, validUntil: null },
    ]);
    expect(buildDstOffsetList("Asia/Kolkata", 5, from)).toEqual([
      { offset: 0, validStarting: MATTER_ZERO, validUntil: null },
    ]);
  });

  it("starts from standard time when DST is not in effect", () => {
    const winter = new Date("2026-01-15T00:00:00Z");
    const springForward2026 = BigInt(Date.parse("2026-03-08T08:00:00Z")) * 1_000n;
    const list = buildDstOffsetList("America/Chicago", 2, winter);
    expect(list[0]).toEqual({ offset: 0, validStarting: MATTER_ZERO, validUntil: springForward2026 });
    expect(list[1]!.offset).toBe(3600);
    expect(list[1]!.validStarting).toBe(springForward2026);
  });
});