central/tests/test_events_feed_frontend.py

1301 lines
54 KiB
Python
Raw Normal View History

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 html
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
import json
from datetime import datetime, timedelta, timezone
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from central.adapter_discovery import discover_adapters
from central.gui import templates as _gui_templates
from central.gui.routes import events_list, events_rows, events_json, _derive_subject
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
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")
feat(filtering): chip-picker filters, search, time presets, active pills (v0.7.1) Biggest PR of the v0.7.x GUI rework arc. Replaces the single-select /events filter row with a multi-select, URL-addressable filtering surface. - Search: full-width box, debounced 300ms, server-side ILIKE over the inner adapter payload (covers the derived subject + location); parameterized with LIKE wildcards escaped (ESCAPE '\'). Injection-safe. - Adapter / Category / Event Type / Severity: multi-select chip-pickers (shared _chip_picker.html macro). Adapter is grouped by domain with color swatches and an in-panel search. Backend uses `= ANY(...)`. URL state is comma-separated. - Event Type is derived as split_part(category,'.',1) (no event_type column yet; a stand-in until the v0.8 canonical schema). Severity maps labels to the numeric scale (4=critical..1=low, 0/NULL=unknown). - Time: preset dropdown (15m/1h/6h/24h/7d/active/all) + custom from/to range, encoded in a single `time` token. GUI defaults to last_24h; events.json keeps its single-value adapter/since/until contract (no default). - Active pills: server-rendered from parsed state, updated out-of-band on each HTMX swap; each x clears that filter and re-submits. - URL state persistence: every filter in the query string; /events/rows sets HX-Push-Url to the /events?... full-page URL so bookmarking/back-forward work. Filter options are rendered server-side at page load (DISTINCT category + split_part, registry adapters, severity enum) -- no new AJAX endpoints. Vanilla JS + HTMX (no framework added). CSS is functional-only; visual polish is deferred to a later pass per the rework plan. Adds TestEventsFiltering (24 tests: multi-value parse, ILIKE injection safety, time-preset resolution with injected clock, severity/NULL handling, active-pill descriptors, URL round-trip). Updates four TestEventsFeedFrontend assertions to the new filter_state/adapters contract. Full suite: 658 passed, 1 skipped (central and unprivileged zvx). No adapter base class change -> central-gui restart only (no supervisor restart). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 00:58:38 +00:00
# v0.7.1: adapter is now a multi-select; single value parses to a 1-list.
assert context["filter_state"]["adapters"] == ["nws"]
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
@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)
feat(filtering): chip-picker filters, search, time presets, active pills (v0.7.1) Biggest PR of the v0.7.x GUI rework arc. Replaces the single-select /events filter row with a multi-select, URL-addressable filtering surface. - Search: full-width box, debounced 300ms, server-side ILIKE over the inner adapter payload (covers the derived subject + location); parameterized with LIKE wildcards escaped (ESCAPE '\'). Injection-safe. - Adapter / Category / Event Type / Severity: multi-select chip-pickers (shared _chip_picker.html macro). Adapter is grouped by domain with color swatches and an in-panel search. Backend uses `= ANY(...)`. URL state is comma-separated. - Event Type is derived as split_part(category,'.',1) (no event_type column yet; a stand-in until the v0.8 canonical schema). Severity maps labels to the numeric scale (4=critical..1=low, 0/NULL=unknown). - Time: preset dropdown (15m/1h/6h/24h/7d/active/all) + custom from/to range, encoded in a single `time` token. GUI defaults to last_24h; events.json keeps its single-value adapter/since/until contract (no default). - Active pills: server-rendered from parsed state, updated out-of-band on each HTMX swap; each x clears that filter and re-submits. - URL state persistence: every filter in the query string; /events/rows sets HX-Push-Url to the /events?... full-page URL so bookmarking/back-forward work. Filter options are rendered server-side at page load (DISTINCT category + split_part, registry adapters, severity enum) -- no new AJAX endpoints. Vanilla JS + HTMX (no framework added). CSS is functional-only; visual polish is deferred to a later pass per the rework plan. Adds TestEventsFiltering (24 tests: multi-value parse, ILIKE injection safety, time-preset resolution with injected clock, severity/NULL handling, active-pill descriptors, URL round-trip). Updates four TestEventsFeedFrontend assertions to the new filter_state/adapters contract. Full suite: 658 passed, 1 skipped (central and unprivileged zvx). No adapter base class change -> central-gui restart only (no supervisor restart). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 00:58:38 +00:00
# v0.7.1: the GUI uses time presets, but legacy since/until are still
# honored (JSON API + bookmarks); they parse without error and apply.
assert context["filter_error"] is None
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
@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)
feat(filtering): chip-picker filters, search, time presets, active pills (v0.7.1) Biggest PR of the v0.7.x GUI rework arc. Replaces the single-select /events filter row with a multi-select, URL-addressable filtering surface. - Search: full-width box, debounced 300ms, server-side ILIKE over the inner adapter payload (covers the derived subject + location); parameterized with LIKE wildcards escaped (ESCAPE '\'). Injection-safe. - Adapter / Category / Event Type / Severity: multi-select chip-pickers (shared _chip_picker.html macro). Adapter is grouped by domain with color swatches and an in-panel search. Backend uses `= ANY(...)`. URL state is comma-separated. - Event Type is derived as split_part(category,'.',1) (no event_type column yet; a stand-in until the v0.8 canonical schema). Severity maps labels to the numeric scale (4=critical..1=low, 0/NULL=unknown). - Time: preset dropdown (15m/1h/6h/24h/7d/active/all) + custom from/to range, encoded in a single `time` token. GUI defaults to last_24h; events.json keeps its single-value adapter/since/until contract (no default). - Active pills: server-rendered from parsed state, updated out-of-band on each HTMX swap; each x clears that filter and re-submits. - URL state persistence: every filter in the query string; /events/rows sets HX-Push-Url to the /events?... full-page URL so bookmarking/back-forward work. Filter options are rendered server-side at page load (DISTINCT category + split_part, registry adapters, severity enum) -- no new AJAX endpoints. Vanilla JS + HTMX (no framework added). CSS is functional-only; visual polish is deferred to a later pass per the rework plan. Adds TestEventsFiltering (24 tests: multi-value parse, ILIKE injection safety, time-preset resolution with injected clock, severity/NULL handling, active-pill descriptors, URL round-trip). Updates four TestEventsFeedFrontend assertions to the new filter_state/adapters contract. Full suite: 658 passed, 1 skipped (central and unprivileged zvx). No adapter base class change -> central-gui restart only (no supervisor restart). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 00:58:38 +00:00
assert context["filter_state"]["region_north"] == "49.5"
assert context["filter_state"]["region_south"] == "31"
assert context["filter_state"]["region_east"] == "-102"
assert context["filter_state"]["region_west"] == "-124.5"
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
@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
feat(layout-pagination): collapse legend, stabilize rows, real offset paginator (v0.7.3) PR #4 of the v0.7.x GUI rework arc. Production code; central-gui restart only. - Adapter legend: collapsed by default ("{n} adapters · Show legend ▾"). Expands to domain-grouped chips (same grouping as the v0.7.1 chip-picker) with uniform ellipsis-truncated names + full-name title tooltips. Clicking a legend chip toggles that adapter's filter (reuses the chip-picker's hidden CSV via syncField), so the legend doubles as a filter affordance. - Row stability: time cell is single-line MM-DD HH:MM UTC (year dropped from the cell; full ISO in the cell tooltip + a new Time row in the expanded detail). Adapter cell is a chip (color swatch + short name; display_name is the tooltip). table-layout:fixed + per-column widths + fixed 37px row height with nowrap/ellipsis cells -> no per-row wrap variation. - Real paginator: _fetch_events offset-mode returns the exact page slice plus the grand total via count(*) OVER() in one roundtrip. Previous/Next + windowed page numbers (1 ... 4 5 [6] 7 8 ... 47) + "showing X-Y of N" + a 25/50/100/250 per-page selector. URL state persists offset + limit. events.json keeps cursor pagination (back-compat): offset param presence selects offset-mode, its absence keeps the cursor path -- cleanly separable by endpoint. Adds TestEventsPagination (12 tests: offset/limit parse incl. max 250, offset-vs-cursor query shape, _build_pagination windowing). Updates the time format + adapter-cell + pagination-mode assertions in the existing frontend tests to the new contract. Full suite: 674 passed, 1 skipped (central and unprivileged zvx). count(*) OVER() is ~7.5ms at current volume; vanilla JS + HTMX; CSS functional-only. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 02:04:23 +00:00
async def test_events_with_limit_builds_pagination(self):
"""GET /events?limit=5 (offset-mode): pagination reflects total/next page."""
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
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"}
feat(layout-pagination): collapse legend, stabilize rows, real offset paginator (v0.7.3) PR #4 of the v0.7.x GUI rework arc. Production code; central-gui restart only. - Adapter legend: collapsed by default ("{n} adapters · Show legend ▾"). Expands to domain-grouped chips (same grouping as the v0.7.1 chip-picker) with uniform ellipsis-truncated names + full-name title tooltips. Clicking a legend chip toggles that adapter's filter (reuses the chip-picker's hidden CSV via syncField), so the legend doubles as a filter affordance. - Row stability: time cell is single-line MM-DD HH:MM UTC (year dropped from the cell; full ISO in the cell tooltip + a new Time row in the expanded detail). Adapter cell is a chip (color swatch + short name; display_name is the tooltip). table-layout:fixed + per-column widths + fixed 37px row height with nowrap/ellipsis cells -> no per-row wrap variation. - Real paginator: _fetch_events offset-mode returns the exact page slice plus the grand total via count(*) OVER() in one roundtrip. Previous/Next + windowed page numbers (1 ... 4 5 [6] 7 8 ... 47) + "showing X-Y of N" + a 25/50/100/250 per-page selector. URL state persists offset + limit. events.json keeps cursor pagination (back-compat): offset param presence selects offset-mode, its absence keeps the cursor path -- cleanly separable by endpoint. Adds TestEventsPagination (12 tests: offset/limit parse incl. max 250, offset-vs-cursor query shape, _build_pagination windowing). Updates the time format + adapter-cell + pagination-mode assertions in the existing frontend tests to the new contract. Full suite: 674 passed, 1 skipped (central and unprivileged zvx). count(*) OVER() is ~7.5ms at current volume; vanilla JS + HTMX; CSS functional-only. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 02:04:23 +00:00
# 5 rows for page 1; total_count=12 (the window count) => 3 pages.
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
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": [],
feat(layout-pagination): collapse legend, stabilize rows, real offset paginator (v0.7.3) PR #4 of the v0.7.x GUI rework arc. Production code; central-gui restart only. - Adapter legend: collapsed by default ("{n} adapters · Show legend ▾"). Expands to domain-grouped chips (same grouping as the v0.7.1 chip-picker) with uniform ellipsis-truncated names + full-name title tooltips. Clicking a legend chip toggles that adapter's filter (reuses the chip-picker's hidden CSV via syncField), so the legend doubles as a filter affordance. - Row stability: time cell is single-line MM-DD HH:MM UTC (year dropped from the cell; full ISO in the cell tooltip + a new Time row in the expanded detail). Adapter cell is a chip (color swatch + short name; display_name is the tooltip). table-layout:fixed + per-column widths + fixed 37px row height with nowrap/ellipsis cells -> no per-row wrap variation. - Real paginator: _fetch_events offset-mode returns the exact page slice plus the grand total via count(*) OVER() in one roundtrip. Previous/Next + windowed page numbers (1 ... 4 5 [6] 7 8 ... 47) + "showing X-Y of N" + a 25/50/100/250 per-page selector. URL state persists offset + limit. events.json keeps cursor pagination (back-compat): offset param presence selects offset-mode, its absence keeps the cursor path -- cleanly separable by endpoint. Adds TestEventsPagination (12 tests: offset/limit parse incl. max 250, offset-vs-cursor query shape, _build_pagination windowing). Updates the time format + adapter-cell + pagination-mode assertions in the existing frontend tests to the new contract. Full suite: 674 passed, 1 skipped (central and unprivileged zvx). count(*) OVER() is ~7.5ms at current volume; vanilla JS + HTMX; CSS functional-only. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 02:04:23 +00:00
"total_count": 12,
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
}
feat(layout-pagination): collapse legend, stabilize rows, real offset paginator (v0.7.3) PR #4 of the v0.7.x GUI rework arc. Production code; central-gui restart only. - Adapter legend: collapsed by default ("{n} adapters · Show legend ▾"). Expands to domain-grouped chips (same grouping as the v0.7.1 chip-picker) with uniform ellipsis-truncated names + full-name title tooltips. Clicking a legend chip toggles that adapter's filter (reuses the chip-picker's hidden CSV via syncField), so the legend doubles as a filter affordance. - Row stability: time cell is single-line MM-DD HH:MM UTC (year dropped from the cell; full ISO in the cell tooltip + a new Time row in the expanded detail). Adapter cell is a chip (color swatch + short name; display_name is the tooltip). table-layout:fixed + per-column widths + fixed 37px row height with nowrap/ellipsis cells -> no per-row wrap variation. - Real paginator: _fetch_events offset-mode returns the exact page slice plus the grand total via count(*) OVER() in one roundtrip. Previous/Next + windowed page numbers (1 ... 4 5 [6] 7 8 ... 47) + "showing X-Y of N" + a 25/50/100/250 per-page selector. URL state persists offset + limit. events.json keeps cursor pagination (back-compat): offset param presence selects offset-mode, its absence keeps the cursor path -- cleanly separable by endpoint. Adds TestEventsPagination (12 tests: offset/limit parse incl. max 250, offset-vs-cursor query shape, _build_pagination windowing). Updates the time format + adapter-cell + pagination-mode assertions in the existing frontend tests to the new contract. Full suite: 674 passed, 1 skipped (central and unprivileged zvx). count(*) OVER() is ~7.5ms at current volume; vanilla JS + HTMX; CSS functional-only. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 02:04:23 +00:00
for i in range(5)
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
]
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")
feat(layout-pagination): collapse legend, stabilize rows, real offset paginator (v0.7.3) PR #4 of the v0.7.x GUI rework arc. Production code; central-gui restart only. - Adapter legend: collapsed by default ("{n} adapters · Show legend ▾"). Expands to domain-grouped chips (same grouping as the v0.7.1 chip-picker) with uniform ellipsis-truncated names + full-name title tooltips. Clicking a legend chip toggles that adapter's filter (reuses the chip-picker's hidden CSV via syncField), so the legend doubles as a filter affordance. - Row stability: time cell is single-line MM-DD HH:MM UTC (year dropped from the cell; full ISO in the cell tooltip + a new Time row in the expanded detail). Adapter cell is a chip (color swatch + short name; display_name is the tooltip). table-layout:fixed + per-column widths + fixed 37px row height with nowrap/ellipsis cells -> no per-row wrap variation. - Real paginator: _fetch_events offset-mode returns the exact page slice plus the grand total via count(*) OVER() in one roundtrip. Previous/Next + windowed page numbers (1 ... 4 5 [6] 7 8 ... 47) + "showing X-Y of N" + a 25/50/100/250 per-page selector. URL state persists offset + limit. events.json keeps cursor pagination (back-compat): offset param presence selects offset-mode, its absence keeps the cursor path -- cleanly separable by endpoint. Adds TestEventsPagination (12 tests: offset/limit parse incl. max 250, offset-vs-cursor query shape, _build_pagination windowing). Updates the time format + adapter-cell + pagination-mode assertions in the existing frontend tests to the new contract. Full suite: 674 passed, 1 skipped (central and unprivileged zvx). count(*) OVER() is ~7.5ms at current volume; vanilla JS + HTMX; CSS functional-only. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 02:04:23 +00:00
pg = context["pagination"]
assert pg["total"] == 12 and pg["total_pages"] == 3 and pg["page"] == 1
assert pg["next_offset"] == 5 and pg["prev_offset"] is None
assert context["next_cursor"] is None # offset-mode (GUI) doesn't use cursor
assert len(context["events"]) == 5
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
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
feat(layout-pagination): collapse legend, stabilize rows, real offset paginator (v0.7.3) PR #4 of the v0.7.x GUI rework arc. Production code; central-gui restart only. - Adapter legend: collapsed by default ("{n} adapters · Show legend ▾"). Expands to domain-grouped chips (same grouping as the v0.7.1 chip-picker) with uniform ellipsis-truncated names + full-name title tooltips. Clicking a legend chip toggles that adapter's filter (reuses the chip-picker's hidden CSV via syncField), so the legend doubles as a filter affordance. - Row stability: time cell is single-line MM-DD HH:MM UTC (year dropped from the cell; full ISO in the cell tooltip + a new Time row in the expanded detail). Adapter cell is a chip (color swatch + short name; display_name is the tooltip). table-layout:fixed + per-column widths + fixed 37px row height with nowrap/ellipsis cells -> no per-row wrap variation. - Real paginator: _fetch_events offset-mode returns the exact page slice plus the grand total via count(*) OVER() in one roundtrip. Previous/Next + windowed page numbers (1 ... 4 5 [6] 7 8 ... 47) + "showing X-Y of N" + a 25/50/100/250 per-page selector. URL state persists offset + limit. events.json keeps cursor pagination (back-compat): offset param presence selects offset-mode, its absence keeps the cursor path -- cleanly separable by endpoint. Adds TestEventsPagination (12 tests: offset/limit parse incl. max 250, offset-vs-cursor query shape, _build_pagination windowing). Updates the time format + adapter-cell + pagination-mode assertions in the existing frontend tests to the new contract. Full suite: 674 passed, 1 skipped (central and unprivileged zvx). count(*) OVER() is ~7.5ms at current volume; vanilla JS + HTMX; CSS functional-only. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 02:04:23 +00:00
async def test_pagination_modes_split_cleanly_by_endpoint(self):
"""v0.7.3: events.json keeps CURSOR pagination (next_cursor set); the
GUI events page uses OFFSET pagination (next_cursor None + pagination)."""
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
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")
feat(layout-pagination): collapse legend, stabilize rows, real offset paginator (v0.7.3) PR #4 of the v0.7.x GUI rework arc. Production code; central-gui restart only. - Adapter legend: collapsed by default ("{n} adapters · Show legend ▾"). Expands to domain-grouped chips (same grouping as the v0.7.1 chip-picker) with uniform ellipsis-truncated names + full-name title tooltips. Clicking a legend chip toggles that adapter's filter (reuses the chip-picker's hidden CSV via syncField), so the legend doubles as a filter affordance. - Row stability: time cell is single-line MM-DD HH:MM UTC (year dropped from the cell; full ISO in the cell tooltip + a new Time row in the expanded detail). Adapter cell is a chip (color swatch + short name; display_name is the tooltip). table-layout:fixed + per-column widths + fixed 37px row height with nowrap/ellipsis cells -> no per-row wrap variation. - Real paginator: _fetch_events offset-mode returns the exact page slice plus the grand total via count(*) OVER() in one roundtrip. Previous/Next + windowed page numbers (1 ... 4 5 [6] 7 8 ... 47) + "showing X-Y of N" + a 25/50/100/250 per-page selector. URL state persists offset + limit. events.json keeps cursor pagination (back-compat): offset param presence selects offset-mode, its absence keeps the cursor path -- cleanly separable by endpoint. Adds TestEventsPagination (12 tests: offset/limit parse incl. max 250, offset-vs-cursor query shape, _build_pagination windowing). Updates the time format + adapter-cell + pagination-mode assertions in the existing frontend tests to the new contract. Full suite: 674 passed, 1 skipped (central and unprivileged zvx). count(*) OVER() is ~7.5ms at current volume; vanilla JS + HTMX; CSS functional-only. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 02:04:23 +00:00
# events.json paginates by cursor; the GUI by offset (no cursor, has paginator).
assert json_cursor is not None
assert html_context["next_cursor"] is None
assert "pagination" in html_context
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
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"
# `subject` is now derived from the inner payload (rendered partial),
# not a DB pass-through, so the mock's input value is no longer echoed;
# just confirm the field is present. See TestEventsJsonSubject for the
# derivation contract.
assert "subject" in context["events"][0]
# --- PR L-b: operator /events tab polish ---------------------------------
def _events_context(events):
"""Minimal context for rendering _events_rows.html as a standalone fragment."""
feat(layout-pagination): collapse legend, stabilize rows, real offset paginator (v0.7.3) PR #4 of the v0.7.x GUI rework arc. Production code; central-gui restart only. - Adapter legend: collapsed by default ("{n} adapters · Show legend ▾"). Expands to domain-grouped chips (same grouping as the v0.7.1 chip-picker) with uniform ellipsis-truncated names + full-name title tooltips. Clicking a legend chip toggles that adapter's filter (reuses the chip-picker's hidden CSV via syncField), so the legend doubles as a filter affordance. - Row stability: time cell is single-line MM-DD HH:MM UTC (year dropped from the cell; full ISO in the cell tooltip + a new Time row in the expanded detail). Adapter cell is a chip (color swatch + short name; display_name is the tooltip). table-layout:fixed + per-column widths + fixed 37px row height with nowrap/ellipsis cells -> no per-row wrap variation. - Real paginator: _fetch_events offset-mode returns the exact page slice plus the grand total via count(*) OVER() in one roundtrip. Previous/Next + windowed page numbers (1 ... 4 5 [6] 7 8 ... 47) + "showing X-Y of N" + a 25/50/100/250 per-page selector. URL state persists offset + limit. events.json keeps cursor pagination (back-compat): offset param presence selects offset-mode, its absence keeps the cursor path -- cleanly separable by endpoint. Adds TestEventsPagination (12 tests: offset/limit parse incl. max 250, offset-vs-cursor query shape, _build_pagination windowing). Updates the time format + adapter-cell + pagination-mode assertions in the existing frontend tests to the new contract. Full suite: 674 passed, 1 skipped (central and unprivileged zvx). count(*) OVER() is ~7.5ms at current volume; vanilla JS + HTMX; CSS functional-only. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 02:04:23 +00:00
n = len(events)
return {
"events": events,
"next_cursor": None,
feat(telemetry-separation): dedicated /telemetry tab split from /events by adapter data_class (v0.7.4) PR #5 of the v0.7.x GUI rework arc. Production code; central-gui restart only (supervisor untouched -- data_class is read only by central-gui per request). - SourceAdapter gains a `data_class` class attr (Literal["event","telemetry"], default "event"). NWIS opts in as "telemetry" (continuous high-volume water gauges); every other adapter stays "event". The /events vs /telemetry split is thus registry-derived from class attrs -- no hardcoded adapter-name lists. - routes.py refactor: `_class_adapter_names(data_class)` and a `data_class` arg on `_adapter_filter_options` scope the flat + domain-grouped chip/legend lists to a class (colors stay keyed to the FULL registry, so an adapter keeps one color across tabs). `_fetch_events` accepts `class_adapters` and adds an `adapter = ANY(...)` condition. Shared `_events_query`, `_events_page(data_class, base_path)` and `_events_rows_fragment(...)` back both tabs; `/events`, `/events/rows`, `/telemetry`, `/telemetry/rows` are thin wrappers. - Templates parameterized with a `base_path` context var (form action, hx-get, hx-push-url header, clear-all redirect, JS BASE_PATH const); the `_events_rows` paginator macro takes `base`. Same templates serve both tabs; nav gains a Telemetry link. - /events.json UNCHANGED -- the cursor path sets no `class_adapters`, so the subject + pagination contract is intact (TestEventsJsonSubject still passes). Adds TestTelemetrySeparation (data_class defaults, registry split 11 event / 1 telemetry, class-scoped filter options, color stability, and the `adapter = ANY(...)` SQL shape incl. the no-class events.json path). Updates the events frontend tests for the base_path-parameterized templates. Full suite: 682 passed, 1 skipped (central and unprivileged zvx, 3x each). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 07:34:08 +00:00
"base_path": "/events",
feat(layout-pagination): collapse legend, stabilize rows, real offset paginator (v0.7.3) PR #4 of the v0.7.x GUI rework arc. Production code; central-gui restart only. - Adapter legend: collapsed by default ("{n} adapters · Show legend ▾"). Expands to domain-grouped chips (same grouping as the v0.7.1 chip-picker) with uniform ellipsis-truncated names + full-name title tooltips. Clicking a legend chip toggles that adapter's filter (reuses the chip-picker's hidden CSV via syncField), so the legend doubles as a filter affordance. - Row stability: time cell is single-line MM-DD HH:MM UTC (year dropped from the cell; full ISO in the cell tooltip + a new Time row in the expanded detail). Adapter cell is a chip (color swatch + short name; display_name is the tooltip). table-layout:fixed + per-column widths + fixed 37px row height with nowrap/ellipsis cells -> no per-row wrap variation. - Real paginator: _fetch_events offset-mode returns the exact page slice plus the grand total via count(*) OVER() in one roundtrip. Previous/Next + windowed page numbers (1 ... 4 5 [6] 7 8 ... 47) + "showing X-Y of N" + a 25/50/100/250 per-page selector. URL state persists offset + limit. events.json keeps cursor pagination (back-compat): offset param presence selects offset-mode, its absence keeps the cursor path -- cleanly separable by endpoint. Adds TestEventsPagination (12 tests: offset/limit parse incl. max 250, offset-vs-cursor query shape, _build_pagination windowing). Updates the time format + adapter-cell + pagination-mode assertions in the existing frontend tests to the new contract. Full suite: 674 passed, 1 skipped (central and unprivileged zvx). count(*) OVER() is ~7.5ms at current volume; vanilla JS + HTMX; CSS functional-only. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 02:04:23 +00:00
"query_string": "",
"pagination": {
"total": n, "offset": 0, "limit": 50, "page": 1, "total_pages": 1,
"start": 1 if n else 0, "end": n, "prev_offset": None, "next_offset": None,
"pages": [{"page": 1, "offset": 0, "current": True}] if n else [],
"per_page_options": [25, 50, 100, 250],
},
feat(layout-pagination): collapse legend, stabilize rows, real offset paginator (v0.7.3) PR #4 of the v0.7.x GUI rework arc. Production code; central-gui restart only. - Adapter legend: collapsed by default ("{n} adapters · Show legend ▾"). Expands to domain-grouped chips (same grouping as the v0.7.1 chip-picker) with uniform ellipsis-truncated names + full-name title tooltips. Clicking a legend chip toggles that adapter's filter (reuses the chip-picker's hidden CSV via syncField), so the legend doubles as a filter affordance. - Row stability: time cell is single-line MM-DD HH:MM UTC (year dropped from the cell; full ISO in the cell tooltip + a new Time row in the expanded detail). Adapter cell is a chip (color swatch + short name; display_name is the tooltip). table-layout:fixed + per-column widths + fixed 37px row height with nowrap/ellipsis cells -> no per-row wrap variation. - Real paginator: _fetch_events offset-mode returns the exact page slice plus the grand total via count(*) OVER() in one roundtrip. Previous/Next + windowed page numbers (1 ... 4 5 [6] 7 8 ... 47) + "showing X-Y of N" + a 25/50/100/250 per-page selector. URL state persists offset + limit. events.json keeps cursor pagination (back-compat): offset param presence selects offset-mode, its absence keeps the cursor path -- cleanly separable by endpoint. Adds TestEventsPagination (12 tests: offset/limit parse incl. max 250, offset-vs-cursor query shape, _build_pagination windowing). Updates the time format + adapter-cell + pagination-mode assertions in the existing frontend tests to the new contract. Full suite: 674 passed, 1 skipped (central and unprivileged zvx). count(*) OVER() is ~7.5ms at current volume; vanilla JS + HTMX; CSS functional-only. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 02:04:23 +00:00
"filter_error": None,
}
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")
feat(telemetry-separation): dedicated /telemetry tab split from /events by adapter data_class (v0.7.4) PR #5 of the v0.7.x GUI rework arc. Production code; central-gui restart only (supervisor untouched -- data_class is read only by central-gui per request). - SourceAdapter gains a `data_class` class attr (Literal["event","telemetry"], default "event"). NWIS opts in as "telemetry" (continuous high-volume water gauges); every other adapter stays "event". The /events vs /telemetry split is thus registry-derived from class attrs -- no hardcoded adapter-name lists. - routes.py refactor: `_class_adapter_names(data_class)` and a `data_class` arg on `_adapter_filter_options` scope the flat + domain-grouped chip/legend lists to a class (colors stay keyed to the FULL registry, so an adapter keeps one color across tabs). `_fetch_events` accepts `class_adapters` and adds an `adapter = ANY(...)` condition. Shared `_events_query`, `_events_page(data_class, base_path)` and `_events_rows_fragment(...)` back both tabs; `/events`, `/events/rows`, `/telemetry`, `/telemetry/rows` are thin wrappers. - Templates parameterized with a `base_path` context var (form action, hx-get, hx-push-url header, clear-all redirect, JS BASE_PATH const); the `_events_rows` paginator macro takes `base`. Same templates serve both tabs; nav gains a Telemetry link. - /events.json UNCHANGED -- the cursor path sets no `class_adapters`, so the subject + pagination contract is intact (TestEventsJsonSubject still passes). Adds TestTelemetrySeparation (data_class defaults, registry split 11 event / 1 telemetry, class-scoped filter options, color stability, and the `adapter = ANY(...)` SQL shape incl. the no-class events.json path). Updates the events frontend tests for the base_path-parameterized templates. Full suite: 682 passed, 1 skipped (central and unprivileged zvx, 3x each). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 07:34:08 +00:00
# v0.7.4: /events shows event-class adapters only (telemetry-class, e.g.
# nwis, moved to /telemetry). Registry-derived, sorted, no extras.
event_names = sorted(n for n, c in registry.items()
if getattr(c, "data_class", "event") == "event")
assert [a["name"] for a in context["adapters"]] == event_names
feat(filtering): chip-picker filters, search, time presets, active pills (v0.7.1) Biggest PR of the v0.7.x GUI rework arc. Replaces the single-select /events filter row with a multi-select, URL-addressable filtering surface. - Search: full-width box, debounced 300ms, server-side ILIKE over the inner adapter payload (covers the derived subject + location); parameterized with LIKE wildcards escaped (ESCAPE '\'). Injection-safe. - Adapter / Category / Event Type / Severity: multi-select chip-pickers (shared _chip_picker.html macro). Adapter is grouped by domain with color swatches and an in-panel search. Backend uses `= ANY(...)`. URL state is comma-separated. - Event Type is derived as split_part(category,'.',1) (no event_type column yet; a stand-in until the v0.8 canonical schema). Severity maps labels to the numeric scale (4=critical..1=low, 0/NULL=unknown). - Time: preset dropdown (15m/1h/6h/24h/7d/active/all) + custom from/to range, encoded in a single `time` token. GUI defaults to last_24h; events.json keeps its single-value adapter/since/until contract (no default). - Active pills: server-rendered from parsed state, updated out-of-band on each HTMX swap; each x clears that filter and re-submits. - URL state persistence: every filter in the query string; /events/rows sets HX-Push-Url to the /events?... full-page URL so bookmarking/back-forward work. Filter options are rendered server-side at page load (DISTINCT category + split_part, registry adapters, severity enum) -- no new AJAX endpoints. Vanilla JS + HTMX (no framework added). CSS is functional-only; visual polish is deferred to a later pass per the rework plan. Adds TestEventsFiltering (24 tests: multi-value parse, ILIKE injection safety, time-preset resolution with injected clock, severity/NULL handling, active-pill descriptors, URL round-trip). Updates four TestEventsFeedFrontend assertions to the new filter_state/adapters contract. Full suite: 658 passed, 1 skipped (central and unprivileged zvx). No adapter base class change -> central-gui restart only (no supervisor restart). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 00:58:38 +00:00
# Each entry carries name + display_name (v0.7.1 adds a positional color).
by_name = {a["name"]: a for a in context["adapters"]}
feat(telemetry-separation): dedicated /telemetry tab split from /events by adapter data_class (v0.7.4) PR #5 of the v0.7.x GUI rework arc. Production code; central-gui restart only (supervisor untouched -- data_class is read only by central-gui per request). - SourceAdapter gains a `data_class` class attr (Literal["event","telemetry"], default "event"). NWIS opts in as "telemetry" (continuous high-volume water gauges); every other adapter stays "event". The /events vs /telemetry split is thus registry-derived from class attrs -- no hardcoded adapter-name lists. - routes.py refactor: `_class_adapter_names(data_class)` and a `data_class` arg on `_adapter_filter_options` scope the flat + domain-grouped chip/legend lists to a class (colors stay keyed to the FULL registry, so an adapter keeps one color across tabs). `_fetch_events` accepts `class_adapters` and adds an `adapter = ANY(...)` condition. Shared `_events_query`, `_events_page(data_class, base_path)` and `_events_rows_fragment(...)` back both tabs; `/events`, `/events/rows`, `/telemetry`, `/telemetry/rows` are thin wrappers. - Templates parameterized with a `base_path` context var (form action, hx-get, hx-push-url header, clear-all redirect, JS BASE_PATH const); the `_events_rows` paginator macro takes `base`. Same templates serve both tabs; nav gains a Telemetry link. - /events.json UNCHANGED -- the cursor path sets no `class_adapters`, so the subject + pagination contract is intact (TestEventsJsonSubject still passes). Adds TestTelemetrySeparation (data_class defaults, registry split 11 event / 1 telemetry, class-scoped filter options, color stability, and the `adapter = ANY(...)` SQL shape incl. the no-class events.json path). Updates the events frontend tests for the base_path-parameterized templates. Full suite: 682 passed, 1 skipped (central and unprivileged zvx, 3x each). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 07:34:08 +00:00
for name in event_names:
cls = registry[name]
assert by_name[name]["display_name"] == cls.display_name
assert by_name[name]["color"].startswith("#")
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
# --- 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:
feat(layout-pagination): collapse legend, stabilize rows, real offset paginator (v0.7.3) PR #4 of the v0.7.x GUI rework arc. Production code; central-gui restart only. - Adapter legend: collapsed by default ("{n} adapters · Show legend ▾"). Expands to domain-grouped chips (same grouping as the v0.7.1 chip-picker) with uniform ellipsis-truncated names + full-name title tooltips. Clicking a legend chip toggles that adapter's filter (reuses the chip-picker's hidden CSV via syncField), so the legend doubles as a filter affordance. - Row stability: time cell is single-line MM-DD HH:MM UTC (year dropped from the cell; full ISO in the cell tooltip + a new Time row in the expanded detail). Adapter cell is a chip (color swatch + short name; display_name is the tooltip). table-layout:fixed + per-column widths + fixed 37px row height with nowrap/ellipsis cells -> no per-row wrap variation. - Real paginator: _fetch_events offset-mode returns the exact page slice plus the grand total via count(*) OVER() in one roundtrip. Previous/Next + windowed page numbers (1 ... 4 5 [6] 7 8 ... 47) + "showing X-Y of N" + a 25/50/100/250 per-page selector. URL state persists offset + limit. events.json keeps cursor pagination (back-compat): offset param presence selects offset-mode, its absence keeps the cursor path -- cleanly separable by endpoint. Adds TestEventsPagination (12 tests: offset/limit parse incl. max 250, offset-vs-cursor query shape, _build_pagination windowing). Updates the time format + adapter-cell + pagination-mode assertions in the existing frontend tests to the new contract. Full suite: 674 passed, 1 skipped (central and unprivileged zvx). count(*) OVER() is ~7.5ms at current volume; vanilla JS + HTMX; CSS functional-only. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 02:04:23 +00:00
"""(A) Server-side 'MM-DD HH:MM UTC' formatting (24h, no seconds, no year;
v0.7.3 dropped the year from the cell for single-line stable rows)."""
def test_format_basic_utc(self):
from central.gui.routes import _format_event_time
feat(layout-pagination): collapse legend, stabilize rows, real offset paginator (v0.7.3) PR #4 of the v0.7.x GUI rework arc. Production code; central-gui restart only. - Adapter legend: collapsed by default ("{n} adapters · Show legend ▾"). Expands to domain-grouped chips (same grouping as the v0.7.1 chip-picker) with uniform ellipsis-truncated names + full-name title tooltips. Clicking a legend chip toggles that adapter's filter (reuses the chip-picker's hidden CSV via syncField), so the legend doubles as a filter affordance. - Row stability: time cell is single-line MM-DD HH:MM UTC (year dropped from the cell; full ISO in the cell tooltip + a new Time row in the expanded detail). Adapter cell is a chip (color swatch + short name; display_name is the tooltip). table-layout:fixed + per-column widths + fixed 37px row height with nowrap/ellipsis cells -> no per-row wrap variation. - Real paginator: _fetch_events offset-mode returns the exact page slice plus the grand total via count(*) OVER() in one roundtrip. Previous/Next + windowed page numbers (1 ... 4 5 [6] 7 8 ... 47) + "showing X-Y of N" + a 25/50/100/250 per-page selector. URL state persists offset + limit. events.json keeps cursor pagination (back-compat): offset param presence selects offset-mode, its absence keeps the cursor path -- cleanly separable by endpoint. Adds TestEventsPagination (12 tests: offset/limit parse incl. max 250, offset-vs-cursor query shape, _build_pagination windowing). Updates the time format + adapter-cell + pagination-mode assertions in the existing frontend tests to the new contract. Full suite: 674 passed, 1 skipped (central and unprivileged zvx). count(*) OVER() is ~7.5ms at current volume; vanilla JS + HTMX; CSS functional-only. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 02:04:23 +00:00
assert _format_event_time("2026-05-21T06:00:00+00:00") == "05-21 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.
feat(layout-pagination): collapse legend, stabilize rows, real offset paginator (v0.7.3) PR #4 of the v0.7.x GUI rework arc. Production code; central-gui restart only. - Adapter legend: collapsed by default ("{n} adapters · Show legend ▾"). Expands to domain-grouped chips (same grouping as the v0.7.1 chip-picker) with uniform ellipsis-truncated names + full-name title tooltips. Clicking a legend chip toggles that adapter's filter (reuses the chip-picker's hidden CSV via syncField), so the legend doubles as a filter affordance. - Row stability: time cell is single-line MM-DD HH:MM UTC (year dropped from the cell; full ISO in the cell tooltip + a new Time row in the expanded detail). Adapter cell is a chip (color swatch + short name; display_name is the tooltip). table-layout:fixed + per-column widths + fixed 37px row height with nowrap/ellipsis cells -> no per-row wrap variation. - Real paginator: _fetch_events offset-mode returns the exact page slice plus the grand total via count(*) OVER() in one roundtrip. Previous/Next + windowed page numbers (1 ... 4 5 [6] 7 8 ... 47) + "showing X-Y of N" + a 25/50/100/250 per-page selector. URL state persists offset + limit. events.json keeps cursor pagination (back-compat): offset param presence selects offset-mode, its absence keeps the cursor path -- cleanly separable by endpoint. Adds TestEventsPagination (12 tests: offset/limit parse incl. max 250, offset-vs-cursor query shape, _build_pagination windowing). Updates the time format + adapter-cell + pagination-mode assertions in the existing frontend tests to the new contract. Full suite: 674 passed, 1 skipped (central and unprivileged zvx). count(*) OVER() is ~7.5ms at current volume; vanilla JS + HTMX; CSS functional-only. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 02:04:23 +00:00
assert _format_event_time("2026-05-20T19:30:00-06:00") == "05-21 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) == ""
feat(layout-pagination): collapse legend, stabilize rows, real offset paginator (v0.7.3) PR #4 of the v0.7.x GUI rework arc. Production code; central-gui restart only. - Adapter legend: collapsed by default ("{n} adapters · Show legend ▾"). Expands to domain-grouped chips (same grouping as the v0.7.1 chip-picker) with uniform ellipsis-truncated names + full-name title tooltips. Clicking a legend chip toggles that adapter's filter (reuses the chip-picker's hidden CSV via syncField), so the legend doubles as a filter affordance. - Row stability: time cell is single-line MM-DD HH:MM UTC (year dropped from the cell; full ISO in the cell tooltip + a new Time row in the expanded detail). Adapter cell is a chip (color swatch + short name; display_name is the tooltip). table-layout:fixed + per-column widths + fixed 37px row height with nowrap/ellipsis cells -> no per-row wrap variation. - Real paginator: _fetch_events offset-mode returns the exact page slice plus the grand total via count(*) OVER() in one roundtrip. Previous/Next + windowed page numbers (1 ... 4 5 [6] 7 8 ... 47) + "showing X-Y of N" + a 25/50/100/250 per-page selector. URL state persists offset + limit. events.json keeps cursor pagination (back-compat): offset param presence selects offset-mode, its absence keeps the cursor path -- cleanly separable by endpoint. Adds TestEventsPagination (12 tests: offset/limit parse incl. max 250, offset-vs-cursor query shape, _build_pagination windowing). Updates the time format + adapter-cell + pagination-mode assertions in the existing frontend tests to the new contract. Full suite: 674 passed, 1 skipped (central and unprivileged zvx). count(*) OVER() is ~7.5ms at current volume; vanilla JS + HTMX; CSS functional-only. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 02:04:23 +00:00
def test_format_no_seconds_no_year_no_offset_suffix(self):
from central.gui.routes import _format_event_time
out = _format_event_time("2026-01-02T03:04:59+00:00")
feat(layout-pagination): collapse legend, stabilize rows, real offset paginator (v0.7.3) PR #4 of the v0.7.x GUI rework arc. Production code; central-gui restart only. - Adapter legend: collapsed by default ("{n} adapters · Show legend ▾"). Expands to domain-grouped chips (same grouping as the v0.7.1 chip-picker) with uniform ellipsis-truncated names + full-name title tooltips. Clicking a legend chip toggles that adapter's filter (reuses the chip-picker's hidden CSV via syncField), so the legend doubles as a filter affordance. - Row stability: time cell is single-line MM-DD HH:MM UTC (year dropped from the cell; full ISO in the cell tooltip + a new Time row in the expanded detail). Adapter cell is a chip (color swatch + short name; display_name is the tooltip). table-layout:fixed + per-column widths + fixed 37px row height with nowrap/ellipsis cells -> no per-row wrap variation. - Real paginator: _fetch_events offset-mode returns the exact page slice plus the grand total via count(*) OVER() in one roundtrip. Previous/Next + windowed page numbers (1 ... 4 5 [6] 7 8 ... 47) + "showing X-Y of N" + a 25/50/100/250 per-page selector. URL state persists offset + limit. events.json keeps cursor pagination (back-compat): offset param presence selects offset-mode, its absence keeps the cursor path -- cleanly separable by endpoint. Adds TestEventsPagination (12 tests: offset/limit parse incl. max 250, offset-vs-cursor query shape, _build_pagination windowing). Updates the time format + adapter-cell + pagination-mode assertions in the existing frontend tests to the new contract. Full suite: 674 passed, 1 skipped (central and unprivileged zvx). count(*) OVER() is ~7.5ms at current volume; vanilla JS + HTMX; CSS functional-only. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 02:04:23 +00:00
assert out == "01-02 03:04 UTC"
assert ":59" not in out and "+00" not in out and "2026" 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)
feat(layout-pagination): collapse legend, stabilize rows, real offset paginator (v0.7.3) PR #4 of the v0.7.x GUI rework arc. Production code; central-gui restart only. - Adapter legend: collapsed by default ("{n} adapters · Show legend ▾"). Expands to domain-grouped chips (same grouping as the v0.7.1 chip-picker) with uniform ellipsis-truncated names + full-name title tooltips. Clicking a legend chip toggles that adapter's filter (reuses the chip-picker's hidden CSV via syncField), so the legend doubles as a filter affordance. - Row stability: time cell is single-line MM-DD HH:MM UTC (year dropped from the cell; full ISO in the cell tooltip + a new Time row in the expanded detail). Adapter cell is a chip (color swatch + short name; display_name is the tooltip). table-layout:fixed + per-column widths + fixed 37px row height with nowrap/ellipsis cells -> no per-row wrap variation. - Real paginator: _fetch_events offset-mode returns the exact page slice plus the grand total via count(*) OVER() in one roundtrip. Previous/Next + windowed page numbers (1 ... 4 5 [6] 7 8 ... 47) + "showing X-Y of N" + a 25/50/100/250 per-page selector. URL state persists offset + limit. events.json keeps cursor pagination (back-compat): offset param presence selects offset-mode, its absence keeps the cursor path -- cleanly separable by endpoint. Adds TestEventsPagination (12 tests: offset/limit parse incl. max 250, offset-vs-cursor query shape, _build_pagination windowing). Updates the time format + adapter-cell + pagination-mode assertions in the existing frontend tests to the new contract. Full suite: 674 passed, 1 skipped (central and unprivileged zvx). count(*) OVER() is ~7.5ms at current volume; vanilla JS + HTMX; CSS functional-only. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 02:04:23 +00:00
# v0.7.3: single-line MM-DD HH:MM UTC (no year in the cell).
assert cells[1].endswith("UTC") and "2026" not in cells[1] and len(cells[1].split()) == 3
# Adapter cell is now a chip showing the short name; display_name is the tooltip.
assert cells[4] == "usgs_quake"
assert self._expected_adapter_display() in resp.text # display_name in title=
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)
feat(layout-pagination): collapse legend, stabilize rows, real offset paginator (v0.7.3) PR #4 of the v0.7.x GUI rework arc. Production code; central-gui restart only. - Adapter legend: collapsed by default ("{n} adapters · Show legend ▾"). Expands to domain-grouped chips (same grouping as the v0.7.1 chip-picker) with uniform ellipsis-truncated names + full-name title tooltips. Clicking a legend chip toggles that adapter's filter (reuses the chip-picker's hidden CSV via syncField), so the legend doubles as a filter affordance. - Row stability: time cell is single-line MM-DD HH:MM UTC (year dropped from the cell; full ISO in the cell tooltip + a new Time row in the expanded detail). Adapter cell is a chip (color swatch + short name; display_name is the tooltip). table-layout:fixed + per-column widths + fixed 37px row height with nowrap/ellipsis cells -> no per-row wrap variation. - Real paginator: _fetch_events offset-mode returns the exact page slice plus the grand total via count(*) OVER() in one roundtrip. Previous/Next + windowed page numbers (1 ... 4 5 [6] 7 8 ... 47) + "showing X-Y of N" + a 25/50/100/250 per-page selector. URL state persists offset + limit. events.json keeps cursor pagination (back-compat): offset param presence selects offset-mode, its absence keeps the cursor path -- cleanly separable by endpoint. Adds TestEventsPagination (12 tests: offset/limit parse incl. max 250, offset-vs-cursor query shape, _build_pagination windowing). Updates the time format + adapter-cell + pagination-mode assertions in the existing frontend tests to the new contract. Full suite: 674 passed, 1 skipped (central and unprivileged zvx). count(*) OVER() is ~7.5ms at current volume; vanilla JS + HTMX; CSS functional-only. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 02:04:23 +00:00
assert cells[1].endswith("UTC") and "2026" not in cells[1] and len(cells[1].split()) == 3
assert cells[4] == "usgs_quake"
assert self._expected_adapter_display() in resp.text
# --- feat(events-json-subject): JSON subject derivation ------------------
# Representative inner adapter payloads (payload->'data'->'data'), captured from
# production -- one per registered adapter. Keyed by adapter name so the
# coverage test below fails loudly if a new adapter ships without a sample.
_SAMPLE_INNER = {
"eonet": {"title": "Kress Wildfire, Swisher, Texas"},
"firms": {"frp": 0.34, "confidence": "nominal"},
"gdacs": {"title": "Green flood alert in Austria", "alertlevel": "Green"},
"inciweb": {"title": "MTHLF Jericho Creek"},
"nwis": {"value": 93.2, "unit_of_measure": "ft^3/s"},
"nws": {"event": "Special Weather Statement", "severity": "Moderate"},
"swpc_alerts": {
"product_id": "EF3A",
"message": (
"Space Weather Message Code: ALTEF3\r\nSerial Number: 3691\r\n"
"Issue Time: 2026 May 21 0509 UTC\r\n\r\nCONTINUED ALERT: "
"Electron 2MeV Integral Flux exceeded 1000pfu"
),
},
"swpc_kindex": {"Kp": 1.0},
"swpc_protons": {"flux": 15.06399917602539, "energy": ">=1 MeV"},
"usgs_quake": {"magnitude": 1.009682538298, "place": "17 km W of Searles Valley, CA"},
"wfigs_incidents": {"county": "Montezuma", "state": "CO"},
"wfigs_perimeters": {"county": "Carbon", "state": "MT"},
"wzdx": {"road_names": ["I-80"], "direction": "eastbound"},
feat(tomtom_flow): TomTom Orbis vector flow-tile telemetry adapter + CENTRAL_TRAFFIC_FLOW (v0.9.3) Third CENTRAL_TRAFFIC-family member, first telemetry traffic source. Polls a configured tile coverage set (Idaho metros, z=10), fetches Orbis vector flow tiles, decodes per-segment relative_speed + road geometry, emits one telemetry Event per road segment per poll to the new CENTRAL_TRAFFIC_FLOW stream. Renders as colored polylines (green free-flow -> red jam) on the /telemetry map. Production code; supervisor + gui + ARCHIVE restart (NEW event-bearing stream central.traffic_flow.> -> archive must resubscribe). Ships disabled; needs a "tomtom" api key in config.api_keys before enable. - Subject central.traffic_flow.{z}.{x}.{y} (token traffic_flow, non-overlapping with central.traffic.>). category="flow.tomtom_flow" -> GUI event_type "flow". - Severity from relative_speed: >=0.75=1, 0.5-0.75=2, 0.25-0.5=3, <0.25=4. - Cadence 300s; 7-day retention (high-volume telemetry). Dedup minute-bucketed, inherited from the v0.9.1 SourceAdapter mixin. - Shared tomtom_flow_parse module (decode + slippy-tile georeference) reused by the v0.9.4 on-demand passthrough endpoint. - Generic framework change (Option A, ~3 lines, inert for the other 14 adapters): Geo.geometry optional field + archive _build_geom_sql prefers it, so segments persist their real LineString to the PostGIS geom column. - Idaho-only (Orbis tier confirmed live). Cameras + Navi passthrough are follow-ups. - deps: mapbox-vector-tile (vector PBF decode); itsdangerous promoted to an explicit dependency (gui/csrf.py + gui/wizard.py imported it as an undeclared transitive that uv re-lock would otherwise prune). Full suite: 780 passed, 1 skipped (central and unprivileged zvx, 3x each). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 23:25:44 +00:00
"tomtom_flow": {"road_category": "primary", "relative_speed": 0.11},
feat(tomtom_incidents): TomTom real-time traffic incidents adapter (v0.9.5) Fourth CENTRAL_TRAFFIC event adapter. Complements wzdx (federal work zones) and state_511_atis (state-DOT reports) with TomTom commercial vehicle-telematics coverage. Polls the Orbis incidentDetails endpoint per metro bbox, emits one event per incident to central.traffic.incident.<state>. Ships disabled. central-supervisor + central-gui restart only -- adapter row on the EXISTING CENTRAL_TRAFFIC stream, so NO archive restart and no new stream/dependency. Reuses the existing "tomtom" api key. - Bbox limit refutation: incidentDetails rejects bbox > 10,000 km^2, so coverage is per-metro bboxes (Treasure Valley / Boise, 8,601 km^2), NOT statewide. One bbox @ 1800s = 1,440 calls/mo = 58% of the 2,500/mo free-tier cap. Expansion rows must respect N*(43200/cadence_min) <= 2500. - category="incident.tomtom_incidents" -> GUI event_type "incident" (shared with state_511_atis; cross-source overlap is by design = additive coverage, distinct dedup ids + categories, no Central-side cross-source dedup). - Severity from magnitudeOfDelay (0->1,1->1,2->2,3->3,4->4; 4=closure). Never None. - geo.geometry carries TomTom's Point/LineString directly (already lon/lat GeoJSON; the v0.9.3 framework renders the affected road as a polyline). No decode needed. - Dedup id <state_code>:tomtom:<tomtom_id> (upstream id stable across polls, verified 154/154 over 60s). Inherits the v0.9.1 dedup mixin. - aiohttp params= URL-encodes the fields{} GraphQL braces (no curl-glob issue); key redacted from logs; poll skips cleanly without a key. Full suite: 809 passed, 1 skipped (central and unprivileged zvx, 3x each). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 00:25:27 +00:00
"tomtom_incidents": {"description": "Roadworks", "from": "Early Road", "to": "Slade Road"},
v0.10.0: ITD 511 official API adapter (events + advisories + cameras) (#85) First official-state-DOT-API pattern landing. Two adapters in one PR: - itd_511 (event-class): polls Events (60s) + Advisories (300s) from https://511.idaho.gov/api/v2/get/{event,alerts}. Decodes EncodedPolyline to LineString via the polyline lib (bookend LineString or Point fallback); ITD Severity string mapped None->1 / Minor->2 / Major->3 with IsFullClosure=true forcing 3 regardless; RecurrenceSchedules / Restrictions / DetourPolyline pass through unmodified. Advisories ship as structural pass-through under data.advisory since the upstream /alerts endpoint currently returns []; per-record try/except keeps a surprise shape from sinking the cycle when ITD posts its first one. - itd_511_cameras (telemetry-class): polls Cameras (600s). One event per camera per UTC day; image URL passes straight through to <img src>. Region uniform US-ID with data.source_jurisdiction preserving the raw upstream Source field for the ~1.2% cross-DOT border-region mirrors (UDOT / ODOT / WYDOT / WSDOT / NDot / MTD / DriveBC / Lemhi County). Subject convention (v0.9.20 forward): central.traffic.<event_type>.us.id and central.traffic_cameras.us.id.<camera_id>. Castle Rock state_511_atis keeps its bare-state subject; consumers stay on central.traffic.> wildcards during the A/B comparison window. Retry predicate tightened from the Castle Rock / TomTom precedent: 5xx + connection / timeout retry; 4xx other than 429 skip-with-warn (don't burn quota on permanent errors); 429 honors Retry-After once then retries. API key (alias 'idaho_511') travels in the ?key= query string, so every error log path runs through self._redact() to scrub the URL. Both adapters ship disabled; operator enables via GUI after registering the API key with 'python -m set_api_key idaho_511'. Reuses existing CENTRAL_TRAFFIC and CENTRAL_TRAFFIC_CAMERAS streams -- no archive restart needed. Scope-cap exception: this PR is ~1.5k lines vs. the standard 500-line cap, authorized as a one-time exception for the first official-state-DOT-API pattern landing. Two adapters + their tests + real-API fixtures naturally exceed the v0.9.x adapter-cap budget. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-03 22:36:26 -06:00
"itd_511": {"event_type_short": "work_zone", "roadway_name": "I-84"},
"itd_511_cameras": {"location": "I-84 Mountain Home", "camera_id": 42},
v0.10.10: new avalanche_org adapter — backcountry avalanche advisories (#98) meshai-requested adapter for avalanche.org's per-center map layers (SNFAC Sawtooth + PAC Payette by default; operator-extensible to any avalanche.org forecast center). Pure passthrough + severity gate, no cross-source fusion, fits Central's adapter pattern cleanly. Adapter surface: - Polls https://api.avalanche.org/v2/public/products/map-layer/{center_id} per configured center; default cadence 1800s (30 min). - Severity gate: only danger_level >= 3 publishes. danger_level 0/1/2 (None/Low/Moderate), -1 ('no rating'), and off_season=true all omitted at adapter level. Idaho summer = all 4 SNFAC + 2 PAC zones yield 0 events; that's correct behavior, verified by the negative-case test against the frozen 2026-06-08 SNFAC fixture. - Severity mapping (corrected from meshai's inverted spec): danger_level 3 (Considerable) → severity 2, 4 (High) → 3, 5 (Extreme) → 4. Matches Central's 4-most-severe convention (nws.SEVERITY_MAP). - Subject: central.avy.advisory.us.{state_lower} — one per state; v0.10.8's category-discriminated Nats-Msg-Id keeps multiple zones in the same state from colliding in JetStream dedup. - Stream: CENTRAL_AVY (central.avy.>); 7-day / 1 GiB retention defaults. - Event.data fields per meshai spec: center_id, zone_name, danger_level, danger_name, travel_advice (truncated to 200 chars), state, valid_date, end_date, off_season=false, latitude/longitude (polygon centroid via shapely), plus geo.geometry passes through as the upstream Polygon. Tests (38 in test_avalanche_org.py): - Pure helpers: _slug (8 cases), _parse_iso (6 cases), _centroid (2 cases). - Severity gate: 3 publish cases (danger 3/4/5 → severity 2/3/4), 4 omit cases (danger -1/0/1/2), off_season=true omit, missing state omit, unparseable geom omit, travel_advice truncation, subject derivation. - Real-fixture negative case: 4-zone SNFAC fixture all omitted off-season. - Real-fixture positive case: same fixture with synthetic winter overrides publishes all 4 with valid centroids on actual Idaho polygons. - End-to-end poll() with mixed severities and the new wiring (streams registry + supervisor family map). - Defensive: empty center_ids list yields nothing without crashing. Wiring + plumbing: - src/central/streams.py: StreamEntry('CENTRAL_AVY', 'central.avy.>') - src/central/supervisor.py: STREAM_CATEGORY_DOMAINS['CENTRAL_AVY']=('avy',) - sql/migrations/035: seed config.streams row (mirror of 019/CENTRAL_SPACE, idempotent ON CONFLICT DO NOTHING). Note: migrations don't auto-run on supervisor restart -- see deferred ops list (schema_migrations cleanup blocks central-migrate from running anything cleanly). - src/central/gui/templates/_event_rows/avalanche_org.html (8 lines) - src/central/gui/templates/_event_summaries/avalanche_org.html (2 lines) Both required by the existing per-adapter template consistency tests. Doc updates (required by existing doc-vs-registry tests): - docs/PRODUCER-INTEGRATION.md §6.1: added 'avy' to top-level-domain list. - docs/PRODUCER-INTEGRATION.md §8: added StreamEntry('CENTRAL_AVY',...) line to the verbatim snippet. - docs/CONSUMER-INTEGRATION.md §3 stream layout table: added CENTRAL_AVY row. - docs/CONSUMER-INTEGRATION.md §6: new '### avalanche_org' subsection with source, subject convention, dedup key, severity gate, Event.data field table, and off-season behavior note. - tests/test_events_feed_frontend.py: added avalanche_org to _SAMPLE_INNER and _EXPECTED_SUBJECT (the events-JSON subject-derivation coverage tests). Budget note: this PR is well over the ~400-line target -- the new-adapter surface picked up downstream consistency tests (doc validators + frontend sample coverage + template partials) I didn't anticipate at probe time. Most of the overrun is the SNFAC fixture (1,135 lines pretty-printed JSON, non-code) and the adapter + tests pair. Stripping the fixture and the required doc/template edits would leave ~620 lines of code; the fixture itself is a frozen snapshot, not a maintenance burden. Full sweep: 1072 passed, 0 failures (+41 from this PR), ruff clean on all new files. One PRE-EXISTING ruff violation in supervisor.py (unused poll_start variable at line 388) surfaces when we touch supervisor.py; confirmed not introduced by this PR via git stash check. Deploy plan (NEW STREAM — archive restart required per [[feedback_new_stream_needs_archive_restart]]): 1. Squash-merge -> tag v0.10.10 -> push. 2. On central: pull main -> systemctl restart central-supervisor -> ALSO systemctl restart central-archive (new event-bearing stream; archive enumerates consumers at startup and doesn't hot-reload). 3. Migration 035 deferred to morning per the schema_migrations cleanup task -- the stream creation itself doesn't depend on it (supervisor creates JetStream streams from the STREAMS registry at startup; the config.streams row is for operator-tunable retention only). 4. Verify: nats stream info CENTRAL_AVY (created), poll log shows yielded=0 / omitted=N (off-season), no positive publishes during summer (correct). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-08 21:57:56 -06:00
"avalanche_org": {"zone_name": "Banner Summit", "danger_name": "Considerable"},
"celestrak_tle": {
"norad_id": 25544,
"satellite_name": "ISS (ZARYA)",
"_enriched": {"orbit": {
"inclination_deg": 51.6336,
"mean_motion_rev_per_day": 15.49672912,
}},
},
"satpass_predict": {
"satellite_name": "ISS (ZARYA)",
"norad_id": 25544,
"peak_time": "2026-06-09T15:39:37+00:00",
"max_elevation_deg": 40.3,
},
v0.12.0: sat_positions adapter (live global satellite positions) + sat_common refactor ## Architectural framing The v0.11.1 `satpass_predict` adapter is **observer-anchored**: "when does satellite X pass over fixed observer Y, and what's the elevation/azimuth at that observer's site?" It answers a fixed-QTH question and emits one event per (observer, satellite, AOS) tuple. The new `sat_positions` adapter is the **global** counterpart: "where is satellite X right now?" No observer. One event per tracked NORAD ID per poll, on subject `central.sat.position.<norad_id>`. Consumers (meshAI, GUI map widgets, anything that wants a live world map) subscribe to `central.sat.position.>` and plot. They complement each other; neither replaces the other. Direct quote from Matt's use-case: *"location of the sats... map of where the sats are then we have meshai or whatever other service calling central's data grab it and do whatever work it needed."* This adapter is that. ## sat_common extraction rationale The four pure SGP4 / coordinate helpers (`EARTH_RADIUS_KM`, `gmst_rad`, `eci_to_ecef`, `subsatellite_point`) were private symbols inside `satpass_predict.py`. `sat_positions` needs the same three helpers. Three options were considered: 1. **Cross-import** from `satpass_predict.py` — creates an adapter-to-adapter dependency, ugly. 2. **Extract to `sat_common.py`** — matches the existing `wfigs_common.py` / `swpc_common.py` precedent. Both adapters become siblings of a shared helper module. ✓ chosen. 3. **Duplicate** — math drift over time. Symbol names dropped their leading underscore on extraction (public-API convention matching `swpc_common.parse_swpc_timestamp` / `wfigs_common.severity_from_acres`). Existing internal call sites in `satpass_predict.py` were updated via mechanical `replace_all`. Observer-specific helpers (`_observer_ecef`, `_topocentric_az_el`, `_visibility_footprint`, `_severity_from_elev`, `_build_pass_geometry`, `_next_passes`) stay in `satpass_predict.py` per YAGNI — they're not used by `sat_positions` today. Existing `tests/test_satpass_predict.py` was updated mechanically to import the helpers from `sat_common` via aliases (preserves the underscore-prefixed local names in the tests so the rest of the test body needs no change). All 44 satpass_predict tests pass unchanged. ## CENTRAL_SAT stream cap bump `config.streams.max_bytes` for `CENTRAL_SAT` goes from **1 GiB → 5 GiB** in migration 039. Sizing math: - celestrak_tle: ~190 sats × 1 envelope/day = ~190 events/day = ~1.4k events/week. Fit in 1 GiB easily. - sat_positions: ~190 sats × 1440 ticks/day (60s cadence) = **~273.6k events/day = ~1.9M events/week**. At ~1 KB per envelope including the CloudEvents wrapper, that's **~1.9 GiB/week**. - Plus existing TLE + pass envelopes already on the stream → ~3 GiB headroom needed. - 5 GiB = 5368709120 bytes = operator-tunable margin without over-provisioning. `STREAM_CATEGORY_DOMAINS["CENTRAL_SAT"]` extends from `("tle", "pass")` to `("tle", "pass", "position")` so the supervisor's retention sweep covers position events too. ## Subject + dedup | Field | Value | |---|---| | Subject | `central.sat.position.<norad_id>` — one subject per satellite, globally | | Dedup id | `<norad_id>:<position_iso>` where `position_iso` is the propagation timestamp truncated to whole seconds (defensive collapse if cadence is ever tightened) | | Severity | 1 (informational telemetry, no alerting) | | data_class | `telemetry` — surfaces on `/telemetry`, not `/events` | | Cadence | 60s default; operator-tunable via standard `cadence_s` field | ## Settings shape ```json {"track_only_norad_ids": [], "max_tle_age_days": 14} ``` - Empty `track_only_norad_ids` = track every NORAD ID with a fresh TLE in the events table (derive-from-celestrak_tle, default behavior). - Non-empty list pins to those NORAD IDs only (operator override — "I only care about the ISS and these 12 Starlink sats"). - `max_tle_age_days` bounds TLE freshness; LEO drag means TLEs go stale in days, GEO is good for months. Parameterized into the SQL query as a timedelta interval so operator-tightened windows (e.g. 3d) apply without code change. ## Event.data fields `norad_id`, `satellite_name`, `lon_deg`, `lat_deg`, `alt_km`, `velocity_kmps`, `heading_deg`, `tle_epoch`. - `lon_deg`/`lat_deg`/`alt_km`: sub-satellite point via SGP4 → ECI → ECEF rotation → spherical-earth lon/lat/alt. - `velocity_kmps`: magnitude of the SGP4 ECI velocity vector. ECI vs ECEF difference is ~6% for LEO (earth rotation 0.46 km/s vs 7.7 km/s orbital speed); fine for consumer "the sat is moving at X km/s" text. - `heading_deg`: great-circle initial bearing from the sub-sat point at `t` to the sub-sat point at `t+1s` (finite-difference; simpler than rotating velocity through GMST + the earth-rotation cross term). ## Diff size — flag for review **+894 / -63 = +831 net** across 14 files. Spec budget was ≤700 lines. **Over by ~131 net** (or ~194 gross). Breakdown: - `sat_positions.py`: 286 lines (under the ≤350 adapter line cap ✓) - `sat_common.py`: 65 lines (the extraction) - Migration 039: 58 lines (heavy on inline comments documenting the size math; could trim ~25 lines if you want) - satpass_predict.py: net -1 line (refactor; lost 4 helper defs and one constant comment, gained 5-line import block) - Templates: 14 lines (event_rows + event_summaries partials) - Wiring: 4 lines (supervisor + ADAPTER_GROUPS) - Docs (CONSUMER-INTEGRATION.md): 40 lines (required by `tests/test_consumer_doc.py::test_every_adapter_has_a_subsection`) - **Tests: 426 lines.** This is the bulk of the overage. The tests are all spec-mandated (sub-sat math, velocity range, heading range, build_event, subject_for, empty-TLE, track_only gate, stale-TLE skip, sat_common helpers, regression-guard on the moved helpers via test_satpass_predict.py preservation). I could shrink `test_sat_positions.py` by consolidating the 11 spec-mandated tests into fewer parameterized cases, but each test pins one behavior the spec called out by name. Flagging for your call: keep as-is, or do you want a tighter parameterized version? ## Test plan - [x] `pytest tests/test_sat_common.py tests/test_sat_positions.py` — **28 new tests, all pass**. - [x] `pytest tests/test_satpass_predict.py` — **44/44 pass** (regression guard: existing tests work after the sat_common extraction). - [x] `pytest tests/test_events_feed_frontend.py` — **119/119 pass** (JSON-feed coverage extended to include sat_positions sample event + expected subject string). - [x] `pytest tests/test_telemetry_separation.py` — **9/9 pass** (`_TELEMETRY` pin extended to include `sat_positions`). - [x] `pytest tests/test_consumer_doc.py` — **6/6 pass** (CONSUMER-INTEGRATION.md `### sat_positions` subsection added). - [x] `pytest tests/test_producer_doc.py` — **10/10 pass** (no PRODUCER-INTEGRATION update needed; CENTRAL_SAT stream is pre-existing). - [x] Full sweep `pytest tests/` (excluding postgres-dep files): **1209 passed, 1 skipped, 0 failures**. - [x] Ruff: clean on all new code. 3 pre-existing F841 unused-variable warnings (supervisor.py:390 `poll_start`, test_events_feed_frontend.py:425 / :466 `result`) confirmed via `git blame` to be from commits May 2026 — not introduced. ## Deploy plan 1. Squash-merge → tag v0.12.0 at merge SHA → push tag. 2. `ssh central`, `git pull` on `/opt/central`. **No `uv sync`** (no new dep). 3. **`central-migrate`** to apply migration 039 (seeds `config.adapters` row + bumps `config.streams.max_bytes` for CENTRAL_SAT). 4. `sudo systemctl restart central-supervisor` (picks up STREAM_CATEGORY_DOMAINS extension + new adapter discovery). 5. `sudo systemctl restart central-gui` (picks up new partials + ADAPTER_GROUPS change). 6. **No** `central-archive` restart (CENTRAL_SAT stream already exists; no new stream). 7. Verify: `nats stream info CENTRAL_SAT` shows max_bytes=5368709120; supervisor journal shows sat_positions discovered. 8. Smoke-test: enable celestrak_tle first if not already, wait for one poll, then enable sat_positions via GUI. Within 60s expect one `central.sat.position.<norad_id>` event per tracked sat on the stream. ## Halt acknowledgment Per spec acceptance bar #6: **squash-merge NOT authorized**. Branch + PR open. Halting for line-by-line review. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-06-09 15:23:32 -06:00
"sat_positions": {
"satellite_name": "ISS (ZARYA)",
"norad_id": 25544,
"lat_deg": 43.6,
"lon_deg": -116.2,
"alt_km": 408.5,
"velocity_kmps": 7.66,
"heading_deg": 87.3,
},
v0.12.1: n2yo_visualpasses adapter (server-side visible-pass alerts) ## Architectural placement — complement, not replacement | | satpass_predict (v0.11.1) | **n2yo_visualpasses (this PR)** | |---|---|---| | Computes from | Raw TLEs via local SGP4 | n2yo's pre-computed visualpasses endpoint | | Magnitude data? | ✗ (SGP4 alone can't compute illumination) | ✓ (server-side sun-geometry) | | Sun illumination filter? | ✗ | ✓ (n2yo returns sunlit passes only) | | Cost per (observer, sat) pair | Local compute, free | One n2yo API transaction | | Failure mode | TLE drift over time | Quota exhaustion, vendor outage | Both adapters serve the same operator question ("when is sat X overhead at site Y?") but with different data sources. Matt's stated use case is to have **both** running so a vendor outage or quota burn on n2yo doesn't blind him to passes that satpass_predict can still propagate locally. ## Subject collision is intentional Both adapters emit on `central.sat.pass.us.<state_lower>.<observer_slug>`. A consumer subscribing to e.g. `central.sat.pass.us.id.boise` receives events from **both** adapters. Disambiguation lives in `data.category`: - `pass.satpass_predict` → local SGP4 - `pass.n2yo_visualpasses` → n2yo API The v0.10.8 category-discriminated `Nats-Msg-Id` keeps both adapters' JetStream dedup windows separate even when they emit for the same (observer, satellite, AOS) tuple (which they will, by design, for sunlit passes). This is documented explicitly in the new `### n2yo_visualpasses` subsection of `docs/CONSUMER-INTEGRATION.md` so future consumer integrators don't get surprised. ## Quota math Default settings ship a curated **6 observers × 6 sats** configuration: - **Observers** (ID + UT): Filer (primary), Boise, Idaho Falls, Ogden, Salt Lake City, Provo - **Satellites** (curated for amateur observation): ISS (25544), NOAA-15 (25338), NOAA-18 (28654), NOAA-19 (33591), SO-50 (27607), AO-91 (43017) At 1h cadence: **6 × 6 × 24 = 864 transactions/day**, comfortably under n2yo's free-tier **1000/day cap** with ~13% headroom for retries or expansion. Operator can extend either dimension if they upgrade quota. ## API key plumbing (tomtom_flow pattern) Exact mirror of the v0.9.3 tomtom_flow precedent — confirmed during recon to be the established pattern: ```python requires_api_key = "n2yo" # class attr, GUI surfaces "requires X" warning api_key_field = "api_key_alias" # class attr, GUI renders api_key_select dropdown # Settings field: api_key_alias: str = "n2yo" ``` Cached `_api_key` populated via `ConfigStore.get_api_key(alias)` in `startup()` and `apply_config()`. Missing-key path: log INFO, return immediately (zero events, no exception). The live key is scrubbed from log strings via a `_redact()` helper before they hit journald. **`python -m set_api_key` does not exist** — that was a speculative invocation in the spec. The actual flow is GUI-based: Matt adds the `n2yo` alias via the `/api-keys` page, then enables the adapter via `/adapters/n2yo_visualpasses/edit`. ## Diff size — flag for review **+848 / −1 = +847 net** across 8 files. Spec budget was ≤600 lines. **Over by ~247** (~41%, similar shape to v0.12.0's overage). | File | Lines | Notes | |---|---|---| | `src/central/adapters/n2yo_visualpasses.py` | 330 | **Under** the ≤350 adapter cap ✓ | | `tests/test_n2yo_visualpasses.py` | 411 | The bulk of the overage | | `sql/migrations/040_add_n2yo_visualpasses_adapter.sql` | 45 | Heavy comment block; could trim ~15 lines | | `docs/CONSUMER-INTEGRATION.md` | 40 | Required by `test_consumer_doc` | | Partials (event_rows + event_summaries) | 13 | | | `tests/test_events_feed_frontend.py` | 8 | _SAMPLE_INNER + _EXPECTED_SUBJECT | | `src/central/gui/routes.py` | 1 | ADAPTER_GROUPS extension | **Test breakdown** (31 tests in 8 classes): - 9 severity-bucketing tests — spec called out 4 boundaries (-3.1, -2.9, -0.5, 2.5); the extra 5 pin inclusive-vs-exclusive at -3.0, -1.0, 2.0 boundaries + the ranges in between. Useful regression guards but not strictly spec-required. - 4 settings-default tests — pin the curated 6×6 set + quota math. - 4 adapter-class-attrs tests — pin requires_api_key/api_key_field/data_class/default_cadence_s wiring. - 3 subject_for tests — happy path + UT-state lowercasing + unknown fallback. - 1 _pass_to_event shape test. - 7 poll-loop tests — missing key, empty observers, empty norad_ids, happy path, empty passes array, fetch-failure-doesn't-kill-poll, multi-obs-multi-sat 6×6 aggregate. - 1 HTTP-layer test — 401 → None (the one test that goes through the real session.get mock). - 2 static-isolation tests — acceptance bar #2 (no hardcoded keys) and #4 (no absolute paths). I can trim the test file to ~250 lines by dropping the non-strictly-spec-mandated tests (settings defaults, class attrs, extra severity boundaries, extra subject_for variants). **Flag for your call:** keep the comprehensive suite, or trim to spec minimum? ## Test plan - [x] `pytest tests/test_n2yo_visualpasses.py` — **31/31 pass** (all offline, zero n2yo API hits). - [x] `pytest tests/test_events_feed_frontend.py` — **122/122 pass** (fixture coverage extended). - [x] `pytest tests/test_consumer_doc.py` — **6/6 pass** (new `### n2yo_visualpasses` subsection accepted). - [x] Full sweep `pytest tests/` (excluding postgres-dep files) — **1243 passed, 1 skipped, 0 failures**. - [x] Ruff: **clean on new files** (`n2yo_visualpasses.py`, `test_n2yo_visualpasses.py`). The pre-existing F841 warnings in routes.py / test_events_feed_frontend.py / supervisor.py are unchanged from v0.11.3-pre. - [x] **No hardcoded API key in diff** — `git diff main..HEAD | grep -iE 'apiKey=[A-Z0-9]{6,}|api_key.*=.*"[A-Z0-9]{6,}'` returns empty. - [x] **No absolute paths in test code** — `TestStaticIsolation` enforces this at runtime. ## Deploy plan 1. Squash-merge PR #N → tag v0.12.1 at merge SHA → push tag. 2. `ssh central`, `git pull` on `/opt/central`. **No `uv sync`** (aiohttp already in venv from earlier adapters). 3. **Matt adds the n2yo API key via GUI `/api-keys` page** (Add → alias `n2yo` → paste key). Do this **before** enabling the adapter — missing-key path is graceful but the adapter logs INFO and skips polling until the key lands. 4. Apply migration 040 manually via psql (per option C established pattern): `sudo -u postgres psql central -f /opt/central/sql/migrations/040_add_n2yo_visualpasses_adapter.sql` **Do NOT** run `central-migrate` — orphan migrations 032-039 stay deferred for the morning queue. 5. `sudo systemctl restart central-supervisor` (picks up the new adapter via discovery) + `sudo systemctl restart central-gui` (picks up new partials + ADAPTER_GROUPS change). 6. **No** `central-archive` restart (CENTRAL_SAT pre-existed; only the adapter row is new). 7. Verify: `config.adapters` has `n2yo_visualpasses` row with `enabled=false`; `config.api_keys` has alias `n2yo`; supervisor log shows the adapter discovered but not polling (matches `enabled=false`). 8. Matt enables via `/adapters/n2yo_visualpasses/edit` when ready. First poll happens within 1h; events surface at `/events` filtered by adapter=n2yo_visualpasses. ## Halt acknowledgment Per spec acceptance bar #6: **squash-merge NOT authorized**. Branch + PR open. Halting for line-by-line review. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-06-09 16:00:55 -06:00
"n2yo_visualpasses": {
"satellite_name": "ISS (ZARYA)",
"norad_id": 25544,
"peak_time": "2026-06-09T21:14:00+00:00",
"magnitude": -3.4,
"max_elevation_deg": 47.0,
},
}
# Exact expected subjects for the deterministic adapters. swpc_alerts is omitted
# (its message runs through Jinja's truncate(80)) and is checked separately.
# swpc_protons expects unescaped ">=" -- _derive_subject html.unescapes the
# autoescaped partial output so JSON consumers get plain text.
_EXPECTED_SUBJECT = {
"eonet": "Kress Wildfire, Swisher, Texas",
"firms": "Fire detected — 0.34 MW radiative power",
"gdacs": "Green flood alert in Austria — Green alert",
"inciweb": "MTHLF Jericho Creek",
"nwis": "Water reading: 93.2 ft^3/s",
"nws": "Special Weather Statement — Moderate",
"swpc_kindex": "Geomagnetic activity (Kp index): 1.0",
"swpc_protons": "Solar proton flux: 15.06 pfu at >=1 MeV",
"usgs_quake": "Magnitude 1.0 — 17 km W of Searles Valley, CA",
"wfigs_incidents": "Wildfire incident — Montezuma, CO",
"wfigs_perimeters": "Wildfire perimeter — Carbon, MT",
"wzdx": "Work zone on I-80 eastbound",
feat(tomtom_flow): TomTom Orbis vector flow-tile telemetry adapter + CENTRAL_TRAFFIC_FLOW (v0.9.3) Third CENTRAL_TRAFFIC-family member, first telemetry traffic source. Polls a configured tile coverage set (Idaho metros, z=10), fetches Orbis vector flow tiles, decodes per-segment relative_speed + road geometry, emits one telemetry Event per road segment per poll to the new CENTRAL_TRAFFIC_FLOW stream. Renders as colored polylines (green free-flow -> red jam) on the /telemetry map. Production code; supervisor + gui + ARCHIVE restart (NEW event-bearing stream central.traffic_flow.> -> archive must resubscribe). Ships disabled; needs a "tomtom" api key in config.api_keys before enable. - Subject central.traffic_flow.{z}.{x}.{y} (token traffic_flow, non-overlapping with central.traffic.>). category="flow.tomtom_flow" -> GUI event_type "flow". - Severity from relative_speed: >=0.75=1, 0.5-0.75=2, 0.25-0.5=3, <0.25=4. - Cadence 300s; 7-day retention (high-volume telemetry). Dedup minute-bucketed, inherited from the v0.9.1 SourceAdapter mixin. - Shared tomtom_flow_parse module (decode + slippy-tile georeference) reused by the v0.9.4 on-demand passthrough endpoint. - Generic framework change (Option A, ~3 lines, inert for the other 14 adapters): Geo.geometry optional field + archive _build_geom_sql prefers it, so segments persist their real LineString to the PostGIS geom column. - Idaho-only (Orbis tier confirmed live). Cameras + Navi passthrough are follow-ups. - deps: mapbox-vector-tile (vector PBF decode); itsdangerous promoted to an explicit dependency (gui/csrf.py + gui/wizard.py imported it as an undeclared transitive that uv re-lock would otherwise prune). Full suite: 780 passed, 1 skipped (central and unprivileged zvx, 3x each). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 23:25:44 +00:00
"tomtom_flow": "Traffic flow (primary) — 11% of free-flow",
feat(tomtom_incidents): TomTom real-time traffic incidents adapter (v0.9.5) Fourth CENTRAL_TRAFFIC event adapter. Complements wzdx (federal work zones) and state_511_atis (state-DOT reports) with TomTom commercial vehicle-telematics coverage. Polls the Orbis incidentDetails endpoint per metro bbox, emits one event per incident to central.traffic.incident.<state>. Ships disabled. central-supervisor + central-gui restart only -- adapter row on the EXISTING CENTRAL_TRAFFIC stream, so NO archive restart and no new stream/dependency. Reuses the existing "tomtom" api key. - Bbox limit refutation: incidentDetails rejects bbox > 10,000 km^2, so coverage is per-metro bboxes (Treasure Valley / Boise, 8,601 km^2), NOT statewide. One bbox @ 1800s = 1,440 calls/mo = 58% of the 2,500/mo free-tier cap. Expansion rows must respect N*(43200/cadence_min) <= 2500. - category="incident.tomtom_incidents" -> GUI event_type "incident" (shared with state_511_atis; cross-source overlap is by design = additive coverage, distinct dedup ids + categories, no Central-side cross-source dedup). - Severity from magnitudeOfDelay (0->1,1->1,2->2,3->3,4->4; 4=closure). Never None. - geo.geometry carries TomTom's Point/LineString directly (already lon/lat GeoJSON; the v0.9.3 framework renders the affected road as a polyline). No decode needed. - Dedup id <state_code>:tomtom:<tomtom_id> (upstream id stable across polls, verified 154/154 over 60s). Inherits the v0.9.1 dedup mixin. - aiohttp params= URL-encodes the fields{} GraphQL braces (no curl-glob issue); key redacted from logs; poll skips cleanly without a key. Full suite: 809 passed, 1 skipped (central and unprivileged zvx, 3x each). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 00:25:27 +00:00
"tomtom_incidents": "Roadworks on Early Road → Slade Road",
v0.10.0: ITD 511 official API adapter (events + advisories + cameras) (#85) First official-state-DOT-API pattern landing. Two adapters in one PR: - itd_511 (event-class): polls Events (60s) + Advisories (300s) from https://511.idaho.gov/api/v2/get/{event,alerts}. Decodes EncodedPolyline to LineString via the polyline lib (bookend LineString or Point fallback); ITD Severity string mapped None->1 / Minor->2 / Major->3 with IsFullClosure=true forcing 3 regardless; RecurrenceSchedules / Restrictions / DetourPolyline pass through unmodified. Advisories ship as structural pass-through under data.advisory since the upstream /alerts endpoint currently returns []; per-record try/except keeps a surprise shape from sinking the cycle when ITD posts its first one. - itd_511_cameras (telemetry-class): polls Cameras (600s). One event per camera per UTC day; image URL passes straight through to <img src>. Region uniform US-ID with data.source_jurisdiction preserving the raw upstream Source field for the ~1.2% cross-DOT border-region mirrors (UDOT / ODOT / WYDOT / WSDOT / NDot / MTD / DriveBC / Lemhi County). Subject convention (v0.9.20 forward): central.traffic.<event_type>.us.id and central.traffic_cameras.us.id.<camera_id>. Castle Rock state_511_atis keeps its bare-state subject; consumers stay on central.traffic.> wildcards during the A/B comparison window. Retry predicate tightened from the Castle Rock / TomTom precedent: 5xx + connection / timeout retry; 4xx other than 429 skip-with-warn (don't burn quota on permanent errors); 429 honors Retry-After once then retries. API key (alias 'idaho_511') travels in the ?key= query string, so every error log path runs through self._redact() to scrub the URL. Both adapters ship disabled; operator enables via GUI after registering the API key with 'python -m set_api_key idaho_511'. Reuses existing CENTRAL_TRAFFIC and CENTRAL_TRAFFIC_CAMERAS streams -- no archive restart needed. Scope-cap exception: this PR is ~1.5k lines vs. the standard 500-line cap, authorized as a one-time exception for the first official-state-DOT-API pattern landing. Two adapters + their tests + real-API fixtures naturally exceed the v0.9.x adapter-cap budget. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-03 22:36:26 -06:00
"itd_511": "Road work on I-84",
"itd_511_cameras": "Camera: I-84 Mountain Home",
v0.10.10: new avalanche_org adapter — backcountry avalanche advisories (#98) meshai-requested adapter for avalanche.org's per-center map layers (SNFAC Sawtooth + PAC Payette by default; operator-extensible to any avalanche.org forecast center). Pure passthrough + severity gate, no cross-source fusion, fits Central's adapter pattern cleanly. Adapter surface: - Polls https://api.avalanche.org/v2/public/products/map-layer/{center_id} per configured center; default cadence 1800s (30 min). - Severity gate: only danger_level >= 3 publishes. danger_level 0/1/2 (None/Low/Moderate), -1 ('no rating'), and off_season=true all omitted at adapter level. Idaho summer = all 4 SNFAC + 2 PAC zones yield 0 events; that's correct behavior, verified by the negative-case test against the frozen 2026-06-08 SNFAC fixture. - Severity mapping (corrected from meshai's inverted spec): danger_level 3 (Considerable) → severity 2, 4 (High) → 3, 5 (Extreme) → 4. Matches Central's 4-most-severe convention (nws.SEVERITY_MAP). - Subject: central.avy.advisory.us.{state_lower} — one per state; v0.10.8's category-discriminated Nats-Msg-Id keeps multiple zones in the same state from colliding in JetStream dedup. - Stream: CENTRAL_AVY (central.avy.>); 7-day / 1 GiB retention defaults. - Event.data fields per meshai spec: center_id, zone_name, danger_level, danger_name, travel_advice (truncated to 200 chars), state, valid_date, end_date, off_season=false, latitude/longitude (polygon centroid via shapely), plus geo.geometry passes through as the upstream Polygon. Tests (38 in test_avalanche_org.py): - Pure helpers: _slug (8 cases), _parse_iso (6 cases), _centroid (2 cases). - Severity gate: 3 publish cases (danger 3/4/5 → severity 2/3/4), 4 omit cases (danger -1/0/1/2), off_season=true omit, missing state omit, unparseable geom omit, travel_advice truncation, subject derivation. - Real-fixture negative case: 4-zone SNFAC fixture all omitted off-season. - Real-fixture positive case: same fixture with synthetic winter overrides publishes all 4 with valid centroids on actual Idaho polygons. - End-to-end poll() with mixed severities and the new wiring (streams registry + supervisor family map). - Defensive: empty center_ids list yields nothing without crashing. Wiring + plumbing: - src/central/streams.py: StreamEntry('CENTRAL_AVY', 'central.avy.>') - src/central/supervisor.py: STREAM_CATEGORY_DOMAINS['CENTRAL_AVY']=('avy',) - sql/migrations/035: seed config.streams row (mirror of 019/CENTRAL_SPACE, idempotent ON CONFLICT DO NOTHING). Note: migrations don't auto-run on supervisor restart -- see deferred ops list (schema_migrations cleanup blocks central-migrate from running anything cleanly). - src/central/gui/templates/_event_rows/avalanche_org.html (8 lines) - src/central/gui/templates/_event_summaries/avalanche_org.html (2 lines) Both required by the existing per-adapter template consistency tests. Doc updates (required by existing doc-vs-registry tests): - docs/PRODUCER-INTEGRATION.md §6.1: added 'avy' to top-level-domain list. - docs/PRODUCER-INTEGRATION.md §8: added StreamEntry('CENTRAL_AVY',...) line to the verbatim snippet. - docs/CONSUMER-INTEGRATION.md §3 stream layout table: added CENTRAL_AVY row. - docs/CONSUMER-INTEGRATION.md §6: new '### avalanche_org' subsection with source, subject convention, dedup key, severity gate, Event.data field table, and off-season behavior note. - tests/test_events_feed_frontend.py: added avalanche_org to _SAMPLE_INNER and _EXPECTED_SUBJECT (the events-JSON subject-derivation coverage tests). Budget note: this PR is well over the ~400-line target -- the new-adapter surface picked up downstream consistency tests (doc validators + frontend sample coverage + template partials) I didn't anticipate at probe time. Most of the overrun is the SNFAC fixture (1,135 lines pretty-printed JSON, non-code) and the adapter + tests pair. Stripping the fixture and the required doc/template edits would leave ~620 lines of code; the fixture itself is a frozen snapshot, not a maintenance burden. Full sweep: 1072 passed, 0 failures (+41 from this PR), ruff clean on all new files. One PRE-EXISTING ruff violation in supervisor.py (unused poll_start variable at line 388) surfaces when we touch supervisor.py; confirmed not introduced by this PR via git stash check. Deploy plan (NEW STREAM — archive restart required per [[feedback_new_stream_needs_archive_restart]]): 1. Squash-merge -> tag v0.10.10 -> push. 2. On central: pull main -> systemctl restart central-supervisor -> ALSO systemctl restart central-archive (new event-bearing stream; archive enumerates consumers at startup and doesn't hot-reload). 3. Migration 035 deferred to morning per the schema_migrations cleanup task -- the stream creation itself doesn't depend on it (supervisor creates JetStream streams from the STREAMS registry at startup; the config.streams row is for operator-tunable retention only). 4. Verify: nats stream info CENTRAL_AVY (created), poll log shows yielded=0 / omitted=N (off-season), no positive publishes during summer (correct). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-08 21:57:56 -06:00
"avalanche_org": "Avalanche advisory — Banner Summit (Considerable)",
"celestrak_tle": "TLE update: ISS (ZARYA) (NORAD 25544) — 92.9min orbit at 51.6°",
"satpass_predict": "ISS (ZARYA) passes overhead at 15:39 UTC — max elevation 40°",
v0.12.0: sat_positions adapter (live global satellite positions) + sat_common refactor ## Architectural framing The v0.11.1 `satpass_predict` adapter is **observer-anchored**: "when does satellite X pass over fixed observer Y, and what's the elevation/azimuth at that observer's site?" It answers a fixed-QTH question and emits one event per (observer, satellite, AOS) tuple. The new `sat_positions` adapter is the **global** counterpart: "where is satellite X right now?" No observer. One event per tracked NORAD ID per poll, on subject `central.sat.position.<norad_id>`. Consumers (meshAI, GUI map widgets, anything that wants a live world map) subscribe to `central.sat.position.>` and plot. They complement each other; neither replaces the other. Direct quote from Matt's use-case: *"location of the sats... map of where the sats are then we have meshai or whatever other service calling central's data grab it and do whatever work it needed."* This adapter is that. ## sat_common extraction rationale The four pure SGP4 / coordinate helpers (`EARTH_RADIUS_KM`, `gmst_rad`, `eci_to_ecef`, `subsatellite_point`) were private symbols inside `satpass_predict.py`. `sat_positions` needs the same three helpers. Three options were considered: 1. **Cross-import** from `satpass_predict.py` — creates an adapter-to-adapter dependency, ugly. 2. **Extract to `sat_common.py`** — matches the existing `wfigs_common.py` / `swpc_common.py` precedent. Both adapters become siblings of a shared helper module. ✓ chosen. 3. **Duplicate** — math drift over time. Symbol names dropped their leading underscore on extraction (public-API convention matching `swpc_common.parse_swpc_timestamp` / `wfigs_common.severity_from_acres`). Existing internal call sites in `satpass_predict.py` were updated via mechanical `replace_all`. Observer-specific helpers (`_observer_ecef`, `_topocentric_az_el`, `_visibility_footprint`, `_severity_from_elev`, `_build_pass_geometry`, `_next_passes`) stay in `satpass_predict.py` per YAGNI — they're not used by `sat_positions` today. Existing `tests/test_satpass_predict.py` was updated mechanically to import the helpers from `sat_common` via aliases (preserves the underscore-prefixed local names in the tests so the rest of the test body needs no change). All 44 satpass_predict tests pass unchanged. ## CENTRAL_SAT stream cap bump `config.streams.max_bytes` for `CENTRAL_SAT` goes from **1 GiB → 5 GiB** in migration 039. Sizing math: - celestrak_tle: ~190 sats × 1 envelope/day = ~190 events/day = ~1.4k events/week. Fit in 1 GiB easily. - sat_positions: ~190 sats × 1440 ticks/day (60s cadence) = **~273.6k events/day = ~1.9M events/week**. At ~1 KB per envelope including the CloudEvents wrapper, that's **~1.9 GiB/week**. - Plus existing TLE + pass envelopes already on the stream → ~3 GiB headroom needed. - 5 GiB = 5368709120 bytes = operator-tunable margin without over-provisioning. `STREAM_CATEGORY_DOMAINS["CENTRAL_SAT"]` extends from `("tle", "pass")` to `("tle", "pass", "position")` so the supervisor's retention sweep covers position events too. ## Subject + dedup | Field | Value | |---|---| | Subject | `central.sat.position.<norad_id>` — one subject per satellite, globally | | Dedup id | `<norad_id>:<position_iso>` where `position_iso` is the propagation timestamp truncated to whole seconds (defensive collapse if cadence is ever tightened) | | Severity | 1 (informational telemetry, no alerting) | | data_class | `telemetry` — surfaces on `/telemetry`, not `/events` | | Cadence | 60s default; operator-tunable via standard `cadence_s` field | ## Settings shape ```json {"track_only_norad_ids": [], "max_tle_age_days": 14} ``` - Empty `track_only_norad_ids` = track every NORAD ID with a fresh TLE in the events table (derive-from-celestrak_tle, default behavior). - Non-empty list pins to those NORAD IDs only (operator override — "I only care about the ISS and these 12 Starlink sats"). - `max_tle_age_days` bounds TLE freshness; LEO drag means TLEs go stale in days, GEO is good for months. Parameterized into the SQL query as a timedelta interval so operator-tightened windows (e.g. 3d) apply without code change. ## Event.data fields `norad_id`, `satellite_name`, `lon_deg`, `lat_deg`, `alt_km`, `velocity_kmps`, `heading_deg`, `tle_epoch`. - `lon_deg`/`lat_deg`/`alt_km`: sub-satellite point via SGP4 → ECI → ECEF rotation → spherical-earth lon/lat/alt. - `velocity_kmps`: magnitude of the SGP4 ECI velocity vector. ECI vs ECEF difference is ~6% for LEO (earth rotation 0.46 km/s vs 7.7 km/s orbital speed); fine for consumer "the sat is moving at X km/s" text. - `heading_deg`: great-circle initial bearing from the sub-sat point at `t` to the sub-sat point at `t+1s` (finite-difference; simpler than rotating velocity through GMST + the earth-rotation cross term). ## Diff size — flag for review **+894 / -63 = +831 net** across 14 files. Spec budget was ≤700 lines. **Over by ~131 net** (or ~194 gross). Breakdown: - `sat_positions.py`: 286 lines (under the ≤350 adapter line cap ✓) - `sat_common.py`: 65 lines (the extraction) - Migration 039: 58 lines (heavy on inline comments documenting the size math; could trim ~25 lines if you want) - satpass_predict.py: net -1 line (refactor; lost 4 helper defs and one constant comment, gained 5-line import block) - Templates: 14 lines (event_rows + event_summaries partials) - Wiring: 4 lines (supervisor + ADAPTER_GROUPS) - Docs (CONSUMER-INTEGRATION.md): 40 lines (required by `tests/test_consumer_doc.py::test_every_adapter_has_a_subsection`) - **Tests: 426 lines.** This is the bulk of the overage. The tests are all spec-mandated (sub-sat math, velocity range, heading range, build_event, subject_for, empty-TLE, track_only gate, stale-TLE skip, sat_common helpers, regression-guard on the moved helpers via test_satpass_predict.py preservation). I could shrink `test_sat_positions.py` by consolidating the 11 spec-mandated tests into fewer parameterized cases, but each test pins one behavior the spec called out by name. Flagging for your call: keep as-is, or do you want a tighter parameterized version? ## Test plan - [x] `pytest tests/test_sat_common.py tests/test_sat_positions.py` — **28 new tests, all pass**. - [x] `pytest tests/test_satpass_predict.py` — **44/44 pass** (regression guard: existing tests work after the sat_common extraction). - [x] `pytest tests/test_events_feed_frontend.py` — **119/119 pass** (JSON-feed coverage extended to include sat_positions sample event + expected subject string). - [x] `pytest tests/test_telemetry_separation.py` — **9/9 pass** (`_TELEMETRY` pin extended to include `sat_positions`). - [x] `pytest tests/test_consumer_doc.py` — **6/6 pass** (CONSUMER-INTEGRATION.md `### sat_positions` subsection added). - [x] `pytest tests/test_producer_doc.py` — **10/10 pass** (no PRODUCER-INTEGRATION update needed; CENTRAL_SAT stream is pre-existing). - [x] Full sweep `pytest tests/` (excluding postgres-dep files): **1209 passed, 1 skipped, 0 failures**. - [x] Ruff: clean on all new code. 3 pre-existing F841 unused-variable warnings (supervisor.py:390 `poll_start`, test_events_feed_frontend.py:425 / :466 `result`) confirmed via `git blame` to be from commits May 2026 — not introduced. ## Deploy plan 1. Squash-merge → tag v0.12.0 at merge SHA → push tag. 2. `ssh central`, `git pull` on `/opt/central`. **No `uv sync`** (no new dep). 3. **`central-migrate`** to apply migration 039 (seeds `config.adapters` row + bumps `config.streams.max_bytes` for CENTRAL_SAT). 4. `sudo systemctl restart central-supervisor` (picks up STREAM_CATEGORY_DOMAINS extension + new adapter discovery). 5. `sudo systemctl restart central-gui` (picks up new partials + ADAPTER_GROUPS change). 6. **No** `central-archive` restart (CENTRAL_SAT stream already exists; no new stream). 7. Verify: `nats stream info CENTRAL_SAT` shows max_bytes=5368709120; supervisor journal shows sat_positions discovered. 8. Smoke-test: enable celestrak_tle first if not already, wait for one poll, then enable sat_positions via GUI. Within 60s expect one `central.sat.position.<norad_id>` event per tracked sat on the stream. ## Halt acknowledgment Per spec acceptance bar #6: **squash-merge NOT authorized**. Branch + PR open. Halting for line-by-line review. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-06-09 15:23:32 -06:00
"sat_positions": "ISS (ZARYA) at 43.6°N 116.2°W, alt 408km, 7.7km/s",
v0.12.1: n2yo_visualpasses adapter (server-side visible-pass alerts) ## Architectural placement — complement, not replacement | | satpass_predict (v0.11.1) | **n2yo_visualpasses (this PR)** | |---|---|---| | Computes from | Raw TLEs via local SGP4 | n2yo's pre-computed visualpasses endpoint | | Magnitude data? | ✗ (SGP4 alone can't compute illumination) | ✓ (server-side sun-geometry) | | Sun illumination filter? | ✗ | ✓ (n2yo returns sunlit passes only) | | Cost per (observer, sat) pair | Local compute, free | One n2yo API transaction | | Failure mode | TLE drift over time | Quota exhaustion, vendor outage | Both adapters serve the same operator question ("when is sat X overhead at site Y?") but with different data sources. Matt's stated use case is to have **both** running so a vendor outage or quota burn on n2yo doesn't blind him to passes that satpass_predict can still propagate locally. ## Subject collision is intentional Both adapters emit on `central.sat.pass.us.<state_lower>.<observer_slug>`. A consumer subscribing to e.g. `central.sat.pass.us.id.boise` receives events from **both** adapters. Disambiguation lives in `data.category`: - `pass.satpass_predict` → local SGP4 - `pass.n2yo_visualpasses` → n2yo API The v0.10.8 category-discriminated `Nats-Msg-Id` keeps both adapters' JetStream dedup windows separate even when they emit for the same (observer, satellite, AOS) tuple (which they will, by design, for sunlit passes). This is documented explicitly in the new `### n2yo_visualpasses` subsection of `docs/CONSUMER-INTEGRATION.md` so future consumer integrators don't get surprised. ## Quota math Default settings ship a curated **6 observers × 6 sats** configuration: - **Observers** (ID + UT): Filer (primary), Boise, Idaho Falls, Ogden, Salt Lake City, Provo - **Satellites** (curated for amateur observation): ISS (25544), NOAA-15 (25338), NOAA-18 (28654), NOAA-19 (33591), SO-50 (27607), AO-91 (43017) At 1h cadence: **6 × 6 × 24 = 864 transactions/day**, comfortably under n2yo's free-tier **1000/day cap** with ~13% headroom for retries or expansion. Operator can extend either dimension if they upgrade quota. ## API key plumbing (tomtom_flow pattern) Exact mirror of the v0.9.3 tomtom_flow precedent — confirmed during recon to be the established pattern: ```python requires_api_key = "n2yo" # class attr, GUI surfaces "requires X" warning api_key_field = "api_key_alias" # class attr, GUI renders api_key_select dropdown # Settings field: api_key_alias: str = "n2yo" ``` Cached `_api_key` populated via `ConfigStore.get_api_key(alias)` in `startup()` and `apply_config()`. Missing-key path: log INFO, return immediately (zero events, no exception). The live key is scrubbed from log strings via a `_redact()` helper before they hit journald. **`python -m set_api_key` does not exist** — that was a speculative invocation in the spec. The actual flow is GUI-based: Matt adds the `n2yo` alias via the `/api-keys` page, then enables the adapter via `/adapters/n2yo_visualpasses/edit`. ## Diff size — flag for review **+848 / −1 = +847 net** across 8 files. Spec budget was ≤600 lines. **Over by ~247** (~41%, similar shape to v0.12.0's overage). | File | Lines | Notes | |---|---|---| | `src/central/adapters/n2yo_visualpasses.py` | 330 | **Under** the ≤350 adapter cap ✓ | | `tests/test_n2yo_visualpasses.py` | 411 | The bulk of the overage | | `sql/migrations/040_add_n2yo_visualpasses_adapter.sql` | 45 | Heavy comment block; could trim ~15 lines | | `docs/CONSUMER-INTEGRATION.md` | 40 | Required by `test_consumer_doc` | | Partials (event_rows + event_summaries) | 13 | | | `tests/test_events_feed_frontend.py` | 8 | _SAMPLE_INNER + _EXPECTED_SUBJECT | | `src/central/gui/routes.py` | 1 | ADAPTER_GROUPS extension | **Test breakdown** (31 tests in 8 classes): - 9 severity-bucketing tests — spec called out 4 boundaries (-3.1, -2.9, -0.5, 2.5); the extra 5 pin inclusive-vs-exclusive at -3.0, -1.0, 2.0 boundaries + the ranges in between. Useful regression guards but not strictly spec-required. - 4 settings-default tests — pin the curated 6×6 set + quota math. - 4 adapter-class-attrs tests — pin requires_api_key/api_key_field/data_class/default_cadence_s wiring. - 3 subject_for tests — happy path + UT-state lowercasing + unknown fallback. - 1 _pass_to_event shape test. - 7 poll-loop tests — missing key, empty observers, empty norad_ids, happy path, empty passes array, fetch-failure-doesn't-kill-poll, multi-obs-multi-sat 6×6 aggregate. - 1 HTTP-layer test — 401 → None (the one test that goes through the real session.get mock). - 2 static-isolation tests — acceptance bar #2 (no hardcoded keys) and #4 (no absolute paths). I can trim the test file to ~250 lines by dropping the non-strictly-spec-mandated tests (settings defaults, class attrs, extra severity boundaries, extra subject_for variants). **Flag for your call:** keep the comprehensive suite, or trim to spec minimum? ## Test plan - [x] `pytest tests/test_n2yo_visualpasses.py` — **31/31 pass** (all offline, zero n2yo API hits). - [x] `pytest tests/test_events_feed_frontend.py` — **122/122 pass** (fixture coverage extended). - [x] `pytest tests/test_consumer_doc.py` — **6/6 pass** (new `### n2yo_visualpasses` subsection accepted). - [x] Full sweep `pytest tests/` (excluding postgres-dep files) — **1243 passed, 1 skipped, 0 failures**. - [x] Ruff: **clean on new files** (`n2yo_visualpasses.py`, `test_n2yo_visualpasses.py`). The pre-existing F841 warnings in routes.py / test_events_feed_frontend.py / supervisor.py are unchanged from v0.11.3-pre. - [x] **No hardcoded API key in diff** — `git diff main..HEAD | grep -iE 'apiKey=[A-Z0-9]{6,}|api_key.*=.*"[A-Z0-9]{6,}'` returns empty. - [x] **No absolute paths in test code** — `TestStaticIsolation` enforces this at runtime. ## Deploy plan 1. Squash-merge PR #N → tag v0.12.1 at merge SHA → push tag. 2. `ssh central`, `git pull` on `/opt/central`. **No `uv sync`** (aiohttp already in venv from earlier adapters). 3. **Matt adds the n2yo API key via GUI `/api-keys` page** (Add → alias `n2yo` → paste key). Do this **before** enabling the adapter — missing-key path is graceful but the adapter logs INFO and skips polling until the key lands. 4. Apply migration 040 manually via psql (per option C established pattern): `sudo -u postgres psql central -f /opt/central/sql/migrations/040_add_n2yo_visualpasses_adapter.sql` **Do NOT** run `central-migrate` — orphan migrations 032-039 stay deferred for the morning queue. 5. `sudo systemctl restart central-supervisor` (picks up the new adapter via discovery) + `sudo systemctl restart central-gui` (picks up new partials + ADAPTER_GROUPS change). 6. **No** `central-archive` restart (CENTRAL_SAT pre-existed; only the adapter row is new). 7. Verify: `config.adapters` has `n2yo_visualpasses` row with `enabled=false`; `config.api_keys` has alias `n2yo`; supervisor log shows the adapter discovered but not polling (matches `enabled=false`). 8. Matt enables via `/adapters/n2yo_visualpasses/edit` when ready. First poll happens within 1h; events surface at `/events` filtered by adapter=n2yo_visualpasses. ## Halt acknowledgment Per spec acceptance bar #6: **squash-merge NOT authorized**. Branch + PR open. Halting for line-by-line review. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-06-09 16:00:55 -06:00
"n2yo_visualpasses": "ISS (ZARYA) visible pass at 21:14 UTC — mag -3.4, peak 47°",
}
def _subject_event(adapter: str, inner: dict) -> dict:
"""Build a minimal event dict shaped like _fetch_events output."""
return {"adapter": adapter, "data": {"data": {"data": inner}}}
class TestEventsJsonSubject:
"""/events.json `subject` is derived from the inner payload and carries the
same human text as the GUI's per-adapter Subject cell (feat/events-json-subject).
The old `payload->>'subject'` SQL was always null (the CloudEvents envelope
has no top-level subject). Parameterized over discover_adapters() -- no
hardcoded adapter list.
"""
def test_sample_covers_every_registered_adapter(self):
"""No hardcoded list: samples must track the live registry exactly."""
assert set(_SAMPLE_INNER) == set(discover_adapters())
@pytest.mark.parametrize("adapter", sorted(discover_adapters()))
def test_subject_non_null_per_adapter(self, adapter):
"""Every registered adapter derives a non-null subject for a real event."""
event = _subject_event(adapter, _SAMPLE_INNER[adapter])
assert _derive_subject(event) is not None
@pytest.mark.parametrize("adapter", sorted(discover_adapters()))
def test_subject_matches_rendered_partial(self, adapter):
"""Derived subject equals the adapter's own partial (unescaped) -- the
JSON path and the GUI Subject cell never diverge."""
event = _subject_event(adapter, _SAMPLE_INNER[adapter])
oracle = html.unescape(
_gui_templates.env.get_template(f"_event_summaries/{adapter}.html").render(event=event)
).strip()
assert _derive_subject(event) == oracle
@pytest.mark.parametrize("adapter", sorted(_EXPECTED_SUBJECT))
def test_subject_exact_human_text(self, adapter):
"""Pin the human-readable subject for the deterministic adapters."""
event = _subject_event(adapter, _SAMPLE_INNER[adapter])
assert _derive_subject(event) == _EXPECTED_SUBJECT[adapter]
def test_swpc_alerts_prefixes_id_and_truncates_message(self):
"""swpc_alerts subject prefixes the product id and truncates the body."""
event = _subject_event("swpc_alerts", _SAMPLE_INNER["swpc_alerts"])
subject = _derive_subject(event)
assert subject is not None
assert subject.startswith("Space weather alert EF3A: ")
assert subject.endswith("...")
def test_unknown_adapter_yields_none(self):
"""Unknown adapters fall back to _default.html -> no subject."""
assert _derive_subject(_subject_event("does_not_exist", {"x": 1})) is None
def test_missing_source_fields_yields_none(self):
"""An event lacking its adapter's source fields derives no subject."""
assert _derive_subject(_subject_event("usgs_quake", {})) is None
class TestShowRemovedToggle:
"""v0.9.11 — tombstone visibility wiring. filter_state.show_removed drives
the 'Show removed' checkbox in events_list.html; defaults to hidden."""
def _pool(self):
conn = AsyncMock()
conn.fetch.return_value = []
conn.fetchrow.return_value = {
"map_tile_url": "https://t/{z}/{x}/{y}.png", "map_attribution": "OSM"}
pool = MagicMock()
pool.acquire.return_value.__aenter__ = AsyncMock(return_value=conn)
pool.acquire.return_value.__aexit__ = AsyncMock(return_value=None)
return pool
async def _filter_state(self, query_params):
req = MagicMock()
req.state.operator = MagicMock(id=1, username="admin")
req.state.csrf_token = "t"
req.query_params = query_params
templates = MagicMock()
templates.TemplateResponse.return_value = MagicMock(status_code=200)
with patch("central.gui.routes._get_templates", return_value=templates):
with patch("central.gui.routes.get_pool", return_value=self._pool()):
await events_list(req)
return templates.TemplateResponse.call_args.kwargs["context"]["filter_state"]
@pytest.mark.asyncio
async def test_default_hides_removed(self):
assert (await self._filter_state({}))["show_removed"] is False
@pytest.mark.asyncio
async def test_show_removed_true_reflected(self):
assert (await self._filter_state({"show_removed": "true"}))["show_removed"] is True