Skip to content

test: assert timezone invariants across every TZDB identifier - #253

Merged
alies-dev merged 4 commits into
masterfrom
test/timezone-invariants
Aug 20, 2026
Merged

test: assert timezone invariants across every TZDB identifier#253
alies-dev merged 4 commits into
masterfrom
test/timezone-invariants

Conversation

@alies-dev

Copy link
Copy Markdown
Collaborator

Context and Purposes

Closes #252.

Every timezone defect closed by #234, #235, #238 and #239 had the same shape: a category of DateTimeZone name nobody had thought of. Each was found by hand or by a reviewer, one at a time, and each needed its own targeted fix and its own targeted test. That question ("does this class of zone name break something") should be asked by a test, not by whoever happens to be reading the diff.

tests/TimezoneInvariantsTest.php sweeps every name DateTimeZone::listIdentifiers(ALL_WITH_BC) returns (598 on PHP 8.5), plus the offset and abbreviation spellings only DateTimeZone accepts (+02:00, -05:00, Z, CEST, gmt+0, gmt-0), through three event shapes and all five generators.

Invariants

# Assertion Specification
1 A TZID parameter carries the whole zone name. A param-value ends at the first colon unless quoted, so a name holding one shows up as the truncation a client would actually read RFC 5545 §3.1
2 Every VTIMEZONE states at least one STANDARD or DAYLIGHT observance RFC 5545 §3.6.5
3 Every endpoint written with a TZID resolves, against the observances in the file, to the offset PHP reports for that zone at that instant RFC 5545 §3.6.5
4 A ctz, stz or etz names one of the link's own zones, the link reports hasResolvableTimezones(), and the name needs no URL encoding RFC 3986 §2.3
5 No generator raises a PHP warning, notice or deprecation

Each test collects what it finds wrong across the whole sweep and asserts it found nothing, so one run names every zone that broke rather than stopping at the first.

Notes

Nothing is measured against a hardcoded transition date. The tzdb ships with PHP and moves, and the CI matrix spans three PHP versions that may carry different releases of it, so every expectation is asked of DateTimeZone at runtime. A wider spread of tzdata widens the search rather than breaking the suite.

Invariants 2 and 5 also sweep the referencedTimezones() extension point. A zone with no transition table is never resolvable, so it reaches no TZID through an endpoint, and a subclass putting one into a component is the only path that gets there at all. That is the path the empty VTIMEZONE and its getTransitions() warning came down, so without this pass those two invariants would have no teeth for exactly the zone class that produced the defect.

Verified by mutation, since a sweep that asserts nothing useful passes just as quietly as one that does:

Mutation Trips
Restore the pre-#249 handling (one observance per kind) 3, for 194 zones, Africa/Casablanca included
Drop the getTransitions() === false guard 2 and 5
Treat every zone as resolvable 1 and 4

Runtime: the full sweep adds ~120ms, so the pair pass runs against a fixed partner zone for every identifier rather than being sampled. A full cross product was left out: it is ~366k links for coverage the fixed partner already gives.

The targeted regression tests added with each fix stay exactly as they are, since they document why each individual rule exists.

Deliberately out of scope

Invariant 4 proves the generators obey the library's own rule about which names are worth emitting. It does not prove the rule is right, because no offline test knows what Google actually resolves. That ground truth needs live checks against the services, which need a logged in account and cannot run in CI. The one part of invariant 4 that holds regardless of what the library decides is the encoding check, which is why it is asserted separately.

Every timezone defect closed by #234, #235, #238 and #239 had the same
shape: a category of DateTimeZone name nobody had thought of. Each was
found by hand, one at a time, and each needed its own targeted fix and
its own targeted test.

Sweep every name DateTimeZone::listIdentifiers(ALL_WITH_BC) returns,
plus the offset and abbreviation spellings only DateTimeZone accepts,
through three event shapes and all five generators, asserting the five
invariants from #252:

1. A TZID parameter carries the whole zone name, so a name holding a
   colon shows up as the truncation a client would read (RFC 5545 3.1).
2. Every VTIMEZONE states at least one observance (RFC 5545 3.6.5).
3. Every endpoint written with a TZID resolves, against the observances
   in the file, to the offset PHP reports for that zone at that instant.
4. A ctz, stz or etz is one of the link's own zones, the link reports
   resolvable timezones, and the name needs no URL encoding (RFC 3986).
5. No generator raises a PHP warning, notice or deprecation.

Nothing is measured against a hardcoded transition date. The tzdb ships
with PHP and moves, and the CI matrix spans three PHP versions carrying
different releases of it, so every expectation is asked of DateTimeZone
at runtime. A wider spread of tzdata widens the search.

Invariants 2 and 5 also sweep the referencedTimezones() extension point.
A zone with no transition table is never resolvable, so it reaches no
TZID through an endpoint, and a subclass putting one in a component is
the only path that gets there at all. That is the path the empty
VTIMEZONE and its warning came down.

Verified by mutation. Restoring the pre-#249 observance handling trips
invariant 3 for 194 zones, Casablanca included; dropping the
getTransitions() === false guard trips 2 and 5; widening the resolvable
set trips 1 and 4. The full sweep adds ~120ms.

The targeted regression tests stay as they are, since they document why
each individual rule exists.
A PHP linked against the system timezone database, which is how most
distributions build it and how the CI runners get theirs, enumerates
the files in its zoneinfo directory rather than a list of zones. That
directory holds more than zones: `leapseconds` is listed there, and
DateTimeZone::__construct() then refuses it, so the sweep errored on
every runner while passing against the tzdata PHP bundles on macOS.

A name that cannot become a DateTimeZone cannot reach this library
either, so it is out of scope rather than a defect. It is recognised by
trying to construct one rather than by naming it, since another build
may ship a different set of such files.
Four gaps found reviewing the sweep against the ICS work in flight.

Invariant 1 expected a DTEND;TZID= to name the end zone, which holds
only while a TZID is reachable exclusively through the distinct-zone
path. Where the two zones collapse into one, the end is a second
spelling already folded into the start's and the generators name the
start's zone on both endpoints, correctly. Both invariant 1 and
invariant 3 now ask one helper which zone a property names and at what
instant, so the two cannot drift apart.

The sweep passed no ICS options, so a recurring event was never swept.
Whether an RRULE is present can decide whether the endpoints are written
as local times named by a TZID at all, since a recurrence repeats the
local time of its DTSTART, so that path went unasked. A shape now
carries the options it needs.

The flight shape always departed the swept zone for a fixed partner, so
the swept zone was never an arrival. hasResolvableTimezones() judges the
start and the end by different rules, so a reversed shape asks a
genuinely different question. It costs one more pass, not the cross
product.

The note on EVENT_START reasoned only about a daylight saving overlap,
which is the hazard for a 09:00 start. The all-day shape starts at
midnight, where the hazard is the opposite one: a zone that springs
forward at midnight has no 00:00 to name at all.

Also records which direction invariant 4 guards. It reads only the
parameters that were written, so it catches a name that should not have
been emitted; an emission wrongly dropped leaves nothing there to judge
and is caught by the snapshots and the targeted tests instead.
The shape that separates the two rules for which zone a DTEND names was
missing, so the previous commit's fix passed either way and proved
nothing. Every shape so far either names one zone at both ends or names
two that genuinely differ; neither tells the end zone's own name apart
from the zone actually written into the TZID.

This one ends in a bare offset equal to the one the swept zone is on. It
shares that offset and names no place, so the pair collapses into the
start's zone, and the generators name the start's zone on both
endpoints while the end zone still answers to `+02:00`. Reverting the
rule now fails against every zone that observes a change, which is what
the fix is there to prevent once a single-zone event can carry a TZID.
@alies-dev
alies-dev merged commit 44d46c5 into master Aug 20, 2026
14 of 15 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Assert timezone invariants across every TZDB identifier, instead of fixing zone classes one at a time

1 participant