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.
@@ -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))}