Files
wadamesh/test/test_time_zones.cpp
Kaj SchittecatandClaude Opus 5.5 1ee32105de touch(clock): Central Africa and 24 more zones, GPS wake no longer sets a stale time, every SNTP sync reaches the clock
Discord (gadgeteerza, ThinkNode M9, beta_100): no Central Africa zone, the
clock often half an hour behind on a Custom UTC offset, and "Sync clock from
system" with Wi-Fi on not always setting it.

Zones: the saved setting is an index into the zone table, and Custom used to
be "one past the list", so appending zones would have moved it. Custom now
keeps its id (16) forever and the new zones (Central/South/East/West Africa,
Egypt, Eastern/Western Europe, Moscow, Turkey, Arabia, Iran, Gulf, Pakistan,
Nepal, Bangladesh, Indochina, Singapore, Perth, Korea, Adelaide, Brisbane,
New Zealand, Argentina, Mexico City, Newfoundland) sit after it. The picker
shows its own west-to-east order and opens on the current zone.

Half an hour behind: not the offset. WadaNmeaLocationProvider::begin() kept
the parser's pre-rest sentence, so after a GPS saver rest the first loop pass
saw a "valid" fix, the 30-minute re-sync was due, and the clock was set to
the moment the receiver went to rest. begin() now clears the parser, and
UITask::setGpsPower(true) calls syncTime() so the core provider (V4 and
others) gets the same. test_gps_time_sync runs the real provider against a
receiver sending true UTC: the pre-fix provider set the clock 1801 s behind
after a 30-minute rest; now every set is within 1 s.

Sync from system: on the M9 the system clock is the device clock, so the
button copied a clock onto itself. And the Wi-Fi loop wrote only the FIRST
SNTP result into the mesh clock: SNTP's hourly re-syncs set the system clock
only, the PCF8563 kept its old value, and the hourly chip resync (#583)
pulled the clock back to it. Every completed sync is now written through
(SntpClockPush.h, test_sntp_clock_push), and the button with Wi-Fi up
restarts SNTP and says "Clock synced" when the answer lands.

test_time_zones: the legacy ids keep their zones, every zone's offset in
January/July/October, Egypt's DST edges, and all 65536 saved
(tz_zone, time_offs) byte pairs through the exact prefs path to localtime().

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 08:19:39 +01:00

263 lines
13 KiB
C++

// Host test for the time zone (#699): every picker zone's POSIX string through the C library's
// localtime(), the Custom offset's sign, and ClockFloorRTC's clock math with the zone in force
// (a later clock set, e.g. the GPS re-sync every 30 min, moves the time but never the zone).
// Also (Discord, Central Africa missing, "clock behind by a half hour" on Custom): the saved ids
// of the zones that existed before the list grew still mean the same zone, and EVERY possible
// pair of saved bytes (tz_zone 0..255 x time_offs -128..127) goes through the exact prefs path
// (TimeZones::buildFromStored) to localtime() and lands on a whole-hour offset for Custom.
// Build + run from the repo root (scripts/run-host-tests.sh does this):
// c++ -std=c++17 -Wall -Wextra -I src -I test/clock_floor_fakes test/test_time_zones.cpp \
// -o /tmp/t && /tmp/t
#include <assert.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/time.h>
#include <time.h>
// The system clock ClockFloorRTC mirrors every accepted set into (settimeofday needs root on a
// desktop), simulated so the test owns it.
static long g_sys = 0;
static int fake_settimeofday(const struct timeval* tv, const void*) { g_sys = (long)tv->tv_sec; return 0; }
static int fake_gettimeofday(struct timeval* tv, void*) { tv->tv_sec = g_sys; tv->tv_usec = 0; return 0; }
#define settimeofday fake_settimeofday
#define gettimeofday fake_gettimeofday
#include "helpers/ClockFloorRTC.h"
#undef settimeofday
#undef gettimeofday
#include "helpers/TimeZones.h"
// UTC offset in minutes (local - UTC) that zone string `tz` gives at epoch `t`.
static int offsetMin(const char* tz, time_t t) {
setenv("TZ", tz, 1);
tzset();
struct tm l, u;
localtime_r(&t, &l);
gmtime_r(&t, &u);
int d = (l.tm_hour - u.tm_hour) * 60 + (l.tm_min - u.tm_min);
const int dd = l.tm_yday - u.tm_yday;
if (dd == 1 || dd < -1) d += 24 * 60; // local is the next day (incl. year wrap)
if (dd == -1 || dd > 1) d -= 24 * 60;
return d;
}
static int zoneOffset(int zone, int custom_h, time_t t) {
char tz[48];
TimeZones::buildPosix(zone, custom_h, tz, sizeof tz);
return offsetMin(tz, t);
}
static int indexOf(const char* label) {
for (int i = 0; i < TimeZones::ID_N; i++) if (!strcmp(TimeZones::ZONES[i].label, label)) return i;
assert(!"zone missing");
return -1;
}
int main() {
// 2026 reference instants (UTC).
const time_t JAN = 1768478400; // 2026-01-15 12:00Z
const time_t JUL = 1784116800; // 2026-07-15 12:00Z
const time_t OCT26 = 1793017200; // 2026-10-26 12:20Z: EU back on winter time, US still on DST
// ---- saved ids are permanent ----
// The ids every earlier build saved (0..15 the zones, 16 Custom = "one past the list"). A
// zone added later must never shift one of these, or an existing install silently changes
// zone on upgrade.
static const char* const kLegacy[17] = {
"Europe (CET/CEST)", "UK (GMT/BST)", "UTC", "US Eastern", "US Central", "US Mountain",
"US Arizona (no DST)", "US Pacific", "US Alaska", "US Hawaii", "Canada Atlantic",
"Brazil (Brasilia)", "India (IST)", "China (CST)", "Japan (JST)", "Sydney (AEST/AEDT)",
"Custom (UTC offset)",
};
for (int i = 0; i < 17; i++) assert(!strcmp(TimeZones::ZONES[i].label, kLegacy[i]));
assert(TimeZones::CUSTOM == 16 && TimeZones::ZONES[16].posix == nullptr);
for (int i = 0; i < TimeZones::ID_N; i++) assert((TimeZones::ZONES[i].posix == nullptr) == (i == TimeZones::CUSTOM));
// The picker shows every id exactly once, Custom last, in west-to-east order.
{
int seen[TimeZones::ID_N] = {0};
for (int r = 0; r < TimeZones::COUNT; r++) seen[TimeZones::idAt(r)]++;
for (int i = 0; i < TimeZones::ID_N; i++) assert(seen[i] == 1);
assert(TimeZones::idAt(TimeZones::COUNT - 1) == TimeZones::CUSTOM);
int prev = -24 * 60;
for (int r = 0; r < TimeZones::COUNT - 1; r++) {
const int o = zoneOffset(TimeZones::idAt(r), 0, JAN); // January: standard time in the north
const int std_o = (TimeZones::idAt(r) == indexOf("Sydney (AEST/AEDT)") ||
TimeZones::idAt(r) == indexOf("Adelaide (ACST/ACDT)") ||
TimeZones::idAt(r) == indexOf("New Zealand (NZST/NZDT)")) ? o - 60 : o;
assert(std_o >= prev);
prev = std_o;
}
assert(TimeZones::idAt(-1) == 0 && TimeZones::idAt(TimeZones::COUNT) == 0);
}
struct { const char* label; int jan, jul, oct26; } want[] = {
{ "Europe (CET/CEST)", 60, 120, 60 },
{ "UK (GMT/BST)", 0, 60, 0 },
{ "UTC", 0, 0, 0 },
{ "US Eastern", -300, -240, -240 },
{ "US Central", -360, -300, -300 },
{ "US Mountain", -420, -360, -360 },
{ "US Arizona (no DST)",-420, -420, -420 },
{ "US Pacific", -480, -420, -420 },
{ "US Alaska", -540, -480, -480 },
{ "US Hawaii", -600, -600, -600 },
{ "Canada Atlantic", -240, -180, -180 },
{ "Brazil (Brasilia)", -180, -180, -180 },
{ "India (IST)", 330, 330, 330 },
{ "China (CST)", 480, 480, 480 },
{ "Japan (JST)", 540, 540, 540 },
{ "Sydney (AEST/AEDT)", 660, 600, 660 },
// appended zones (ids 17+)
{ "Central Africa (CAT)", 120, 120, 120 },
{ "South Africa (SAST)", 120, 120, 120 },
{ "East Africa (EAT)", 180, 180, 180 },
{ "West Africa (WAT)", 60, 60, 60 },
{ "Egypt (EET/EEST)", 120, 180, 180 }, // still summer time: it ends Thu 29 Oct
{ "Eastern Europe (EET)", 120, 180, 120 },
{ "Western Europe (WET)", 0, 60, 0 },
{ "Moscow (MSK)", 180, 180, 180 },
{ "Turkey (TRT)", 180, 180, 180 },
{ "Arabia (AST)", 180, 180, 180 },
{ "Iran (IRST)", 210, 210, 210 },
{ "Gulf (GST)", 240, 240, 240 },
{ "Pakistan (PKT)", 300, 300, 300 },
{ "Nepal (NPT)", 345, 345, 345 },
{ "Bangladesh (BST)", 360, 360, 360 },
{ "Indochina (ICT)", 420, 420, 420 },
{ "Singapore (SGT)", 480, 480, 480 },
{ "Perth (AWST)", 480, 480, 480 },
{ "Korea (KST)", 540, 540, 540 },
{ "Adelaide (ACST/ACDT)", 630, 570, 630 },
{ "Brisbane (AEST)", 600, 600, 600 },
{ "New Zealand (NZST/NZDT)", 780, 720, 780 },
{ "Argentina (ART)", -180, -180, -180 },
{ "Mexico City (CST)", -360, -360, -360 },
{ "Newfoundland (NST/NDT)", -210, -150, -150 },
};
static_assert(sizeof(want) / sizeof(want[0]) == (size_t)TimeZones::ID_N - 1, "a zone without a test row");
for (auto& w : want) {
const int z = indexOf(w.label);
if (zoneOffset(z, 0, JAN) != w.jan || zoneOffset(z, 0, JUL) != w.jul || zoneOffset(z, 0, OCT26) != w.oct26)
printf("FAIL %s: %d %d %d\n", w.label, zoneOffset(z, 0, JAN), zoneOffset(z, 0, JUL), zoneOffset(z, 0, OCT26));
assert(zoneOffset(z, 0, JAN) == w.jan);
assert(zoneOffset(z, 0, JUL) == w.jul);
assert(zoneOffset(z, 0, OCT26) == w.oct26);
}
// DST edges to the minute: US 2026-03-08 07:00Z, EU 2026-03-29 01:00Z.
const int us = indexOf("US Eastern"), eu = indexOf("Europe (CET/CEST)");
assert(zoneOffset(us, 0, 1772953200 - 60) == -300 && zoneOffset(us, 0, 1772953200) == -240);
assert(zoneOffset(eu, 0, 1774746000 - 60) == 60 && zoneOffset(eu, 0, 1774746000) == 120);
// Egypt 2026: summer time from Fri 24 Apr 00:00 local (23 Apr 22:00Z) to the end of
// Thu 29 Oct (24:00 EEST = 29 Oct 21:00Z).
const int eg = indexOf("Egypt (EET/EEST)");
assert(zoneOffset(eg, 0, 1776981600 - 60) == 120 && zoneOffset(eg, 0, 1776981600) == 180);
assert(zoneOffset(eg, 0, 1793307600 - 60) == 180 && zoneOffset(eg, 0, 1793307600) == 120);
// Custom (UTC offset): UTC+N reads N hours ahead of UTC, all year, both signs.
for (int h = -12; h <= 14; h++) {
assert(zoneOffset(TimeZones::CUSTOM, h, JAN) == h * 60);
assert(zoneOffset(TimeZones::CUSTOM, h, JUL) == h * 60);
}
assert(zoneOffset(TimeZones::CUSTOM, 99, JAN) == 23 * 60); // clamped like the setter
// A stale id (a newer build's longer list, after a downgrade) reads as the default, as the
// saved setting does.
char tz[48];
TimeZones::buildPosix(TimeZones::ID_N + 3, 0, tz, sizeof tz);
assert(!strcmp(tz, TimeZones::ZONES[0].posix));
TimeZones::buildPosix(0, 0, tz, 4); // truncates, stays terminated
assert(strlen(tz) == 3);
// ---- every saved byte pair, through the exact prefs path, to localtime() ----
// touchPrefsBuildLocalTz() = buildFromStored(s_cfg.tz_zone, s_cfg.time_offs): no other
// arithmetic sits between the blob and setenv("TZ"). So for Custom, every stored offset
// must read as that many WHOLE hours (no half hour from anywhere), at every quarter of an
// hour of the day, and every other stored zone byte must read as its own zone (or Europe).
int pairs = 0;
for (int z = 0; z <= 255; z++) {
const int id = (z == TimeZones::STORED_MIGRATE_TO_CUSTOM) ? TimeZones::CUSTOM : (z < TimeZones::ID_N ? z : 0);
assert(TimeZones::resolveStored((uint8_t)z) == id);
for (int o = -128; o <= 127; o++) {
TimeZones::buildFromStored((uint8_t)z, (int8_t)o, tz, sizeof tz);
char ref[48];
TimeZones::buildPosix(id, o < -23 ? -23 : (o > 23 ? 23 : o), ref, sizeof ref);
assert(!strcmp(tz, ref));
++pairs;
}
}
for (int o = -128; o <= 127; o++) {
const int h = o < -23 ? -23 : (o > 23 ? 23 : o);
TimeZones::buildFromStored((uint8_t)TimeZones::CUSTOM, (int8_t)o, tz, sizeof tz);
for (time_t t = JAN; t < JAN + 86400; t += 900) {
assert(offsetMin(tz, t) == h * 60);
struct tm l, u; localtime_r(&t, &l); gmtime_r(&t, &u);
assert(l.tm_min == u.tm_min && l.tm_sec == u.tm_sec); // minutes never shift on Custom
}
assert(offsetMin(tz, JUL) == h * 60); // and no DST
}
// The Discord case: Custom UTC+2 at 10:05:00Z reads 12:05 local, same as Central Africa.
{
const time_t t = JAN - 2 * 3600 + 5 * 60; // 10:05Z
TimeZones::buildFromStored((uint8_t)TimeZones::CUSTOM, 2, tz, sizeof tz);
setenv("TZ", tz, 1); tzset();
struct tm l; localtime_r(&t, &l);
assert(l.tm_hour == 12 && l.tm_min == 5);
TimeZones::buildFromStored((uint8_t)indexOf("Central Africa (CAT)"), 0, tz, sizeof tz);
setenv("TZ", tz, 1); tzset();
localtime_r(&t, &l);
assert(l.tm_hour == 12 && l.tm_min == 5);
}
// ---- ClockFloorRTC with the zone in force ----
struct Dummy : mesh::RTCClock {
uint32_t getCurrentTime() override { return 0; }
void setCurrentTime(uint32_t) override {}
} fallback;
ClockFloorRTC clk(fallback);
const uint32_t T = (uint32_t)OCT26;
// A sync (NTP / GPS / phone) lands: chip, floor and system clock all follow.
g_sys = ClockFloorRTC::MIN_VALID_EPOCH; // the power-on seed
FakeRtcChip::now() = ClockFloorRTC::MIN_VALID_EPOCH;
clk.setCurrentTime(T);
assert(FakeRtcChip::now() == T && g_sys == (long)T && clk.getCurrentTime() == T);
assert(clk.timeIsCurrent());
// The display reads the system clock through the zone: US Eastern = 08:20 local.
TimeZones::buildPosix(us, 0, tz, sizeof tz);
setenv("TZ", tz, 1); tzset();
time_t now = (time_t)g_sys; struct tm l; localtime_r(&now, &l);
assert(l.tm_hour == 8 && l.tm_min == 20);
// 30 minutes on, the GPS re-sync sets the clock again: the time moves, the zone does not.
clk.setCurrentTime(T + 1800);
assert(!strcmp(getenv("TZ"), tz));
now = (time_t)g_sys; localtime_r(&now, &l);
assert(l.tm_hour == 8 && l.tm_min == 50);
// Garbage sets are refused whatever the source (1902 GPS date, the unset seed, 2043 chip).
clk.setCurrentTime(0);
clk.setCurrentTime(ClockFloorRTC::MIN_VALID_EPOCH);
clk.setCurrentTime(ClockFloorRTC::MAX_PLAUSIBLE_EPOCH + 1);
assert(g_sys == (long)(T + 1800) && FakeRtcChip::now() == T + 1800);
// The chip reads behind the floor (a write that did not take, a stopped oscillator after
// the floor moved on) while the system clock runs: the system clock wins (#746/#756), and
// the floor follows it.
assert(clk.getCurrentTime() == T + 1800 && clk.getFloor() == T + 1800);
FakeRtcChip::now() = T; // the chip lost the last set
g_sys = T + 3600;
assert(clk.getCurrentTime() == T + 3600 && clk.getFloor() == T + 3600);
// ...and the chip asserting garbage-future is ignored the same way.
FakeRtcChip::now() = ClockFloorRTC::MAX_PLAUSIBLE_EPOCH + 100000;
g_sys = T + 3700;
assert(clk.getCurrentTime() == T + 3700);
// Both behind the floor: hold the floor (protocol timestamps never step back).
FakeRtcChip::now() = T; g_sys = T;
assert(clk.getCurrentTime() == T + 3700);
// A real correction more than 10 min back pulls the floor down with it.
clk.setCurrentTime(T);
assert(clk.getFloor() == T && clk.getCurrentTime() == T);
printf("test_time_zones: all passed (%d zones + custom, %d saved byte pairs)\n", TimeZones::ID_N - 1, pairs);
return 0;
}