mirror of
https://github.com/zvx-echo6/central.git
synced 2026-08-26 17:31:39 +00:00
v0.14.0: multi-bbox monitoring areas (config.monitoring_areas set-union filter)
Generalize the single config.system bbox into a named config.monitoring_areas list with set-union semantics (kept if geometry intersects ANY area; no-geom always kept; empty list keeps everything). Migration 042 seeds 'default' from the existing bounds (49.0/41.8/-111/-117.5); old monitor_* columns preserved for v0.14.1. Archive + supervisor both apply the list. GUI /monitoring-area gains list/create/update/delete + multi-rectangle map.
This commit is contained in:
parent
3f1fec9846
commit
c2864a94ce
13 changed files with 792 additions and 237 deletions
|
|
@ -80,3 +80,40 @@ class TestProcessMessageFilter:
|
|||
await c._process_message(msg, conn)
|
||||
conn.execute.assert_awaited_once()
|
||||
assert c._dropped == {}
|
||||
|
||||
|
||||
# NYC metro box -- disjoint from IDAHO, for set-union (v0.14.0) coverage.
|
||||
NYC_BOX = MonitoringArea(north=41.0, south=40.3, east=-73.5, west=-74.5)
|
||||
|
||||
|
||||
class TestProcessMessageMultiArea:
|
||||
"""v0.14.0: archive keeps an event if it intersects ANY configured area."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dropped_when_outside_the_only_configured_area(self):
|
||||
# Boise event, but only the NYC area is configured -> dropped.
|
||||
c = ArchiveConsumer("nats://x", "postgresql://x")
|
||||
c._monitoring_areas = [NYC_BOX]
|
||||
conn = AsyncMock()
|
||||
await c._process_message(_make_msg(_envelope("nws", -114.0, 43.5)), conn)
|
||||
conn.execute.assert_not_called()
|
||||
assert c._dropped == {"nws": 1}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_kept_when_inside_any_of_several_areas(self):
|
||||
# Same Boise event, but IDAHO is also configured -> kept via union.
|
||||
c = ArchiveConsumer("nats://x", "postgresql://x")
|
||||
c._monitoring_areas = [NYC_BOX, IDAHO]
|
||||
conn = AsyncMock()
|
||||
await c._process_message(_make_msg(_envelope("nws", -114.0, 43.5)), conn)
|
||||
conn.execute.assert_awaited_once()
|
||||
assert c._dropped == {}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_list_keeps_everything(self):
|
||||
c = ArchiveConsumer("nats://x", "postgresql://x")
|
||||
c._monitoring_areas = []
|
||||
conn = AsyncMock()
|
||||
await c._process_message(_make_msg(_envelope("wzdx", -74.0, 40.7)), conn)
|
||||
conn.execute.assert_awaited_once()
|
||||
assert c._dropped == {}
|
||||
|
|
|
|||
146
tests/test_gui_monitoring_area.py
Normal file
146
tests/test_gui_monitoring_area.py
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
"""v0.14.0 monitoring-areas GUI routes: list / create / update / delete.
|
||||
|
||||
Server-rendered forms (matching the rest of the GUI), so these call the route
|
||||
handlers directly with a mock pool + request and assert status + side effects.
|
||||
"""
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from asyncpg.exceptions import UniqueViolationError
|
||||
from fastapi.responses import RedirectResponse
|
||||
|
||||
from central.gui.routes import (
|
||||
monitoring_area_create,
|
||||
monitoring_area_delete,
|
||||
monitoring_area_list,
|
||||
monitoring_area_update,
|
||||
)
|
||||
|
||||
_AREA_ROW = {"id": 1, "name": "treasure_valley",
|
||||
"north": 44.0, "south": 43.0, "east": -115.5, "west": -116.5}
|
||||
|
||||
|
||||
class _Tmpl:
|
||||
"""Stand-in for Jinja templates -- echoes status + context for assertions."""
|
||||
def TemplateResponse(self, **kw):
|
||||
return SimpleNamespace(
|
||||
status_code=kw.get("status_code", 200), context=kw["context"])
|
||||
|
||||
|
||||
def _conn(*, fetch=None, fetchrow=None, execute_error=None):
|
||||
c = MagicMock()
|
||||
c.fetch = AsyncMock(return_value=fetch if fetch is not None else [])
|
||||
c.fetchrow = AsyncMock(return_value=fetchrow)
|
||||
c.execute = AsyncMock(side_effect=execute_error)
|
||||
return c
|
||||
|
||||
|
||||
def _pool(conn):
|
||||
pool = MagicMock()
|
||||
cm = MagicMock()
|
||||
cm.__aenter__ = AsyncMock(return_value=conn)
|
||||
cm.__aexit__ = AsyncMock(return_value=False)
|
||||
pool.acquire = MagicMock(return_value=cm)
|
||||
return pool
|
||||
|
||||
|
||||
def _req(form=None):
|
||||
r = MagicMock()
|
||||
r.state.csrf_token = "tok"
|
||||
r.state.operator = SimpleNamespace(id=1, username="admin")
|
||||
|
||||
async def _form():
|
||||
return form or {}
|
||||
r.form = _form
|
||||
return r
|
||||
|
||||
|
||||
def _form(**over):
|
||||
base = {"csrf_token": "tok", "name": "magic_valley",
|
||||
"north": "43.0", "south": "42.3", "east": "-113.4", "west": "-114.9"}
|
||||
base.update(over)
|
||||
return base
|
||||
|
||||
|
||||
def _patches(conn):
|
||||
return (
|
||||
patch("central.gui.routes.get_pool", return_value=_pool(conn)),
|
||||
patch("central.gui.routes._get_templates", return_value=_Tmpl()),
|
||||
patch("central.gui.routes.write_audit", new=AsyncMock()),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestList:
|
||||
async def test_renders_areas(self):
|
||||
conn = _conn(fetch=[_AREA_ROW], fetchrow=None)
|
||||
p1, p2, p3 = _patches(conn)
|
||||
with p1, p2, p3:
|
||||
res = await monitoring_area_list(_req())
|
||||
assert res.status_code == 200
|
||||
assert res.context["areas"] == [_AREA_ROW]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestCreate:
|
||||
async def test_valid_redirects_and_inserts(self):
|
||||
conn = _conn()
|
||||
p1, p2, p3 = _patches(conn)
|
||||
with p1, p2, p3:
|
||||
res = await monitoring_area_create(_req(_form()))
|
||||
assert isinstance(res, RedirectResponse) and res.status_code == 302
|
||||
assert "INSERT INTO config.monitoring_areas" in conn.execute.call_args[0][0]
|
||||
|
||||
async def test_invalid_name_rerenders_no_insert(self):
|
||||
conn = _conn()
|
||||
p1, p2, p3 = _patches(conn)
|
||||
with p1, p2, p3:
|
||||
res = await monitoring_area_create(_req(_form(name="")))
|
||||
assert res.status_code == 200 and res.context["error"]
|
||||
conn.execute.assert_not_called()
|
||||
|
||||
async def test_inverted_bounds_rerenders(self):
|
||||
conn = _conn()
|
||||
p1, p2, p3 = _patches(conn)
|
||||
with p1, p2, p3:
|
||||
res = await monitoring_area_create(_req(_form(north="42.0", south="43.0")))
|
||||
assert res.status_code == 200 and res.context["error"]
|
||||
conn.execute.assert_not_called()
|
||||
|
||||
async def test_duplicate_name_rerenders(self):
|
||||
conn = _conn(execute_error=UniqueViolationError("dup"))
|
||||
p1, p2, p3 = _patches(conn)
|
||||
with p1, p2, p3:
|
||||
res = await monitoring_area_create(_req(_form()))
|
||||
assert res.status_code == 200 and "already exists" in res.context["error"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestUpdate:
|
||||
async def test_valid_redirects_and_updates(self):
|
||||
conn = _conn(fetchrow=_AREA_ROW)
|
||||
p1, p2, p3 = _patches(conn)
|
||||
with p1, p2, p3:
|
||||
res = await monitoring_area_update(_req(_form()), 1)
|
||||
assert isinstance(res, RedirectResponse) and res.status_code == 302
|
||||
assert "UPDATE config.monitoring_areas" in conn.execute.call_args[0][0]
|
||||
|
||||
async def test_missing_id_returns_404(self):
|
||||
conn = _conn(fetchrow=None)
|
||||
p1, p2, p3 = _patches(conn)
|
||||
with p1, p2, p3:
|
||||
res = await monitoring_area_update(_req(_form()), 999)
|
||||
assert res.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestDelete:
|
||||
async def test_redirects_and_deletes(self):
|
||||
conn = _conn(fetchrow=_AREA_ROW)
|
||||
p1, p2, p3 = _patches(conn)
|
||||
with p1, p2, p3:
|
||||
res = await monitoring_area_delete(_req({"csrf_token": "tok"}), 1)
|
||||
assert isinstance(res, RedirectResponse) and res.status_code == 302
|
||||
assert "DELETE FROM config.monitoring_areas" in conn.execute.call_args[0][0]
|
||||
40
tests/test_migration_042.py
Normal file
40
tests/test_migration_042.py
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
"""v0.14.0 migration 042: single config.system bbox -> config.monitoring_areas.
|
||||
|
||||
The suite has no live Postgres (it runs identically as zvx or central), so this
|
||||
asserts the migration's shape statically: it creates the table, enforces the
|
||||
union-filter invariants, seeds 'default' from the existing config.system bbox to
|
||||
preserve current bounds, and -- deliberately for v0.14.0 -- does NOT drop the old
|
||||
columns (that lands in v0.14.1).
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
_SQL = Path("sql/migrations/042_monitoring_area_to_multi_areas.sql").read_text()
|
||||
_NORM = " ".join(_SQL.split()) # whitespace-insensitive matching
|
||||
|
||||
|
||||
def test_creates_monitoring_areas_table():
|
||||
assert "CREATE TABLE IF NOT EXISTS config.monitoring_areas" in _NORM
|
||||
|
||||
|
||||
def test_name_is_unique():
|
||||
assert "name TEXT NOT NULL UNIQUE" in _NORM
|
||||
|
||||
|
||||
def test_check_constraints_enforce_bbox_ordering():
|
||||
assert "CHECK (north > south)" in _NORM
|
||||
assert "CHECK (east > west)" in _NORM
|
||||
|
||||
|
||||
def test_seeds_default_from_config_system_preserving_bounds():
|
||||
assert "INSERT INTO config.monitoring_areas" in _NORM
|
||||
assert "FROM config.system" in _NORM
|
||||
assert "'default'" in _NORM
|
||||
# Idempotent re-run / already-seeded installs must not error or duplicate.
|
||||
assert "ON CONFLICT (name) DO NOTHING" in _NORM
|
||||
|
||||
|
||||
def test_does_not_drop_old_columns_in_v0_14_0():
|
||||
upper = _NORM.upper()
|
||||
assert "DROP COLUMN" not in upper
|
||||
assert "DROP TABLE" not in upper
|
||||
|
|
@ -20,10 +20,14 @@ from central.monitoring_area import (
|
|||
MonitoringArea,
|
||||
build_geom_json,
|
||||
classify_geom,
|
||||
classify_geom_areas,
|
||||
load_monitoring_area,
|
||||
load_monitoring_areas,
|
||||
)
|
||||
|
||||
IDAHO = MonitoringArea(north=44.5, south=41.8, east=-111.0, west=-117.5)
|
||||
# A second, disjoint area to exercise set-union semantics (the NYC metro box).
|
||||
NYC_BOX = MonitoringArea(north=41.0, south=40.3, east=-73.5, west=-74.5)
|
||||
|
||||
|
||||
def _pt(lon, lat):
|
||||
|
|
@ -117,6 +121,55 @@ class TestClassifyGeom:
|
|||
assert classify_geom(json.dumps({"type": "Nonsense"}), IDAHO) == "invalid-geom"
|
||||
|
||||
|
||||
class TestClassifyGeomAreas:
|
||||
"""v0.14.0 set-union: keep if the geometry intersects ANY area."""
|
||||
|
||||
def test_empty_list_keeps_everything(self):
|
||||
assert classify_geom_areas(_pt(-74.0, 40.7), []) == "no-area"
|
||||
|
||||
def test_null_geom_always_kept(self):
|
||||
assert classify_geom_areas(None, [IDAHO]) == "null-geom"
|
||||
assert classify_geom_areas(None, []) == "null-geom"
|
||||
|
||||
def test_single_area_matches_single_bbox_behavior(self):
|
||||
# Same verdicts as the legacy single-area classify_geom for a 1-list.
|
||||
assert classify_geom_areas(_pt(-114.0, 43.5), [IDAHO]) == "in-bounds"
|
||||
assert classify_geom_areas(_pt(-74.0, 40.7), [IDAHO]) == "out-of-bounds"
|
||||
|
||||
def test_kept_if_in_either_area(self):
|
||||
# Boise -> IDAHO; NYC -> NYC_BOX. Union keeps both.
|
||||
assert classify_geom_areas(_pt(-114.0, 43.5), [IDAHO, NYC_BOX]) == "in-bounds"
|
||||
assert classify_geom_areas(_pt(-74.0, 40.7), [IDAHO, NYC_BOX]) == "in-bounds"
|
||||
|
||||
def test_dropped_only_when_outside_every_area(self):
|
||||
# London is in neither box.
|
||||
assert classify_geom_areas(_pt(-0.13, 51.5), [IDAHO, NYC_BOX]) == "out-of-bounds"
|
||||
|
||||
def test_invalid_geom_fails_open(self):
|
||||
assert classify_geom_areas("{bad json", [IDAHO]) == "invalid-geom"
|
||||
|
||||
def test_overlapping_areas_still_in_bounds(self):
|
||||
overlap = MonitoringArea(north=44.0, south=42.0, east=-112.0, west=-118.0)
|
||||
assert classify_geom_areas(_pt(-114.0, 43.0), [IDAHO, overlap]) == "in-bounds"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestLoadMonitoringAreas:
|
||||
async def test_returns_all_rows_as_areas(self):
|
||||
conn = MagicMock()
|
||||
conn.fetch = AsyncMock(return_value=[
|
||||
{"north": 44.5, "south": 41.8, "east": -111.0, "west": -117.5},
|
||||
{"north": 41.0, "south": 40.3, "east": -73.5, "west": -74.5},
|
||||
])
|
||||
areas = await load_monitoring_areas(conn)
|
||||
assert areas == [IDAHO, NYC_BOX]
|
||||
|
||||
async def test_empty_table_returns_empty_list(self):
|
||||
conn = MagicMock()
|
||||
conn.fetch = AsyncMock(return_value=[])
|
||||
assert await load_monitoring_areas(conn) == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestLoadMonitoringArea:
|
||||
async def test_returns_area_when_all_columns_set(self):
|
||||
|
|
|
|||
|
|
@ -71,6 +71,9 @@ def sup_factory():
|
|||
store.set_adapter_last_error = AsyncMock()
|
||||
store.get_api_key = AsyncMock(return_value=None)
|
||||
store.get_monitoring_area = AsyncMock(return_value=area)
|
||||
store.get_monitoring_areas = AsyncMock(
|
||||
return_value=[area] if area is not None else []
|
||||
)
|
||||
config_source = MagicMock()
|
||||
config_source.get_enrichment_config = AsyncMock(return_value=EnrichmentConfig())
|
||||
sup = sup_mod.Supervisor(
|
||||
|
|
@ -173,7 +176,7 @@ async def test_refresh_loop_reloads_area_and_logs_summary(
|
|||
sup = sup_factory(None)
|
||||
sup._dropped_publish = {"mock": 7}
|
||||
monkeypatch.setattr(sup_mod, "MONITORING_AREA_REFRESH_S", 0.05)
|
||||
sup._config_store.get_monitoring_area = AsyncMock(return_value=IDAHO)
|
||||
sup._config_store.get_monitoring_areas = AsyncMock(return_value=[IDAHO])
|
||||
with caplog.at_level(logging.INFO):
|
||||
task = asyncio.create_task(sup._refresh_monitoring_area_loop())
|
||||
await asyncio.sleep(0.15)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue