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>
This commit is contained in:
Matt Johnson 2026-05-25 00:58:38 +00:00
commit 380cde31f8
7 changed files with 785 additions and 141 deletions

View file

@ -104,7 +104,8 @@ class TestEventsFeedFrontendAuthenticated:
assert result.status_code == 200
context = mock_templates.TemplateResponse.call_args.kwargs.get("context")
assert context["filter_values"]["adapter"] == "nws"
# v0.7.1: adapter is now a multi-select; single value parses to a 1-list.
assert context["filter_state"]["adapters"] == ["nws"]
@pytest.mark.asyncio
async def test_events_since_until_filter(self):
@ -154,8 +155,9 @@ class TestEventsFeedFrontendAuthenticated:
mock_templates.TemplateResponse.assert_called_once()
call_kwargs = mock_templates.TemplateResponse.call_args.kwargs
context = call_kwargs.get("context", call_kwargs)
assert context["filter_values"]["since"] == "2026-05-17T00:00:00"
assert context["filter_values"]["until"] == "2026-05-17T12:00:00"
# 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
@pytest.mark.asyncio
async def test_events_region_filter(self):
@ -207,10 +209,10 @@ class TestEventsFeedFrontendAuthenticated:
mock_templates.TemplateResponse.assert_called_once()
call_kwargs = mock_templates.TemplateResponse.call_args.kwargs
context = call_kwargs.get("context", call_kwargs)
assert context["filter_values"]["region_north"] == "49.5"
assert context["filter_values"]["region_south"] == "31"
assert context["filter_values"]["region_east"] == "-102"
assert context["filter_values"]["region_west"] == "-124.5"
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"
@pytest.mark.asyncio
async def test_events_partial_region_shows_error_banner(self):
@ -772,9 +774,11 @@ class TestRegistryDrivenAdapterFilter:
# The list is exactly the registry, sorted by name (stable), no extras.
assert [a["name"] for a in context["adapters"]] == sorted(registry.keys())
# Each entry carries name + display_name straight from the adapter class.
# Each entry carries name + display_name (v0.7.1 adds a positional color).
by_name = {a["name"]: a for a in context["adapters"]}
for cls in registry.values():
assert {"name": cls.name, "display_name": cls.display_name} in context["adapters"]
assert by_name[cls.name]["display_name"] == cls.display_name
assert by_name[cls.name]["color"].startswith("#")
class TestPerAdapterRowPartials:

View file

@ -0,0 +1,210 @@
"""Tests for the v0.7.1 /events filtering (search, multi-select, time presets,
active pills, URL round-trip). Covers the pure helpers and the query builder
(via a captured-SQL mock), with no live DB.
"""
from datetime import datetime, timedelta, timezone
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from central.gui import routes
NOW = datetime(2026, 5, 24, 12, 0, tzinfo=timezone.utc)
# --- pure helpers -----------------------------------------------------------
def test_csv_param_splits_and_trims():
assert routes._csv_param({"adapter": "nws, firms ,usgs_quake"}, "adapter") == \
["nws", "firms", "usgs_quake"]
assert routes._csv_param({}, "adapter") == []
assert routes._csv_param({"adapter": ""}, "adapter") == []
def test_ilike_escape_escapes_wildcards():
assert routes._ilike_escape("50%_off\\x") == "50\\%\\_off\\\\x"
@pytest.mark.parametrize("token,delta", [
("last_15m", timedelta(minutes=15)),
("last_1h", timedelta(hours=1)),
("last_6h", timedelta(hours=6)),
("last_24h", timedelta(hours=24)),
("last_7d", timedelta(days=7)),
])
def test_resolve_time_presets(token, delta):
since, until, active, err = routes._resolve_time(token, NOW)
assert err is None and active is False and until is None
assert since == NOW - delta
def test_resolve_time_active_all_custom():
assert routes._resolve_time("active", NOW) == (None, None, True, None)
assert routes._resolve_time("all", NOW) == (None, None, False, None)
assert routes._resolve_time(None, NOW) == (None, None, False, None)
since, until, active, err = routes._resolve_time("custom:2026-05-01T00:00,2026-05-02T00:00", NOW)
assert err is None and active is False
# datetime-local values carry no tz (naive), matching the legacy since/until path.
assert since == datetime(2026, 5, 1)
assert until == datetime(2026, 5, 2)
def test_resolve_time_custom_inverted_is_error():
_, _, _, err = routes._resolve_time("custom:2026-05-02T00:00,2026-05-01T00:00", NOW)
assert err and "before" in err
def test_resolve_time_unknown_token_is_error():
_, _, _, err = routes._resolve_time("yesterday", NOW)
assert err and "Unknown" in err
# --- _parse_events_params ---------------------------------------------------
def test_parse_multi_value_csv():
parsed, err = routes._parse_events_params({
"adapter": "nws,firms", "category": "quake.event.light,wx.alert.tornado_warning",
"event_type": "fire,quake", "severity": "critical,high", "q": "wildfire", "time": "all",
})
assert err is None
assert parsed["adapters"] == ["nws", "firms"]
assert parsed["categories"] == ["quake.event.light", "wx.alert.tornado_warning"]
assert parsed["event_types"] == ["fire", "quake"]
assert parsed["severities"] == ["critical", "high"]
assert parsed["q"] == "wildfire"
def test_parse_drops_unknown_severity_labels():
parsed, err = routes._parse_events_params({"severity": "critical,bogus,low"})
assert err is None
assert parsed["severities"] == ["critical", "low"]
def test_parse_gui_defaults_to_last_24h():
parsed, _ = routes._parse_events_params({}, default_time="last_24h")
assert parsed["time_token"] == "last_24h"
assert parsed["since"] is not None
def test_parse_json_api_has_no_default_time():
parsed, _ = routes._parse_events_params({}) # no default_time (events.json)
assert parsed["time_token"] is None
assert parsed["since"] is None and parsed["until"] is None
def test_parse_legacy_since_until_still_honored():
parsed, err = routes._parse_events_params(
{"since": "2026-05-17T00:00:00", "until": "2026-05-17T12:00:00"},
default_time="last_24h",
)
assert err is None
assert parsed["since"] == datetime(2026, 5, 17, 0, 0)
assert parsed["time_token"] is None # legacy path, not a preset
# --- query builder (captured SQL) -------------------------------------------
async def _capture(parsed):
captured = {}
async def fake_fetch(query, *args):
captured["query"] = query
captured["params"] = list(args)
return []
conn = MagicMock()
conn.fetch = fake_fetch
pool = MagicMock()
pool.acquire.return_value.__aenter__ = AsyncMock(return_value=conn)
pool.acquire.return_value.__aexit__ = AsyncMock(return_value=None)
with patch("central.gui.routes.get_pool", return_value=pool):
await routes._fetch_events(parsed)
return captured
@pytest.mark.asyncio
async def test_multi_adapter_uses_any():
parsed, _ = routes._parse_events_params({"adapter": "nws,firms,usgs_quake", "time": "all"})
cap = await _capture(parsed)
assert "adapter = ANY($" in cap["query"]
assert ["nws", "firms", "usgs_quake"] in cap["params"]
@pytest.mark.asyncio
async def test_event_type_uses_split_part():
parsed, _ = routes._parse_events_params({"event_type": "fire,quake", "time": "all"})
cap = await _capture(parsed)
assert "split_part(category, '.', 1) = ANY($" in cap["query"]
assert ["fire", "quake"] in cap["params"]
@pytest.mark.asyncio
async def test_severity_unknown_includes_null():
parsed, _ = routes._parse_events_params({"severity": "critical,unknown", "time": "all"})
cap = await _capture(parsed)
assert "severity = ANY($" in cap["query"] and "OR severity IS NULL" in cap["query"]
assert [0, 4] in cap["params"] # unknown->0, critical->4 (sorted)
@pytest.mark.asyncio
async def test_severity_without_unknown_has_no_null_clause():
parsed, _ = routes._parse_events_params({"severity": "high", "time": "all"})
cap = await _capture(parsed)
assert "OR severity IS NULL" not in cap["query"]
assert [3] in cap["params"]
@pytest.mark.asyncio
async def test_active_now_condition():
parsed, _ = routes._parse_events_params({"time": "active"})
cap = await _capture(parsed)
assert "time <= now() AND (expires IS NULL OR expires > now())" in cap["query"]
@pytest.mark.asyncio
async def test_search_is_parameterized_and_escaped():
# A term loaded with SQL/LIKE metacharacters must be safely parameterized.
parsed, _ = routes._parse_events_params({"q": "50%_x'; DROP TABLE events;--", "time": "all"})
cap = await _capture(parsed)
assert "(payload->'data'->'data')::text ILIKE $" in cap["query"]
assert "ESCAPE '\\'" in cap["query"]
# The dangerous term never appears inline in the SQL; only as a bound param.
assert "DROP TABLE" not in cap["query"]
assert cap["params"][0] == "%50\\%\\_x'; DROP TABLE events;--%"
# --- active pills + URL round-trip ------------------------------------------
def test_build_active_pills():
parsed = {
"q": "fire", "adapters": ["nws", "firms"], "categories": ["a", "b"],
"event_types": ["fire"], "severities": ["high", "critical"], "time_token": "last_24h",
}
pills = {p["key"]: p["label"] for p in routes._build_active_pills(parsed, adapter_total=12)}
assert pills["q"] == 'Search: "fire"'
assert pills["adapter"] == "Adapters: 2 of 12"
assert pills["category"] == "Categories: 2"
assert pills["event_type"] == "Event types: fire"
assert pills["severity"] == "Severity: critical, high" # ranked order
assert pills["time"] == "Last 24 hours"
def test_no_time_pill_for_all():
pills = routes._build_active_pills({"time_token": "all"}, adapter_total=12)
assert all(p["key"] != "time" for p in pills)
def test_url_round_trip():
q = {"q": "fire", "adapter": "nws,firms", "category": "quake.event.light",
"event_type": "fire", "severity": "critical,high", "time": "last_1h", "limit": "50"}
parsed, err = routes._parse_events_params(q)
assert err is None
# Re-serialize the parsed multi-values as CSV and re-parse: identical state.
requery = {
"q": parsed["q"], "adapter": ",".join(parsed["adapters"]),
"category": ",".join(parsed["categories"]), "event_type": ",".join(parsed["event_types"]),
"severity": ",".join(parsed["severities"]), "time": parsed["time_token"], "limit": "50",
}
reparsed, _ = routes._parse_events_params(requery)
for k in ("q", "adapters", "categories", "event_types", "severities", "time_token"):
assert reparsed[k] == parsed[k]