Files
Northern-Thailand-Ping-Rive…/tests/test_console.py
T
grabowski ffd3030352
CI / Format & lint (push) Successful in 25s
CI / Test suite (push) Successful in 58s
Docs / Validate documentation (push) Successful in 11s
feat(dashboard): console phase 3 - history, forecast accuracy, data statistics
- History in the station panel (inline SVG): 3 d / 7 d / 30 d / 90 d / 1 y /
  all or a date range; level and discharge, the 24 h peaks as the model
  issued them, warning/danger bands, hover/touch values. Up to 7 d the chart
  ends in the current forecast. Above 2,500 readings it shows the daily mean
  with the daily low-high band, so a flood peak is not averaged away; gaps
  (RID's nightly pause) break the line instead of being bridged. Days are
  Bangkok days computed arithmetically: Intl per reading took a minute for
  "All" (67,000 rows), now 2 s.
- "How accurate is the forecast?" for P.1 (/api/forecast/skill), loaded when
  opened because a cold skill query takes up to 20 s.
- Data statistics (/api/stats) behind "2.7M readings since 2018" in the
  status bar.

fix(dashboard): "All time" history ended in October 2024. The history route
returns the oldest rows first and stops at 50,000 by default; P.1 has about
67,000 hours. Both dashboards now ask for limit=100000 (the route's maximum).
2026-10-19 17:12:39 +07:00

131 lines
4.6 KiB
Python

"""The console dashboard (src/static/console.html, docs/DASHBOARD_REDESIGN.md).
Served at /static/console.html while it is built; it replaces dashboard.html
once it covers every feature. The rules are the dashboard's: every fetched
path is routed, every string has EN and TH, no emoji in the interface.
"""
import re
from pathlib import Path
STATIC = Path(__file__).parents[1] / "src" / "static"
CONSOLE = STATIC / "console.html"
ROUTER = Path(__file__).parents[1] / "rust" / "collector" / "src" / "web" / "mod.rs"
def _html() -> str:
return CONSOLE.read_text(encoding="utf-8")
def _body(html: str) -> str:
return html[html.index("<body>") : html.index("<script src=")]
def _tables(html: str) -> dict:
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 = set(re.findall(r'\.route\(\s*"([^"]+)"', ROUTER.read_text("utf-8")))
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"console fetches paths the server does not route: {missing}"
def test_static_files_it_loads_exist():
for path in re.findall(
r"/static/([\w./-]+\.(?:geojson|json|woff2|pmtiles))", _html()
):
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():
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 console.html: {found}"
def test_thai_first_and_river_time():
html = _html()
assert "if (code.startsWith('th')) return 'th';" in html
assert "const TZ = 'Asia/Bangkok';" in html
assert "'+07:00'" in html # naive API timestamps are pinned to Bangkok
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_have_a_fallback_feed():
html = _html()
assert "fetch('/api/hii/waterlevel/latest')" in html
assert "fetch('/sensors/thaiwater')" 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."""
assert "hours=${hours}&limit=100000" in _html()
dashboard = (STATIC / "dashboard.html").read_text(encoding="utf-8")
assert "&limit=100000" in dashboard
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_preview_is_not_indexed():
assert '<meta name="robots" content="noindex">' in _html()