openmapstack 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- openmapstack/__init__.py +6 -0
- openmapstack/__main__.py +3 -0
- openmapstack/checks/__init__.py +112 -0
- openmapstack/checks/geodata.py +318 -0
- openmapstack/checks/overrides.py +263 -0
- openmapstack/checks/presentation.py +168 -0
- openmapstack/checks/project.py +261 -0
- openmapstack/checks/provenance.py +133 -0
- openmapstack/checks/qgis.py +832 -0
- openmapstack/checks/rerun.py +340 -0
- openmapstack/checks/spatial.py +65 -0
- openmapstack/checks/validation.py +288 -0
- openmapstack/checks/visual.py +642 -0
- openmapstack/cli.py +431 -0
- openmapstack/expectations.py +284 -0
- openmapstack/integrity.py +137 -0
- openmapstack/project.py +79 -0
- openmapstack/rerun.py +332 -0
- openmapstack/schema.py +39 -0
- openmapstack/schemas/__init__.py +1 -0
- openmapstack/schemas/project-v1.schema.json +264 -0
- openmapstack/validation.py +1019 -0
- openmapstack/verify.py +386 -0
- openmapstack-0.2.0.dist-info/METADATA +268 -0
- openmapstack-0.2.0.dist-info/RECORD +29 -0
- openmapstack-0.2.0.dist-info/WHEEL +5 -0
- openmapstack-0.2.0.dist-info/entry_points.txt +2 -0
- openmapstack-0.2.0.dist-info/licenses/LICENSE +21 -0
- openmapstack-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,642 @@
|
|
|
1
|
+
"""Visual integration assertions: rendered-map substantiveness and live
|
|
2
|
+
browser validation of the generated dashboard.
|
|
3
|
+
|
|
4
|
+
PR 7 of the eval epic. These checks supplement — never replace — the
|
|
5
|
+
deterministic structural assertions: a dashboard that loads cleanly but
|
|
6
|
+
shows an empty map, hides a declared warning, or renders a scenario layer
|
|
7
|
+
indistinguishable from the baseline must fail here.
|
|
8
|
+
|
|
9
|
+
Browser checks require Playwright with a Chromium install and return
|
|
10
|
+
``not_testable`` (never a silent pass) when the execution environment lacks
|
|
11
|
+
them. PNG analysis is stdlib-only so it runs anywhere, including offline
|
|
12
|
+
fixture CI.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import re
|
|
19
|
+
import tempfile
|
|
20
|
+
import struct
|
|
21
|
+
import zlib
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
from . import AssertionResult, failed, get_in, load_project_yaml, not_testable, passed, project_root
|
|
26
|
+
|
|
27
|
+
# A rendered map is considered blank when fewer than this fraction of
|
|
28
|
+
# pixels differ from the modal (background) color. Genuine sparse vector
|
|
29
|
+
# content - a small parcel in a generous frame, a thin road line - still
|
|
30
|
+
# contributes at least ~0.05% ink; a truly blank render (missing layers,
|
|
31
|
+
# collapsed extent, displaced CRS) contributes none beyond encoder noise.
|
|
32
|
+
_BLANK_MAX_NON_MODAL_FRACTION = 0.0002
|
|
33
|
+
|
|
34
|
+
# Two screenshots count as "the same image" when fewer than this fraction
|
|
35
|
+
# of pixels differ. Headless Chromium renders identical content
|
|
36
|
+
# deterministically, so the threshold only absorbs encoder noise; it must
|
|
37
|
+
# stay far below the ink of even the smallest declared feature.
|
|
38
|
+
_SAME_IMAGE_DIFF_FRACTION = 0.00005
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# ---------------------------------------------------------------------------
|
|
42
|
+
# Minimal PNG decoding (stdlib only — no Pillow/numpy dependency)
|
|
43
|
+
# ---------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
_PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _paeth(a: int, b: int, c: int) -> int:
|
|
49
|
+
p = a + b - c
|
|
50
|
+
pa, pb, pc = abs(p - a), abs(p - b), abs(p - c)
|
|
51
|
+
if pa <= pb and pa <= pc:
|
|
52
|
+
return a
|
|
53
|
+
if pb <= pc:
|
|
54
|
+
return b
|
|
55
|
+
return c
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def decode_png(path: str | Path) -> tuple[int, int, int, list[bytes]]:
|
|
59
|
+
"""Decode an 8-bit PNG into ``(width, height, bytes_per_pixel, rows)``.
|
|
60
|
+
|
|
61
|
+
Supports color types 0 (gray), 2 (RGB), 4 (gray+alpha) and 6 (RGBA) —
|
|
62
|
+
everything Chromium screenshots and Qt rendered images produce — with
|
|
63
|
+
all five scanline filters. Anything else raises ValueError.
|
|
64
|
+
"""
|
|
65
|
+
data = Path(path).read_bytes()
|
|
66
|
+
if not data.startswith(_PNG_SIGNATURE):
|
|
67
|
+
raise ValueError("not a PNG file")
|
|
68
|
+
pos = len(_PNG_SIGNATURE)
|
|
69
|
+
width = height = bit_depth = color_type = 0
|
|
70
|
+
idat = bytearray()
|
|
71
|
+
while pos + 8 <= len(data):
|
|
72
|
+
length, chunk = struct.unpack(">I4s", data[pos:pos + 8])
|
|
73
|
+
pos += 8
|
|
74
|
+
chunk_data = data[pos:pos + length]
|
|
75
|
+
pos += length + 4 # skip CRC
|
|
76
|
+
if chunk == b"IHDR":
|
|
77
|
+
width, height, bit_depth, color_type = struct.unpack(">IIBB", chunk_data[:10])
|
|
78
|
+
elif chunk == b"IDAT":
|
|
79
|
+
idat.extend(chunk_data)
|
|
80
|
+
elif chunk == b"IEND":
|
|
81
|
+
break
|
|
82
|
+
if bit_depth != 8:
|
|
83
|
+
raise ValueError(f"unsupported bit depth {bit_depth}")
|
|
84
|
+
channels = {0: 1, 2: 3, 3: 1, 4: 2, 6: 4}.get(color_type)
|
|
85
|
+
if channels is None:
|
|
86
|
+
raise ValueError(f"unsupported color type {color_type}")
|
|
87
|
+
|
|
88
|
+
try:
|
|
89
|
+
raw = zlib.decompress(bytes(idat))
|
|
90
|
+
except zlib.error as exc:
|
|
91
|
+
raise ValueError(f"corrupt or truncated PNG data: {exc}") from exc
|
|
92
|
+
stride = width * channels
|
|
93
|
+
bpp = channels # filter offset equals channel count for 8-bit depth
|
|
94
|
+
rows: list[bytes] = []
|
|
95
|
+
prev = bytearray(stride)
|
|
96
|
+
cursor = 0
|
|
97
|
+
for _ in range(height):
|
|
98
|
+
if cursor >= len(raw):
|
|
99
|
+
raise ValueError("truncated PNG data")
|
|
100
|
+
filter_type = raw[cursor]
|
|
101
|
+
cursor += 1
|
|
102
|
+
line = bytearray(raw[cursor:cursor + stride])
|
|
103
|
+
cursor += stride
|
|
104
|
+
if len(line) != stride:
|
|
105
|
+
raise ValueError("truncated PNG scanline")
|
|
106
|
+
if filter_type == 1: # Sub
|
|
107
|
+
for i in range(bpp, stride):
|
|
108
|
+
line[i] = (line[i] + line[i - bpp]) & 0xFF
|
|
109
|
+
elif filter_type == 2: # Up
|
|
110
|
+
for i in range(stride):
|
|
111
|
+
line[i] = (line[i] + prev[i]) & 0xFF
|
|
112
|
+
elif filter_type == 3: # Average
|
|
113
|
+
for i in range(stride):
|
|
114
|
+
left = line[i - bpp] if i >= bpp else 0
|
|
115
|
+
line[i] = (line[i] + ((left + prev[i]) >> 1)) & 0xFF
|
|
116
|
+
elif filter_type == 4: # Paeth
|
|
117
|
+
for i in range(stride):
|
|
118
|
+
left = line[i - bpp] if i >= bpp else 0
|
|
119
|
+
up_left = prev[i - bpp] if i >= bpp else 0
|
|
120
|
+
line[i] = (line[i] + _paeth(left, prev[i], up_left)) & 0xFF
|
|
121
|
+
elif filter_type != 0:
|
|
122
|
+
raise ValueError(f"unsupported filter type {filter_type}")
|
|
123
|
+
rows.append(bytes(line))
|
|
124
|
+
prev = line
|
|
125
|
+
return width, height, channels, rows
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def image_stats(path: Path) -> dict[str, Any]:
|
|
129
|
+
"""Coarse color statistics of a rendered image, robust to encoder noise.
|
|
130
|
+
|
|
131
|
+
Colors are quantized to 5 bits per channel before counting so that
|
|
132
|
+
antialiasing dithering cannot make a blank render look "substantive".
|
|
133
|
+
"""
|
|
134
|
+
width, height, channels, rows = decode_png(path)
|
|
135
|
+
counts: dict[int, int] = {}
|
|
136
|
+
total = width * height
|
|
137
|
+
for row in rows:
|
|
138
|
+
for i in range(0, len(row), channels):
|
|
139
|
+
key = (row[i] >> 3) << 10 | (row[i + 1] >> 3) << 5 | (row[i + 2] >> 3)
|
|
140
|
+
counts[key] = counts.get(key, 0) + 1
|
|
141
|
+
modal = max(counts.values()) if counts else 0
|
|
142
|
+
return {
|
|
143
|
+
"width": width,
|
|
144
|
+
"height": height,
|
|
145
|
+
"distinct_colors_quantized": len(counts),
|
|
146
|
+
"modal_color_fraction": round(modal / total, 6) if total else 1.0,
|
|
147
|
+
"non_modal_fraction": round(1 - modal / total, 6) if total else 0.0,
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def images_differ(path_a: Path, path_b: Path) -> tuple[bool, float]:
|
|
152
|
+
"""Compare two decoded images; returns ``(differ, differing_fraction)``.
|
|
153
|
+
|
|
154
|
+
Images with different dimensions always differ.
|
|
155
|
+
"""
|
|
156
|
+
wa, ha, ca, rows_a = decode_png(path_a)
|
|
157
|
+
wb, hb, cb, rows_b = decode_png(path_b)
|
|
158
|
+
if (wa, ha, ca) != (wb, hb, cb):
|
|
159
|
+
return True, 1.0
|
|
160
|
+
differing = 0
|
|
161
|
+
total = wa * ha
|
|
162
|
+
for row_a, row_b in zip(rows_a, rows_b):
|
|
163
|
+
if row_a == row_b:
|
|
164
|
+
continue
|
|
165
|
+
for i in range(0, len(row_a), ca):
|
|
166
|
+
if row_a[i:i + ca] != row_b[i:i + ca]:
|
|
167
|
+
differing += 1
|
|
168
|
+
fraction = differing / total if total else 0.0
|
|
169
|
+
return fraction > _SAME_IMAGE_DIFF_FRACTION, fraction
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _is_blank(stats: dict[str, Any]) -> bool:
|
|
173
|
+
return (
|
|
174
|
+
stats["non_modal_fraction"] < _BLANK_MAX_NON_MODAL_FRACTION
|
|
175
|
+
or stats["distinct_colors_quantized"] < 2
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
# ---------------------------------------------------------------------------
|
|
180
|
+
# render_substantive: a rendered PNG must show actual map content
|
|
181
|
+
# ---------------------------------------------------------------------------
|
|
182
|
+
|
|
183
|
+
def render_substantive(workspace: Path, path: str, project_dir: str = ".") -> AssertionResult:
|
|
184
|
+
"""A rendered map snapshot (PyQGIS render or dashboard screenshot) must
|
|
185
|
+
contain real drawn content. Detects the empty-map failure mode: a valid
|
|
186
|
+
project that renders to a single background color, whether from missing
|
|
187
|
+
layers, a collapsed extent, gross CRS displacement, or styling that
|
|
188
|
+
paints nothing."""
|
|
189
|
+
image_path = project_root(workspace, project_dir) / path
|
|
190
|
+
if not image_path.exists():
|
|
191
|
+
return failed(f"rendered snapshot {path} does not exist", code="snapshot_missing")
|
|
192
|
+
try:
|
|
193
|
+
stats = image_stats(image_path)
|
|
194
|
+
except ValueError as exc:
|
|
195
|
+
return failed(f"snapshot {path} is not decodable: {exc}", code="snapshot_undecodable")
|
|
196
|
+
if _is_blank(stats):
|
|
197
|
+
return failed(
|
|
198
|
+
f"rendered snapshot {path} is blank ({stats['modal_color_fraction']:.1%} one color, "
|
|
199
|
+
f"{stats['distinct_colors_quantized']} quantized colors)",
|
|
200
|
+
code="blank_render",
|
|
201
|
+
stats=stats,
|
|
202
|
+
)
|
|
203
|
+
return passed(
|
|
204
|
+
f"rendered snapshot {path} shows substantive content "
|
|
205
|
+
f"({stats['distinct_colors_quantized']} quantized colors, "
|
|
206
|
+
f"{stats['non_modal_fraction']:.1%} non-background)",
|
|
207
|
+
stats=stats,
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
# ---------------------------------------------------------------------------
|
|
212
|
+
# dashboard_loads_in_browser: live headless-browser validation
|
|
213
|
+
# ---------------------------------------------------------------------------
|
|
214
|
+
|
|
215
|
+
def _playwright():
|
|
216
|
+
from playwright.sync_api import sync_playwright # type: ignore
|
|
217
|
+
|
|
218
|
+
return sync_playwright
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
_MAP_SELECTOR = '[data-testid="map"], #map, .maplibregl-map, canvas'
|
|
222
|
+
_LEGEND_SELECTOR = '[data-testid="legend"], #legend, .legend'
|
|
223
|
+
_PROVENANCE_SELECTOR = '[data-testid="provenance"], #provenance, .provenance'
|
|
224
|
+
_WARNINGS_SELECTOR = '[data-testid="warnings"], #warnings, .warnings'
|
|
225
|
+
_RESET_SELECTOR = '[data-testid="canonical-reset"], #reset'
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _first_visible(page: Any, selector: str) -> Any | None:
|
|
229
|
+
for element in page.query_selector_all(selector):
|
|
230
|
+
try:
|
|
231
|
+
if element.bounding_box() and element.bounding_box()["width"] > 0:
|
|
232
|
+
return element
|
|
233
|
+
except Exception: # noqa: BLE001
|
|
234
|
+
continue
|
|
235
|
+
return None
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _screenshot_map(page: Any, output_path: Path) -> str | None:
|
|
239
|
+
element = _first_visible(page, _MAP_SELECTOR)
|
|
240
|
+
if element is None:
|
|
241
|
+
return "no visible map element"
|
|
242
|
+
try:
|
|
243
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
244
|
+
element.screenshot(path=str(output_path))
|
|
245
|
+
except Exception as exc: # noqa: BLE001
|
|
246
|
+
return f"map screenshot failed: {exc}"
|
|
247
|
+
return None
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _settle(page: Any, settle_ms: int) -> None:
|
|
251
|
+
"""Wait for background-map tiles and render to settle: prefer the page's
|
|
252
|
+
network going idle (tiles, CDN), then a fixed grace period. Best effort —
|
|
253
|
+
an environment without network simply uses the grace period."""
|
|
254
|
+
try:
|
|
255
|
+
page.wait_for_load_state("networkidle", timeout=5000)
|
|
256
|
+
except Exception: # noqa: BLE001
|
|
257
|
+
pass
|
|
258
|
+
page.wait_for_timeout(settle_ms)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
class _Problems:
|
|
262
|
+
"""Collected defects, each recorded with the code of the check that
|
|
263
|
+
found it.
|
|
264
|
+
|
|
265
|
+
Mutation cases pin `expect_code`, so these codes are load-bearing:
|
|
266
|
+
inferring them by substring-matching the human-readable message made
|
|
267
|
+
them depend on wording, and made a message that merely *mentions*
|
|
268
|
+
another subject take that subject's code -- a layer group literally
|
|
269
|
+
named "basemap", for instance, reporting `basemap_absent`. The check
|
|
270
|
+
that detects a problem is the thing that knows what it is.
|
|
271
|
+
"""
|
|
272
|
+
|
|
273
|
+
def __init__(self) -> None:
|
|
274
|
+
self.messages: list[str] = []
|
|
275
|
+
self.codes: list[str] = []
|
|
276
|
+
|
|
277
|
+
def add(self, code: str, message: str) -> None:
|
|
278
|
+
self.messages.append(message)
|
|
279
|
+
self.codes.append(code)
|
|
280
|
+
|
|
281
|
+
def __bool__(self) -> bool:
|
|
282
|
+
return bool(self.messages)
|
|
283
|
+
|
|
284
|
+
@property
|
|
285
|
+
def primary_code(self) -> str:
|
|
286
|
+
return self.codes[0] if self.codes else "dashboard_visual_failure"
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _stable_screenshot(page: Any, output_path: Path, settle_ms: int, attempts: int = 4) -> str | None:
|
|
290
|
+
"""Capture the map only once two consecutive captures agree.
|
|
291
|
+
|
|
292
|
+
A raster basemap loads and fades in asynchronously, so tiles arriving
|
|
293
|
+
between a "before" and an "after" capture would register as a change and
|
|
294
|
+
let a dead layer toggle look like a working one. Waiting for the frame to
|
|
295
|
+
stop moving is what makes the later comparison mean what it claims.
|
|
296
|
+
"""
|
|
297
|
+
# Keep the .png suffix: Playwright picks the encoder from the extension.
|
|
298
|
+
probe = output_path.with_name(f"{output_path.stem}.probe.png")
|
|
299
|
+
try:
|
|
300
|
+
for _ in range(attempts):
|
|
301
|
+
error = _screenshot_map(page, output_path)
|
|
302
|
+
if error:
|
|
303
|
+
return error
|
|
304
|
+
_settle(page, settle_ms)
|
|
305
|
+
error = _screenshot_map(page, probe)
|
|
306
|
+
if error:
|
|
307
|
+
return error
|
|
308
|
+
differ, _fraction = images_differ(output_path, probe)
|
|
309
|
+
if not differ:
|
|
310
|
+
return None
|
|
311
|
+
return f"map never stopped changing after {attempts} settle attempts"
|
|
312
|
+
finally:
|
|
313
|
+
probe.unlink(missing_ok=True)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _toggle_changes_render(
|
|
317
|
+
page: Any,
|
|
318
|
+
control: Any,
|
|
319
|
+
*,
|
|
320
|
+
baseline: Path,
|
|
321
|
+
toggled: Path,
|
|
322
|
+
settle_ms: int,
|
|
323
|
+
no_effect_detail: str = "toggle does not change the rendered map (layer absent or indistinguishable)",
|
|
324
|
+
) -> tuple[str | None, float | None]:
|
|
325
|
+
"""Switch ``control`` off, confirm the rendered map actually changes, and
|
|
326
|
+
switch it back on again.
|
|
327
|
+
|
|
328
|
+
The restore runs in a ``finally`` block: a screenshot failure mid-check
|
|
329
|
+
must not leave the layer hidden, because every later comparison — and
|
|
330
|
+
the canonical-reset check, which compares against the control state
|
|
331
|
+
captured while it was on — would then measure the wrong page.
|
|
332
|
+
|
|
333
|
+
Returns ``(problem, differing_fraction)``; ``problem`` is None when the
|
|
334
|
+
toggle demonstrably changes the render.
|
|
335
|
+
"""
|
|
336
|
+
error = _stable_screenshot(page, baseline, settle_ms)
|
|
337
|
+
if error:
|
|
338
|
+
return error, None
|
|
339
|
+
try:
|
|
340
|
+
control.uncheck()
|
|
341
|
+
_settle(page, settle_ms)
|
|
342
|
+
error = _stable_screenshot(page, toggled, settle_ms)
|
|
343
|
+
if error:
|
|
344
|
+
return error, None
|
|
345
|
+
differ, fraction = images_differ(baseline, toggled)
|
|
346
|
+
if not differ:
|
|
347
|
+
return f"{no_effect_detail} (only {fraction:.4%} of pixels differ)", fraction
|
|
348
|
+
return None, fraction
|
|
349
|
+
finally:
|
|
350
|
+
control.check()
|
|
351
|
+
_settle(page, settle_ms)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _checkbox_states(page: Any) -> dict[str, bool]:
|
|
355
|
+
return page.evaluate(
|
|
356
|
+
"""() => Object.fromEntries(
|
|
357
|
+
[...document.querySelectorAll('input[type="checkbox"]')]
|
|
358
|
+
.map(cb => [cb.dataset.layerGroup || cb.dataset.scenario || cb.id || cb.name || '', cb.checked])
|
|
359
|
+
)"""
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def dashboard_loads_in_browser(
|
|
364
|
+
workspace: Path,
|
|
365
|
+
project_dir: str = ".",
|
|
366
|
+
dashboard: str = "dashboard.html",
|
|
367
|
+
screenshots_dir: str | None = None,
|
|
368
|
+
desktop_size: str = "1280x800",
|
|
369
|
+
mobile_size: str = "390x844",
|
|
370
|
+
settle_ms: int = 800,
|
|
371
|
+
) -> AssertionResult:
|
|
372
|
+
"""Open the generated dashboard in headless Chromium and verify the
|
|
373
|
+
manifest's presentation claims against the actually rendered product.
|
|
374
|
+
|
|
375
|
+
Fails on page/console errors, absent map, blank map, absent
|
|
376
|
+
legend/provenance panels that the manifest declares visible, manifest
|
|
377
|
+
warnings not visible in the product, layer controls missing or not
|
|
378
|
+
affecting the render, a scenario control whose layer is
|
|
379
|
+
indistinguishable from the baseline, and a broken canonical reset.
|
|
380
|
+
Captures desktop and mobile screenshots as retained evidence.
|
|
381
|
+
|
|
382
|
+
Returns ``not_testable`` only when the environment cannot run the check
|
|
383
|
+
at all (no Playwright, no launchable browser). Once the dashboard is
|
|
384
|
+
open, every outcome -- including an unexpected exception -- is graded as
|
|
385
|
+
evidence about the product.
|
|
386
|
+
"""
|
|
387
|
+
proj = load_project_yaml(workspace, project_dir)
|
|
388
|
+
if proj is None:
|
|
389
|
+
return failed("project.yaml missing", code="manifest_missing")
|
|
390
|
+
dashboard_path = project_root(workspace, project_dir) / dashboard
|
|
391
|
+
if not dashboard_path.exists():
|
|
392
|
+
return failed(f"{dashboard} does not exist", code="file_missing")
|
|
393
|
+
|
|
394
|
+
try:
|
|
395
|
+
sync_playwright = _playwright()
|
|
396
|
+
except ImportError:
|
|
397
|
+
return not_testable(
|
|
398
|
+
"Playwright is not installed in this execution environment", code="playwright_unavailable"
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
label = re.sub(r"[^A-Za-z0-9_.-]+", "_", project_dir.strip("./")) or "project"
|
|
402
|
+
|
|
403
|
+
warnings = proj.get("warnings") or []
|
|
404
|
+
layer_groups = get_in(proj, "presentation.map.layer_groups", []) or []
|
|
405
|
+
scenarios = get_in(proj, "presentation.controls.scenarios", []) or []
|
|
406
|
+
legend_visible = bool(get_in(proj, "presentation.legend.visible"))
|
|
407
|
+
provenance_declared = bool(get_in(proj, "presentation.provenance_ui"))
|
|
408
|
+
canonical_reset = bool(get_in(proj, "presentation.controls.canonical_reset"))
|
|
409
|
+
|
|
410
|
+
# Screenshot comparisons (toggle effects, scenario distinguishability,
|
|
411
|
+
# blank-map detection) always run. When no retained screenshots_dir is
|
|
412
|
+
# declared, a throwaway temp directory holds the intermediate frames.
|
|
413
|
+
tmp_context = None
|
|
414
|
+
if screenshots_dir:
|
|
415
|
+
screenshot_dir = workspace / screenshots_dir
|
|
416
|
+
else:
|
|
417
|
+
tmp_context = tempfile.TemporaryDirectory(prefix="openmapstack-visual-")
|
|
418
|
+
screenshot_dir = Path(tmp_context.name)
|
|
419
|
+
|
|
420
|
+
# Everything up to and including opening the page is about the execution
|
|
421
|
+
# environment; once the dashboard is open, an exception is evidence about
|
|
422
|
+
# the product and must be reported as a failure, never as "could not
|
|
423
|
+
# check" -- otherwise a hanging or self-destructing dashboard would score
|
|
424
|
+
# the same as a machine without a browser.
|
|
425
|
+
dashboard_opened = False
|
|
426
|
+
|
|
427
|
+
try:
|
|
428
|
+
with sync_playwright() as p:
|
|
429
|
+
try:
|
|
430
|
+
browser = p.chromium.launch()
|
|
431
|
+
except Exception as exc: # noqa: BLE001
|
|
432
|
+
return not_testable(
|
|
433
|
+
f"headless browser unavailable in this environment: {exc}",
|
|
434
|
+
code="browser_unavailable",
|
|
435
|
+
)
|
|
436
|
+
try:
|
|
437
|
+
context = browser.new_context(viewport=_viewport(desktop_size))
|
|
438
|
+
page = context.new_page()
|
|
439
|
+
page_errors: list[str] = []
|
|
440
|
+
console_errors: list[str] = []
|
|
441
|
+
requested_urls: list[str] = []
|
|
442
|
+
page.on("pageerror", lambda exc: page_errors.append(str(exc)))
|
|
443
|
+
page.on(
|
|
444
|
+
"console",
|
|
445
|
+
lambda msg: console_errors.append(msg.text) if msg.type == "error" else None,
|
|
446
|
+
)
|
|
447
|
+
page.on("request", lambda request: requested_urls.append(request.url))
|
|
448
|
+
# `domcontentloaded` rather than the default `load`: a slow or
|
|
449
|
+
# unreachable third-party subresource must not decide whether
|
|
450
|
+
# the dashboard is judged at all. `_settle` then gives tiles
|
|
451
|
+
# and rendering their chance to finish.
|
|
452
|
+
page.goto(dashboard_path.as_uri(), wait_until="domcontentloaded")
|
|
453
|
+
dashboard_opened = True
|
|
454
|
+
_settle(page, settle_ms)
|
|
455
|
+
|
|
456
|
+
if page_errors:
|
|
457
|
+
return failed(
|
|
458
|
+
f"dashboard raised {len(page_errors)} page error(s): {page_errors[:3]}",
|
|
459
|
+
code="browser_page_error",
|
|
460
|
+
errors=page_errors,
|
|
461
|
+
)
|
|
462
|
+
if console_errors:
|
|
463
|
+
return failed(
|
|
464
|
+
f"dashboard logged {len(console_errors)} console error(s): {console_errors[:3]}",
|
|
465
|
+
code="browser_console_error",
|
|
466
|
+
errors=console_errors,
|
|
467
|
+
)
|
|
468
|
+
|
|
469
|
+
problems = _Problems()
|
|
470
|
+
evidence: dict[str, Any] = {}
|
|
471
|
+
|
|
472
|
+
# --- map present and substantive -------------------------
|
|
473
|
+
if _first_visible(page, _MAP_SELECTOR) is None:
|
|
474
|
+
problems.add("map_absent", "no visible map element")
|
|
475
|
+
else:
|
|
476
|
+
shot = screenshot_dir / f"{label}-desktop.png"
|
|
477
|
+
error = _screenshot_map(page, shot)
|
|
478
|
+
if error:
|
|
479
|
+
problems.add("map_screenshot_failed", f"desktop map screenshot: {error}")
|
|
480
|
+
else:
|
|
481
|
+
stats = image_stats(shot)
|
|
482
|
+
evidence["desktop_map_stats"] = stats
|
|
483
|
+
if _is_blank(stats):
|
|
484
|
+
problems.add("blank_map", "map renders blank on desktop")
|
|
485
|
+
|
|
486
|
+
# --- declared panels actually visible --------------------
|
|
487
|
+
if legend_visible and _first_visible(page, _LEGEND_SELECTOR) is None:
|
|
488
|
+
problems.add("legend_absent", "manifest declares legend visible but no legend is rendered")
|
|
489
|
+
if provenance_declared and _first_visible(page, _PROVENANCE_SELECTOR) is None:
|
|
490
|
+
problems.add("provenance_absent", "manifest declares provenance_ui but no provenance panel is rendered")
|
|
491
|
+
if warnings:
|
|
492
|
+
panel = _first_visible(page, _WARNINGS_SELECTOR)
|
|
493
|
+
body_text = page.inner_text("body")
|
|
494
|
+
for w in warnings:
|
|
495
|
+
warning_id = str(w.get("id", ""))
|
|
496
|
+
if panel is None:
|
|
497
|
+
problems.add("warning_not_visible", f"manifest warning {warning_id} has no visible warning panel")
|
|
498
|
+
break
|
|
499
|
+
if warning_id and warning_id not in body_text:
|
|
500
|
+
problems.add("warning_not_visible", f"manifest warning {warning_id} not visible in the rendered product")
|
|
501
|
+
|
|
502
|
+
# --- declared interactive basemap is real -----------------
|
|
503
|
+
# A manifest that presents a map must declare its background
|
|
504
|
+
# map; that omission is caught by the v1 schema
|
|
505
|
+
# (project-spec.md s. 3), which every case checks in every
|
|
506
|
+
# mode. What only a browser can prove is the rest: that the
|
|
507
|
+
# declared tiles are really requested and the required
|
|
508
|
+
# attribution is really visible.
|
|
509
|
+
basemap = get_in(proj, "presentation.map.basemap")
|
|
510
|
+
if basemap:
|
|
511
|
+
# Match any tile under the basemap's URL template:
|
|
512
|
+
# "https://host/{z}/{x}/{y}.png" -> "https://host/".
|
|
513
|
+
tile_prefix = ((basemap.get("tiles") or [basemap.get("url") or ""])[0] or "").split("{z}")[0]
|
|
514
|
+
tile_requests = [url for url in requested_urls if tile_prefix and url.startswith(tile_prefix)]
|
|
515
|
+
if _first_visible(page, f'{_MAP_SELECTOR}, .maplibregl-canvas') is None:
|
|
516
|
+
problems.add("basemap_absent", "manifest declares a basemap but no interactive map canvas is rendered")
|
|
517
|
+
if not tile_requests:
|
|
518
|
+
problems.add(
|
|
519
|
+
"basemap_absent",
|
|
520
|
+
f"manifest declares basemap {basemap.get('id')!r} but the product never "
|
|
521
|
+
f"requested its tiles ({tile_prefix}...) — the background map is not interactive",
|
|
522
|
+
)
|
|
523
|
+
attribution = basemap.get("attribution")
|
|
524
|
+
if attribution and attribution not in page.inner_text("body"):
|
|
525
|
+
problems.add(
|
|
526
|
+
"basemap_absent",
|
|
527
|
+
f"basemap attribution {attribution!r} required by the manifest is not visible "
|
|
528
|
+
"in the rendered product",
|
|
529
|
+
)
|
|
530
|
+
|
|
531
|
+
# --- layer toggles must affect the render ----------------
|
|
532
|
+
checkboxes = page.query_selector_all('input[type="checkbox"][data-layer-group]')
|
|
533
|
+
if layer_groups and not checkboxes:
|
|
534
|
+
problems.add("layer_toggles_absent", "manifest declares layer groups but the product has no layer toggles")
|
|
535
|
+
initial_states = _checkbox_states(page)
|
|
536
|
+
for group in layer_groups:
|
|
537
|
+
group_id = group.get("id")
|
|
538
|
+
control = page.query_selector(f'input[type="checkbox"][data-layer-group="{group_id}"]')
|
|
539
|
+
if control is None:
|
|
540
|
+
problems.add("layer_group_not_rendered", f"layer group {group_id} has no toggle control")
|
|
541
|
+
continue
|
|
542
|
+
problem, fraction = _toggle_changes_render(
|
|
543
|
+
page,
|
|
544
|
+
control,
|
|
545
|
+
baseline=screenshot_dir / f"{label}-group-{group_id}-before.png",
|
|
546
|
+
toggled=screenshot_dir / f"{label}-group-{group_id}-after.png",
|
|
547
|
+
settle_ms=settle_ms,
|
|
548
|
+
)
|
|
549
|
+
if problem is not None:
|
|
550
|
+
problems.add("layer_group_not_rendered", f"layer group {group_id}: {problem}")
|
|
551
|
+
elif fraction is not None:
|
|
552
|
+
evidence.setdefault("toggle_diff_fraction", {})[f"group:{group_id}"] = fraction
|
|
553
|
+
|
|
554
|
+
# --- scenario layer must be distinguishable --------------
|
|
555
|
+
for scenario in scenarios:
|
|
556
|
+
scenario_id = scenario.get("id")
|
|
557
|
+
control = page.query_selector(f'input[type="checkbox"][data-scenario="{scenario_id}"]')
|
|
558
|
+
if control is None:
|
|
559
|
+
problems.add("scenario_layer_indistinguishable", f"scenario {scenario_id} has no toggle control")
|
|
560
|
+
continue
|
|
561
|
+
problem, fraction = _toggle_changes_render(
|
|
562
|
+
page,
|
|
563
|
+
control,
|
|
564
|
+
baseline=screenshot_dir / f"{label}-scenario-{scenario_id}-before.png",
|
|
565
|
+
toggled=screenshot_dir / f"{label}-scenario-{scenario_id}-after.png",
|
|
566
|
+
settle_ms=settle_ms,
|
|
567
|
+
no_effect_detail="is indistinguishable from the authoritative baseline when toggled off",
|
|
568
|
+
)
|
|
569
|
+
if problem is not None:
|
|
570
|
+
problems.add("scenario_layer_indistinguishable", f"scenario {scenario_id}: {problem}")
|
|
571
|
+
elif fraction is not None:
|
|
572
|
+
evidence.setdefault("toggle_diff_fraction", {})[f"scenario:{scenario_id}"] = fraction
|
|
573
|
+
|
|
574
|
+
# --- canonical reset -------------------------------------
|
|
575
|
+
if canonical_reset:
|
|
576
|
+
reset = _first_visible(page, _RESET_SELECTOR)
|
|
577
|
+
if reset is None:
|
|
578
|
+
buttons = page.query_selector_all("button")
|
|
579
|
+
reset = next(
|
|
580
|
+
(b for b in buttons if re.search(r"reset|canonical", (b.inner_text() or "").lower())),
|
|
581
|
+
None,
|
|
582
|
+
)
|
|
583
|
+
if reset is None:
|
|
584
|
+
problems.add("canonical_reset_failed", "manifest declares canonical_reset but no reset control exists")
|
|
585
|
+
else:
|
|
586
|
+
for cb in page.query_selector_all('input[type="checkbox"]'):
|
|
587
|
+
cb.uncheck()
|
|
588
|
+
page.wait_for_timeout(settle_ms)
|
|
589
|
+
reset.click()
|
|
590
|
+
page.wait_for_timeout(settle_ms)
|
|
591
|
+
if _checkbox_states(page) != initial_states:
|
|
592
|
+
problems.add("canonical_reset_failed", "canonical reset does not restore the canonical control state")
|
|
593
|
+
|
|
594
|
+
# --- mobile snapshot --------------------------------------
|
|
595
|
+
mobile_context = browser.new_context(viewport=_viewport(mobile_size))
|
|
596
|
+
mobile_page = mobile_context.new_page()
|
|
597
|
+
mobile_page.goto(dashboard_path.as_uri(), wait_until="domcontentloaded")
|
|
598
|
+
_settle(mobile_page, settle_ms)
|
|
599
|
+
mobile_shot = screenshot_dir / f"{label}-mobile.png"
|
|
600
|
+
error = _screenshot_map(mobile_page, mobile_shot)
|
|
601
|
+
if error:
|
|
602
|
+
problems.add("map_screenshot_failed", f"mobile map screenshot: {error}")
|
|
603
|
+
else:
|
|
604
|
+
stats = image_stats(mobile_shot)
|
|
605
|
+
evidence["mobile_map_stats"] = stats
|
|
606
|
+
if _is_blank(stats):
|
|
607
|
+
problems.add("blank_map", "map renders blank on mobile viewport")
|
|
608
|
+
mobile_context.close()
|
|
609
|
+
context.close()
|
|
610
|
+
|
|
611
|
+
if problems:
|
|
612
|
+
return failed(
|
|
613
|
+
"; ".join(problems.messages),
|
|
614
|
+
code=problems.primary_code,
|
|
615
|
+
problems=problems.messages,
|
|
616
|
+
problem_codes=problems.codes,
|
|
617
|
+
)
|
|
618
|
+
return passed(
|
|
619
|
+
"dashboard loads cleanly; map, legend, provenance, toggles, "
|
|
620
|
+
"scenario and canonical reset all render as the manifest declares",
|
|
621
|
+
evidence=evidence,
|
|
622
|
+
)
|
|
623
|
+
finally:
|
|
624
|
+
browser.close()
|
|
625
|
+
except Exception as exc: # noqa: BLE001
|
|
626
|
+
if dashboard_opened:
|
|
627
|
+
return failed(
|
|
628
|
+
f"browser validation crashed while inspecting the opened dashboard: "
|
|
629
|
+
f"{type(exc).__name__}: {exc}",
|
|
630
|
+
code="browser_check_error",
|
|
631
|
+
)
|
|
632
|
+
return not_testable(f"browser validation could not run: {type(exc).__name__}: {exc}", code="browser_error")
|
|
633
|
+
finally:
|
|
634
|
+
if tmp_context is not None:
|
|
635
|
+
tmp_context.cleanup()
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
def _viewport(size: str) -> dict[str, int]:
|
|
639
|
+
match = re.fullmatch(r"(\d+)x(\d+)", size.strip())
|
|
640
|
+
if not match:
|
|
641
|
+
raise ValueError(f"invalid viewport size {size!r}; expected WxH")
|
|
642
|
+
return {"width": int(match.group(1)), "height": int(match.group(2))}
|