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.
197 lines
7.5 KiB
Python
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()
|