Files
Northern-Thailand-Ping-Rive…/tests/test_dashboard.py
T
grabowski 180d95c6bd
CI / Format & lint (push) Successful in 28s
CI / Test suite (push) Successful in 1m4s
Docs / Validate documentation (push) Successful in 17s
Rust / fmt, clippy, test, build (push) Successful in 13m1s
fix(forecast): no nightly gaps in the issued model forecasts
The P.1 chart's "24 h peak, as issued" line had a gap every night and a
dip at 21:00. Two causes:

- A run forecasts from the newest reading only. RID publishes 22:00..06:00
  in one batch in the morning (and now and then one hour late), so those
  hours were never forecast from. After each run the collector now
  forecasts the hours of the last 48 h (two weeks on the first run after a
  start) that have a RID reading but no model forecast, each as a run at
  that hour would have: gauges, HII fill and dam state up to that hour.
  Rain is fetched once, so a late hour's rain_fc24 partly holds rain that
  has since fallen (as in training). Only missing (hour, station) pairs are
  written. `ping-collector catch-up-forecasts --hours N` goes further back.
- Overnight each hourly run re-issues the 21:00 reading, after 6 h as the
  heuristic, which overwrote the 21:00 model row. A heuristic row no longer
  replaces a model row in forecast_history.

The dashboard draws only model rows on the issued line.

Checked on a local copy of 18 days of RID data: the catch-up of an hour
matches `ping-collector forecast --now <hour>`; 288 hours took 20 s
(debug build). Tests for both database rules.
2026-10-20 15:21:48 +07:00

197 lines
7.5 KiB
Python

"""The dashboard served at / (src/static/dashboard.html, docs/DASHBOARD_REDESIGN.md).
Rules: every fetched path is routed, every string has EN and TH (Thai first),
no emoji in the interface, and a replay or a simulation is never shown as
live data.
"""
import re
from pathlib import Path
STATIC = Path(__file__).parents[1] / "src" / "static"
DASHBOARD = STATIC / "dashboard.html"
ROUTER = Path(__file__).parents[1] / "rust" / "collector" / "src" / "web" / "mod.rs"
def _html() -> str:
return DASHBOARD.read_text(encoding="utf-8")
def _body(html: str) -> str:
"""Markup only. The STRINGS table repeats every English phrase, so a
whole-file search would pass even if an element were deleted."""
return html[html.index("<body>") : html.index("<script src=")]
def _routes() -> set:
"""Paths the website serves (ping-collector's axum router)."""
return set(re.findall(r'\.route\(\s*"([^"]+)"', ROUTER.read_text("utf-8")))
def _tables(html: str) -> dict:
"""The `en:` / `th:` key sets of the STRINGS literal. Crude, but it
catches a key added to one language and forgotten in the other, which
silently falls back to English for Thai readers."""
start = html.index("const STRINGS = {")
end = html.index("// Thai unless the visitor's browser", start)
block = html[start:end]
tables = {}
for lang in ("en", "th"):
section = re.search(rf"\n {lang}: {{\n(.*?)\n }},\n", block, re.S)
assert section, f"{lang} table not found in STRINGS"
tables[lang] = set(re.findall(r"^\s{12}'([^']+)':", section.group(1), re.M))
return tables
def test_every_fetched_path_is_routed():
routes = _routes()
patterns = [
re.compile("^" + re.sub(r"\\\{[^}]+\\\}", "[^/]+", re.escape(r)) + "$")
for r in routes
]
fetched = set(re.findall(r"fetch\(\s*[`'\"](/[^`'\"?$]*)", _html()))
assert {"/stations", "/forecast", "/api/notifications"} <= fetched
missing = [
p
for p in fetched
if not p.startswith("/static/")
and not any(r.match(p.rstrip("/") or "/") for r in patterns)
and not any(r.startswith(p) for r in routes)
]
assert not missing, f"dashboard fetches paths the server does not route: {missing}"
def test_static_files_it_loads_exist():
paths = re.findall(r"/static/([\w./-]+\.(?:geojson|json|woff2|pmtiles))", _html())
assert "ping-river-network.geojson" in paths # mapped rivers, not lines
assert "flood-2024-all.json" in paths
for path in paths:
assert (STATIC / path).is_file(), path
def test_translations_cover_both_languages():
tables = _tables(_html())
assert len(tables["en"]) > 100
assert not tables["en"] - tables["th"], sorted(tables["en"] - tables["th"])
assert not tables["th"] - tables["en"], sorted(tables["th"] - tables["en"])
def test_markup_keys_exist():
html = _html()
used = set(re.findall(r'data-i18n(?:-placeholder|-title|-aria)?="([^"]+)"', html))
assert not used - _tables(html)["en"], sorted(used - _tables(html)["en"])
def test_aria_labels_are_translated():
"""A Thai page must not hand screen-reader users English landmarks."""
for match in re.finditer(r'<[^>]*\saria-label="[^"]+"[^>]*>', _body(_html())):
assert "data-i18n-aria" in match.group(0), match.group(0)[:120]
def test_no_emoji_in_the_interface():
emoji = re.compile("[\U0001f000-\U0001faff\u2600-\u27bf\u2b00-\u2bff\ufe0f]")
found = sorted(set(emoji.findall(_html())))
assert not found, f"emoji in dashboard.html: {found}"
def test_thai_first_and_river_time():
"""navigator.languages = ['th-TH','en-US'] must resolve to Thai."""
html = _html()
assert "if (code.startsWith('th')) return 'th';" in html
assert "ping-monitor-lang" in html # remembered language choice
assert "const TZ = 'Asia/Bangkok';" in html
assert "'+07:00'" in html # naive API timestamps are pinned to Bangkok
def test_phone_safe():
html = _html()
assert "overflow-x: hidden" in html
assert 'name="viewport"' in html
def test_fonts_are_self_hosted_with_their_licence():
html = _html()
assert "fonts.googleapis.com" not in html
for name in ("inter-latin.woff2", "noto-sans-thai-thai.woff2"):
assert f"/static/fonts/{name}" in html
assert (STATIC / "fonts" / "inter-OFL.txt").is_file()
assert (STATIC / "fonts" / "noto-sans-thai-OFL.txt").is_file()
def test_basemap_is_self_hosted():
html = _html()
assert "/static/basemap/ping-basin.pmtiles" in html
assert "protomaps-leaflet@5.1.0" in html and 'integrity="sha384-' in html
# the attribution OpenStreetMap's licence requires
assert "openstreetmap.org/copyright" in html
def test_basin_stations_and_rain_have_their_feeds():
html = _html()
assert "fetch('/api/hii/waterlevel/latest')" in html
assert "fetch('/sensors/thaiwater')" in html # fallback feed
assert "fetch('/api/hii/rainfall/latest')" in html
def test_history_reaches_today():
"""/measurements/history returns the oldest rows first and stops at its
limit (50,000 by default): "All" must ask for more, or P.1 ends in 2024."""
html = _html()
assert "hours=${hours}&limit=100000" in html
assert "/api/forecast/history/" in html
# the issued line is the model's; a heuristic row (the level itself) dips
assert "r.predicted_max_level != null && r.source === 'model'" in html
def test_skill_loads_only_when_opened():
html = _html()
assert "fetch('/api/forecast/skill?station_code=P.1&horizon=24')" in html
assert "sk.addEventListener('toggle'" in html
def test_one_current_river_level():
"""The verdict and the P.1 outlook must not disagree about "now".
The old dashboard once showed 1.66 m in the banner and 1.52 m in the
outlook: the outlook used the forecast payload's current_level from an
older as_of. Every view reads the gauge's level from one station record.
"""
html = _html()
assert "current_level" not in html
assert "const p1 = state.byCode.get('P.1');" in html
def test_replay_and_simulation_are_never_shown_as_live():
"""Relabelling a replay or a pinned simulation as live would present
2024 or invented flood data as today's."""
html = _html()
# the mode bar and the data-age KPI are drawn from state.mode alone, and
# redrawn on a language switch
assert "bar.hidden = state.mode === 'live';" in html
assert "if (newest && state.mode === 'live') {" in html
assert "renderStats();\n renderModebar();\n }" in html
# the 5-minute refresh would replace the replay frame with today's data
assert "if (state.mode !== 'replay') { load(); loadStats(); }" in html
# the demo hooks
assert "params.get('demo_level')" in html and "params.has('demo_rise')" in html
for key in ("mode.replay", "mode.sim", "sim.pinned", "sim.rising"):
assert f"'{key}':" in html
def test_seo_and_indexing_files():
html = _html()
assert '<meta name="description"' in html
assert '<link rel="canonical" href="https://water.buildfor.life/">' in html
assert 'property="og:title"' in html
assert "application/ld+json" in html
assert 'rel="icon"' in html
assert "noindex" not in html
robots = (STATIC / "robots.txt").read_text(encoding="utf-8")
assert "Sitemap: https://water.buildfor.life/sitemap.xml" in robots
sitemap = (STATIC / "sitemap.xml").read_text(encoding="utf-8")
assert "<loc>https://water.buildfor.life/</loc>" in sitemap
llms = (STATIC / "llms.txt").read_text(encoding="utf-8")
assert "Ping River Live Monitor" in llms
assert {"/robots.txt", "/llms.txt", "/sitemap.xml"} <= _routes()