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.
This commit is contained in:
1 parent
ffd3030352
commit
cf10f2e4c8
15 files changed
+2419
-4599
No files matched your search
@@ -1,130 +0,0 @@
|
||||
"""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()
|
||||
+149
-187
@@ -1,47 +1,20 @@
|
||||
"""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
|
||||
|
||||
DASHBOARD_PATH = Path(__file__).parents[1] / "src" / "static" / "dashboard.html"
|
||||
ROUTER_PATH = (
|
||||
Path(__file__).parents[1] / "rust" / "collector" / "src" / "web" / "mod.rs"
|
||||
)
|
||||
STATIC = Path(__file__).parents[1] / "src" / "static"
|
||||
DASHBOARD = STATIC / "dashboard.html"
|
||||
ROUTER = Path(__file__).parents[1] / "rust" / "collector" / "src" / "web" / "mod.rs"
|
||||
|
||||
|
||||
def _rust_routes() -> set:
|
||||
"""Paths the website serves (ping-collector's axum router)."""
|
||||
source = ROUTER_PATH.read_text(encoding="utf-8")
|
||||
return set(re.findall(r'\.route\(\s*"([^"]+)"', source))
|
||||
|
||||
|
||||
def test_every_api_path_the_dashboard_fetches_is_served():
|
||||
html = DASHBOARD_PATH.read_text(encoding="utf-8")
|
||||
routes = _rust_routes()
|
||||
patterns = [
|
||||
re.compile("^" + re.sub(r"\\\{[^}]+\\\}", "[^/]+", re.escape(r)) + "$")
|
||||
for r in routes
|
||||
]
|
||||
fetched = set(re.findall(r"fetch\(\s*[`'\"](/[^`'\"?$]*)", html))
|
||||
assert fetched, "no fetch() calls found"
|
||||
missing = [
|
||||
path
|
||||
for path in fetched
|
||||
if not path.startswith("/static/")
|
||||
and not any(p.match(path.rstrip("/") or "/") for p in patterns)
|
||||
and not any(r.startswith(path) for r in routes)
|
||||
]
|
||||
assert not missing, f"dashboard fetches paths the server does not route: {missing}"
|
||||
|
||||
|
||||
def test_dashboard_contains_live_map_and_flow_visualization():
|
||||
html = DASHBOARD_PATH.read_text(encoding="utf-8")
|
||||
|
||||
assert 'id="station-map"' in html
|
||||
assert 'id="river-flow"' in html
|
||||
assert "fetch('/stations')" in html or 'fetch("/stations")' in html
|
||||
assert (
|
||||
"fetch('/measurements/latest" in html or 'fetch("/measurements/latest' in html
|
||||
)
|
||||
assert "leaflet" in html.lower()
|
||||
def _html() -> str:
|
||||
return DASHBOARD.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _body(html: str) -> str:
|
||||
@@ -50,92 +23,15 @@ def _body(html: str) -> str:
|
||||
return html[html.index("<body>") : html.index("<script src=")]
|
||||
|
||||
|
||||
def test_dashboard_explains_flow_legend_and_refresh():
|
||||
body = _body(DASHBOARD_PATH.read_text(encoding="utf-8"))
|
||||
|
||||
assert 'data-i18n="legend.flow"' in body
|
||||
assert 'data-i18n="stat.updated"' in body
|
||||
assert 'data-i18n="action.refresh"' in body
|
||||
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 test_dashboard_uses_mapped_river_network_instead_of_station_connections():
|
||||
html = DASHBOARD_PATH.read_text(encoding="utf-8")
|
||||
river_network = DASHBOARD_PATH.with_name("ping-river-network.geojson")
|
||||
|
||||
assert river_network.exists()
|
||||
assert "fetch('/static/ping-river-network.geojson')" in html
|
||||
assert "mainBasin.map" not in html
|
||||
|
||||
|
||||
def test_dashboard_loads_additional_thaiwater_sensors():
|
||||
html = DASHBOARD_PATH.read_text(encoding="utf-8")
|
||||
|
||||
assert "fetch('/sensors/thaiwater')" in html
|
||||
assert 'data-i18n="sensors.title"' in _body(html)
|
||||
assert 'id="station-search"' in html
|
||||
assert "applyStationSearch" in html
|
||||
|
||||
|
||||
def test_dashboard_has_seo_and_indexing_files():
|
||||
html = DASHBOARD_PATH.read_text(encoding="utf-8")
|
||||
static_dir = DASHBOARD_PATH.parent
|
||||
|
||||
assert '<meta name="description"' in html
|
||||
assert '<link rel="canonical" href="https://water.buildfor.life/">' in html
|
||||
assert 'property="og:title"' in html
|
||||
assert 'rel="icon"' in html
|
||||
|
||||
robots = (static_dir / "robots.txt").read_text(encoding="utf-8")
|
||||
assert "Sitemap: https://water.buildfor.life/sitemap.xml" in robots
|
||||
assert "<loc>https://water.buildfor.life/</loc>" in (
|
||||
static_dir / "sitemap.xml"
|
||||
).read_text(encoding="utf-8")
|
||||
assert "Ping River Live Monitor" in (static_dir / "llms.txt").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
|
||||
assert {"/robots.txt", "/llms.txt", "/sitemap.xml"} <= _rust_routes()
|
||||
|
||||
|
||||
def test_dashboard_shows_hii_rainfall_layer():
|
||||
html = DASHBOARD_PATH.read_text(encoding="utf-8")
|
||||
|
||||
assert "fetch('/api/hii/rainfall/latest')" in html
|
||||
assert "fetch('/api/hii/waterlevel/latest')" in html
|
||||
assert "renderRainLayer" in html
|
||||
assert "rain-toggle" in html
|
||||
body = _body(html)
|
||||
assert 'data-i18n="legend.rain"' in body
|
||||
# TMD rain classes on the legend
|
||||
assert 'data-i18n="legend.rain.heavy"' in body
|
||||
assert 'data-i18n="legend.rain.extreme"' in body
|
||||
|
||||
|
||||
def test_dashboard_loads_station_history_chart():
|
||||
html = DASHBOARD_PATH.read_text(encoding="utf-8")
|
||||
|
||||
assert 'id="history-card"' in html
|
||||
assert "/api/forecast/history/" in html
|
||||
assert "'chart.model'" in html
|
||||
assert "PostgreSQL" not in html
|
||||
assert "/measurements/history/" in html
|
||||
assert "history-chart" in html
|
||||
# Date-range picker alongside the quick-range dropdown
|
||||
assert 'id="history-start"' in html
|
||||
assert 'id="history-end"' in html
|
||||
assert 'data-i18n="range.7d"' in _body(html)
|
||||
assert 'data-i18n="range.90d"' in _body(html)
|
||||
|
||||
|
||||
def _extract_lang_tables(html: str) -> dict:
|
||||
"""Pull the `en:` / `th:` key sets out of the STRINGS literal.
|
||||
|
||||
Parsing the JS with a regex is crude, but it is enough to catch the failure
|
||||
that matters: a key added to one language and forgotten in the other, which
|
||||
silently falls back to English for Thai readers.
|
||||
"""
|
||||
import re
|
||||
|
||||
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]
|
||||
@@ -147,86 +43,152 @@ def _extract_lang_tables(html: str) -> dict:
|
||||
return tables
|
||||
|
||||
|
||||
def test_dashboard_translations_cover_both_languages():
|
||||
html = DASHBOARD_PATH.read_text(encoding="utf-8")
|
||||
tables = _extract_lang_tables(html)
|
||||
|
||||
assert len(tables["en"]) > 100, "expected the full English string table"
|
||||
missing_th = tables["en"] - tables["th"]
|
||||
missing_en = tables["th"] - tables["en"]
|
||||
assert not missing_th, f"keys missing a Thai translation: {sorted(missing_th)}"
|
||||
assert (
|
||||
not missing_en
|
||||
), f"Thai-only keys with no English fallback: {sorted(missing_en)}"
|
||||
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_dashboard_i18n_markup_keys_exist():
|
||||
"""Every data-i18n attribute must resolve to a real string key."""
|
||||
import re
|
||||
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
|
||||
|
||||
html = DASHBOARD_PATH.read_text(encoding="utf-8")
|
||||
keys = _extract_lang_tables(html)["en"]
|
||||
|
||||
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))
|
||||
unknown = used - keys
|
||||
assert not unknown, f"markup references undefined string keys: {sorted(unknown)}"
|
||||
assert not used - _tables(html)["en"], sorted(used - _tables(html)["en"])
|
||||
|
||||
|
||||
def test_dashboard_is_mobile_portrait_safe():
|
||||
html = DASHBOARD_PATH.read_text(encoding="utf-8")
|
||||
|
||||
# The header row overflowed a 412 px Android viewport by 64 px until it wrapped
|
||||
assert "flex-wrap: wrap" in html
|
||||
assert "overflow-x: hidden" in html
|
||||
assert 'id="lang-toggle"' in html
|
||||
assert "ping-monitor-lang" in html # remembered language choice
|
||||
|
||||
|
||||
def test_dashboard_translates_user_visible_aria_labels():
|
||||
def test_aria_labels_are_translated():
|
||||
"""A Thai page must not hand screen-reader users English landmarks."""
|
||||
import re
|
||||
|
||||
html = DASHBOARD_PATH.read_text(encoding="utf-8")
|
||||
body = _body(html)
|
||||
for match in re.finditer(r'<[^>]*\saria-label="[^"]+"[^>]*>', body):
|
||||
tag = match.group(0)
|
||||
if "data-i18n-aria" in tag or 'id="lang-toggle"' in tag:
|
||||
continue # the toggle sets its own label per language in JS
|
||||
raise AssertionError(f"aria-label without a translation key: {tag[:120]}")
|
||||
for match in re.finditer(r'<[^>]*\saria-label="[^"]+"[^>]*>', _body(_html())):
|
||||
assert "data-i18n-aria" in match.group(0), match.group(0)[:120]
|
||||
|
||||
|
||||
def test_dashboard_keeps_simulation_and_replay_labels_on_language_switch():
|
||||
"""Relabelling a pinned simulation as LIVE would present fake flood data as real."""
|
||||
html = DASHBOARD_PATH.read_text(encoding="utf-8")
|
||||
|
||||
assert "setLiveIndicator(state.liveMode || 'live', state.liveLabelKey)" in html
|
||||
assert "if (!state.replayTimer) setLiveIndicator('live');" not in html
|
||||
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_dashboard_default_language_respects_browser_order():
|
||||
"""navigator.languages = ['th-TH','en-US'] must resolve to Thai, not English."""
|
||||
html = DASHBOARD_PATH.read_text(encoding="utf-8")
|
||||
|
||||
assert "langs.some" not in html # the old any-English-wins test
|
||||
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_dashboard_shows_one_current_river_level():
|
||||
"""The verdict banner and the P.1 outlook must not disagree about "now".
|
||||
def test_phone_safe():
|
||||
html = _html()
|
||||
assert "overflow-x: hidden" in html
|
||||
assert 'name="viewport"' in html
|
||||
|
||||
Production served 1.66 m in the banner and 1.52 m in the outlook at the
|
||||
same moment: the banner used the latest measurement, the outlook used the
|
||||
forecast payload's current_level from an older as_of.
|
||||
|
||||
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 = DASHBOARD_PATH.read_text(encoding="utf-8")
|
||||
html = _html()
|
||||
assert "current_level" not in html
|
||||
assert "const p1 = state.byCode.get('P.1');" in html
|
||||
|
||||
# One arbitrated writer, freshest-wins
|
||||
assert "function setP1Level(" in html
|
||||
assert "state.p1NowAt" in html
|
||||
|
||||
# Neither feed may assign the level directly any more
|
||||
assert "state.p1Now = Number(p1.water_level)" not in html
|
||||
assert "state.p1Now = row.current_level" not 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
|
||||
|
||||
# The outlook renders the arbitrated level, and never a peak below it
|
||||
assert "const shownNow = state.p1Now != null" in html
|
||||
assert "Math.max(Number(row.predicted_max_level), shownNow)" 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()
|
||||
Reference in new issue
Block a user