src.nth.io/

summaryrefslogtreecommitdiff
path: root/test
diff options
context:
space:
mode:
Diffstat (limited to 'test')
-rw-r--r--test/commissioning.test.ts33
-rw-r--r--test/config.test.ts73
-rw-r--r--test/fabrics.test.ts46
-rw-r--r--test/fixtures/alpstuga-inspect.json504
-rw-r--r--test/state.test.ts103
-rw-r--r--test/sync.test.ts133
-rw-r--r--test/timesync.test.ts79
-rw-r--r--test/timezone.test.ts139
8 files changed, 1110 insertions, 0 deletions
diff --git a/test/commissioning.test.ts b/test/commissioning.test.ts
new file mode 100644
index 0000000..676a5e1
--- /dev/null
+++ b/test/commissioning.test.ts
@@ -0,0 +1,33 @@
+import { ManualPairingCodeCodec } from "@matter/main/types";
+import { describe, expect, it } from "vitest";
+import { parsePairingCode } from "../src/commissioning.js";
+
+describe("parsePairingCode", () => {
+ const validCode = ManualPairingCodeCodec.encode({ discriminator: 3840, passcode: 20202021 });
+
+ it("parses a valid 11-digit manual code", () => {
+ const parsed = parsePairingCode(validCode);
+ expect(parsed.passcode).toBe(20202021);
+ expect(parsed.shortDiscriminator).toBe(3840 >> 8);
+ });
+
+ it("accepts hyphens and whitespace", () => {
+ const withHyphens = `${validCode.slice(0, 4)}-${validCode.slice(4, 7)}-${validCode.slice(7)}`;
+ expect(parsePairingCode(` ${withHyphens} `).passcode).toBe(20202021);
+ });
+
+ it("rejects empty input", () => {
+ expect(() => parsePairingCode("")).toThrow(/empty/);
+ expect(() => parsePairingCode(" ")).toThrow(/empty/);
+ });
+
+ it("rejects codes of the wrong length", () => {
+ expect(() => parsePairingCode("12345")).toThrow(/11 or 21 digits/);
+ expect(() => parsePairingCode("123456789012")).toThrow(/11 or 21 digits/);
+ });
+
+ it("rejects codes with an invalid checksum", () => {
+ const corrupted = validCode.slice(0, -1) + ((Number(validCode.at(-1)) + 1) % 10).toString();
+ expect(() => parsePairingCode(corrupted)).toThrow();
+ });
+});
diff --git a/test/config.test.ts b/test/config.test.ts
new file mode 100644
index 0000000..1caecd8
--- /dev/null
+++ b/test/config.test.ts
@@ -0,0 +1,73 @@
+import { describe, expect, it } from "vitest";
+import { ConfigError, parseNodeId, validateConfig } from "../src/config.js";
+
+const minimal = { storagePath: "/var/lib/mattertimesync" };
+
+describe("validateConfig", () => {
+ it("accepts a minimal configuration and applies defaults", () => {
+ const config = validateConfig(minimal);
+ expect(config.storagePath).toBe(minimal.storagePath);
+ expect(config.timezone).toBe("America/Chicago");
+ expect(config.logLevel).toBe("info");
+ });
+
+ it("accepts a full configuration", () => {
+ const config = validateConfig({
+ ...minimal,
+ timezone: "Europe/Berlin",
+ logLevel: "debug",
+ });
+ expect(config.timezone).toBe("Europe/Berlin");
+ expect(config.logLevel).toBe("debug");
+ });
+
+ it("rejects non-object documents", () => {
+ expect(() => validateConfig([])).toThrow(ConfigError);
+ expect(() => validateConfig("x")).toThrow(ConfigError);
+ expect(() => validateConfig(null)).toThrow(ConfigError);
+ });
+
+ it("requires storagePath", () => {
+ expect(() => validateConfig({})).toThrow(/storagePath/);
+ expect(() => validateConfig({ storagePath: "" })).toThrow(/storagePath/);
+ });
+
+ it("rejects unknown fields", () => {
+ expect(() => validateConfig({ ...minimal, unexpected: 1 })).toThrow(/unknown field "unexpected"/);
+ // Fields from abandoned designs are rejected, not ignored.
+ expect(() => validateConfig({ ...minimal, syncIntervalHours: 24 })).toThrow(/unknown field/);
+ expect(() => validateConfig({ ...minimal, syncOnReconnect: true })).toThrow(/unknown field/);
+ expect(() => validateConfig({ ...minimal, nodeId: "1" })).toThrow(/unknown field/);
+ });
+
+ it("rejects invalid time zones", () => {
+ expect(() => validateConfig({ ...minimal, timezone: "Central Time" })).toThrow(/timezone/);
+ expect(() => validateConfig({ ...minimal, timezone: "" })).toThrow(/timezone/);
+ expect(() => validateConfig({ ...minimal, timezone: 5 })).toThrow(/timezone/);
+ });
+
+ it("rejects invalid log levels", () => {
+ expect(() => validateConfig({ ...minimal, logLevel: "verbose" })).toThrow(/logLevel/);
+ });
+});
+
+describe("parseNodeId", () => {
+ it("parses decimal strings without precision loss", () => {
+ expect(parseNodeId("1")).toBe(1n);
+ expect(parseNodeId("9007199254740993")).toBe(9007199254740993n);
+ expect(parseNodeId("18446744073709551615")).toBe(18446744073709551615n);
+ });
+
+ it("accepts small safe integers", () => {
+ expect(parseNodeId(42)).toBe(42n);
+ });
+
+ it("rejects unsafe or invalid values", () => {
+ expect(() => parseNodeId("18446744073709551616")).toThrow(ConfigError); // 2^64
+ expect(() => parseNodeId("-1")).toThrow(ConfigError);
+ expect(() => parseNodeId(1.5)).toThrow(ConfigError);
+ expect(() => parseNodeId("0x10")).toThrow(ConfigError);
+ expect(() => parseNodeId(Number.MAX_SAFE_INTEGER + 2)).toThrow(ConfigError);
+ expect(() => parseNodeId(null)).toThrow(ConfigError);
+ });
+});
diff --git a/test/fabrics.test.ts b/test/fabrics.test.ts
new file mode 100644
index 0000000..b3cd18e
--- /dev/null
+++ b/test/fabrics.test.ts
@@ -0,0 +1,46 @@
+import { describe, expect, it } from "vitest";
+import { findLikelyStrays, formatFabricTable, type FabricTable } from "../src/fabrics.js";
+
+const table: FabricTable = {
+ supportedFabrics: 5,
+ commissionedFabrics: 4,
+ entries: [
+ { fabricIndex: 1, fabricId: 100n, nodeId: 10n, vendorId: 0x1349, label: "Apple Home", isOurs: false },
+ { fabricIndex: 2, fabricId: 200n, nodeId: 20n, vendorId: 0xfff1, label: "mattertimesync", isOurs: true },
+ { fabricIndex: 3, fabricId: 300n, nodeId: 30n, vendorId: 0xfff1, label: "mattertimesync", isOurs: false },
+ { fabricIndex: 4, fabricId: 400n, nodeId: 40n, vendorId: 0xfff1, label: "mattertimesync", isOurs: false },
+ ],
+};
+
+describe("findLikelyStrays", () => {
+ it("flags entries with our label that are not our identity", () => {
+ expect(findLikelyStrays(table).map(entry => entry.fabricIndex)).toEqual([3, 4]);
+ });
+
+ it("never flags other ecosystems' entries", () => {
+ const strays = findLikelyStrays(table);
+ expect(strays.some(entry => entry.label === "Apple Home")).toBe(false);
+ });
+
+ it("flags nothing when our identity is not in the table", () => {
+ const withoutOurs: FabricTable = {
+ ...table,
+ entries: table.entries.map(entry => ({ ...entry, isOurs: false })),
+ };
+ expect(findLikelyStrays(withoutOurs)).toEqual([]);
+ });
+});
+
+describe("formatFabricTable", () => {
+ const output = formatFabricTable(1n, table);
+
+ it("shows slot usage and marks our entry", () => {
+ expect(output).toContain("4 of 5 slots used");
+ expect(output).toContain("[this controller]");
+ });
+
+ it("names known vendors and lists stale indices", () => {
+ expect(output).toContain("(Apple)");
+ expect(output).toContain("index 3, index 4");
+ });
+});
diff --git a/test/fixtures/alpstuga-inspect.json b/test/fixtures/alpstuga-inspect.json
new file mode 100644
index 0000000..1edfbfc
--- /dev/null
+++ b/test/fixtures/alpstuga-inspect.json
@@ -0,0 +1,504 @@
+{
+ "nodeId": "1",
+ "basicInformation": {
+ "vendorName": "IKEA of Sweden",
+ "vendorId": 4476,
+ "productName": "ALPSTUGA air quality monitor",
+ "productId": 12289,
+ "hardwareVersion": "P2.0",
+ "softwareVersion": "1.0.26",
+ "uniqueId": "3e4b6ef1d93c64187e026d0c1c1ba319"
+ },
+ "endpoints": [
+ {
+ "endpointId": 0,
+ "name": "MA-otarequestor",
+ "deviceTypes": [
+ {
+ "name": "MA-otarequestor",
+ "code": "0x0012",
+ "revision": 1
+ },
+ {
+ "name": "MA-rootnode",
+ "code": "0x0016",
+ "revision": 4
+ }
+ ],
+ "serverClusters": [
+ {
+ "id": "0x001d",
+ "name": "Descriptor",
+ "revision": 2,
+ "isUnknown": false,
+ "vendorSpecific": false,
+ "supportedFeatures": {
+ "tagList": false
+ }
+ },
+ {
+ "id": "0x001f",
+ "name": "AccessControl",
+ "revision": 1,
+ "isUnknown": false,
+ "vendorSpecific": false,
+ "supportedFeatures": {
+ "extension": false,
+ "managedDevice": false,
+ "auxiliary": false
+ }
+ },
+ {
+ "id": "0x0028",
+ "name": "BasicInformation",
+ "revision": 3,
+ "isUnknown": false,
+ "vendorSpecific": false,
+ "supportedFeatures": {}
+ },
+ {
+ "id": "0x002a",
+ "name": "OtaSoftwareUpdateRequestor",
+ "revision": 1,
+ "isUnknown": false,
+ "vendorSpecific": false,
+ "supportedFeatures": {}
+ },
+ {
+ "id": "0x0030",
+ "name": "GeneralCommissioning",
+ "revision": 1,
+ "isUnknown": false,
+ "vendorSpecific": false,
+ "supportedFeatures": {
+ "termsAndConditions": false,
+ "networkRecovery": false
+ }
+ },
+ {
+ "id": "0x0031",
+ "name": "NetworkCommissioning",
+ "revision": 2,
+ "isUnknown": false,
+ "vendorSpecific": false,
+ "supportedFeatures": {
+ "wiFiNetworkInterface": false,
+ "threadNetworkInterface": true,
+ "ethernetNetworkInterface": false
+ }
+ },
+ {
+ "id": "0x0033",
+ "name": "GeneralDiagnostics",
+ "revision": 2,
+ "isUnknown": false,
+ "vendorSpecific": false,
+ "supportedFeatures": {
+ "dataModelTest": false
+ }
+ },
+ {
+ "id": "0x0035",
+ "name": "ThreadNetworkDiagnostics",
+ "revision": 2,
+ "isUnknown": false,
+ "vendorSpecific": false,
+ "supportedFeatures": {
+ "packetCounts": false,
+ "errorCounts": false,
+ "mleCounts": false,
+ "macCounts": false
+ }
+ },
+ {
+ "id": "0x0038",
+ "name": "TimeSynchronization",
+ "revision": 2,
+ "isUnknown": false,
+ "vendorSpecific": false,
+ "supportedFeatures": {
+ "timeZone": true,
+ "ntpClient": false,
+ "ntpServer": false,
+ "timeSyncClient": false
+ }
+ },
+ {
+ "id": "0x003c",
+ "name": "AdministratorCommissioning",
+ "revision": 1,
+ "isUnknown": false,
+ "vendorSpecific": false,
+ "supportedFeatures": {
+ "basic": true
+ }
+ },
+ {
+ "id": "0x003e",
+ "name": "OperationalCredentials",
+ "revision": 1,
+ "isUnknown": false,
+ "vendorSpecific": false,
+ "supportedFeatures": {}
+ },
+ {
+ "id": "0x003f",
+ "name": "GroupKeyManagement",
+ "revision": 2,
+ "isUnknown": false,
+ "vendorSpecific": false,
+ "supportedFeatures": {
+ "cacheAndSync": false,
+ "groupcast": false
+ }
+ }
+ ],
+ "clientClusters": [
+ "0x0029"
+ ]
+ },
+ {
+ "endpointId": 1,
+ "name": "MA-airqualitysensor",
+ "deviceTypes": [
+ {
+ "name": "MA-airqualitysensor",
+ "code": "0x002c",
+ "revision": 1
+ }
+ ],
+ "serverClusters": [
+ {
+ "id": "0x0003",
+ "name": "Identify",
+ "revision": 4,
+ "isUnknown": false,
+ "vendorSpecific": false,
+ "supportedFeatures": {}
+ },
+ {
+ "id": "0x0006",
+ "name": "OnOff",
+ "revision": 6,
+ "isUnknown": false,
+ "vendorSpecific": false,
+ "supportedFeatures": {
+ "lighting": false,
+ "deadFrontBehavior": true,
+ "offOnly": false
+ }
+ },
+ {
+ "id": "0x001d",
+ "name": "Descriptor",
+ "revision": 2,
+ "isUnknown": false,
+ "vendorSpecific": false,
+ "supportedFeatures": {
+ "tagList": false
+ }
+ },
+ {
+ "id": "0x005b",
+ "name": "AirQuality",
+ "revision": 1,
+ "isUnknown": false,
+ "vendorSpecific": false,
+ "supportedFeatures": {
+ "fair": false,
+ "moderate": false,
+ "veryPoor": false,
+ "extremelyPoor": false
+ }
+ },
+ {
+ "id": "0x0402",
+ "name": "TemperatureMeasurement",
+ "revision": 4,
+ "isUnknown": false,
+ "vendorSpecific": false,
+ "supportedFeatures": {}
+ },
+ {
+ "id": "0x0405",
+ "name": "RelativeHumidityMeasurement",
+ "revision": 3,
+ "isUnknown": false,
+ "vendorSpecific": false,
+ "supportedFeatures": {}
+ },
+ {
+ "id": "0x040d",
+ "name": "CarbonDioxideConcentrationMeasurement",
+ "revision": 3,
+ "isUnknown": false,
+ "vendorSpecific": false,
+ "supportedFeatures": {
+ "numericMeasurement": true,
+ "levelIndication": true,
+ "mediumLevel": false,
+ "criticalLevel": false,
+ "peakMeasurement": false,
+ "averageMeasurement": false
+ }
+ },
+ {
+ "id": "0x042a",
+ "name": "Pm25ConcentrationMeasurement",
+ "revision": 3,
+ "isUnknown": false,
+ "vendorSpecific": false,
+ "supportedFeatures": {
+ "numericMeasurement": true,
+ "levelIndication": true,
+ "mediumLevel": false,
+ "criticalLevel": false,
+ "peakMeasurement": false,
+ "averageMeasurement": false
+ }
+ }
+ ],
+ "clientClusters": []
+ }
+ ],
+ "timeSynchronization": {
+ "endpointId": 0,
+ "clusterRevision": 2,
+ "supportedFeatures": {
+ "timeZone": true,
+ "ntpClient": false,
+ "ntpServer": false,
+ "timeSyncClient": false
+ },
+ "featureMap": {
+ "timeZone": true,
+ "ntpClient": false,
+ "ntpServer": false,
+ "timeSyncClient": false
+ },
+ "supportedCommands": {
+ "setUtcTime": true,
+ "setTrustedTimeSource": false,
+ "setTimeZone": true,
+ "setDstOffset": true,
+ "setDefaultNtp": false
+ },
+ "attributes": [
+ {
+ "id": "0x0",
+ "name": "0",
+ "value": "1785111495000000"
+ },
+ {
+ "id": "0x1",
+ "name": "1",
+ "value": 4
+ },
+ {
+ "id": "0x5",
+ "name": "5",
+ "value": [
+ {
+ "offset": -21600,
+ "validAt": "946684800000000",
+ "name": "America/Chicago"
+ }
+ ]
+ },
+ {
+ "id": "0x6",
+ "name": "6",
+ "value": [
+ {
+ "offset": 3600,
+ "validStarting": "946684800000000",
+ "validUntil": "1793516400000000"
+ },
+ {
+ "offset": 0,
+ "validStarting": "1793516400000000",
+ "validUntil": "1805011200000000"
+ }
+ ]
+ },
+ {
+ "id": "0x7",
+ "name": "7",
+ "value": "1785093495000000"
+ },
+ {
+ "id": "0x8",
+ "name": "8",
+ "value": 2
+ },
+ {
+ "id": "0xa",
+ "name": "10",
+ "value": 2
+ },
+ {
+ "id": "0xb",
+ "name": "11",
+ "value": 2
+ },
+ {
+ "id": "0xfff8",
+ "name": "65528",
+ "value": [
+ 3
+ ]
+ },
+ {
+ "id": "0xfff9",
+ "name": "65529",
+ "value": [
+ 0,
+ 2,
+ 4
+ ]
+ },
+ {
+ "id": "0xfffb",
+ "name": "65531",
+ "value": [
+ 0,
+ 1,
+ 5,
+ 6,
+ 7,
+ 8,
+ 10,
+ 11,
+ 65528,
+ 65529,
+ 65531,
+ 65532,
+ 65533
+ ]
+ },
+ {
+ "id": "0xfffc",
+ "name": "65532",
+ "value": {
+ "timeZone": true,
+ "ntpClient": false,
+ "ntpServer": false,
+ "timeSyncClient": false
+ }
+ },
+ {
+ "id": "0xfffd",
+ "name": "65533",
+ "value": 2
+ },
+ {
+ "id": "0xfffd",
+ "name": "clusterRevision",
+ "value": 2
+ },
+ {
+ "id": "0xfffc",
+ "name": "featureMap",
+ "value": {
+ "timeZone": true,
+ "ntpClient": false,
+ "ntpServer": false,
+ "timeSyncClient": false
+ }
+ },
+ {
+ "id": "0x0",
+ "name": "utcTime",
+ "value": "1785111495000000"
+ },
+ {
+ "id": "0x1",
+ "name": "granularity",
+ "value": 4
+ },
+ {
+ "id": "0x5",
+ "name": "timeZone",
+ "value": [
+ {
+ "offset": -21600,
+ "validAt": "946684800000000",
+ "name": "America/Chicago"
+ }
+ ]
+ },
+ {
+ "id": "0x6",
+ "name": "dstOffset",
+ "value": [
+ {
+ "offset": 3600,
+ "validStarting": "946684800000000",
+ "validUntil": "1793516400000000"
+ },
+ {
+ "offset": 0,
+ "validStarting": "1793516400000000",
+ "validUntil": "1805011200000000"
+ }
+ ]
+ },
+ {
+ "id": "0x7",
+ "name": "localTime",
+ "value": "1785093495000000"
+ },
+ {
+ "id": "0x8",
+ "name": "timeZoneDatabase",
+ "value": 2
+ },
+ {
+ "id": "0xa",
+ "name": "timeZoneListMaxSize",
+ "value": 2
+ },
+ {
+ "id": "0xb",
+ "name": "dstOffsetListMaxSize",
+ "value": 2
+ },
+ {
+ "id": "0xfffb",
+ "name": "attributeList",
+ "value": [
+ 0,
+ 1,
+ 5,
+ 6,
+ 7,
+ 8,
+ 10,
+ 11,
+ 65528,
+ 65529,
+ 65531,
+ 65532,
+ 65533
+ ]
+ },
+ {
+ "id": "0xfff9",
+ "name": "acceptedCommandList",
+ "value": [
+ 0,
+ 2,
+ 4
+ ]
+ },
+ {
+ "id": "0xfff8",
+ "name": "generatedCommandList",
+ "value": [
+ 3
+ ]
+ }
+ ]
+ },
+ "vendorSpecificClusters": []
+}
diff --git a/test/state.test.ts b/test/state.test.ts
new file mode 100644
index 0000000..a80fcfd
--- /dev/null
+++ b/test/state.test.ts
@@ -0,0 +1,103 @@
+import { mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import {
+ EMPTY_NODE_STATE,
+ nodeState,
+ readServiceState,
+ removeNodeState,
+ serviceStatePath,
+ updateNodeState,
+ writeServiceState,
+} from "../src/state.js";
+
+let dir: string;
+
+beforeEach(() => {
+ dir = mkdtempSync(join(tmpdir(), "mattertimesync-state-"));
+});
+
+afterEach(() => {
+ rmSync(dir, { recursive: true, force: true });
+});
+
+describe("service state", () => {
+ it("returns empty state when the file does not exist", () => {
+ expect(readServiceState(join(dir, "missing.json"))).toEqual({ nodes: {} });
+ });
+
+ it("round-trips per-node state through disk", () => {
+ const path = serviceStatePath(dir);
+ const state = {
+ nodes: {
+ "12345678901234567890": {
+ lastSuccessfulConnection: "2026-07-26T20:00:00Z",
+ lastSuccessfulSync: "2026-07-26T20:00:02Z",
+ lastAttemptedSync: "2026-07-26T20:00:01Z",
+ lastError: null,
+ },
+ "2": { ...EMPTY_NODE_STATE, lastError: "unreachable" },
+ },
+ };
+ writeServiceState(path, state);
+ expect(readServiceState(path)).toEqual(state);
+ });
+
+ it("leaves no temporary file behind after writing", () => {
+ const path = serviceStatePath(dir);
+ writeServiceState(path, { nodes: {} });
+ expect(readdirSync(dir)).toEqual(["service-state.json"]);
+ });
+
+ it("returns empty state for a corrupt file instead of throwing", () => {
+ const path = join(dir, "corrupt.json");
+ writeFileSync(path, "{ this is not json");
+ expect(readServiceState(path)).toEqual({ nodes: {} });
+ });
+
+ it("drops malformed node keys and wrongly typed fields", () => {
+ const path = join(dir, "typed.json");
+ writeFileSync(
+ path,
+ JSON.stringify({
+ nodes: {
+ "not-a-node-id": { lastError: "x" },
+ "7": { lastError: ["array"], lastSuccessfulSync: "2026-07-26T20:00:02Z" },
+ },
+ }),
+ );
+ expect(readServiceState(path)).toEqual({
+ nodes: { "7": { ...EMPTY_NODE_STATE, lastSuccessfulSync: "2026-07-26T20:00:02Z" } },
+ });
+ });
+
+ it("merges patches into one node without touching others", () => {
+ const path = serviceStatePath(dir);
+ updateNodeState(path, 1n, { lastError: "boom" });
+ updateNodeState(path, 2n, { lastSuccessfulSync: "2026-07-26T20:00:02Z" });
+ updateNodeState(path, 1n, { lastAttemptedSync: "2026-07-26T20:00:01Z" });
+
+ const state = readServiceState(path);
+ expect(nodeState(state, 1n)).toEqual({
+ ...EMPTY_NODE_STATE,
+ lastError: "boom",
+ lastAttemptedSync: "2026-07-26T20:00:01Z",
+ });
+ expect(nodeState(state, 2n)).toEqual({
+ ...EMPTY_NODE_STATE,
+ lastSuccessfulSync: "2026-07-26T20:00:02Z",
+ });
+ });
+
+ it("removes a node's entry", () => {
+ const path = serviceStatePath(dir);
+ updateNodeState(path, 1n, { lastError: "boom" });
+ updateNodeState(path, 2n, {});
+ removeNodeState(path, 1n);
+
+ const state = readServiceState(path);
+ expect(Object.keys(state.nodes)).toEqual(["2"]);
+ expect(nodeState(state, 1n)).toEqual(EMPTY_NODE_STATE);
+ });
+});
diff --git a/test/sync.test.ts b/test/sync.test.ts
new file mode 100644
index 0000000..d48f286
--- /dev/null
+++ b/test/sync.test.ts
@@ -0,0 +1,133 @@
+import { describe, expect, it } from "vitest";
+import {
+ MATTER_EPOCH_UNIX_SECONDS,
+ compareDeviceClock,
+ currentMatterUtcMicroseconds,
+ formatDurationMicros,
+ formatMatterMicros,
+ matterMicrosToUnixMillis,
+ unixMillisToMatterMicros,
+} from "../src/sync.js";
+
+describe("Matter epoch conversion", () => {
+ it("maps 2000-01-01T00:00:00Z to 0 microseconds", () => {
+ const unixMs = BigInt(Date.parse("2000-01-01T00:00:00Z"));
+ expect(unixMillisToMatterMicros(unixMs)).toBe(0n);
+ });
+
+ it("maps 2000-01-01T00:00:01Z to 1,000,000 microseconds", () => {
+ const unixMs = BigInt(Date.parse("2000-01-01T00:00:01Z"));
+ expect(unixMillisToMatterMicros(unixMs)).toBe(1_000_000n);
+ });
+
+ it("keeps the Unix epoch conversion exact", () => {
+ expect(unixMillisToMatterMicros(0n)).toBe(-MATTER_EPOCH_UNIX_SECONDS * 1_000_000n);
+ expect(unixMillisToMatterMicros(0n)).toBe(-946_684_800_000_000n);
+ });
+
+ it("keeps current dates exact through bigint round-trips", () => {
+ const unixMs = BigInt(Date.parse("2026-07-26T20:00:00.123Z"));
+ const micros = unixMillisToMatterMicros(unixMs);
+ expect(micros).toBe((unixMs - 946_684_800_000n) * 1_000n);
+ expect(matterMicrosToUnixMillis(micros)).toBe(unixMs);
+ });
+
+ it("stays exact beyond JavaScript number precision", () => {
+ // Microsecond timestamps pass Number.MAX_SAFE_INTEGER around year 2285;
+ // conversion must not degrade there, proving no number arithmetic occurs.
+ const unixMs = BigInt(Date.parse("2300-01-01T00:00:00.001Z"));
+ const micros = unixMillisToMatterMicros(unixMs);
+ expect(micros > BigInt(Number.MAX_SAFE_INTEGER)).toBe(true);
+ expect(matterMicrosToUnixMillis(micros)).toBe(unixMs);
+ });
+
+ it("computes the current time via the injected clock", () => {
+ const nowMs = Date.parse("2026-07-26T20:00:00Z");
+ const micros = currentMatterUtcMicroseconds(() => nowMs);
+ expect(micros).toBe(unixMillisToMatterMicros(BigInt(nowMs)));
+ });
+
+ it("formats Matter microseconds as an ISO instant", () => {
+ expect(formatMatterMicros(0n)).toBe("2000-01-01T00:00:00.000Z");
+ expect(formatMatterMicros(1_000_000n)).toBe("2000-01-01T00:00:01.000Z");
+ });
+});
+
+describe("formatDurationMicros", () => {
+ it("scales units with magnitude", () => {
+ expect(formatDurationMicros(0n)).toBe("0us");
+ expect(formatDurationMicros(999n)).toBe("999us");
+ expect(formatDurationMicros(412_000n)).toBe("412ms");
+ expect(formatDurationMicros(3_200_000n)).toBe("3.2s");
+ expect(formatDurationMicros(59_000_000n)).toBe("59s");
+ expect(formatDurationMicros(125_000_000n)).toBe("2m 5s");
+ expect(formatDurationMicros(3_840_000_000n)).toBe("1h 4m");
+ // 90,061 seconds = 1d 1h 1m 1s; seconds are dropped once days appear.
+ expect(formatDurationMicros(90_061_000_000n)).toBe("1d 1h 1m");
+ });
+
+ it("rejects negative durations", () => {
+ expect(() => formatDurationMicros(-1n)).toThrow();
+ });
+});
+
+describe("compareDeviceClock", () => {
+ // compareDeviceClock operates on Unix-epoch microseconds (matter.js convention).
+ const host = BigInt(Date.parse("2026-07-26T20:00:00.000Z")) * 1_000n;
+
+ it("reports an unset device clock", () => {
+ const comparison = compareDeviceClock(null, host);
+ expect(comparison.deviceTime).toBeNull();
+ expect(comparison.deltaMicroseconds).toBeNull();
+ expect(comparison.hostTime).toBe("2026-07-26T20:00:00.000Z");
+ expect(comparison.description).toBe("device clock was unset");
+ });
+
+ it("reports a device clock behind the host", () => {
+ const comparison = compareDeviceClock(host - 83_000_000n, host);
+ expect(comparison.deviceTime).toBe("2026-07-26T19:58:37.000Z");
+ expect(comparison.deltaMicroseconds).toBe(-83_000_000n);
+ expect(comparison.description).toBe("device clock was 1m 23s behind");
+ });
+
+ it("reports a device clock ahead of the host", () => {
+ const comparison = compareDeviceClock(host + 5_500_000n, host);
+ expect(comparison.deltaMicroseconds).toBe(5_500_000n);
+ expect(comparison.description).toBe("device clock was 5.5s ahead");
+ });
+
+ it("treats sub-second deltas as in sync but still reports them", () => {
+ const comparison = compareDeviceClock(host - 412_000n, host);
+ expect(comparison.description).toBe("device clock was within 1s of host time (412ms behind)");
+ });
+
+ it("detects a device that encodes Unix-epoch time on the wire (wrong epoch bug)", () => {
+ // Such firmware decodes to exactly 946,684,800s ahead at the matter.js
+ // API boundary. The raw delta stays exact; the effective delta folds
+ // the shift out.
+ const wrong = host + 946_684_800n * 1_000_000n + 1_400_000n;
+ const comparison = compareDeviceClock(wrong, host);
+ expect(comparison.epochShifted).toBe(true);
+ expect(comparison.deltaMicroseconds).toBe(946_684_800_000_000n + 1_400_000n);
+ expect(comparison.effectiveDeltaMicroseconds).toBe(1_400_000n);
+ expect(comparison.deviceTime).toBe("2026-07-26T20:00:01.400Z");
+ expect(comparison.description).toBe(
+ "device encodes Unix-epoch time on the wire (off-spec); corrected, its clock was 1.4s ahead",
+ );
+ });
+
+ it("does not fold out large errors that are not the epoch shift", () => {
+ // 30 days past the shift window: a genuinely broken clock, not epoch bug.
+ const wrong = host + 946_684_800n * 1_000_000n + 30n * 86_400n * 1_000_000n;
+ const comparison = compareDeviceClock(wrong, host);
+ expect(comparison.epochShifted).toBe(false);
+ expect(comparison.effectiveDeltaMicroseconds).toBe(comparison.deltaMicroseconds);
+ expect(comparison.description).toContain("ahead");
+ });
+
+ it("keeps spec-compliant comparisons unshifted", () => {
+ const comparison = compareDeviceClock(host - 83_000_000n, host);
+ expect(comparison.epochShifted).toBe(false);
+ expect(comparison.effectiveDeltaMicroseconds).toBe(-83_000_000n);
+ });
+});
diff --git a/test/timesync.test.ts b/test/timesync.test.ts
new file mode 100644
index 0000000..f51369f
--- /dev/null
+++ b/test/timesync.test.ts
@@ -0,0 +1,79 @@
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
+import { describe, expect, it } from "vitest";
+import type { TimeSyncSummary } from "../src/inspection.js";
+import { compareDeviceClock } from "../src/sync.js";
+import { capabilitiesFromTimeSync, planTimeSync } from "../src/timesync.js";
+
+/**
+ * Tests driven by the captured `inspect --json` output of the real device
+ * (IKEA ALPSTUGA air quality monitor, firmware 1.0.26), per the plan: the
+ * sync behavior is derived from inspected capabilities, not assumptions.
+ */
+
+const fixture = JSON.parse(readFileSync(join(__dirname, "fixtures", "alpstuga-inspect.json"), "utf8")) as {
+ basicInformation: { vendorName: string; productName: string };
+ timeSynchronization: TimeSyncSummary;
+};
+
+describe("alpstuga fixture", () => {
+ it("is the expected device", () => {
+ expect(fixture.basicInformation.vendorName).toBe("IKEA of Sweden");
+ expect(fixture.timeSynchronization).not.toBeNull();
+ });
+
+ it("derives capabilities from the inspection", () => {
+ const capabilities = capabilitiesFromTimeSync(fixture.timeSynchronization);
+ expect(capabilities).toEqual({
+ timeZoneFeature: true,
+ supportsSetUtcTime: true,
+ supportsSetTimeZone: true,
+ supportsSetDstOffset: true,
+ timeZoneListMaxSize: 2,
+ dstOffsetListMaxSize: 2,
+ });
+ });
+
+ it("plans UTC + time zone + a 2-entry DST list for this device", () => {
+ const capabilities = capabilitiesFromTimeSync(fixture.timeSynchronization);
+ const plan = planTimeSync(capabilities, "America/Chicago", new Date("2026-07-26T00:00:00Z"));
+ expect(plan.setUtcTime).toBe(true);
+ expect(plan.timeZone).toEqual([
+ { offset: -21600, validAt: 946_684_800_000_000n, name: "America/Chicago" },
+ ]);
+ expect(plan.dstOffsets).toHaveLength(2);
+ expect(plan.dstOffsets![0]!.offset).toBe(3600);
+ expect(plan.dstOffsets![1]!.offset).toBe(0);
+ });
+
+ it("reads the fixture's utcTime as healthy Unix-epoch microseconds", () => {
+ // matter.js's TlvEpochUs decodes wire values to Unix-epoch microseconds,
+ // so the fixture's utcTime (read at commissioning, 2026-07-27T00:18:15Z
+ // wall clock) compares cleanly against that instant with no epoch shift.
+ const attribute = fixture.timeSynchronization.attributes.find(a => a.name === "utcTime");
+ const deviceMicros = BigInt(attribute!.value as string);
+ const captureInstant = BigInt(Date.parse("2026-07-27T00:18:20Z")) * 1_000n;
+ const comparison = compareDeviceClock(deviceMicros, captureInstant);
+ expect(comparison.epochShifted).toBe(false);
+ expect(comparison.deviceTime).toBe("2026-07-27T00:18:15.000Z");
+ const effective = comparison.effectiveDeltaMicroseconds!;
+ const magnitude = effective < 0n ? -effective : effective;
+ expect(magnitude < 60n * 1_000_000n).toBe(true);
+ });
+
+ it("degrades to UTC-only when the TimeZone feature is absent", () => {
+ const utcOnly: TimeSyncSummary = {
+ ...fixture.timeSynchronization,
+ supportedFeatures: { ...fixture.timeSynchronization.supportedFeatures, timeZone: false },
+ supportedCommands: {
+ ...fixture.timeSynchronization.supportedCommands,
+ setTimeZone: false,
+ setDstOffset: false,
+ },
+ };
+ const plan = planTimeSync(capabilitiesFromTimeSync(utcOnly), "America/Chicago");
+ expect(plan.setUtcTime).toBe(true);
+ expect(plan.timeZone).toBeNull();
+ expect(plan.dstOffsets).toBeNull();
+ });
+});
diff --git a/test/timezone.test.ts b/test/timezone.test.ts
new file mode 100644
index 0000000..d4ba6d6
--- /dev/null
+++ b/test/timezone.test.ts
@@ -0,0 +1,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);
+ });
+});