Finding a zone meant already knowing its name, which is the wrong way round when the thing you know is the country -- Ghana's zone is named after Accra, and nothing in the picker said so. Zones now carry the countries they cover, drawn from tzdata's own tables: zone1970.tab and zone.tab for the zone-to-country-code rows, iso3166.tab for the names. The picker already matched on description as well as label, so putting the countries there makes them searchable and shows them in the list. Those tables only name about 420 of the ~600 zones; the rest are backward-compatibility aliases like America/Buenos_Aires and US/Pacific, which tzdata ships as hard links to their canonical zone. Matching an alias to its canonical name by inode lifts coverage to 553, and the 45 left are the Etc/GMT pseudo-zones, which have no country by definition. The inherited countries are the union of every zone sharing the inode, not the first one found: those siblings are one clock under several countries' names, and each table names a different subset, so first-match made Africa/Asmera findable as Ethiopia but not as Eritrea. Joining is done in ClockModel against a fixture, so it does not depend on the host's tzdata version; the shell side only reads the files. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MhxtL7WzL5XmuTd8GHbcTY
324 lines
13 KiB
JavaScript
324 lines
13 KiB
JavaScript
// 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" : ""))
|