// Unit tests for ClockModel.js. Run with: node tests/model.test.js // No dependencies — the model is plain ES5 so it runs identically here and in // Qt's QML engine. var assert = require("assert") var M = require("../ClockModel.js") var passed = 0 function test(name, fn) { try { fn(); passed++ } catch (e) { console.error("FAIL: " + name + "\n " + e.message); process.exitCode = 1 } } // 2026-09-04T15:24:00Z — a Friday. Johannesburg is +0200, so local is 17:24. var NOW = Date.UTC(2026, 8, 4, 15, 24, 0) var SAST = "+0200" var OFFSETS = { "America/Los_Angeles": "-0700", // PDT "America/New_York": "-0400", // EDT "Asia/Singapore": "+0800", "Australia/Sydney": "+1000", // AEST "Pacific/Auckland": "+1200" // NZST } var CONFIG = { clocks: [ { label: "NZ", timezone: "Pacific/Auckland" }, { label: "LA", timezone: "America/Los_Angeles" }, { label: "AUS", timezone: "Australia/Sydney" }, { label: "NYC", timezone: "America/New_York" }, { label: "SG", timezone: "Asia/Singapore" } ], workingHours: { start: 8, end: 18, workdays: [1, 2, 3, 4, 5] } } test("parseOffset handles the tzdata forms", function () { assert.strictEqual(M.parseOffset("+0200"), 120) assert.strictEqual(M.parseOffset("-0700"), -420) assert.strictEqual(M.parseOffset("+0545"), 345) // Kathmandu assert.strictEqual(M.parseOffset("-0330"), -210) // Newfoundland assert.strictEqual(M.parseOffset("+00:00"), 0) assert.strictEqual(M.parseOffset(""), null) assert.strictEqual(M.parseOffset("garbage"), null) assert.strictEqual(M.parseOffset(null), null) }) test("times are correct against known offsets", function () { var bar = M.buildBar(CONFIG, OFFSETS, NOW, SAST) var byLabel = {} bar.left.concat(bar.right).forEach(function (c) { byLabel[c.label] = c }) assert.strictEqual(bar.center.time, "17:24", "local") assert.strictEqual(byLabel.LA.time, "08:24") assert.strictEqual(byLabel.NYC.time, "11:24") assert.strictEqual(byLabel.SG.time, "23:24") assert.strictEqual(byLabel.AUS.time, "01:24") assert.strictEqual(byLabel.NZ.time, "03:24") }) test("clocks land on the correct side of local", function () { var bar = M.buildBar(CONFIG, OFFSETS, NOW, SAST) assert.deepStrictEqual(bar.left.map(function (c) { return c.label }), ["LA", "NYC"]) assert.deepStrictEqual(bar.right.map(function (c) { return c.label }), ["SG", "AUS", "NZ"]) }) test("config order does not affect rendered order", function () { var shuffled = { clocks: CONFIG.clocks.slice().reverse(), workingHours: CONFIG.workingHours } var a = M.buildBar(CONFIG, OFFSETS, NOW, SAST) var b = M.buildBar(shuffled, OFFSETS, NOW, SAST) var names = function (bar) { return bar.left.concat(bar.right).map(function (c) { return c.label }) } assert.deepStrictEqual(names(a), names(b)) }) test("day markers show +1 and -1", function () { var bar = M.buildBar(CONFIG, OFFSETS, NOW, SAST) var byLabel = {} bar.left.concat(bar.right).forEach(function (c) { byLabel[c.label] = c }) assert.strictEqual(byLabel.AUS.marker, "+1", "Sydney is already tomorrow") assert.strictEqual(byLabel.NZ.marker, "+1") assert.strictEqual(byLabel.SG.marker, "", "Singapore is still today") assert.strictEqual(byLabel.LA.marker, "", "LA is still today at 08:24") // Same zones, but local is 01:24 on the 5th — LA is then on the 4th. var early = Date.UTC(2026, 8, 4, 23, 24, 0) var lateBar = M.buildBar(CONFIG, OFFSETS, early, SAST) var late = {} lateBar.left.concat(lateBar.right).forEach(function (c) { late[c.label] = c }) assert.strictEqual(lateBar.center.time, "01:24") assert.strictEqual(late.LA.marker, "-1", "LA is yesterday") assert.strictEqual(late.NYC.marker, "-1") }) test("working hours are evaluated in the clock's own zone", function () { var bar = M.buildBar(CONFIG, OFFSETS, NOW, SAST) var byLabel = {} bar.left.concat(bar.right).forEach(function (c) { byLabel[c.label] = c }) assert.strictEqual(byLabel.LA.working, true, "08:24 Fri in LA") assert.strictEqual(byLabel.NYC.working, true, "11:24 Fri in NYC") assert.strictEqual(byLabel.SG.working, false, "23:24 Fri in Singapore") assert.strictEqual(byLabel.NZ.working, false, "03:24 Sat in Auckland") }) test("weekends count as outside working hours", function () { // 2026-09-05 is a Saturday. 10:00 local. var sat = Date.UTC(2026, 8, 5, 8, 0, 0) var bar = M.buildBar({ clocks: [{ label: "JHB", timezone: "Africa/Johannesburg" }] }, { "Africa/Johannesburg": "+0200" }, sat, SAST) assert.strictEqual(bar.right[0].time, "10:00") assert.strictEqual(bar.right[0].working, false) }) test("a window that wraps midnight is handled", function () { var hours = { start: 22, end: 6, workdays: [0, 1, 2, 3, 4, 5, 6] } assert.strictEqual(M.isWorkingHours(new Date(Date.UTC(2026, 8, 4, 23, 0)), hours), true) assert.strictEqual(M.isWorkingHours(new Date(Date.UTC(2026, 8, 4, 3, 0)), hours), true) assert.strictEqual(M.isWorkingHours(new Date(Date.UTC(2026, 8, 4, 12, 0)), hours), false) }) test("an unresolvable zone is flagged, not rendered as UTC", function () { var bar = M.buildBar({ clocks: [{ label: "BAD", timezone: "Not/AZone" }] }, {}, NOW, SAST) var clock = bar.right[0] assert.strictEqual(clock.known, false) assert.strictEqual(clock.time, "--:--") assert.strictEqual(bar.left.length, 0, "unknown zones never sort into the timeline") }) test("half-hour and quarter-hour zones sort correctly", function () { var cfg = { clocks: [ { label: "IND", timezone: "Asia/Kolkata" }, { label: "NPL", timezone: "Asia/Kathmandu" }, { label: "SG", timezone: "Asia/Singapore" } ] } var offs = { "Asia/Kolkata": "+0530", "Asia/Kathmandu": "+0545", "Asia/Singapore": "+0800" } var bar = M.buildBar(cfg, offs, NOW, SAST) assert.deepStrictEqual(bar.right.map(function (c) { return c.label }), ["IND", "NPL", "SG"]) assert.strictEqual(bar.right[0].time, "20:54") assert.strictEqual(bar.right[1].time, "21:09") }) test("a DST shift re-sorts the bar with no config change", function () { // Sydney +1000 sits behind Auckland +1200; in the southern summer both move // but keep order, while LA leaving PDT widens its gap from local. var winter = M.buildBar(CONFIG, OFFSETS, NOW, SAST) assert.strictEqual(winter.left[0].label, "LA") assert.strictEqual(winter.left[0].relativeMinutes, -540) var pst = {} Object.keys(OFFSETS).forEach(function (k) { pst[k] = OFFSETS[k] }) pst["America/Los_Angeles"] = "-0800" var summer = M.buildBar(CONFIG, pst, NOW, SAST) assert.strictEqual(summer.left[0].relativeMinutes, -600) }) test("zonesToProbe dedupes and rejects shell-unsafe names", function () { var zones = M.zonesToProbe({ clocks: [ { label: "A", timezone: "Europe/London" }, { label: "B", timezone: "Europe/London" }, { label: "C", timezone: "; rm -rf /" }, { label: "D", timezone: "$(whoami)" }, { label: "E", timezone: "" }, { label: "F", timezone: "America/Argentina/Buenos_Aires" }, { label: "G", timezone: "Etc/GMT+5" } ] }) assert.deepStrictEqual(zones, ["Europe/London", "America/Argentina/Buenos_Aires", "Etc/GMT+5"]) }) test("parseOffsetReport reads the probe output", function () { var got = M.parseOffsetReport("Europe/London\t+0100\nAsia/Tokyo\t+0900\n\n") assert.deepStrictEqual(got, { "Europe/London": "+0100", "Asia/Tokyo": "+0900" }) }) test("an empty config renders an empty bar, not a crash", function () { var bar = M.buildBar({}, {}, NOW, SAST) assert.deepStrictEqual(bar.left, []) assert.deepStrictEqual(bar.right, []) assert.strictEqual(bar.center.time, "17:24") assert.deepStrictEqual(M.buildBar(null, null, NOW, null).center.time, "15:24") }) test("seconds are per clock, with a config-wide default", function () { var cfg = { showSeconds: false, clocks: [ { label: "LA", timezone: "America/Los_Angeles", showSeconds: true }, { label: "NYC", timezone: "America/New_York" } ] } var bar = M.buildBar(cfg, OFFSETS, NOW, SAST) var byLabel = {} bar.left.forEach(function (c) { byLabel[c.label] = c }) assert.strictEqual(byLabel.LA.time, "08:24:00", "opted in") assert.strictEqual(byLabel.NYC.time, "11:24", "inherits the default") assert.strictEqual(bar.center.time, "17:24", "centre inherits the default") }) test("an explicit false on a clock beats a true default", function () { var cfg = { showSeconds: true, clocks: [ { label: "LA", timezone: "America/Los_Angeles", showSeconds: false }, { label: "NYC", timezone: "America/New_York" } ] } var bar = M.buildBar(cfg, OFFSETS, NOW, SAST) var byLabel = {} bar.left.forEach(function (c) { byLabel[c.label] = c }) assert.strictEqual(byLabel.LA.time, "08:24", "opted out") assert.strictEqual(byLabel.NYC.time, "11:24:00") assert.strictEqual(bar.center.time, "17:24:00") }) test("the centre clock carries its own seconds override", function () { var cfg = { showSeconds: false, local: { showSeconds: true }, clocks: [{ label: "LA", timezone: "America/Los_Angeles" }] } var bar = M.buildBar(cfg, OFFSETS, NOW, SAST) assert.strictEqual(bar.center.time, "17:24:00") assert.strictEqual(bar.left[0].time, "08:24", "clocks are unaffected by it") }) test("anyShowsSeconds drives the tick rate", function () { assert.strictEqual(M.anyShowsSeconds({ clocks: [{ label: "A", timezone: "X" }] }), false) assert.strictEqual(M.anyShowsSeconds({ showSeconds: true, clocks: [] }), true, "the default alone counts") assert.strictEqual(M.anyShowsSeconds({ local: { showSeconds: true }, clocks: [] }), true, "the centre alone counts") assert.strictEqual(M.anyShowsSeconds({ clocks: [{ label: "A", timezone: "X" }, { label: "B", timezone: "Y", showSeconds: true }] }), true, "one clock is enough") assert.strictEqual(M.anyShowsSeconds({ showSeconds: true, local: { showSeconds: false }, clocks: [{ label: "A", timezone: "X", showSeconds: false }] }), false, "everything opted out") assert.strictEqual(M.anyShowsSeconds(null), false) }) test("an unresolved clock keeps the width of the format it asked for", function () { var bar = M.buildBar({ clocks: [ { label: "A", timezone: "Not/AZone" }, { label: "B", timezone: "Not/AZone", showSeconds: true } ] }, {}, NOW, SAST) assert.strictEqual(bar.right[0].time, "--:--") assert.strictEqual(bar.right[1].time, "--:--:--") }) // A miniature stand-in for the tzdata files, so these tests do not depend on // the host's tzdata version. Africa/Asmera is a hard link to Africa/Nairobi // (inode 100) and carries no country row of its own. var CATALOG = [ "@ISO", "# a comment that must be skipped", "ZA\tSouth Africa", "LS\tLesotho", "ER\tEritrea", "ET\tEthiopia", "CH\tSwitzerland", "DE\tGermany", "@ZONE", "ZA,LS\t-2615+02800\tAfrica/Johannesburg", "CH,DE\t+4723+00832\tEurope/Zurich\tBusingen", "ER\t+1520+03853\tAfrica/Asmara", "ET\t+0901+03842\tAfrica/Nairobi", "@INODE", "100\tAfrica/Nairobi", "100\tAfrica/Asmara", "100\tAfrica/Asmera", "200\tAfrica/Johannesburg", "300\tEtc/GMT+5", "@LIST", "Africa/Johannesburg", "Europe/Zurich", "Africa/Asmera", "Etc/GMT+5" ].join("\n") test("the catalogue tags zones with their countries", function () { var cat = M.parseZoneCatalog(CATALOG) var by = {} cat.forEach(function (c) { by[c.zone] = c.countries }) assert.strictEqual(by["Africa/Johannesburg"], "South Africa, Lesotho", "all codes on the row") assert.strictEqual(by["Europe/Zurich"], "Switzerland, Germany") }) test("a legacy alias inherits countries from its hard-link siblings", function () { var by = {} M.parseZoneCatalog(CATALOG).forEach(function (c) { by[c.zone] = c.countries }) // Asmera has no row of its own; it shares inode 100 with Asmara and Nairobi, // so it must be findable by BOTH of their countries, not just the first. assert.ok(by["Africa/Asmera"].indexOf("Eritrea") !== -1, "Eritrea, via Asmara") assert.ok(by["Africa/Asmera"].indexOf("Ethiopia") !== -1, "Ethiopia, via Nairobi") }) test("zones with no country are listed, not dropped", function () { var cat = M.parseZoneCatalog(CATALOG) var etc = cat.filter(function (c) { return c.zone === "Etc/GMT+5" })[0] assert.ok(etc, "still offered in the picker") assert.strictEqual(etc.countries, "", "just has nothing to match a country search") }) test("the catalogue is sorted and only contains listed zones", function () { var cat = M.parseZoneCatalog(CATALOG) var names = cat.map(function (c) { return c.zone }) assert.deepStrictEqual(names, names.slice().sort(), "sorted by zone") assert.strictEqual(names.indexOf("Africa/Nairobi"), -1, "Nairobi supplies country data but was not in @LIST, so it is not offered") assert.strictEqual(names.length, 4) }) test("a malformed or empty catalogue yields an empty list, not a crash", function () { assert.deepStrictEqual(M.parseZoneCatalog(""), []) assert.deepStrictEqual(M.parseZoneCatalog(null), []) assert.deepStrictEqual(M.parseZoneCatalog("garbage\nwith no sections"), []) assert.deepStrictEqual(M.parseZoneCatalog("@LIST\nAfrica/Cairo"), [{ zone: "Africa/Cairo", countries: "" }], "a list with no country data still works") }) console.log(passed + " passed" + (process.exitCode ? ", some FAILED" : ""))