mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-18 09:08:06 +00:00
fix(web): sample both sides of DST when comparing zones
CodeRabbit caught this and it is right: comparing the wall clock at one instant treats zones that merely coincide right now as the same one. America/New_York and America/Lima hold the same offset all winter, so a panel set to the wrong one of the two ticked the step in January and then ran an hour off from March -- a silent false pass, which is the failure the whole check exists to prevent. Same shape as the dateStyle problem in the previous commit: a comparison coarser than it looks. Three instants now, all of which must agree: now, and mid-January and mid-July of the current year. Those sit either side of DST in both hemispheres, so only zones that agree year-round match. Toronto still matches New York, which is correct -- either renders the same times. Two tests. A static one asserts the comparison samples more than the current instant, since reverting to `[now]` looks like a simplification. And a table pinning which pairs must count as the same zone: aliases and same-rule zones equal, seasonal coincidences (New York/Lima, Phoenix/Los_Angeles, Sydney/Guadalcanal) not. That table mirrors the algorithm rather than executing the shipped JS -- there is no JS runtime here and the repo has no JS test infra -- so it records the verdicts the browser code has to reach, and the static guard keeps the two aligned. Mutation-checked: reverting to a single instant fails the static guard. Also documented what the city test compares. CodeRabbit read it as always failing, on the grounds that the label differs between Tampa and Seattle. It does, but timezone_step() returns the opening tag only, so the comparison is over data-done and data-tz and the label is not in it. The assertion is left as an equality over the whole tag, which is stronger than checking the two attributes by name; the docstring now says so. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01STMbQE4YctTacQXfbYqKuW
This commit is contained in:
co-authored by
Claude Opus 5
parent
dcba6120f9
commit
5a6dfbfc9a
@@ -119,7 +119,12 @@ def test_timezone_step_carries_the_configured_zone(timezone):
|
|||||||
|
|
||||||
def test_city_does_not_influence_the_timezone_step():
|
def test_city_does_not_influence_the_timezone_step():
|
||||||
"""The coupling this change removes: city said nothing about the timezone,
|
"""The coupling this change removes: city said nothing about the timezone,
|
||||||
and OR-ing it let a saved city tick the step off with the zone still wrong."""
|
and OR-ing it let a saved city tick the step off with the zone still wrong.
|
||||||
|
|
||||||
|
timezone_step() returns the opening tag only, so this compares the state
|
||||||
|
the step is in -- data-done and data-tz -- and not the label, which does
|
||||||
|
still show the configured city as context and so differs between the two.
|
||||||
|
"""
|
||||||
tampa = timezone_step(render(config_with(
|
tampa = timezone_step(render(config_with(
|
||||||
location={"city": "Tampa", "state": "Florida", "country": "US"})))
|
location={"city": "Tampa", "state": "Florida", "country": "US"})))
|
||||||
seattle = timezone_step(render(config_with(
|
seattle = timezone_step(render(config_with(
|
||||||
@@ -158,6 +163,67 @@ def test_zone_comparison_asks_for_the_time_of_day():
|
|||||||
assert field in body, f"zone comparison dropped {field!r}"
|
assert field in body, f"zone comparison dropped {field!r}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_zone_comparison_samples_both_sides_of_dst():
|
||||||
|
"""One instant is not enough, and the shortfall is invisible for months.
|
||||||
|
|
||||||
|
America/New_York and America/Lima hold the same offset all winter, so a
|
||||||
|
check against now alone ticks the step in January for a panel that runs an
|
||||||
|
hour off from March. The comparison has to sample instants either side of
|
||||||
|
DST -- mid-January and mid-July, which covers both hemispheres.
|
||||||
|
"""
|
||||||
|
template = (PROJECT_ROOT / "web_interface" / "templates" / "v3"
|
||||||
|
/ "partials" / "overview.html").read_text()
|
||||||
|
body = template[template.index("function sameZone"):]
|
||||||
|
body = body[:body.index("}())")]
|
||||||
|
code = "\n".join(line for line in body.splitlines()
|
||||||
|
if not line.lstrip().startswith("//"))
|
||||||
|
assert "Date.UTC" in code, (
|
||||||
|
"zone comparison samples only the current instant, so zones that "
|
||||||
|
"coincide seasonally would read as equal")
|
||||||
|
assert code.count("Date.UTC") >= 2, "expected an instant either side of DST"
|
||||||
|
|
||||||
|
|
||||||
|
def _stamp(zone, instant):
|
||||||
|
"""The JS comparison's algorithm, for pinning what it must decide.
|
||||||
|
|
||||||
|
There is no JS runtime here (and the repo has no JS test infra), so this
|
||||||
|
mirrors sameZone rather than executing it: same instants, same wall-clock
|
||||||
|
equality. It records the verdicts the shipped code has to reach.
|
||||||
|
"""
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
return instant.astimezone(ZoneInfo(zone)).strftime("%m/%d/%Y %H:%M")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"left,right,equivalent",
|
||||||
|
[
|
||||||
|
# Aliases: one zone under two names.
|
||||||
|
("Asia/Calcutta", "Asia/Kolkata", True),
|
||||||
|
("Europe/Kiev", "Europe/Kyiv", True),
|
||||||
|
# Same rules year-round: either renders the same times, so a panel set
|
||||||
|
# to one and browsed from the other is correctly configured.
|
||||||
|
("America/New_York", "America/Toronto", True),
|
||||||
|
# Coincide in winter only -- the case a single-instant check gets wrong.
|
||||||
|
("America/New_York", "America/Lima", False),
|
||||||
|
("America/Phoenix", "America/Los_Angeles", False),
|
||||||
|
("Australia/Sydney", "Pacific/Guadalcanal", False),
|
||||||
|
# Plainly different.
|
||||||
|
("America/New_York", "America/Chicago", False),
|
||||||
|
("America/New_York", "Europe/Madrid", False),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_which_zone_pairs_must_count_as_the_same(left, right, equivalent):
|
||||||
|
from datetime import datetime
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
year = 2026
|
||||||
|
instants = [datetime(year, 1, 15, 12, tzinfo=ZoneInfo("UTC")),
|
||||||
|
datetime(year, 7, 15, 12, tzinfo=ZoneInfo("UTC"))]
|
||||||
|
matched = all(_stamp(left, at) == _stamp(right, at) for at in instants)
|
||||||
|
assert matched is equivalent, (
|
||||||
|
f"{left} vs {right}: sampling both seasons gave {matched}")
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"hardware,expected",
|
"hardware,expected",
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -184,13 +184,24 @@
|
|||||||
// the old test ticked it the moment the value stopped being the default.
|
// the old test ticked it the moment the value stopped being the default.
|
||||||
function sameZone(a, b) {
|
function sameZone(a, b) {
|
||||||
if (a === b) return true;
|
if (a === b) return true;
|
||||||
// Compare the wall-clock time each zone yields for one instant, not
|
// Compare the wall-clock time each zone yields, not the identifiers:
|
||||||
// the identifiers: aliases (Asia/Calcutta vs Asia/Kolkata,
|
// aliases (Asia/Calcutta vs Asia/Kolkata, Europe/Kiev vs Europe/Kyiv)
|
||||||
// Europe/Kiev vs Europe/Kyiv) name one zone and must not read as a
|
// name one zone and must not read as a mismatch.
|
||||||
// mismatch.
|
//
|
||||||
|
// Sampled at three instants, all of which have to agree. Checking only
|
||||||
|
// now is not enough: America/New_York and America/Lima hold the same
|
||||||
|
// offset all winter, so a panel set to the wrong one of those would
|
||||||
|
// tick in January and then run an hour off from March. Mid-January and
|
||||||
|
// mid-July sit either side of DST in both hemispheres, so only zones
|
||||||
|
// that agree year-round match -- while Toronto still matches New York,
|
||||||
|
// which is right, since either renders the same times.
|
||||||
try {
|
try {
|
||||||
var now = new Date();
|
var now = new Date();
|
||||||
var stamp = function (tz) {
|
var year = now.getUTCFullYear();
|
||||||
|
var instants = [now,
|
||||||
|
new Date(Date.UTC(year, 0, 15, 12)),
|
||||||
|
new Date(Date.UTC(year, 6, 15, 12))];
|
||||||
|
var stamp = function (tz, at) {
|
||||||
// Explicit numeric fields rather than dateStyle/timeStyle:
|
// Explicit numeric fields rather than dateStyle/timeStyle:
|
||||||
// those are late additions to Intl (Firefox shipped them in
|
// those are late additions to Intl (Firefox shipped them in
|
||||||
// 91), and an implementation that does not know them ignores
|
// 91), and an implementation that does not know them ignores
|
||||||
@@ -203,9 +214,14 @@
|
|||||||
timeZone: tz, year: 'numeric', month: '2-digit',
|
timeZone: tz, year: 'numeric', month: '2-digit',
|
||||||
day: '2-digit', hour: '2-digit', minute: '2-digit',
|
day: '2-digit', hour: '2-digit', minute: '2-digit',
|
||||||
hour12: false
|
hour12: false
|
||||||
}).format(now);
|
}).format(at);
|
||||||
};
|
};
|
||||||
return stamp(a) === stamp(b);
|
for (var i = 0; i < instants.length; i++) {
|
||||||
|
if (stamp(a, instants[i]) !== stamp(b, instants[i])) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// An unparseable zone in the config is worth surfacing, not hiding.
|
// An unparseable zone in the config is worth surfacing, not hiding.
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
Reference in New Issue
Block a user