LGV_TZ_Lookup 1.4.2
Time zone lookup for PHP applications and HTTP clients
Loading...
Searching...
No Matches
SystemTests.php
Go to the documentation of this file.
1<?php
2/***************************************************************************************************************************/
3/**
4 © Copyright 2023-2026, [Little Green Viper Software Development LLC](https://littlegreenviper.com)
5
6 LICENSE:
7
8 MIT License
9
10 Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation
11 files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy,
12 modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the
13 Software is furnished to do so, subject to the following conditions:
14
15 The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
16
17 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
18 OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
19 IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF
20 CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
21
22 [Little Green Viper Software Development LLC](https://littlegreenviper.com)
23*/
24/***************************************************************************************************************************/
25/**
26 \file
27 \brief These are the independent geometry checks and audit helpers used by the Composer demo.
28
29 This file is included after the temporary application's Composer autoloader has been loaded. The installed
30 package performs the actual database load and lookup. These helpers retain source geometry, generate expected
31 answers without calling the package's lookup algorithm, and record which stored polygons the package evaluates.
32*/
33
34declare(strict_types=1);
35
36/***************************************************************************************************************************/
37/**
38 \brief This retains a compact reference copy of each polygon in the decoded GeoJSON.
39
40 A shape corresponds to one Polygon, or one member of a MultiPolygon. Rings are packed as native doubles, in
41 longitude/latitude order, rounded to six decimal places. The first ring is the exterior; remaining rings are holes.
42 Storage validation checks the package's documented exterior-ring encoding. Lookup expectations also account
43 for the holes, using horizontal ray casting instead of the package's vertical winding-number test.
44*/
46 /***********************************************************************************************************************/
47 /**
48 \brief These are the source polygon records, keyed by their expected database row IDs.
49
50 Each record contains id, tzname, rings, bounds, vertices, bytes, and sha256. The bounds come from the source's
51 exterior ring. vertices, bytes, and sha256 describe the exterior ring that the package stores in its polygon column.
52 Records follow source traversal order, matching a newly initialized table's auto-increment IDs.
53 */
54 public array $shapes = [];
55 /***********************************************************************************************************************/
56 /** \brief This is the total number of exterior-ring points, including repeated closing points. */
57 public int $vertices = 0;
58 /***********************************************************************************************************************/
59 /** \brief This is the total expected size, in bytes, of the stored exterior-ring polygon data. */
60 public int $polygonBytes = 0;
61
62 /***********************************************************************************************************************/
63 /**
64 \brief This records the polygons from one decoded GeoJSON Feature.
65
66 MultiPolygons are separated into individual records, in the same source order used by the package loader.
67 We retain holes for the independent geographic reference, and compute exterior-ring hashes for storage checks.
68
69 \throws RuntimeException if the geometry type, coordinate dimensions, or polygon contents are unsupported.
70 */
71 public function observe(array $feature ///< The decoded Feature, with properties.tzid and geometry.coordinates.
72 ): void {
73 $geometry = $feature['geometry'];
74 if ($geometry['type'] === 'Polygon') {
75 $polygons = [$geometry['coordinates']];
76 } elseif ($geometry['type'] === 'MultiPolygon') {
77 $polygons = $geometry['coordinates'];
78 } else {
79 throw new RuntimeException('Unsupported source geometry: '.$geometry['type']);
80 }
81 foreach ($polygons as $polygon) {
82 $rings = [];
83 $bounds = ['east' => -INF, 'west' => INF, 'north' => -INF, 'south' => INF];
84 foreach ($polygon as $ringIndex => $ring) {
85 $packed = '';
86 foreach ($ring as $point) {
87 if (count($point) !== 2) {
88 throw new RuntimeException('Expected two-dimensional source coordinates.');
89 }
90 $packed .= pack('d2', round($point[0], 6), round($point[1], 6));
91 if ($ringIndex === 0) {
92 $bounds['east'] = max($bounds['east'], $point[0]);
93 $bounds['west'] = min($bounds['west'], $point[0]);
94 $bounds['north'] = max($bounds['north'], $point[1]);
95 $bounds['south'] = min($bounds['south'], $point[1]);
96 }
97 }
98 $rings[] = $packed;
99 }
100 if (empty($rings) || strlen($rings[0]) < 48) {
101 throw new RuntimeException('The source contains an empty or degenerate polygon.');
102 }
103 $count = intdiv(strlen($rings[0]), 16);
104 $id = count($this->shapes) + 1;
105 $this->shapes[$id] = [
106 'id' => $id, 'tzname' => $feature['properties']['tzid'], 'rings' => $rings,
107 'bounds' => $bounds, 'vertices' => $count, 'bytes' => strlen($rings[0]),
108 'sha256' => hash('sha256', $rings[0]),
109 ];
110 $this->vertices += $count;
111 $this->polygonBytes += strlen($rings[0]);
112 }
113 }
114
115 /***********************************************************************************************************************/
116 /**
117 \brief This selects the most detailed polygons, using exterior-ring point counts.
118
119 "Largest" means the most points to decode, rather than the greatest geographic area. Equal counts are ordered
120 by row ID, so the selection is repeatable for a given boundary release.
121
122 \returns: Up to the requested number of shape records, in descending point-count order.
123 */
124 public function largest(int $count ///< The positive number of polygons to select.
125 ): array {
126 $shapes = array_values($this->shapes);
127 usort($shapes, static fn($a, $b) => ($b['vertices'] <=> $a['vertices']) ?: ($a['id'] <=> $b['id']));
128 return array_slice($shapes, 0, $count);
129 }
130
131 /***********************************************************************************************************************/
132 /**
133 \brief This checks a point against one reference ring, using horizontal ray casting.
134
135 We decode points in bounded blocks and carry the previous point across blocks. The last point is joined to
136 the first, so a closing point need not be duplicated. Ring orientation does not change the result. Generated
137 boundary probes are placed away from the boundary, where an exact edge/vertex result could be ambiguous.
138
139 \returns: True, if the point is inside the ring; false, for an exterior point or a ring with fewer than three points.
140 */
141 public static function ringContains(string $ring, ///< Packed longitude/latitude pairs, using native doubles.
142 float $lng, ///< The point's longitude, in degrees.
143 float $lat ///< The point's latitude, in degrees.
144 ): bool {
145 $bytes = strlen($ring);
146 if ($bytes < 48) { return false; }
147 $previous = unpack('dx/dy', $ring, $bytes - 16);
148 $previousX = $previous['x'];
149 $previousY = $previous['y'];
150 $inside = false;
151 for ($offset = 0; $offset < $bytes; $offset += 16384) {
152 $count = min(2048, intdiv($bytes - $offset, 8));
153 $coordinates = unpack('d'.$count, $ring, $offset);
154 for ($i = 1; $i < $count; $i += 2) {
155 $x = $coordinates[$i];
156 $y = $coordinates[$i + 1];
157 if (($previousY > $lat) !== ($y > $lat) &&
158 $lng < $previousX + ($x - $previousX) * ($lat - $previousY) / ($y - $previousY)) {
159 $inside = !$inside;
160 }
161 $previousX = $x;
162 $previousY = $y;
163 }
164 }
165 return $inside;
166 }
167
168 /***********************************************************************************************************************/
169 /**
170 \brief This checks a point against a complete source polygon, including its holes.
171
172 The source domain rect rejects obvious misses. A remaining point must be inside the exterior ring and outside
173 every hole. This preserves the GeoJSON reference even when a package implementation does not store holes.
174
175 \returns: True, if the source polygon contains the point.
176 */
177 public static function contains(array $shape, ///< A source shape record, including bounds and rings.
178 float $lng, ///< The point's longitude, in degrees.
179 float $lat ///< The point's latitude, in degrees.
180 ): bool {
181 $bounds = $shape['bounds'];
182 if ($lng < $bounds['west'] || $lng > $bounds['east'] || $lat < $bounds['south'] || $lat > $bounds['north'] ||
183 !self::ringContains($shape['rings'][0], $lng, $lat)) {
184 return false;
185 }
186 // GeoJSON holes are part of the reference, even if a package implementation omits them.
187 for ($i = 1; $i < count($shape['rings']); ++$i) {
188 if (self::ringContains($shape['rings'][$i], $lng, $lat)) { return false; }
189 }
190 return true;
191 }
192
193 /***********************************************************************************************************************/
194 /**
195 \brief This finds all acceptable time zone names at a point, using the source geometry.
196
197 Named zones take precedence over Etc/ ocean zones. More than one named zone may contain a point where
198 source polygons overlap; any of those names is a valid generated expectation. No package lookup is performed.
199
200 \returns: A sorted array of unique time zone names, or an empty array for a point outside source coverage.
201 */
202 public function zonesAt(float $lng, ///< The point's longitude, in degrees.
203 float $lat ///< The point's latitude, in degrees.
204 ): array {
205 $named = [];
206 $ocean = [];
207 foreach ($this->shapes as $shape) {
208 if (!str_starts_with($shape['tzname'], 'Etc/') && self::contains($shape, $lng, $lat)) {
209 $named[$shape['tzname']] = true;
210 }
211 }
212 if (!empty($named)) {
213 $zones = array_keys($named);
214 } else {
215 foreach ($this->shapes as $shape) {
216 if (str_starts_with($shape['tzname'], 'Etc/') && self::contains($shape, $lng, $lat)) {
217 $ocean[$shape['tzname']] = true;
218 }
219 }
220 $zones = array_keys($ocean);
221 }
222 sort($zones);
223 return $zones;
224 }
225
226 /***********************************************************************************************************************/
227 /**
228 \brief This compares every stored polygon with its decoded source record.
229
230 We check row counts, names, exterior-ring byte lengths and SHA-256 hashes. Domain rect coordinates allow a
231 small tolerance for the schema's single-precision storage. Hashes are calculated by the database server, avoiding
232 the memory cost of fetching all stored polygon blobs into PHP again.
233
234 \returns: An array of failure descriptions. An empty array means all checks passed.
235 \throws PDOException if the validation query fails.
236 */
237 public function validateStorage(PDO $connection ///< A PDO connection to this run's temporary database.
238 ): array {
239 // Server-side hashes avoid transferring every large blob back to PHP just to check its encoding.
240 $hash = $connection->getAttribute(PDO::ATTR_DRIVER_NAME) === 'pgsql' ? "encode(sha256(polygon), 'hex')" : 'SHA2(polygon, 256)';
241 $rows = $connection->query('SELECT id, tzname, OCTET_LENGTH(polygon) AS bytes, '.$hash.' AS sha256,
242 east + 0.0 AS east, west + 0.0 AS west, north + 0.0 AS north, south + 0.0 AS south
243 FROM timezones ORDER BY id')->fetchAll(PDO::FETCH_ASSOC);
244 $failures = [];
245 if (count($rows) !== count($this->shapes)) {
246 $failures[] = 'Source/database polygon counts differ: '.count($this->shapes).' versus '.count($rows);
247 }
248 foreach ($rows as $row) {
249 $source = $this->shapes[(int)$row['id']] ?? null;
250 if (null === $source || $row['tzname'] !== $source['tzname'] ||
251 (int)$row['bytes'] !== $source['bytes'] || $row['sha256'] !== $source['sha256']) {
252 $failures[] = 'Stored geometry/name mismatch for polygon '.$row['id'].' ('.$row['tzname'].')';
253 continue;
254 }
255 foreach ($source['bounds'] as $edge => $value) {
256 // Both schemas store bounding coordinates with single precision.
257 if (abs((float)$row[$edge] - $value) > 0.00002) {
258 $failures[] = 'Stored '.$edge.' bound mismatch for polygon '.$row['id'];
259 }
260 }
261 }
262 return $failures;
263 }
264
265 /***********************************************************************************************************************/
266 /**
267 \brief This generates boundary and interior tests for the largest polygons.
268
269 Boundary samples are spread around each exterior ring, with additional samples next to decoding-block
270 transitions and the closing edge. For each usable edge, we offset a point to each side by approximately the
271 requested distance. The local conversion adjusts longitude distance for latitude. Degenerate edges, edges
272 crossing the date line, and offsets too close to a pole are skipped.
273
274 We also select up to three interior points from a small grid over the domain rect. All expected names come
275 from zonesAt(), using the complete source collection. A narrow or winding boundary may put both offset
276 points in the same zone; the report counts actual crossings separately.
277
278 \returns: Test records with title, params, expected, and kind. Boundary records also contain pair and side.
279 */
280 public function casesForLargest(int $count, ///< The positive number of largest polygons to test.
281 float $distanceKm = 5.0 ///< Approximate distance from each boundary, in kilometers.
282 ): array {
283 $cases = [];
284 foreach ($this->largest($count) as $shape) {
285 $ring = $shape['rings'][0];
286 $vertices = $shape['vertices'];
287 // Spread probes around the contour; test both sides approximately three miles from each edge.
288 $indices = [];
289 for ($sample = 0; $sample < 8; ++$sample) { $indices[] = (int)floor($sample * ($vertices - 1) / 8); }
290 // Include edges adjoining decoding blocks and the closing edge, as well as contour-wide probes.
291 $indices = array_unique(array_merge($indices, [1023, 1024, 2047, 2048, $vertices - 2]));
292 foreach ($indices as $index) {
293 if ($index >= $vertices - 1) { continue; }
294 $a = unpack('dx/dy', $ring, $index * 16);
295 $b = unpack('dx/dy', $ring, (($index + 1) % $vertices) * 16);
296 $latitude = ($a['y'] + $b['y']) / 2;
297 $cosine = cos(deg2rad($latitude));
298 if (abs($cosine) < 0.02 || abs($b['x'] - $a['x']) > 180) { continue; }
299 $dx = ($b['x'] - $a['x']) * $cosine;
300 $dy = $b['y'] - $a['y'];
301 $length = hypot($dx, $dy);
302 if ($length === 0.0) { continue; }
303 foreach ([-1, 1] as $side) {
304 $lng = ($a['x'] + $b['x']) / 2 - $side * $dy / $length * $distanceKm / (111.32 * $cosine);
305 $lat = $latitude + $side * $dx / $length * $distanceKm / 111.32;
306 while ($lng < -180) { $lng += 360; }
307 while ($lng > 180) { $lng -= 360; }
308 if (abs($lat) >= 89.9) { continue; }
309 $cases[] = [
310 'title' => $shape['tzname'].' polygon '.$shape['id'].' border vertex '.$index.' side '.$side,
311 'params' => ['lng' => $lng, 'lat' => $lat], 'expected' => $this->zonesAt($lng, $lat),
312 'kind' => 'generated boundary', 'pair' => $shape['id'].':'.$index, 'side' => $side,
313 ];
314 }
315 }
316 $inside = 0;
317 foreach ([0.37, 0.59, 0.73] as $x) {
318 foreach ([0.37, 0.59, 0.73] as $y) {
319 $lng = $shape['bounds']['west'] + $x * ($shape['bounds']['east'] - $shape['bounds']['west']);
320 $lat = $shape['bounds']['south'] + $y * ($shape['bounds']['north'] - $shape['bounds']['south']);
321 if ($inside < 3 && self::contains($shape, $lng, $lat)) {
322 ++$inside;
323 $cases[] = ['title' => $shape['tzname'].' polygon '.$shape['id'].' interior '.$inside,
324 'params' => ['lng' => $lng, 'lat' => $lat], 'expected' => $this->zonesAt($lng, $lat),
325 'kind' => 'generated interior'];
326 }
327 }
328 }
329 }
330 return $cases;
331 }
332}
333
334/***************************************************************************************************************************/
335/**
336 \brief This adds audit counters around the installed package's normal polygon reader.
337
338 Loading, SQL, decoding, and lookup selection remain in the package. We count the rows and bytes actually yielded
339 to the lookup algorithm, and record the row IDs encountered during generated calls. The package's documented
340 primary-key order lets us associate yielded rows with the requested IDs without fetching another copy of the blobs.
341*/
343 /***********************************************************************************************************************/
344 /** \brief This is true while a generated test is being measured, and false for the known-location tests. */
345 public bool $generatedCall = false;
346 /***********************************************************************************************************************/
347 /** \brief This counts polygon rows actually evaluated across all lookup calls. */
348 public int $polygonRows = 0;
349 /***********************************************************************************************************************/
350 /** \brief This sums the bytes yielded across lookup calls; repeated evaluations are counted again. */
351 public int $polygonBytes = 0;
352 /***********************************************************************************************************************/
353 /** \brief These row IDs were evaluated by generated calls, and are used to check largest-polygon coverage. */
354 public array $generatedPolygons = [];
355
356 /***********************************************************************************************************************/
357 /**
358 \brief This forwards the package's polygon iterator, recording each yielded row.
359
360 The parent generator retains responsibility for cursor cleanup, including an early return after a match.
361
362 \returns: A generator yielding the package's unchanged tzname/polygon rows.
363 */
364 public function get_tz_polygons($ids ///< Existing row IDs returned by the package's domain-rect query.
365 ) {
366 // The package documents primary-key ordering for its polygon reader.
367 $ordered = array_map('intval', $ids);
368 sort($ordered, SORT_NUMERIC);
369 $index = 0;
370 foreach (parent::get_tz_polygons($ids) as $row) {
372 $this->polygonBytes += strlen($row['polygon']);
373 if ($this->generatedCall) { $this->generatedPolygons[$ordered[$index]] = true; }
374 ++$index;
375 yield $row;
376 }
377 }
378}
379
380/***************************************************************************************************************************/
381/**
382 \brief This forwards parser events to the installed loader and observes the decoded source features.
383
384 All parser events reach the package unchanged. When a Feature object closes, the loader's GeoJSON listener
385 exposes that decoded Feature through getJson(). We retain its compact reference geometry before the next
386 Feature replaces it. We do not build a second nested copy of the entire GeoJSON document.
387*/
388class DemoInspectingListener implements JsonStreamingParser\Listener\ListenerInterface {
389 /***********************************************************************************************************************/
390 /** \brief This is the installed package's listener, which performs the actual database load. */
392 /***********************************************************************************************************************/
393 /** \brief This receives the decoded source polygons for independent validation. */
395 /***********************************************************************************************************************/
396 /** \brief This tracks object/array nesting; a Feature ends when nesting returns to level two. */
397 private int $depth = 0;
398
399 /***********************************************************************************************************************/
400 /** \brief This connects the installed loader with the independent source collector. */
401 public function __construct(LGV_TZ_Lookup_Loader $loader, ///< The initialized package listener to receive parser events.
402 DemoSourceGeometry $source ///< The collector to receive each complete source Feature.
403 ) {
404 $this->loader = $loader;
405 $this->source = $source;
406 }
407 /***********************************************************************************************************************/
408 /** \brief This resets nesting and forwards the start-of-document event. */
409 public function startDocument(): void { $this->depth = 0; $this->loader->startDocument(); }
410 /***********************************************************************************************************************/
411 /** \brief This forwards the end-of-document event. */
412 public function endDocument(): void { $this->loader->endDocument(); }
413 /***********************************************************************************************************************/
414 /** \brief This records entry into an object, and forwards that event. */
415 public function startObject(): void { ++$this->depth; $this->loader->startObject(); }
416 /***********************************************************************************************************************/
417 /** \brief This forwards a completed object, collecting source geometry when that object is a Feature. */
418 public function endObject(): void {
419 $this->loader->endObject();
420 --$this->depth;
421 if ($this->depth === 2) { $this->source->observe($this->loader->getJson()); }
422 }
423 /***********************************************************************************************************************/
424 /** \brief This records entry into an array, and forwards that event. */
425 public function startArray(): void { ++$this->depth; $this->loader->startArray(); }
426 /***********************************************************************************************************************/
427 /** \brief This forwards a completed array and updates nesting. */
428 public function endArray(): void { $this->loader->endArray(); --$this->depth; }
429 /***********************************************************************************************************************/
430 /** \brief This forwards a JSON object key. */
431 public function key(string $key ///< The decoded property name supplied by the parser.
432 ): void { $this->loader->key($key); }
433 /***********************************************************************************************************************/
434 /** \brief This forwards a JSON scalar value without modification. */
435 public function value($value ///< The string, number, boolean, or null value supplied by the parser.
436 ): void { $this->loader->value($value); }
437 /***********************************************************************************************************************/
438 /** \brief This forwards whitespace reported by the parser. */
439 public function whitespace(string $whitespace ///< The whitespace supplied by the parser.
440 ): void { $this->loader->whitespace($whitespace); }
441}
$failures
Definition benchmark.php:79
This forwards parser events to the installed loader and observes the decoded source features.
key(string $key)
This forwards a JSON object key.
__construct(LGV_TZ_Lookup_Loader $loader, DemoSourceGeometry $source)
This connects the installed loader with the independent source collector.
endArray()
This forwards a completed array and updates nesting.
value($value)
This forwards a JSON scalar value without modification.
whitespace(string $whitespace)
This forwards whitespace reported by the parser.
LGV_TZ_Lookup_Loader $loader
This is the installed package's listener, which performs the actual database load.
endObject()
This forwards a completed object, collecting source geometry when that object is a Feature.
startObject()
This records entry into an object, and forwards that event.
startArray()
This records entry into an array, and forwards that event.
startDocument()
This resets nesting and forwards the start-of-document event.
endDocument()
This forwards the end-of-document event.
int $depth
This tracks object/array nesting; a Feature ends when nesting returns to level two.
DemoSourceGeometry $source
This receives the decoded source polygons for independent validation.
This retains a compact reference copy of each polygon in the decoded GeoJSON.
largest(int $count)
This selects the most detailed polygons, using exterior-ring point counts.
int $polygonBytes
This is the total expected size, in bytes, of the stored exterior-ring polygon data.
array $shapes
These are the source polygon records, keyed by their expected database row IDs.
validateStorage(PDO $connection)
This compares every stored polygon with its decoded source record.
static contains(array $shape, float $lng, float $lat)
This checks a point against a complete source polygon, including its holes.
observe(array $feature)
This records the polygons from one decoded GeoJSON Feature.
static ringContains(string $ring, float $lng, float $lat)
This checks a point against one reference ring, using horizontal ray casting.
int $vertices
This is the total number of exterior-ring points, including repeated closing points.
casesForLargest(int $count, float $distanceKm=5.0)
This generates boundary and interior tests for the largest polygons.
zonesAt(float $lng, float $lat)
This finds all acceptable time zone names at a point, using the source geometry.
This adds audit counters around the installed package's normal polygon reader.
int $polygonBytes
This sums the bytes yielded across lookup calls; repeated evaluations are counted again.
int $polygonRows
This counts polygon rows actually evaluated across all lookup calls.
array $generatedPolygons
These row IDs were evaluated by generated calls, and are used to check largest-polygon coverage.
bool $generatedCall
This is true while a generated test is being measured, and false for the known-location tests.
get_tz_polygons($ids)
This forwards the package's polygon iterator, recording each yielded row.
$connection
Definition composer.php:31
if(PHP_VERSION_ID< 80100) $rings
$rows
$cases
Definition demo.php:82
$source
Definition demo.php:67