Files
Northern-Thailand-Ping-Rive…/tests/test_dashboard.py
T
grabowski cf10f2e4c8
CI / Format & lint (push) Successful in 26s
CI / Test suite (push) Successful in 1m15s
Docs / Validate documentation (push) Successful in 12s
feat(dashboard): console phase 4 - 2024 replay, simulation, replaces the old page
The console is now the dashboard at / (src/static/dashboard.html); the old
page and console.html are gone.

- Replay of the October 2024 flood (flood-2024-all.json): the page's clock,
  verdict, list, markers, flood zones and panel follow the replay; the panel
  charts the season on fixed axes with the 2024 model's 24 h peak. Hourly
  while the model alerts or P.1 is above 3.50 m, faster through quiet days;
  pause and drag. Rain, basin stations and the 5-minute refresh are off
  during the replay (today's data).
- ?demo_level= and ?demo_rise= simulate P.1. A violet REPLAY / amber
  SIMULATION bar, the top bar's KPI and the P.1 panel say so; the mode is
  drawn from state.mode alone, so a refresh or language switch cannot
  relabel either as live.
- History: rows without discharge_percent are ThaiWater's copy of the gauge
  (night hours RID publishes late), with a different rating (P.1: 26 against
  RID's 54 m3/s); their discharge is not drawn for gauges with RID discharge.
  The discharge strip has headroom so its label stays clear of the line.
- Swap: the old page's title, description, Open Graph and JSON-LD tags; no
  noindex; tests/test_dashboard.py = console tests + the old checks that
  still apply (one P.1 level, SEO files) + a replay/simulation labelling test.
- Docs: README screenshot (docs/img/dashboard.jpg, replacing two unused
  images), CLAUDE.md, CONTRIBUTING.md, FLOOD_FORECASTING.md (where the model
  shows), DASHBOARD_REDESIGN.md (phase 4 done, mode rules).

Deploy needs a restart: the server reads dashboard.html at start.
2026-10-20 13:47:15 +07:00

195 lines
7.4 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
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()