feat(gui): add events feed frontend with map and filters (1b-9b) (#26)
* feat(gui): add events feed frontend with map and filters
GET /events: Full page with filter form, table, and Leaflet map
GET /events/rows: HTMX fragment for table updates
Features:
- Filterable by adapter, category, time range, region bbox
- Cursor-based pagination with Next button
- Leaflet map showing event geometries
- Click/hover row highlights geometry on map
- Draw rectangle on map to filter by region
- Validation errors shown as banner, not 400
- Events link added to nav between Adapters and Streams
Refactored events query into shared helper for JSON and HTML routes.
Tests: 14 new tests covering filters, fragments, geometry handling.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* refactor(events): use shared helpers for /events.json, fix tests
- Refactor /events.json to use _parse_events_params and _fetch_events
helpers, removing ~200 lines of duplicate query logic
- Delete smoke test (test_events_unauthenticated_redirects) that had
no assertions
- Add TestCrossEndpointParity: verify /events.json and /events return
identical results with same params, test category filter and cursor
pagination on both endpoints
- Add TestErrorSemantics: verify /events.json returns 400 on bad params
while /events returns 200 with error banner (intentional API vs HTML
divergence)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* test: add real assertions to since/until and region filter tests
Replace trivial status_code==200 assertions with checks that verify
the filter values were actually parsed and passed to the template.
These tests now fail if the handler ignores the filter parameters.
* fix: remove double-escaping from data-geometry attribute
tojson already produces HTML-attribute-safe JSON. The extra |e filter
was double-escaping, causing JSON.parse to fail in the browser JS.
Switch to single-quoted attribute to avoid conflicts with JSON double
quotes.
---------
Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-18 11:23:38 -06:00
|
|
|
"""Tests for events feed frontend routes."""
|
|
|
|
|
|
|
|
|
|
import json
|
|
|
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
|
|
|
|
from central.gui.routes import events_list, events_rows, events_json
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestEventsFeedFrontendAuthenticated:
|
|
|
|
|
"""Test events feed frontend with authentication."""
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_events_no_filters_returns_html(self):
|
|
|
|
|
"""GET /events authenticated, no filters returns HTML with events."""
|
|
|
|
|
mock_request = MagicMock()
|
|
|
|
|
mock_request.state.operator = MagicMock(id=1, username="admin")
|
|
|
|
|
mock_request.state.csrf_token = "test_csrf_token"
|
|
|
|
|
mock_request.query_params = {}
|
|
|
|
|
|
|
|
|
|
mock_events = [
|
|
|
|
|
{
|
|
|
|
|
"id": f"event_{i}",
|
|
|
|
|
"time": datetime(2026, 5, 17, 12, 0, tzinfo=timezone.utc) - timedelta(hours=i),
|
|
|
|
|
"received": datetime(2026, 5, 17, 12, 0, tzinfo=timezone.utc) - timedelta(hours=i),
|
|
|
|
|
"adapter": "nws",
|
|
|
|
|
"category": "Weather Alert",
|
|
|
|
|
"subject": f"Test Alert {i}",
|
|
|
|
|
"geometry": '{"type": "Point", "coordinates": [-122.4, 37.8]}' if i % 2 == 0 else None,
|
|
|
|
|
"data": {},
|
|
|
|
|
"regions": [],
|
|
|
|
|
}
|
|
|
|
|
for i in range(5)
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
mock_conn = AsyncMock()
|
|
|
|
|
mock_conn.fetch.return_value = mock_events
|
|
|
|
|
mock_conn.fetchrow.return_value = {
|
|
|
|
|
"map_tile_url": "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
|
|
|
|
|
"map_attribution": "OpenStreetMap",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
mock_pool = MagicMock()
|
|
|
|
|
mock_pool.acquire.return_value.__aenter__ = AsyncMock(return_value=mock_conn)
|
|
|
|
|
mock_pool.acquire.return_value.__aexit__ = AsyncMock(return_value=None)
|
|
|
|
|
|
|
|
|
|
mock_templates = MagicMock()
|
|
|
|
|
mock_templates.TemplateResponse.return_value = MagicMock(status_code=200)
|
|
|
|
|
|
|
|
|
|
with patch("central.gui.routes._get_templates", return_value=mock_templates):
|
|
|
|
|
with patch("central.gui.routes.get_pool", return_value=mock_pool):
|
|
|
|
|
result = await events_list(mock_request)
|
|
|
|
|
|
|
|
|
|
assert result.status_code == 200
|
|
|
|
|
call_args = mock_templates.TemplateResponse.call_args
|
|
|
|
|
context = call_args.kwargs.get("context", call_args[1].get("context"))
|
|
|
|
|
assert "events" in context
|
|
|
|
|
assert context["filter_error"] is None
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_events_adapter_filter(self):
|
|
|
|
|
"""GET /events?adapter=nws returns only nws events."""
|
|
|
|
|
mock_request = MagicMock()
|
|
|
|
|
mock_request.state.operator = MagicMock(id=1, username="admin")
|
|
|
|
|
mock_request.state.csrf_token = "test_csrf_token"
|
|
|
|
|
mock_request.query_params = {"adapter": "nws"}
|
|
|
|
|
|
|
|
|
|
mock_events = [
|
|
|
|
|
{
|
|
|
|
|
"id": "nws_event_1",
|
|
|
|
|
"time": datetime(2026, 5, 17, 12, 0, tzinfo=timezone.utc),
|
|
|
|
|
"received": datetime(2026, 5, 17, 12, 0, tzinfo=timezone.utc),
|
|
|
|
|
"adapter": "nws",
|
|
|
|
|
"category": "Alert",
|
|
|
|
|
"subject": "NWS Alert",
|
|
|
|
|
"geometry": None,
|
|
|
|
|
"data": {},
|
|
|
|
|
"regions": [],
|
|
|
|
|
},
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
mock_conn = AsyncMock()
|
|
|
|
|
mock_conn.fetch.return_value = mock_events
|
|
|
|
|
mock_conn.fetchrow.return_value = {
|
|
|
|
|
"map_tile_url": "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
|
|
|
|
|
"map_attribution": "OpenStreetMap",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
mock_pool = MagicMock()
|
|
|
|
|
mock_pool.acquire.return_value.__aenter__ = AsyncMock(return_value=mock_conn)
|
|
|
|
|
mock_pool.acquire.return_value.__aexit__ = AsyncMock(return_value=None)
|
|
|
|
|
|
|
|
|
|
mock_templates = MagicMock()
|
|
|
|
|
mock_templates.TemplateResponse.return_value = MagicMock(status_code=200)
|
|
|
|
|
|
|
|
|
|
with patch("central.gui.routes._get_templates", return_value=mock_templates):
|
|
|
|
|
with patch("central.gui.routes.get_pool", return_value=mock_pool):
|
|
|
|
|
result = await events_list(mock_request)
|
|
|
|
|
|
|
|
|
|
assert result.status_code == 200
|
|
|
|
|
context = mock_templates.TemplateResponse.call_args.kwargs.get("context")
|
|
|
|
|
assert context["filter_values"]["adapter"] == "nws"
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_events_since_until_filter(self):
|
|
|
|
|
"""GET /events?since=...&until=... filters by time window."""
|
|
|
|
|
mock_request = MagicMock()
|
|
|
|
|
mock_request.state.operator = MagicMock(id=1, username="admin")
|
|
|
|
|
mock_request.state.csrf_token = "test_csrf_token"
|
|
|
|
|
mock_request.query_params = {
|
|
|
|
|
"since": "2026-05-17T00:00:00",
|
|
|
|
|
"until": "2026-05-17T12:00:00",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
mock_events = [
|
|
|
|
|
{
|
|
|
|
|
"id": "in_range",
|
|
|
|
|
"time": datetime(2026, 5, 17, 6, 0, tzinfo=timezone.utc),
|
|
|
|
|
"received": datetime(2026, 5, 17, 6, 0, tzinfo=timezone.utc),
|
|
|
|
|
"adapter": "nws",
|
|
|
|
|
"category": "Alert",
|
|
|
|
|
"subject": "In Range",
|
|
|
|
|
"geometry": None,
|
|
|
|
|
"data": {},
|
|
|
|
|
"regions": [],
|
|
|
|
|
},
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
mock_conn = AsyncMock()
|
|
|
|
|
mock_conn.fetch.return_value = mock_events
|
|
|
|
|
mock_conn.fetchrow.return_value = {
|
|
|
|
|
"map_tile_url": "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
|
|
|
|
|
"map_attribution": "OpenStreetMap",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
mock_pool = MagicMock()
|
|
|
|
|
mock_pool.acquire.return_value.__aenter__ = AsyncMock(return_value=mock_conn)
|
|
|
|
|
mock_pool.acquire.return_value.__aexit__ = AsyncMock(return_value=None)
|
|
|
|
|
|
|
|
|
|
mock_templates = MagicMock()
|
|
|
|
|
mock_templates.TemplateResponse.return_value = MagicMock(status_code=200)
|
|
|
|
|
|
|
|
|
|
with patch("central.gui.routes._get_templates", return_value=mock_templates):
|
|
|
|
|
with patch("central.gui.routes.get_pool", return_value=mock_pool):
|
|
|
|
|
result = await events_list(mock_request)
|
|
|
|
|
|
|
|
|
|
assert result.status_code == 200
|
|
|
|
|
# Verify filter was actually parsed and passed to template
|
|
|
|
|
mock_templates.TemplateResponse.assert_called_once()
|
|
|
|
|
call_kwargs = mock_templates.TemplateResponse.call_args.kwargs
|
|
|
|
|
context = call_kwargs.get("context", call_kwargs)
|
|
|
|
|
assert context["filter_values"]["since"] == "2026-05-17T00:00:00"
|
|
|
|
|
assert context["filter_values"]["until"] == "2026-05-17T12:00:00"
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_events_region_filter(self):
|
|
|
|
|
"""GET /events with full region bbox filters by location."""
|
|
|
|
|
mock_request = MagicMock()
|
|
|
|
|
mock_request.state.operator = MagicMock(id=1, username="admin")
|
|
|
|
|
mock_request.state.csrf_token = "test_csrf_token"
|
|
|
|
|
mock_request.query_params = {
|
|
|
|
|
"region_north": "49.5",
|
|
|
|
|
"region_south": "31",
|
|
|
|
|
"region_east": "-102",
|
|
|
|
|
"region_west": "-124.5",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
mock_events = [
|
|
|
|
|
{
|
|
|
|
|
"id": "in_bbox",
|
|
|
|
|
"time": datetime(2026, 5, 17, 12, 0, tzinfo=timezone.utc),
|
|
|
|
|
"received": datetime(2026, 5, 17, 12, 0, tzinfo=timezone.utc),
|
|
|
|
|
"adapter": "nws",
|
|
|
|
|
"category": "Alert",
|
|
|
|
|
"subject": "In BBox",
|
|
|
|
|
"geometry": '{"type": "Point", "coordinates": [-120, 40]}',
|
|
|
|
|
"data": {},
|
|
|
|
|
"regions": [],
|
|
|
|
|
},
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
mock_conn = AsyncMock()
|
|
|
|
|
mock_conn.fetch.return_value = mock_events
|
|
|
|
|
mock_conn.fetchrow.return_value = {
|
|
|
|
|
"map_tile_url": "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
|
|
|
|
|
"map_attribution": "OpenStreetMap",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
mock_pool = MagicMock()
|
|
|
|
|
mock_pool.acquire.return_value.__aenter__ = AsyncMock(return_value=mock_conn)
|
|
|
|
|
mock_pool.acquire.return_value.__aexit__ = AsyncMock(return_value=None)
|
|
|
|
|
|
|
|
|
|
mock_templates = MagicMock()
|
|
|
|
|
mock_templates.TemplateResponse.return_value = MagicMock(status_code=200)
|
|
|
|
|
|
|
|
|
|
with patch("central.gui.routes._get_templates", return_value=mock_templates):
|
|
|
|
|
with patch("central.gui.routes.get_pool", return_value=mock_pool):
|
|
|
|
|
result = await events_list(mock_request)
|
|
|
|
|
|
|
|
|
|
assert result.status_code == 200
|
|
|
|
|
# Verify region filter was actually parsed and passed to template
|
|
|
|
|
mock_templates.TemplateResponse.assert_called_once()
|
|
|
|
|
call_kwargs = mock_templates.TemplateResponse.call_args.kwargs
|
|
|
|
|
context = call_kwargs.get("context", call_kwargs)
|
|
|
|
|
assert context["filter_values"]["region_north"] == "49.5"
|
|
|
|
|
assert context["filter_values"]["region_south"] == "31"
|
|
|
|
|
assert context["filter_values"]["region_east"] == "-102"
|
|
|
|
|
assert context["filter_values"]["region_west"] == "-124.5"
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_events_partial_region_shows_error_banner(self):
|
|
|
|
|
"""GET /events with partial region shows error banner, not 400."""
|
|
|
|
|
mock_request = MagicMock()
|
|
|
|
|
mock_request.state.operator = MagicMock(id=1, username="admin")
|
|
|
|
|
mock_request.state.csrf_token = "test_csrf_token"
|
|
|
|
|
mock_request.query_params = {"region_north": "49"}
|
|
|
|
|
|
|
|
|
|
mock_conn = AsyncMock()
|
|
|
|
|
mock_conn.fetchrow.return_value = {
|
|
|
|
|
"map_tile_url": "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
|
|
|
|
|
"map_attribution": "OpenStreetMap",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
mock_pool = MagicMock()
|
|
|
|
|
mock_pool.acquire.return_value.__aenter__ = AsyncMock(return_value=mock_conn)
|
|
|
|
|
mock_pool.acquire.return_value.__aexit__ = AsyncMock(return_value=None)
|
|
|
|
|
|
|
|
|
|
mock_templates = MagicMock()
|
|
|
|
|
mock_templates.TemplateResponse.return_value = MagicMock(status_code=200)
|
|
|
|
|
|
|
|
|
|
with patch("central.gui.routes._get_templates", return_value=mock_templates):
|
|
|
|
|
with patch("central.gui.routes.get_pool", return_value=mock_pool):
|
|
|
|
|
result = await events_list(mock_request)
|
|
|
|
|
|
|
|
|
|
# Should be 200, not 400
|
|
|
|
|
assert result.status_code == 200
|
|
|
|
|
context = mock_templates.TemplateResponse.call_args.kwargs.get("context")
|
|
|
|
|
assert context["filter_error"] is not None
|
|
|
|
|
assert "region" in context["filter_error"].lower()
|
|
|
|
|
# Events should be empty due to validation error
|
|
|
|
|
assert context["events"] == []
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_events_with_limit_shows_next_button(self):
|
|
|
|
|
"""GET /events?limit=5 shows Next button when more events exist."""
|
|
|
|
|
mock_request = MagicMock()
|
|
|
|
|
mock_request.state.operator = MagicMock(id=1, username="admin")
|
|
|
|
|
mock_request.state.csrf_token = "test_csrf_token"
|
|
|
|
|
mock_request.query_params = {"limit": "5"}
|
|
|
|
|
|
|
|
|
|
# Return 6 events (limit+1) to trigger pagination
|
|
|
|
|
mock_events = [
|
|
|
|
|
{
|
|
|
|
|
"id": f"event_{i}",
|
|
|
|
|
"time": datetime(2026, 5, 17, 12, 0, tzinfo=timezone.utc) - timedelta(hours=i),
|
|
|
|
|
"received": datetime(2026, 5, 17, 12, 0, tzinfo=timezone.utc) - timedelta(hours=i),
|
|
|
|
|
"adapter": "nws",
|
|
|
|
|
"category": "Alert",
|
|
|
|
|
"subject": f"Event {i}",
|
|
|
|
|
"geometry": None,
|
|
|
|
|
"data": {},
|
|
|
|
|
"regions": [],
|
|
|
|
|
}
|
|
|
|
|
for i in range(6)
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
mock_conn = AsyncMock()
|
|
|
|
|
mock_conn.fetch.return_value = mock_events
|
|
|
|
|
mock_conn.fetchrow.return_value = {
|
|
|
|
|
"map_tile_url": "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
|
|
|
|
|
"map_attribution": "OpenStreetMap",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
mock_pool = MagicMock()
|
|
|
|
|
mock_pool.acquire.return_value.__aenter__ = AsyncMock(return_value=mock_conn)
|
|
|
|
|
mock_pool.acquire.return_value.__aexit__ = AsyncMock(return_value=None)
|
|
|
|
|
|
|
|
|
|
mock_templates = MagicMock()
|
|
|
|
|
mock_templates.TemplateResponse.return_value = MagicMock(status_code=200)
|
|
|
|
|
|
|
|
|
|
with patch("central.gui.routes._get_templates", return_value=mock_templates):
|
|
|
|
|
with patch("central.gui.routes.get_pool", return_value=mock_pool):
|
|
|
|
|
result = await events_list(mock_request)
|
|
|
|
|
|
|
|
|
|
assert result.status_code == 200
|
|
|
|
|
context = mock_templates.TemplateResponse.call_args.kwargs.get("context")
|
|
|
|
|
assert context["next_cursor"] is not None
|
|
|
|
|
assert len(context["events"]) == 5 # Should be trimmed to limit
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestEventsRowsFragment:
|
|
|
|
|
"""Test /events/rows HTMX fragment."""
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_events_rows_returns_fragment(self):
|
|
|
|
|
"""GET /events/rows returns table fragment, not full page."""
|
|
|
|
|
mock_request = MagicMock()
|
|
|
|
|
mock_request.state.operator = MagicMock(id=1, username="admin")
|
|
|
|
|
mock_request.query_params = {"limit": "5"}
|
|
|
|
|
|
|
|
|
|
mock_events = [
|
|
|
|
|
{
|
|
|
|
|
"id": "event_1",
|
|
|
|
|
"time": datetime(2026, 5, 17, 12, 0, tzinfo=timezone.utc),
|
|
|
|
|
"received": datetime(2026, 5, 17, 12, 0, tzinfo=timezone.utc),
|
|
|
|
|
"adapter": "nws",
|
|
|
|
|
"category": "Alert",
|
|
|
|
|
"subject": "Event 1",
|
|
|
|
|
"geometry": None,
|
|
|
|
|
"data": {},
|
|
|
|
|
"regions": [],
|
|
|
|
|
},
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
mock_conn = AsyncMock()
|
|
|
|
|
mock_conn.fetch.return_value = mock_events
|
|
|
|
|
|
|
|
|
|
mock_pool = MagicMock()
|
|
|
|
|
mock_pool.acquire.return_value.__aenter__ = AsyncMock(return_value=mock_conn)
|
|
|
|
|
mock_pool.acquire.return_value.__aexit__ = AsyncMock(return_value=None)
|
|
|
|
|
|
|
|
|
|
mock_templates = MagicMock()
|
|
|
|
|
mock_templates.TemplateResponse.return_value = MagicMock(status_code=200)
|
|
|
|
|
|
|
|
|
|
with patch("central.gui.routes._get_templates", return_value=mock_templates):
|
|
|
|
|
with patch("central.gui.routes.get_pool", return_value=mock_pool):
|
|
|
|
|
result = await events_rows(mock_request)
|
|
|
|
|
|
|
|
|
|
assert result.status_code == 200
|
|
|
|
|
# Verify it uses the fragment template
|
|
|
|
|
call_args = mock_templates.TemplateResponse.call_args
|
|
|
|
|
assert call_args.kwargs.get("name") == "_events_rows.html"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestGeometrySummary:
|
|
|
|
|
"""Test geometry summary function."""
|
|
|
|
|
|
|
|
|
|
def test_geometry_summary_polygon(self):
|
|
|
|
|
"""Polygon geometry shows point count."""
|
|
|
|
|
from central.gui.routes import _geometry_summary
|
|
|
|
|
|
|
|
|
|
geom = {
|
|
|
|
|
"type": "Polygon",
|
|
|
|
|
"coordinates": [[[-122, 37], [-122, 38], [-121, 38], [-121, 37], [-122, 37]]]
|
|
|
|
|
}
|
|
|
|
|
summary = _geometry_summary(geom)
|
|
|
|
|
assert "Polygon" in summary
|
|
|
|
|
assert "5 pts" in summary
|
|
|
|
|
|
|
|
|
|
def test_geometry_summary_point(self):
|
|
|
|
|
"""Point geometry shows 'Point'."""
|
|
|
|
|
from central.gui.routes import _geometry_summary
|
|
|
|
|
|
|
|
|
|
geom = {"type": "Point", "coordinates": [-122.4, 37.8]}
|
|
|
|
|
summary = _geometry_summary(geom)
|
|
|
|
|
assert summary == "Point"
|
|
|
|
|
|
|
|
|
|
def test_geometry_summary_linestring(self):
|
|
|
|
|
"""LineString geometry shows point count."""
|
|
|
|
|
from central.gui.routes import _geometry_summary
|
|
|
|
|
|
|
|
|
|
geom = {
|
|
|
|
|
"type": "LineString",
|
|
|
|
|
"coordinates": [[-122, 37], [-121, 38], [-120, 39]]
|
|
|
|
|
}
|
|
|
|
|
summary = _geometry_summary(geom)
|
|
|
|
|
assert "Line" in summary
|
|
|
|
|
assert "3 pts" in summary
|
|
|
|
|
|
|
|
|
|
def test_geometry_summary_none(self):
|
|
|
|
|
"""None geometry shows 'None'."""
|
|
|
|
|
from central.gui.routes import _geometry_summary
|
|
|
|
|
|
|
|
|
|
summary = _geometry_summary(None)
|
|
|
|
|
assert summary == "None"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestDataGeometryAttribute:
|
|
|
|
|
"""Test that rows have valid geometry data attributes."""
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_event_with_geometry_has_valid_json(self):
|
|
|
|
|
"""Events with geometry have parseable JSON in data-geometry."""
|
|
|
|
|
mock_request = MagicMock()
|
|
|
|
|
mock_request.state.operator = MagicMock(id=1, username="admin")
|
|
|
|
|
mock_request.query_params = {}
|
|
|
|
|
|
|
|
|
|
mock_events = [
|
|
|
|
|
{
|
|
|
|
|
"id": "geom_event",
|
|
|
|
|
"time": datetime(2026, 5, 17, 12, 0, tzinfo=timezone.utc),
|
|
|
|
|
"received": datetime(2026, 5, 17, 12, 0, tzinfo=timezone.utc),
|
|
|
|
|
"adapter": "nws",
|
|
|
|
|
"category": "Alert",
|
|
|
|
|
"subject": "With Geometry",
|
|
|
|
|
"geometry": '{"type": "Polygon", "coordinates": [[[-122, 37], [-122, 38], [-121, 38], [-121, 37], [-122, 37]]]}',
|
|
|
|
|
"data": {},
|
|
|
|
|
"regions": [],
|
|
|
|
|
},
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
mock_conn = AsyncMock()
|
|
|
|
|
mock_conn.fetch.return_value = mock_events
|
|
|
|
|
|
|
|
|
|
mock_pool = MagicMock()
|
|
|
|
|
mock_pool.acquire.return_value.__aenter__ = AsyncMock(return_value=mock_conn)
|
|
|
|
|
mock_pool.acquire.return_value.__aexit__ = AsyncMock(return_value=None)
|
|
|
|
|
|
|
|
|
|
mock_templates = MagicMock()
|
|
|
|
|
mock_templates.TemplateResponse.return_value = MagicMock(status_code=200)
|
|
|
|
|
|
|
|
|
|
with patch("central.gui.routes._get_templates", return_value=mock_templates):
|
|
|
|
|
with patch("central.gui.routes.get_pool", return_value=mock_pool):
|
|
|
|
|
result = await events_rows(mock_request)
|
|
|
|
|
|
|
|
|
|
context = mock_templates.TemplateResponse.call_args.kwargs.get("context")
|
|
|
|
|
event = context["events"][0]
|
|
|
|
|
# Geometry should be parsed dict, not string
|
|
|
|
|
assert isinstance(event["geometry"], dict)
|
|
|
|
|
assert event["geometry"]["type"] == "Polygon"
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_event_without_geometry_has_none(self):
|
|
|
|
|
"""Events without geometry have None for geometry field."""
|
|
|
|
|
mock_request = MagicMock()
|
|
|
|
|
mock_request.state.operator = MagicMock(id=1, username="admin")
|
|
|
|
|
mock_request.query_params = {}
|
|
|
|
|
|
|
|
|
|
mock_events = [
|
|
|
|
|
{
|
|
|
|
|
"id": "no_geom_event",
|
|
|
|
|
"time": datetime(2026, 5, 17, 12, 0, tzinfo=timezone.utc),
|
|
|
|
|
"received": datetime(2026, 5, 17, 12, 0, tzinfo=timezone.utc),
|
|
|
|
|
"adapter": "nws",
|
|
|
|
|
"category": "Alert",
|
|
|
|
|
"subject": "No Geometry",
|
|
|
|
|
"geometry": None,
|
|
|
|
|
"data": {},
|
|
|
|
|
"regions": [],
|
|
|
|
|
},
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
mock_conn = AsyncMock()
|
|
|
|
|
mock_conn.fetch.return_value = mock_events
|
|
|
|
|
|
|
|
|
|
mock_pool = MagicMock()
|
|
|
|
|
mock_pool.acquire.return_value.__aenter__ = AsyncMock(return_value=mock_conn)
|
|
|
|
|
mock_pool.acquire.return_value.__aexit__ = AsyncMock(return_value=None)
|
|
|
|
|
|
|
|
|
|
mock_templates = MagicMock()
|
|
|
|
|
mock_templates.TemplateResponse.return_value = MagicMock(status_code=200)
|
|
|
|
|
|
|
|
|
|
with patch("central.gui.routes._get_templates", return_value=mock_templates):
|
|
|
|
|
with patch("central.gui.routes.get_pool", return_value=mock_pool):
|
|
|
|
|
result = await events_rows(mock_request)
|
|
|
|
|
|
|
|
|
|
context = mock_templates.TemplateResponse.call_args.kwargs.get("context")
|
|
|
|
|
event = context["events"][0]
|
|
|
|
|
assert event["geometry"] is None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestCrossEndpointParity:
|
|
|
|
|
"""Test that /events.json and /events return the same filtered results."""
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_category_filter_both_endpoints(self):
|
|
|
|
|
"""Category filter works on both /events.json and /events."""
|
|
|
|
|
mock_events = [
|
|
|
|
|
{
|
|
|
|
|
"id": "weather_event",
|
|
|
|
|
"time": datetime(2026, 5, 17, 12, 0, tzinfo=timezone.utc),
|
|
|
|
|
"received": datetime(2026, 5, 17, 12, 0, tzinfo=timezone.utc),
|
|
|
|
|
"adapter": "nws",
|
|
|
|
|
"category": "Weather Alert",
|
|
|
|
|
"subject": "Weather Event",
|
|
|
|
|
"geometry": None,
|
|
|
|
|
"data": {},
|
|
|
|
|
"regions": [],
|
|
|
|
|
},
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
mock_conn = AsyncMock()
|
|
|
|
|
mock_conn.fetch.return_value = mock_events
|
|
|
|
|
mock_conn.fetchrow.return_value = {
|
|
|
|
|
"map_tile_url": "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
|
|
|
|
|
"map_attribution": "OpenStreetMap",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
mock_pool = MagicMock()
|
|
|
|
|
mock_pool.acquire.return_value.__aenter__ = AsyncMock(return_value=mock_conn)
|
|
|
|
|
mock_pool.acquire.return_value.__aexit__ = AsyncMock(return_value=None)
|
|
|
|
|
|
|
|
|
|
query_params = {"category": "Weather Alert"}
|
|
|
|
|
|
|
|
|
|
# Test /events.json
|
|
|
|
|
json_request = MagicMock()
|
|
|
|
|
json_request.state.operator = MagicMock(id=1, username="admin")
|
|
|
|
|
json_request.query_params = query_params
|
|
|
|
|
|
|
|
|
|
with patch("central.gui.routes.get_pool", return_value=mock_pool):
|
|
|
|
|
json_response = await events_json(json_request)
|
|
|
|
|
|
|
|
|
|
json_data = json.loads(json_response.body)
|
|
|
|
|
assert len(json_data["events"]) == 1
|
|
|
|
|
assert json_data["events"][0]["category"] == "Weather Alert"
|
|
|
|
|
|
|
|
|
|
# Test /events
|
|
|
|
|
html_request = MagicMock()
|
|
|
|
|
html_request.state.operator = MagicMock(id=1, username="admin")
|
|
|
|
|
html_request.state.csrf_token = "test_csrf"
|
|
|
|
|
html_request.query_params = query_params
|
|
|
|
|
|
|
|
|
|
mock_templates = MagicMock()
|
|
|
|
|
mock_templates.TemplateResponse.return_value = MagicMock(status_code=200)
|
|
|
|
|
|
|
|
|
|
mock_conn.fetch.return_value = mock_events
|
|
|
|
|
|
|
|
|
|
with patch("central.gui.routes._get_templates", return_value=mock_templates):
|
|
|
|
|
with patch("central.gui.routes.get_pool", return_value=mock_pool):
|
|
|
|
|
await events_list(html_request)
|
|
|
|
|
|
|
|
|
|
html_context = mock_templates.TemplateResponse.call_args.kwargs.get("context")
|
|
|
|
|
assert len(html_context["events"]) == 1
|
|
|
|
|
assert html_context["events"][0]["category"] == "Weather Alert"
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_cursor_pagination_both_endpoints(self):
|
|
|
|
|
"""Cursor pagination works identically on both endpoints."""
|
|
|
|
|
first_page = [
|
|
|
|
|
{
|
|
|
|
|
"id": f"event_{i}",
|
|
|
|
|
"time": datetime(2026, 5, 17, 12, 0, tzinfo=timezone.utc) - timedelta(hours=i),
|
|
|
|
|
"received": datetime(2026, 5, 17, 12, 0, tzinfo=timezone.utc) - timedelta(hours=i),
|
|
|
|
|
"adapter": "nws",
|
|
|
|
|
"category": "Alert",
|
|
|
|
|
"subject": f"Event {i}",
|
|
|
|
|
"geometry": None,
|
|
|
|
|
"data": {},
|
|
|
|
|
"regions": [],
|
|
|
|
|
}
|
|
|
|
|
for i in range(3)
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
mock_conn = AsyncMock()
|
|
|
|
|
mock_conn.fetch.return_value = first_page
|
|
|
|
|
mock_conn.fetchrow.return_value = {
|
|
|
|
|
"map_tile_url": "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
|
|
|
|
|
"map_attribution": "OpenStreetMap",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
mock_pool = MagicMock()
|
|
|
|
|
mock_pool.acquire.return_value.__aenter__ = AsyncMock(return_value=mock_conn)
|
|
|
|
|
mock_pool.acquire.return_value.__aexit__ = AsyncMock(return_value=None)
|
|
|
|
|
|
|
|
|
|
json_request = MagicMock()
|
|
|
|
|
json_request.state.operator = MagicMock(id=1, username="admin")
|
|
|
|
|
json_request.query_params = {"limit": "2"}
|
|
|
|
|
|
|
|
|
|
with patch("central.gui.routes.get_pool", return_value=mock_pool):
|
|
|
|
|
json_response = await events_json(json_request)
|
|
|
|
|
|
|
|
|
|
json_data = json.loads(json_response.body)
|
|
|
|
|
json_cursor = json_data["next_cursor"]
|
|
|
|
|
assert json_cursor is not None
|
|
|
|
|
|
|
|
|
|
html_request = MagicMock()
|
|
|
|
|
html_request.state.operator = MagicMock(id=1, username="admin")
|
|
|
|
|
html_request.state.csrf_token = "test_csrf"
|
|
|
|
|
html_request.query_params = {"limit": "2"}
|
|
|
|
|
|
|
|
|
|
mock_templates = MagicMock()
|
|
|
|
|
mock_templates.TemplateResponse.return_value = MagicMock(status_code=200)
|
|
|
|
|
|
|
|
|
|
mock_conn.fetch.return_value = first_page
|
|
|
|
|
|
|
|
|
|
with patch("central.gui.routes._get_templates", return_value=mock_templates):
|
|
|
|
|
with patch("central.gui.routes.get_pool", return_value=mock_pool):
|
|
|
|
|
await events_list(html_request)
|
|
|
|
|
|
|
|
|
|
html_context = mock_templates.TemplateResponse.call_args.kwargs.get("context")
|
|
|
|
|
html_cursor = html_context["next_cursor"]
|
|
|
|
|
|
|
|
|
|
assert json_cursor == html_cursor
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestErrorSemantics:
|
|
|
|
|
"""Test error handling differences between JSON and HTML endpoints."""
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_json_endpoint_returns_400_on_invalid_limit(self):
|
|
|
|
|
"""/events.json?limit=0 returns 400 JSON error."""
|
|
|
|
|
mock_request = MagicMock()
|
|
|
|
|
mock_request.state.operator = MagicMock(id=1, username="admin")
|
|
|
|
|
mock_request.query_params = {"limit": "0"}
|
|
|
|
|
|
|
|
|
|
response = await events_json(mock_request)
|
|
|
|
|
|
|
|
|
|
assert response.status_code == 400
|
|
|
|
|
data = json.loads(response.body)
|
|
|
|
|
assert "error" in data
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_html_endpoint_returns_200_with_error_banner(self):
|
|
|
|
|
"""/events?limit=0 returns 200 HTML with error banner."""
|
|
|
|
|
mock_request = MagicMock()
|
|
|
|
|
mock_request.state.operator = MagicMock(id=1, username="admin")
|
|
|
|
|
mock_request.state.csrf_token = "test_csrf"
|
|
|
|
|
mock_request.query_params = {"limit": "0"}
|
|
|
|
|
|
|
|
|
|
mock_conn = AsyncMock()
|
|
|
|
|
mock_conn.fetchrow.return_value = {
|
|
|
|
|
"map_tile_url": "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
|
|
|
|
|
"map_attribution": "OpenStreetMap",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
mock_pool = MagicMock()
|
|
|
|
|
mock_pool.acquire.return_value.__aenter__ = AsyncMock(return_value=mock_conn)
|
|
|
|
|
mock_pool.acquire.return_value.__aexit__ = AsyncMock(return_value=None)
|
|
|
|
|
|
|
|
|
|
mock_templates = MagicMock()
|
|
|
|
|
mock_templates.TemplateResponse.return_value = MagicMock(status_code=200)
|
|
|
|
|
|
|
|
|
|
with patch("central.gui.routes._get_templates", return_value=mock_templates):
|
|
|
|
|
with patch("central.gui.routes.get_pool", return_value=mock_pool):
|
|
|
|
|
result = await events_list(mock_request)
|
|
|
|
|
|
|
|
|
|
assert result.status_code == 200
|
|
|
|
|
context = mock_templates.TemplateResponse.call_args.kwargs.get("context")
|
|
|
|
|
assert context["filter_error"] is not None
|
|
|
|
|
assert "limit" in context["filter_error"].lower()
|
|
|
|
|
assert context["events"] == []
|
1b-9c: Events feed UX iteration — colors, popups, viewport filter, expandable rows (#28)
* feat: events feed UX iteration - colors, popups, viewport filter
A. Color-code polygons by adapter (NWS amber, FIRMS red, USGS violet)
B. Click popup on polygons showing time + adapter + category + subject
C. Map viewport drives spatial filter - pan/zoom updates table via HTMX
D. Add legend showing adapter color mapping
E. Remove draw-bbox control, region inputs now hidden (auto-managed)
Template changes:
- _events_rows.html: add data-adapter, data-category, data-time, data-subject
- events_list.html: ADAPTER_COLORS mapping, bindPopup, moveend handler
Test: verify template renders adapter/category/subject for JS consumption
* fix: remove isoformat() call on already-formatted time string
* feat: full events feed UX iteration
A. Color-code polygons by adapter with legend
B. Click popup on polygons with "View details" link
C. Viewport-driven spatial filter - pan/zoom updates table via HTMX
Map never auto-fits after initial load (user controls viewport)
D. Expandable row details showing full event data payload
Changes:
- _events_rows.html: add data-event-id, expand button, detail row
- events_list.html: eventLayerGroup pattern, buildPopup, rebindEventLayers
Fit to results button, expand/collapse handlers, CSS.escape for IDs
* fix: add programmaticMove flag to prevent viewport refresh loop
Suppress moveend handler during fitBounds/setView calls to prevent
feedback loop: fitBounds -> moveend -> applyViewportFilter -> HTMX
swap -> repeat.
* fix: map never auto-fits - user controls viewport
- Disable initial fitToAllLayers on page load
- Remove fitBounds/setView from row click handler
- Map only moves when user pans/zooms
- Table filters based on visible viewport
* fix: map shows all events always, only table filters
Map polygons are drawn once on load and never cleared/redrawn.
HTMX swap only updates the table, not the map layers.
User viewport is fully preserved.
* fix: use htmx.trigger instead of dispatchEvent for HTMX swap
dispatchEvent(submit) was triggering native form submission (full page
reload). htmx.trigger() properly triggers HTMX swap.
Also re-enable initial rebindEventLayers so polygons load on first render.
---------
Co-authored-by: Matt Johnson <mj@k7zvx.com>
2026-05-18 14:19:27 -06:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestEventRowDataAttributes:
|
|
|
|
|
"""Test that _events_rows.html renders required data attributes."""
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_row_renders_data_adapter_attribute(self):
|
|
|
|
|
"""Event rows include data-adapter attribute for color coding."""
|
|
|
|
|
mock_request = MagicMock()
|
|
|
|
|
mock_request.state.operator = MagicMock(id=1, username="admin")
|
|
|
|
|
mock_request.state.csrf_token = "test_csrf"
|
|
|
|
|
mock_request.query_params = {}
|
|
|
|
|
|
|
|
|
|
mock_events = [
|
|
|
|
|
{
|
|
|
|
|
"id": "test1",
|
|
|
|
|
"time": datetime(2026, 5, 17, 12, 0, tzinfo=timezone.utc),
|
|
|
|
|
"received": datetime(2026, 5, 17, 12, 0, tzinfo=timezone.utc),
|
|
|
|
|
"adapter": "usgs_quake",
|
|
|
|
|
"category": "quake.event",
|
|
|
|
|
"subject": "M4.2 Earthquake",
|
|
|
|
|
"geometry": None,
|
|
|
|
|
"data": {},
|
|
|
|
|
"regions": [],
|
|
|
|
|
},
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
mock_conn = AsyncMock()
|
|
|
|
|
mock_conn.fetch.return_value = mock_events
|
|
|
|
|
mock_conn.fetchrow.return_value = {
|
|
|
|
|
"map_tile_url": "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
|
|
|
|
|
"map_attribution": "OpenStreetMap",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
mock_pool = MagicMock()
|
|
|
|
|
mock_pool.acquire.return_value.__aenter__ = AsyncMock(return_value=mock_conn)
|
|
|
|
|
mock_pool.acquire.return_value.__aexit__ = AsyncMock(return_value=None)
|
|
|
|
|
|
|
|
|
|
mock_templates = MagicMock()
|
|
|
|
|
mock_templates.TemplateResponse.return_value = MagicMock(status_code=200)
|
|
|
|
|
|
|
|
|
|
with patch("central.gui.routes._get_templates", return_value=mock_templates):
|
|
|
|
|
with patch("central.gui.routes.get_pool", return_value=mock_pool):
|
|
|
|
|
result = await events_list(mock_request)
|
|
|
|
|
|
|
|
|
|
assert result.status_code == 200
|
|
|
|
|
mock_templates.TemplateResponse.assert_called_once()
|
|
|
|
|
context = mock_templates.TemplateResponse.call_args.kwargs.get("context")
|
|
|
|
|
# The template receives events with adapter field for data-adapter attribute
|
|
|
|
|
assert len(context["events"]) == 1
|
|
|
|
|
assert context["events"][0]["adapter"] == "usgs_quake"
|
|
|
|
|
assert context["events"][0]["category"] == "quake.event"
|
|
|
|
|
assert context["events"][0]["subject"] == "M4.2 Earthquake"
|
2026-05-21 05:45:15 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- PR L-b: operator /events tab polish ---------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _events_context(events):
|
|
|
|
|
"""Minimal context for rendering _events_rows.html as a standalone fragment."""
|
|
|
|
|
return {
|
|
|
|
|
"events": events,
|
|
|
|
|
"next_cursor": None,
|
|
|
|
|
"filter_error": None,
|
|
|
|
|
"filter_values": {
|
|
|
|
|
"adapter": "", "category": "", "since": "", "until": "",
|
|
|
|
|
"region_north": "", "region_south": "", "region_east": "",
|
|
|
|
|
"region_west": "", "limit": "50",
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _event(adapter, inner=None, geometry=None):
|
|
|
|
|
"""Build an event dict matching _fetch_events output shape.
|
|
|
|
|
|
|
|
|
|
`inner` populates payload->data->data (the adapter-specific payload) at
|
|
|
|
|
event["data"]["data"]["data"], which the per-adapter partials read.
|
|
|
|
|
"""
|
|
|
|
|
return {
|
|
|
|
|
"id": "evt-" + adapter,
|
|
|
|
|
"time": "2026-05-17T12:00:00+00:00",
|
|
|
|
|
"received": "2026-05-17T12:00:00+00:00",
|
|
|
|
|
"adapter": adapter,
|
|
|
|
|
"category": adapter + ".test",
|
|
|
|
|
"subject": "subject",
|
|
|
|
|
"geometry": geometry,
|
|
|
|
|
"geometry_summary": "",
|
|
|
|
|
"data": {"data": {"data": inner or {}}},
|
|
|
|
|
"regions": [],
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _render_rows(events):
|
|
|
|
|
"""Render _events_rows.html through the real Jinja environment."""
|
|
|
|
|
from central.gui import templates as gui_templates
|
|
|
|
|
return gui_templates.env.get_template("_events_rows.html").render(
|
|
|
|
|
**_events_context(events)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestRegistryDrivenAdapterFilter:
|
|
|
|
|
"""(A) Adapter filter <select> is driven by discover_adapters(), no hardcoded list."""
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_filter_options_cover_every_discovered_adapter(self):
|
|
|
|
|
from central.adapter_discovery import discover_adapters
|
|
|
|
|
registry = discover_adapters()
|
|
|
|
|
|
|
|
|
|
mock_request = MagicMock()
|
|
|
|
|
mock_request.state.operator = MagicMock(id=1, username="admin")
|
|
|
|
|
mock_request.state.csrf_token = "test_csrf"
|
|
|
|
|
mock_request.query_params = {}
|
|
|
|
|
|
|
|
|
|
mock_conn = AsyncMock()
|
|
|
|
|
mock_conn.fetch.return_value = []
|
|
|
|
|
mock_conn.fetchrow.return_value = {
|
|
|
|
|
"map_tile_url": "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
|
|
|
|
|
"map_attribution": "OpenStreetMap",
|
|
|
|
|
}
|
|
|
|
|
mock_pool = MagicMock()
|
|
|
|
|
mock_pool.acquire.return_value.__aenter__ = AsyncMock(return_value=mock_conn)
|
|
|
|
|
mock_pool.acquire.return_value.__aexit__ = AsyncMock(return_value=None)
|
|
|
|
|
|
|
|
|
|
mock_templates = MagicMock()
|
|
|
|
|
mock_templates.TemplateResponse.return_value = MagicMock(status_code=200)
|
|
|
|
|
|
|
|
|
|
with patch("central.gui.routes._get_templates", return_value=mock_templates):
|
|
|
|
|
with patch("central.gui.routes.get_pool", return_value=mock_pool):
|
|
|
|
|
await events_list(mock_request)
|
|
|
|
|
|
|
|
|
|
context = mock_templates.TemplateResponse.call_args.kwargs.get("context")
|
|
|
|
|
|
|
|
|
|
# The list is exactly the registry, sorted by name (stable), no extras.
|
|
|
|
|
assert [a["name"] for a in context["adapters"]] == sorted(registry.keys())
|
|
|
|
|
# Each entry carries name + display_name straight from the adapter class.
|
|
|
|
|
for cls in registry.values():
|
|
|
|
|
assert {"name": cls.name, "display_name": cls.display_name} in context["adapters"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestPerAdapterRowPartials:
|
|
|
|
|
"""(C) Per-adapter row partials with registry-derived dispatch + _default fallback."""
|
|
|
|
|
|
|
|
|
|
def test_every_discovered_adapter_has_a_partial(self):
|
|
|
|
|
from central.adapter_discovery import discover_adapters
|
|
|
|
|
from central.gui import templates as gui_templates
|
|
|
|
|
# get_template raises TemplateNotFound if a per-adapter file is missing.
|
|
|
|
|
for name in discover_adapters():
|
|
|
|
|
gui_templates.env.get_template("_event_rows/%s.html" % name)
|
|
|
|
|
|
|
|
|
|
def test_default_fallback_partial_exists(self):
|
|
|
|
|
from central.gui import templates as gui_templates
|
|
|
|
|
gui_templates.env.get_template("_event_rows/_default.html")
|
|
|
|
|
|
|
|
|
|
def test_every_discovered_adapter_renders_without_error(self):
|
|
|
|
|
from central.adapter_discovery import discover_adapters
|
|
|
|
|
for name in discover_adapters():
|
|
|
|
|
html = _render_rows([_event(name)])
|
|
|
|
|
assert 'data-adapter="%s"' % name in html
|
|
|
|
|
|
|
|
|
|
def test_unknown_adapter_falls_back_to_default(self):
|
|
|
|
|
# No bespoke partial -> dispatch resolves to _default.html (no crash),
|
|
|
|
|
# and the raw payload block is still rendered.
|
|
|
|
|
html = _render_rows([_event("not_a_real_adapter", inner={"foo": "bar"})])
|
|
|
|
|
assert 'data-adapter="not_a_real_adapter"' in html
|
|
|
|
|
assert "event-data-pre" in html
|
|
|
|
|
|
|
|
|
|
def test_usgs_quake_partial_surfaces_curated_fields(self):
|
|
|
|
|
html = _render_rows([_event(
|
|
|
|
|
"usgs_quake",
|
|
|
|
|
inner={"magnitude": 4.2, "magType": "mb", "place": "10km N of Town", "depth": 5.0},
|
|
|
|
|
)])
|
|
|
|
|
assert "Magnitude" in html
|
|
|
|
|
assert "4.2" in html
|
|
|
|
|
assert "10km N of Town" in html
|
|
|
|
|
|
|
|
|
|
def test_nws_partial_surfaces_curated_fields(self):
|
|
|
|
|
html = _render_rows([_event(
|
|
|
|
|
"nws",
|
|
|
|
|
inner={"event": "Tornado Warning", "headline": "TORNADO", "severity": "Extreme"},
|
|
|
|
|
)])
|
|
|
|
|
assert "Tornado Warning" in html
|
|
|
|
|
assert "Extreme" in html
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestMapAllAdapterGeometry:
|
|
|
|
|
"""(B) Every non-null geometry reaches the map; rebind-on-swap is enabled."""
|
|
|
|
|
|
|
|
|
|
def test_polygon_geometry_emitted_as_data_geometry(self):
|
|
|
|
|
poly = {
|
|
|
|
|
"type": "Polygon",
|
|
|
|
|
"coordinates": [[[-104.18, 37.14], [-103.66, 37.14],
|
|
|
|
|
[-103.66, 37.43], [-104.18, 37.43], [-104.18, 37.14]]],
|
|
|
|
|
}
|
|
|
|
|
html = _render_rows([_event("nws", inner={"event": "x"}, geometry=poly)])
|
|
|
|
|
assert "data-geometry=" in html
|
|
|
|
|
assert "Polygon" in html
|
|
|
|
|
|
|
|
|
|
def test_event_without_geometry_omits_data_geometry(self):
|
|
|
|
|
html = _render_rows([_event("swpc_protons", inner={"flux": 1.0})])
|
|
|
|
|
assert "data-geometry=" not in html
|
|
|
|
|
|
|
|
|
|
def test_map_rebinds_on_swap_and_handles_degenerate_geometry(self):
|
|
|
|
|
# Regression guard for the NWIS-only map: rebind must fire on HTMX swap
|
|
|
|
|
# and the degenerate-geometry fallback must exist.
|
|
|
|
|
import pathlib
|
|
|
|
|
from central.gui import templates as gui_templates
|
|
|
|
|
src = pathlib.Path(
|
|
|
|
|
gui_templates.env.loader.searchpath[0], "events_list.html"
|
|
|
|
|
).read_text()
|
|
|
|
|
assert "// rebindEventLayers(); // DISABLED" not in src
|
|
|
|
|
assert "isDegenerate" in src
|
2026-05-21 07:05:20 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- PR L-c: readable Time / Location / Subject / Adapter columns ---------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _first_row_cells(html):
|
|
|
|
|
"""Visible <td> text of the first event-row (before its detail row).
|
|
|
|
|
|
|
|
|
|
Splits on the detail row's class attribute (not the bare string
|
|
|
|
|
'event-detail', which also appears in the page's <style> block).
|
|
|
|
|
"""
|
|
|
|
|
import re
|
|
|
|
|
body = html.split('class="event-detail"')[0]
|
|
|
|
|
return [
|
|
|
|
|
re.sub(r"<[^>]+>", "", c).strip()
|
|
|
|
|
for c in re.findall(r"<td[^>]*>(.*?)</td>", body, re.S)
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestEventTimeFormat:
|
|
|
|
|
"""(A) Server-side 'MM-DD-YYYY HH:MM UTC' formatting (24h, no seconds)."""
|
|
|
|
|
|
|
|
|
|
def test_format_basic_utc(self):
|
|
|
|
|
from central.gui.routes import _format_event_time
|
|
|
|
|
assert _format_event_time("2026-05-21T06:00:00+00:00") == "05-21-2026 06:00 UTC"
|
|
|
|
|
|
|
|
|
|
def test_format_converts_offset_to_utc(self):
|
|
|
|
|
from central.gui.routes import _format_event_time
|
|
|
|
|
# 19:30 at -06:00 is 01:30 UTC the next day.
|
|
|
|
|
assert _format_event_time("2026-05-20T19:30:00-06:00") == "05-21-2026 01:30 UTC"
|
|
|
|
|
|
|
|
|
|
def test_format_empty_and_none(self):
|
|
|
|
|
from central.gui.routes import _format_event_time
|
|
|
|
|
assert _format_event_time("") == ""
|
|
|
|
|
assert _format_event_time(None) == ""
|
|
|
|
|
|
|
|
|
|
def test_format_no_seconds_no_offset_suffix(self):
|
|
|
|
|
from central.gui.routes import _format_event_time
|
|
|
|
|
out = _format_event_time("2026-01-02T03:04:59+00:00")
|
|
|
|
|
assert out == "01-02-2026 03:04 UTC"
|
|
|
|
|
assert ":59" not in out and "+00" not in out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestTableDisplayDecoration:
|
|
|
|
|
"""(A)/(D) _decorate_table_events adds display fields; /events.json unaffected."""
|
|
|
|
|
|
|
|
|
|
def test_adapter_display_matches_registry(self):
|
|
|
|
|
from central.adapter_discovery import discover_adapters
|
|
|
|
|
from central.gui.routes import _decorate_table_events, _format_event_time
|
|
|
|
|
registry = discover_adapters()
|
|
|
|
|
events = [_event(name) for name in registry]
|
|
|
|
|
_decorate_table_events(events)
|
|
|
|
|
for ev in events:
|
|
|
|
|
cls = registry[ev["adapter"]]
|
|
|
|
|
assert ev["adapter_display"] == cls.display_name
|
|
|
|
|
assert ev["time_human"] == _format_event_time(ev["time"])
|
|
|
|
|
|
|
|
|
|
def test_unknown_adapter_display_falls_back_to_name(self):
|
|
|
|
|
from central.gui.routes import _decorate_table_events
|
|
|
|
|
events = [_event("not_a_real_adapter")]
|
|
|
|
|
_decorate_table_events(events)
|
|
|
|
|
assert events[0]["adapter_display"] == "not_a_real_adapter"
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_events_json_has_no_table_only_fields(self):
|
|
|
|
|
# No /events.json schema change: display fields must not leak into JSON.
|
|
|
|
|
mock_request = MagicMock()
|
|
|
|
|
mock_request.state.operator = MagicMock(id=1, username="admin")
|
|
|
|
|
mock_request.query_params = {}
|
|
|
|
|
|
|
|
|
|
mock_conn = AsyncMock()
|
|
|
|
|
mock_conn.fetch.return_value = [{
|
|
|
|
|
"id": "e1",
|
|
|
|
|
"time": datetime(2026, 5, 21, 6, 0, tzinfo=timezone.utc),
|
|
|
|
|
"received": datetime(2026, 5, 21, 6, 0, tzinfo=timezone.utc),
|
|
|
|
|
"adapter": "nwis",
|
|
|
|
|
"category": "hydro",
|
|
|
|
|
"subject": None,
|
|
|
|
|
"geometry": None,
|
|
|
|
|
"data": {},
|
|
|
|
|
"regions": [],
|
|
|
|
|
}]
|
|
|
|
|
mock_pool = MagicMock()
|
|
|
|
|
mock_pool.acquire.return_value.__aenter__ = AsyncMock(return_value=mock_conn)
|
|
|
|
|
mock_pool.acquire.return_value.__aexit__ = AsyncMock(return_value=None)
|
|
|
|
|
|
|
|
|
|
with patch("central.gui.routes.get_pool", return_value=mock_pool):
|
|
|
|
|
resp = await events_json(mock_request)
|
|
|
|
|
|
|
|
|
|
ev = json.loads(resp.body)["events"][0]
|
|
|
|
|
assert "time_human" not in ev
|
|
|
|
|
assert "adapter_display" not in ev
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestLocationColumn:
|
|
|
|
|
"""(B) Generic _enriched/top-level location reader — no adapter logic."""
|
|
|
|
|
|
|
|
|
|
def _location(self, inner):
|
|
|
|
|
return _first_row_cells(_render_rows([_event("usgs_quake", inner=inner)]))[2]
|
|
|
|
|
|
|
|
|
|
def test_city_state_country(self):
|
|
|
|
|
loc = self._location({"_enriched": {"geocoder": {
|
|
|
|
|
"city": "Trinidad", "state": "Colorado", "country": "United States"}}})
|
|
|
|
|
assert loc == "Trinidad, Colorado, United States"
|
|
|
|
|
|
|
|
|
|
def test_state_country_when_city_null(self):
|
|
|
|
|
loc = self._location({"_enriched": {"geocoder": {
|
|
|
|
|
"city": None, "state": "Missouri", "country": "United States"}}})
|
|
|
|
|
assert loc == "Missouri, United States"
|
|
|
|
|
|
|
|
|
|
def test_top_level_country_fallback(self):
|
|
|
|
|
# gdacs-style: no geocoder, country at top level.
|
|
|
|
|
assert self._location({"country": "Austria"}) == "Austria"
|
|
|
|
|
|
|
|
|
|
def test_top_level_state_only_fallback(self):
|
|
|
|
|
# wfigs-style: state code at top level, no country.
|
|
|
|
|
assert self._location({"state": "CO"}) == "CO"
|
|
|
|
|
|
|
|
|
|
def test_landclass_fallback(self):
|
|
|
|
|
loc = self._location({"_enriched": {"geocoder": {
|
|
|
|
|
"city": None, "state": None, "country": None,
|
|
|
|
|
"landclass": "Ridgecrest Field Office"}}})
|
|
|
|
|
assert loc == "Ridgecrest Field Office"
|
|
|
|
|
|
|
|
|
|
def test_coordinates_alone_are_not_a_location(self):
|
|
|
|
|
# Bare lat/lon is a position, not a place name -> "—".
|
|
|
|
|
assert self._location({"latitude": 35.3603324, "longitude": -117.7854995}) == "—"
|
|
|
|
|
|
|
|
|
|
def test_none_when_nothing_available(self):
|
|
|
|
|
assert self._location({}) == "—"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestSubjectColumn:
|
|
|
|
|
"""(C) Per-adapter one-line summaries, registry-derived dispatch + fallback."""
|
|
|
|
|
|
|
|
|
|
def test_every_adapter_has_a_summary_partial(self):
|
|
|
|
|
from central.adapter_discovery import discover_adapters
|
|
|
|
|
from central.gui import templates as gui_templates
|
|
|
|
|
for name in discover_adapters():
|
|
|
|
|
gui_templates.env.get_template("_event_summaries/%s.html" % name)
|
|
|
|
|
|
|
|
|
|
def test_default_summary_partial_exists(self):
|
|
|
|
|
from central.gui import templates as gui_templates
|
|
|
|
|
gui_templates.env.get_template("_event_summaries/_default.html")
|
|
|
|
|
|
|
|
|
|
def test_every_adapter_summary_renders_without_error(self):
|
|
|
|
|
from central.adapter_discovery import discover_adapters
|
|
|
|
|
for name in discover_adapters():
|
|
|
|
|
_render_rows([_event(name)]) # must not raise
|
|
|
|
|
|
|
|
|
|
def test_usgs_quake_summary_is_plain_language(self):
|
|
|
|
|
cells = _first_row_cells(_render_rows([_event(
|
|
|
|
|
"usgs_quake", inner={"magnitude": 1.347, "magType": "ml",
|
|
|
|
|
"place": "14 km W of Johannesburg, CA"})]))
|
|
|
|
|
assert cells[3] == "Magnitude 1.3 — 14 km W of Johannesburg, CA"
|
|
|
|
|
assert "ml" not in cells[3] # scale code dropped
|
|
|
|
|
|
|
|
|
|
def test_nwis_summary_drops_parameter_code(self):
|
|
|
|
|
cells = _first_row_cells(_render_rows([_event(
|
|
|
|
|
"nwis", inner={"parameter_code": "00060", "value": 111.0,
|
|
|
|
|
"unit_of_measure": "ft^3/s"})]))
|
|
|
|
|
assert cells[3] == "Water reading: 111.0 ft^3/s"
|
|
|
|
|
assert "00060" not in cells[3] # opaque pcode dropped
|
|
|
|
|
|
|
|
|
|
def test_unknown_adapter_summary_is_dash(self):
|
|
|
|
|
cells = _first_row_cells(_render_rows([_event("not_a_real_adapter", inner={"x": 1})]))
|
|
|
|
|
assert cells[3] == "—"
|
|
|
|
|
|
|
|
|
|
def test_summary_populates_data_subject_for_popup(self):
|
|
|
|
|
html = _render_rows([_event("usgs_quake", inner={"magnitude": 1.347,
|
|
|
|
|
"place": "near Town"})])
|
|
|
|
|
assert 'data-subject="Magnitude 1.3 — near Town"' in html
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestTableRendersThroughHTTP:
|
|
|
|
|
"""End-to-end HTTP render: Time and Adapter cells must be populated in the
|
|
|
|
|
real response, guarding the route->template binding (not the helper alone)."""
|
|
|
|
|
|
|
|
|
|
def _mock_pool(self):
|
|
|
|
|
now = datetime(2026, 5, 21, 6, 0, tzinfo=timezone.utc)
|
|
|
|
|
|
|
|
|
|
def _fetchrow(query, *args):
|
|
|
|
|
q = " ".join(str(query).split())
|
|
|
|
|
if "config.sessions" in q:
|
|
|
|
|
return {"id": 1, "username": "admin", "created_at": now,
|
|
|
|
|
"password_changed_at": now, "csrf_token": "csrf"}
|
|
|
|
|
if "map_tile_url" in q:
|
|
|
|
|
return {"map_tile_url": "https://t/{z}/{x}/{y}.png",
|
|
|
|
|
"map_attribution": "OSM"}
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
rows = [
|
|
|
|
|
{"id": "evt-q", "time": now, "received": now, "adapter": "usgs_quake",
|
|
|
|
|
"category": "quake", "subject": None, "geometry": None,
|
|
|
|
|
"data": {"data": {"data": {"magnitude": 1.3, "place": "near Town"}}},
|
|
|
|
|
"regions": []},
|
|
|
|
|
{"id": "evt-w", "time": now, "received": now, "adapter": "nwis",
|
|
|
|
|
"category": "hydro", "subject": None, "geometry": None,
|
|
|
|
|
"data": {"data": {"data": {"value": 5.0, "unit_of_measure": "ft"}}},
|
|
|
|
|
"regions": []},
|
|
|
|
|
]
|
|
|
|
|
mock_conn = MagicMock()
|
|
|
|
|
mock_conn.fetchrow = AsyncMock(side_effect=_fetchrow)
|
|
|
|
|
mock_conn.fetch = AsyncMock(return_value=rows)
|
|
|
|
|
mock_conn.__aenter__ = AsyncMock(return_value=mock_conn)
|
|
|
|
|
mock_conn.__aexit__ = AsyncMock()
|
|
|
|
|
mock_pool = MagicMock()
|
|
|
|
|
mock_pool.acquire = MagicMock(return_value=mock_conn)
|
|
|
|
|
return mock_pool
|
|
|
|
|
|
|
|
|
|
def _client(self):
|
|
|
|
|
from fastapi import FastAPI
|
|
|
|
|
from starlette.testclient import TestClient
|
|
|
|
|
from central.gui.middleware import SessionMiddleware
|
|
|
|
|
from central.gui.routes import router
|
|
|
|
|
app = FastAPI()
|
|
|
|
|
app.include_router(router)
|
|
|
|
|
app.add_middleware(SessionMiddleware)
|
|
|
|
|
return TestClient(app, cookies={"central_session": "valid"})
|
|
|
|
|
|
|
|
|
|
def _expected_adapter_display(self):
|
|
|
|
|
from central.adapter_discovery import discover_adapters
|
|
|
|
|
return discover_adapters()["usgs_quake"].display_name
|
|
|
|
|
|
|
|
|
|
def test_events_page_time_and_adapter_cells_populated(self):
|
|
|
|
|
mock_pool = self._mock_pool()
|
|
|
|
|
with patch("central.gui.middleware.get_pool", return_value=mock_pool), \
|
|
|
|
|
patch("central.gui.routes.get_pool", return_value=mock_pool):
|
|
|
|
|
resp = self._client().get("/events")
|
|
|
|
|
assert resp.status_code == 200
|
|
|
|
|
cells = _first_row_cells(resp.text)
|
|
|
|
|
assert cells[1].endswith("UTC") and "2026" in cells[1] # Time non-empty
|
|
|
|
|
assert cells[4] == self._expected_adapter_display() # Adapter display_name
|
|
|
|
|
|
|
|
|
|
def test_events_rows_fragment_time_and_adapter_cells_populated(self):
|
|
|
|
|
mock_pool = self._mock_pool()
|
|
|
|
|
with patch("central.gui.middleware.get_pool", return_value=mock_pool), \
|
|
|
|
|
patch("central.gui.routes.get_pool", return_value=mock_pool):
|
|
|
|
|
resp = self._client().get("/events/rows")
|
|
|
|
|
assert resp.status_code == 200
|
|
|
|
|
cells = _first_row_cells(resp.text)
|
|
|
|
|
assert cells[1].endswith("UTC") and "2026" in cells[1] # Time non-empty
|
|
|
|
|
assert cells[4] == self._expected_adapter_display() # Adapter display_name
|