[verified] feat: add live river dashboard
Add mapped river and ThaiWater sensor layers, PostgreSQL history charts, API endpoints, and dashboard tests.
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
DASHBOARD_PATH = Path(__file__).parents[1] / "src" / "static" / "dashboard.html"
|
||||
|
||||
|
||||
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 test_dashboard_explains_flow_legend_and_refresh():
|
||||
html = DASHBOARD_PATH.read_text(encoding="utf-8")
|
||||
|
||||
assert "Flow status" in html
|
||||
assert "Last updated" in html
|
||||
assert "Refresh" in html
|
||||
|
||||
|
||||
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 "Additional ThaiWater sensor" in html
|
||||
|
||||
|
||||
def test_dashboard_loads_postgresql_history_chart():
|
||||
html = DASHBOARD_PATH.read_text(encoding="utf-8")
|
||||
|
||||
assert "PostgreSQL history" in html
|
||||
assert "/measurements/history/" in html
|
||||
assert "history-chart" in html
|
||||
@@ -0,0 +1,49 @@
|
||||
import datetime
|
||||
|
||||
from sqlalchemy import create_engine, text
|
||||
|
||||
from src.postgres_history import PostgresHistory
|
||||
|
||||
|
||||
def test_history_returns_station_series_in_chronological_order(tmp_path):
|
||||
engine = create_engine(f"sqlite:///{tmp_path / 'history.db'}")
|
||||
with engine.begin() as connection:
|
||||
connection.execute(text("CREATE TABLE stations (id INTEGER PRIMARY KEY, station_code TEXT)"))
|
||||
connection.execute(
|
||||
text(
|
||||
"CREATE TABLE water_measurements ("
|
||||
"timestamp DATETIME, station_id INTEGER, water_level REAL, "
|
||||
"discharge REAL, discharge_percent REAL)"
|
||||
)
|
||||
)
|
||||
connection.execute(text("INSERT INTO stations VALUES (1, 'P.1'), (2, 'P.20')"))
|
||||
connection.execute(
|
||||
text(
|
||||
"INSERT INTO water_measurements VALUES "
|
||||
"('2026-08-09 13:00:00', 1, 3.2, 110.0, 40.0),"
|
||||
"('2026-08-09 14:00:00', 1, 3.4, 120.0, 42.0),"
|
||||
"('2026-08-09 14:00:00', 2, 2.1, 30.0, 15.0)"
|
||||
)
|
||||
)
|
||||
|
||||
history = PostgresHistory(engine=engine).station_history(
|
||||
"P.1",
|
||||
start=datetime.datetime(2026, 8, 9, 12),
|
||||
end=datetime.datetime(2026, 8, 9, 15),
|
||||
limit=100,
|
||||
)
|
||||
|
||||
assert [row["timestamp"].hour for row in history] == [13, 14]
|
||||
assert [row["discharge"] for row in history] == [110.0, 120.0]
|
||||
assert all(row["station_code"] == "P.1" for row in history)
|
||||
|
||||
|
||||
def test_history_rejects_excessive_limit():
|
||||
history = PostgresHistory.__new__(PostgresHistory)
|
||||
|
||||
try:
|
||||
history.station_history("P.1", datetime.datetime.now(), datetime.datetime.now(), 5001)
|
||||
except ValueError as error:
|
||||
assert "limit" in str(error)
|
||||
else:
|
||||
raise AssertionError("Expected excessive history limit to be rejected")
|
||||
@@ -0,0 +1,75 @@
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from src.thaiwater import ThaiWaterClient
|
||||
|
||||
|
||||
SAMPLE_RESPONSE = {
|
||||
"data": {
|
||||
"50": {
|
||||
"type": "FeatureCollection",
|
||||
"features": [
|
||||
{
|
||||
"geometry": {"type": "Point", "coordinates": [98.635262, 19.638411]},
|
||||
"properties": {
|
||||
"id": "123",
|
||||
"waterlevelDatetime": "2026-08-09T15:00:00+07:00",
|
||||
"waterlevelMsl": 742.34,
|
||||
"storagePercent": 38.57,
|
||||
"diffWlBank": 1.87,
|
||||
"riverName": "Ping River",
|
||||
"station": {
|
||||
"stationCode": "G07003-P.65",
|
||||
"station": "Ban Muang Pok",
|
||||
},
|
||||
"agency": {"agencyShort": "RID"},
|
||||
"basin": {"basin": "Ping"},
|
||||
},
|
||||
},
|
||||
{
|
||||
"geometry": {"type": "Point", "coordinates": [100.1, 18.1]},
|
||||
"properties": {
|
||||
"id": "999",
|
||||
"station": {"stationCode": "N.1", "station": "Nan station"},
|
||||
"basin": {"basin": "Nan"},
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def test_fetch_ping_sensors_normalizes_and_filters_basin():
|
||||
session = MagicMock()
|
||||
response = session.get.return_value
|
||||
response.json.return_value = SAMPLE_RESPONSE
|
||||
response.raise_for_status.return_value = None
|
||||
|
||||
sensors = ThaiWaterClient(session=session, api_key="public-key").fetch_ping_sensors()
|
||||
|
||||
assert sensors == [
|
||||
{
|
||||
"id": "thaiwater:123",
|
||||
"station_code": "P.65",
|
||||
"station_name": "Ban Muang Pok",
|
||||
"latitude": 19.638411,
|
||||
"longitude": 98.635262,
|
||||
"timestamp": "2026-08-09T15:00:00+07:00",
|
||||
"water_level_msl": 742.34,
|
||||
"bank_percent": 38.57,
|
||||
"distance_to_bank": 1.87,
|
||||
"river_name": "Ping River",
|
||||
"agency": "RID",
|
||||
"source": "ThaiWater",
|
||||
}
|
||||
]
|
||||
session.get.assert_called_once()
|
||||
assert session.get.call_args.kwargs["headers"]["x-api-key"] == "public-key"
|
||||
|
||||
|
||||
def test_fetch_ping_sensors_skips_features_without_coordinates():
|
||||
response_data = {"data": {"50": {"features": [{"geometry": None, "properties": {"basin": {"basin": "Ping"}}}]}}}
|
||||
session = MagicMock()
|
||||
session.get.return_value.json.return_value = response_data
|
||||
|
||||
assert ThaiWaterClient(session=session, api_key="key").fetch_ping_sensors() == []
|
||||
Reference in New Issue
Block a user