mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-06-10 17:04:45 +02:00
feat(v0.6-4): gauge_sites + town_anchors curation tables + GUI CRUD
Closes Section A.5 (gauge_sites) and A.12 (town_anchors) of the audit
doc by lifting both Python-dict curation lists into editable SQLite
tables. Operators can add/edit/disable rows from the dashboard without
a deploy; runtime reads go through cached accessors that invalidate
when the REST API mutates state.
Schema:
v8.sql adds gauge_sites(site_id PK, gauge_name, lat, lon, action_ft,
flood_minor_ft, flood_moderate_ft, flood_major_ft, enabled, updated_at).
v9.sql adds town_anchors(anchor_id AUTOINC PK, name UNIQUE, lat, lon,
state, enabled, updated_at).
SCHEMA_VERSION 7 -> 9.
Seed (meshai/persistence/curation.py):
_GAUGE_SITES_SEED carries the original 9 Idaho rows from
IDAHO_CURATED_SITES verbatim.
_TOWN_ANCHORS_SEED carries the 29 Idaho-and-neighbor towns from
_TOWN_COORDS verbatim.
seed_gauge_sites() / seed_town_anchors() INSERT OR IGNORE -- safe to
re-run; never overwrites user edits.
Handler integration:
- meshai/central/idaho_gauge_sites.py: IDAHO_CURATED_SITES dict deleted.
lookup_site() now calls meshai.persistence.curation.lookup_gauge_site()
which reads the table. THRESHOLD_RANK, normalize_site_id, and
compute_threshold_state remain in this module (CODE per Matt s rule).
- meshai/central/nwis_handler.py drops IDAHO_CURATED_SITES from its
import list; the table-backed lookup_site() is API-compatible.
- meshai/central_normalizer.py: _TOWN_COORDS dict deleted.
_compute_distance_bearing() now calls
meshai.persistence.curation.lookup_town_anchor() with the same
lowercased-name semantics it always used.
REST API (meshai/dashboard/api/curation_routes.py):
/api/gauge-sites GET list, GET one, POST add, PUT update, DELETE
/api/town-anchors GET list, GET one, POST add, PUT update, DELETE
Every mutation calls invalidate_curation_cache() so handler reads see
the new state on the next call -- no container restart.
Dashboard (dashboard-frontend/src/pages/):
- GaugeSites.tsx: table view with Add row / Edit row inline / Delete
confirm + per-row enabled toggle. 8 columns mirror the schema.
- TownAnchors.tsx: same pattern, 5 columns. Name is lowercased on
save to match the lookup key.
- Left-nav entries "Gauge Sites" (Droplets icon) and "Town Anchors"
(MapPin icon) added to Layout.tsx; routes added to App.tsx.
Tests (tests/test_curation.py, 18 cases):
- v8/v9 tables exist
- Seed lands every row from both dicts
- Seed idempotent; never overwrites user edits
- lookup_gauge_site hits/miss, disabled rows are invisible
- lookup_town_anchor case-insensitive
- REST API: GET list, GET one, GET 404, POST add, PUT update, DELETE,
POST missing-field 400; both gauge_sites + town_anchors
- Accessor reflects API mutations after invalidate_curation_cache()
tests/test_nwis_handler.py back-compat: IDAHO_CURATED_SITES dict alias
points at _GAUGE_SITES_SEED so the existing assertion suite still passes.
tests/test_adapter_config_foundation.py schema_meta v7 -> v9 bump.
Test count: 797 -> 819 (+18 curation cases + 4 maintenance updates).
This commit is contained in:
parent
eb84f27941
commit
e3bf53ade4
20 changed files with 1322 additions and 272 deletions
|
|
@ -54,11 +54,11 @@ def test_v6_tables_exist(fresh_db):
|
|||
assert "adapter_meta" in tables
|
||||
|
||||
|
||||
def test_schema_meta_at_v7(fresh_db):
|
||||
def test_schema_meta_at_v9(fresh_db):
|
||||
v = fresh_db.execute(
|
||||
"SELECT value FROM schema_meta WHERE key='version'"
|
||||
).fetchone()["value"]
|
||||
assert int(v) == 7
|
||||
assert int(v) == 9
|
||||
|
||||
|
||||
def test_adapter_config_type_check_constrains_vocabulary(fresh_db):
|
||||
|
|
|
|||
214
tests/test_curation.py
Normal file
214
tests/test_curation.py
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
"""v0.6-4 curation tests: schema, seed, accessors, REST API."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from meshai.persistence.curation import (
|
||||
invalidate_curation_cache,
|
||||
lookup_gauge_site,
|
||||
lookup_town_anchor,
|
||||
seed_gauge_sites,
|
||||
seed_town_anchors,
|
||||
_GAUGE_SITES_SEED,
|
||||
_TOWN_ANCHORS_SEED,
|
||||
)
|
||||
from meshai.persistence import get_db
|
||||
from meshai.dashboard.api.curation_routes import router as curation_router
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Schema + seed
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def test_v8_v9_tables_present():
|
||||
conn = get_db()
|
||||
tables = {r["name"] for r in conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table'"
|
||||
).fetchall()}
|
||||
assert "gauge_sites" in tables
|
||||
assert "town_anchors" in tables
|
||||
|
||||
|
||||
def test_gauge_sites_seeded_at_init():
|
||||
conn = get_db()
|
||||
n = conn.execute("SELECT COUNT(*) FROM gauge_sites").fetchone()[0]
|
||||
assert n == len(_GAUGE_SITES_SEED)
|
||||
|
||||
|
||||
def test_town_anchors_seeded_at_init():
|
||||
conn = get_db()
|
||||
n = conn.execute("SELECT COUNT(*) FROM town_anchors").fetchone()[0]
|
||||
assert n == len(_TOWN_ANCHORS_SEED)
|
||||
|
||||
|
||||
def test_seed_idempotent():
|
||||
conn = get_db()
|
||||
a = seed_gauge_sites(conn)
|
||||
b = seed_town_anchors(conn)
|
||||
assert a == 0 and b == 0
|
||||
|
||||
|
||||
def test_seed_does_not_overwrite_user_edits():
|
||||
conn = get_db()
|
||||
conn.execute(
|
||||
"UPDATE gauge_sites SET action_ft=99 WHERE site_id='USGS-13186000'"
|
||||
)
|
||||
seed_gauge_sites(conn)
|
||||
r = conn.execute(
|
||||
"SELECT action_ft FROM gauge_sites WHERE site_id='USGS-13186000'"
|
||||
).fetchone()
|
||||
assert r["action_ft"] == 99
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Accessors
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def test_lookup_gauge_site_hits():
|
||||
invalidate_curation_cache()
|
||||
r = lookup_gauge_site("USGS-13186000")
|
||||
assert r is not None
|
||||
assert r["gauge_name"] == "Snake River at Heise"
|
||||
assert r["action_ft"] == 12.0
|
||||
|
||||
|
||||
def test_lookup_gauge_site_miss():
|
||||
invalidate_curation_cache()
|
||||
assert lookup_gauge_site("USGS-99999") is None
|
||||
|
||||
|
||||
def test_lookup_gauge_disabled_row_invisible():
|
||||
conn = get_db()
|
||||
conn.execute("UPDATE gauge_sites SET enabled=0 WHERE site_id='USGS-13186000'")
|
||||
invalidate_curation_cache()
|
||||
assert lookup_gauge_site("USGS-13186000") is None
|
||||
|
||||
|
||||
def test_lookup_town_anchor_hits():
|
||||
invalidate_curation_cache()
|
||||
coord = lookup_town_anchor("Boise")
|
||||
assert coord is not None
|
||||
assert coord[0] == pytest.approx(43.6150)
|
||||
assert coord[1] == pytest.approx(-116.2023)
|
||||
|
||||
|
||||
def test_lookup_town_anchor_case_insensitive():
|
||||
invalidate_curation_cache()
|
||||
a = lookup_town_anchor("MERIDIAN")
|
||||
b = lookup_town_anchor("meridian")
|
||||
assert a == b
|
||||
|
||||
|
||||
def test_lookup_town_anchor_miss():
|
||||
invalidate_curation_cache()
|
||||
assert lookup_town_anchor("xxxx") is None
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# REST API: gauge_sites
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
app = FastAPI()
|
||||
app.include_router(curation_router, prefix="/api")
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_api_list_gauges(client):
|
||||
r = client.get("/api/gauge-sites")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert len(body) == len(_GAUGE_SITES_SEED)
|
||||
|
||||
|
||||
def test_api_get_one_gauge(client):
|
||||
r = client.get("/api/gauge-sites/USGS-13186000")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["gauge_name"] == "Snake River at Heise"
|
||||
|
||||
|
||||
def test_api_get_404(client):
|
||||
r = client.get("/api/gauge-sites/USGS-99999")
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
def test_api_post_add_gauge(client):
|
||||
r = client.post("/api/gauge-sites", json={
|
||||
"site_id": "USGS-NEW1", "gauge_name": "Test Gauge",
|
||||
"lat": 44.0, "lon": -115.0, "action_ft": 5.0,
|
||||
})
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["site_id"] == "USGS-NEW1"
|
||||
# Accessor sees it.
|
||||
invalidate_curation_cache()
|
||||
assert lookup_gauge_site("USGS-NEW1") is not None
|
||||
|
||||
|
||||
def test_api_put_updates_gauge(client):
|
||||
r = client.put("/api/gauge-sites/USGS-13186000",
|
||||
json={"action_ft": 15.5})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["action_ft"] == 15.5
|
||||
invalidate_curation_cache()
|
||||
assert lookup_gauge_site("USGS-13186000")["action_ft"] == 15.5
|
||||
|
||||
|
||||
def test_api_delete_gauge(client):
|
||||
r = client.delete("/api/gauge-sites/USGS-13186000")
|
||||
assert r.status_code == 200
|
||||
invalidate_curation_cache()
|
||||
assert lookup_gauge_site("USGS-13186000") is None
|
||||
|
||||
|
||||
def test_api_post_missing_field_400(client):
|
||||
r = client.post("/api/gauge-sites", json={"gauge_name": "X"})
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# REST API: town_anchors
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def test_api_list_towns(client):
|
||||
r = client.get("/api/town-anchors")
|
||||
assert r.status_code == 200
|
||||
assert len(r.json()) == len(_TOWN_ANCHORS_SEED)
|
||||
|
||||
|
||||
def test_api_post_add_town(client):
|
||||
r = client.post("/api/town-anchors", json={
|
||||
"name": "Bellevue", "lat": 43.4670, "lon": -114.2557, "state": "ID",
|
||||
})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["name"] == "bellevue"
|
||||
invalidate_curation_cache()
|
||||
coord = lookup_town_anchor("bellevue")
|
||||
assert coord is not None
|
||||
|
||||
|
||||
def test_api_put_town(client):
|
||||
# Find Boise's anchor_id first.
|
||||
r = client.get("/api/town-anchors")
|
||||
boise = next(t for t in r.json() if t["name"] == "boise")
|
||||
r2 = client.put(f"/api/town-anchors/{boise['anchor_id']}",
|
||||
json={"enabled": False})
|
||||
assert r2.status_code == 200
|
||||
assert r2.json()["enabled"] is False
|
||||
invalidate_curation_cache()
|
||||
assert lookup_town_anchor("boise") is None
|
||||
|
||||
|
||||
def test_api_delete_town(client):
|
||||
r = client.get("/api/town-anchors")
|
||||
nampa = next(t for t in r.json() if t["name"] == "nampa")
|
||||
r2 = client.delete(f"/api/town-anchors/{nampa['anchor_id']}")
|
||||
assert r2.status_code == 200
|
||||
invalidate_curation_cache()
|
||||
assert lookup_town_anchor("nampa") is None
|
||||
|
|
@ -1,7 +1,9 @@
|
|||
"""Tests for v0.5.12 usgs_nwis handler."""
|
||||
import pytest
|
||||
|
||||
from meshai.central.idaho_gauge_sites import IDAHO_CURATED_SITES
|
||||
# v0.6-4: IDAHO_CURATED_SITES dict moved to gauge_sites SQLite table;
|
||||
# import the seed data from the curation module as a back-compat alias.
|
||||
from meshai.persistence.curation import _GAUGE_SITES_SEED as IDAHO_CURATED_SITES
|
||||
from meshai.central.nwis_handler import handle_nwis
|
||||
from meshai.persistence import close_thread_connection, init_db
|
||||
from meshai.persistence import db as persistence_db
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue