feat: Thai localisation and portrait-phone layout for the dashboard
Thai is the default unless the browser prefers English, chosen by first match in navigator.languages order and remembered in localStorage. A STRINGS table carries both languages (interpolated strings as functions), applyTranslations() drives static markup via data-i18n attributes, and setLang() rebuilds everything the JS renders — including map layers, so popups render in the current language and sensor markers are replaced rather than stacked. Thai dates use the Buddhist era, matching the replay label; station names lead with the reader's language. Portrait phones: the header overflowed a 412 px Android viewport by 64 px, so the page scrolled sideways and the Refresh button sat off screen. The header now wraps into two deliberate rows (DOM order matches visual order, so focus order is unaffected), the map description box is hidden on phones, map height is capped by viewport — including a height-gated rule for landscape phones, whose 850-960 px widths never matched the width breakpoints — and the forecast grid goes single column. Verified in a real browser at 412x915, 360x800, 915x412 and 1440x900: zero horizontal overflow, no desktop change. Review-swarm fixes: a language switch no longer relabels a pinned SIMULATION or the 2024 replay as LIVE DATA (it kept the mode from state); Thai wording corrected where it asserted a rising trend the code never checks, labelled every gauge 'critical', or used a malformed compound; aria-labels, the Leaflet load failure and the flood-stage chips are translated; the language toggle states its action instead of an aria-pressed value that contradicted its label; and Thai font families sit after the Latin stack so they cannot restyle English text.
This commit is contained in:
+722
-174
File diff suppressed because it is too large
Load Diff
+101
-13
@@ -14,12 +14,18 @@ def test_dashboard_contains_live_map_and_flow_visualization():
|
|||||||
assert "leaflet" in html.lower()
|
assert "leaflet" in html.lower()
|
||||||
|
|
||||||
|
|
||||||
def test_dashboard_explains_flow_legend_and_refresh():
|
def _body(html: str) -> str:
|
||||||
html = DASHBOARD_PATH.read_text(encoding="utf-8")
|
"""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=")]
|
||||||
|
|
||||||
assert "Flow status" in html
|
|
||||||
assert "Last updated" in html
|
def test_dashboard_explains_flow_legend_and_refresh():
|
||||||
assert "Refresh" in html
|
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 test_dashboard_uses_mapped_river_network_instead_of_station_connections():
|
def test_dashboard_uses_mapped_river_network_instead_of_station_connections():
|
||||||
@@ -35,7 +41,7 @@ def test_dashboard_loads_additional_thaiwater_sensors():
|
|||||||
html = DASHBOARD_PATH.read_text(encoding="utf-8")
|
html = DASHBOARD_PATH.read_text(encoding="utf-8")
|
||||||
|
|
||||||
assert "fetch('/sensors/thaiwater')" in html
|
assert "fetch('/sensors/thaiwater')" in html
|
||||||
assert "Additional basin stations" in html
|
assert 'data-i18n="sensors.title"' in _body(html)
|
||||||
assert 'id="station-search"' in html
|
assert 'id="station-search"' in html
|
||||||
assert "applyStationSearch" in html
|
assert "applyStationSearch" in html
|
||||||
|
|
||||||
@@ -67,23 +73,105 @@ def test_dashboard_shows_hii_rainfall_layer():
|
|||||||
assert "fetch('/api/hii/waterlevel/latest')" in html
|
assert "fetch('/api/hii/waterlevel/latest')" in html
|
||||||
assert "renderRainLayer" in html
|
assert "renderRainLayer" in html
|
||||||
assert "rain-toggle" in html
|
assert "rain-toggle" in html
|
||||||
assert "Rainfall · 24 h" in html
|
body = _body(html)
|
||||||
|
assert 'data-i18n="legend.rain"' in body
|
||||||
# TMD rain classes on the legend
|
# TMD rain classes on the legend
|
||||||
assert "Heavy 35–90" in html
|
assert 'data-i18n="legend.rain.heavy"' in body
|
||||||
assert "Extreme > 150" in html
|
assert 'data-i18n="legend.rain.extreme"' in body
|
||||||
|
|
||||||
|
|
||||||
def test_dashboard_loads_station_history_chart():
|
def test_dashboard_loads_station_history_chart():
|
||||||
html = DASHBOARD_PATH.read_text(encoding="utf-8")
|
html = DASHBOARD_PATH.read_text(encoding="utf-8")
|
||||||
|
|
||||||
assert "Station history" in html
|
assert 'id="history-card"' in html
|
||||||
assert "/api/forecast/history/" in html
|
assert "/api/forecast/history/" in html
|
||||||
assert "Model 24 h peak (as issued)" in html
|
assert "'chart.model'" in html
|
||||||
assert "PostgreSQL" not in html
|
assert "PostgreSQL" not in html
|
||||||
assert "/measurements/history/" in html
|
assert "/measurements/history/" in html
|
||||||
assert "history-chart" in html
|
assert "history-chart" in html
|
||||||
# Date-range picker alongside the quick-range dropdown
|
# Date-range picker alongside the quick-range dropdown
|
||||||
assert 'id="history-start"' in html
|
assert 'id="history-start"' in html
|
||||||
assert 'id="history-end"' in html
|
assert 'id="history-end"' in html
|
||||||
assert "Last 7 days" in html
|
assert 'data-i18n="range.7d"' in _body(html)
|
||||||
assert "Last 90 days" in 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
|
||||||
|
|
||||||
|
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_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_dashboard_i18n_markup_keys_exist():
|
||||||
|
"""Every data-i18n attribute must resolve to a real string key."""
|
||||||
|
import re
|
||||||
|
|
||||||
|
html = DASHBOARD_PATH.read_text(encoding="utf-8")
|
||||||
|
keys = _extract_lang_tables(html)["en"]
|
||||||
|
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)}"
|
||||||
|
|
||||||
|
|
||||||
|
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():
|
||||||
|
"""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]}")
|
||||||
|
|
||||||
|
|
||||||
|
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_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
|
||||||
|
assert "if (code.startsWith('th')) return 'th';" in html
|
||||||
|
|||||||
Reference in New Issue
Block a user