diff options
Diffstat (limited to 'test')
| -rw-r--r-- | test/commissioning.test.ts | 33 | ||||
| -rw-r--r-- | test/config.test.ts | 73 | ||||
| -rw-r--r-- | test/fabrics.test.ts | 46 | ||||
| -rw-r--r-- | test/fixtures/alpstuga-inspect.json | 504 | ||||
| -rw-r--r-- | test/state.test.ts | 103 | ||||
| -rw-r--r-- | test/sync.test.ts | 133 | ||||
| -rw-r--r-- | test/timesync.test.ts | 79 | ||||
| -rw-r--r-- | test/timezone.test.ts | 139 |
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); + }); +}); |
