mirror of
https://github.com/zvx-echo6/central.git
synced 2026-06-10 11:54:37 +02: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>
This commit is contained in:
parent
8802f2e45b
commit
f8d89d53d4
5 changed files with 391 additions and 70 deletions
|
|
@ -248,14 +248,14 @@ class TestEventsFeedFrontendAuthenticated:
|
|||
assert context["events"] == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_events_with_limit_shows_next_button(self):
|
||||
"""GET /events?limit=5 shows Next button when more events exist."""
|
||||
async def test_events_with_limit_builds_pagination(self):
|
||||
"""GET /events?limit=5 (offset-mode): pagination reflects total/next page."""
|
||||
mock_request = MagicMock()
|
||||
mock_request.state.operator = MagicMock(id=1, username="admin")
|
||||
mock_request.state.csrf_token = "test_csrf_token"
|
||||
mock_request.query_params = {"limit": "5"}
|
||||
|
||||
# Return 6 events (limit+1) to trigger pagination
|
||||
# 5 rows for page 1; total_count=12 (the window count) => 3 pages.
|
||||
mock_events = [
|
||||
{
|
||||
"id": f"event_{i}",
|
||||
|
|
@ -267,8 +267,9 @@ class TestEventsFeedFrontendAuthenticated:
|
|||
"geometry": None,
|
||||
"data": {},
|
||||
"regions": [],
|
||||
"total_count": 12,
|
||||
}
|
||||
for i in range(6)
|
||||
for i in range(5)
|
||||
]
|
||||
|
||||
mock_conn = AsyncMock()
|
||||
|
|
@ -291,8 +292,11 @@ class TestEventsFeedFrontendAuthenticated:
|
|||
|
||||
assert result.status_code == 200
|
||||
context = mock_templates.TemplateResponse.call_args.kwargs.get("context")
|
||||
assert context["next_cursor"] is not None
|
||||
assert len(context["events"]) == 5 # Should be trimmed to limit
|
||||
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
|
||||
|
||||
|
||||
class TestEventsRowsFragment:
|
||||
|
|
@ -531,8 +535,9 @@ class TestCrossEndpointParity:
|
|||
assert html_context["events"][0]["category"] == "Weather Alert"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cursor_pagination_both_endpoints(self):
|
||||
"""Cursor pagination works identically on both endpoints."""
|
||||
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)."""
|
||||
first_page = [
|
||||
{
|
||||
"id": f"event_{i}",
|
||||
|
|
@ -585,9 +590,10 @@ class TestCrossEndpointParity:
|
|||
await events_list(html_request)
|
||||
|
||||
html_context = mock_templates.TemplateResponse.call_args.kwargs.get("context")
|
||||
html_cursor = html_context["next_cursor"]
|
||||
|
||||
assert json_cursor == html_cursor
|
||||
# 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
|
||||
|
||||
|
||||
class TestErrorSemantics:
|
||||
|
|
@ -700,15 +706,18 @@ class TestEventRowDataAttributes:
|
|||
|
||||
def _events_context(events):
|
||||
"""Minimal context for rendering _events_rows.html as a standalone fragment."""
|
||||
n = len(events)
|
||||
return {
|
||||
"events": events,
|
||||
"next_cursor": None,
|
||||
"filter_error": None,
|
||||
"filter_values": {
|
||||
"adapter": "", "category": "", "since": "", "until": "",
|
||||
"region_north": "", "region_south": "", "region_east": "",
|
||||
"region_west": "", "limit": "50",
|
||||
"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],
|
||||
},
|
||||
"filter_error": None,
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -873,27 +882,28 @@ def _first_row_cells(html):
|
|||
|
||||
|
||||
class TestEventTimeFormat:
|
||||
"""(A) Server-side 'MM-DD-YYYY HH:MM UTC' formatting (24h, no seconds)."""
|
||||
"""(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
|
||||
assert _format_event_time("2026-05-21T06:00:00+00:00") == "05-21-2026 06:00 UTC"
|
||||
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.
|
||||
assert _format_event_time("2026-05-20T19:30:00-06:00") == "05-21-2026 01:30 UTC"
|
||||
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) == ""
|
||||
|
||||
def test_format_no_seconds_no_offset_suffix(self):
|
||||
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")
|
||||
assert out == "01-02-2026 03:04 UTC"
|
||||
assert ":59" not in out and "+00" not in out
|
||||
assert out == "01-02 03:04 UTC"
|
||||
assert ":59" not in out and "+00" not in out and "2026" not in out
|
||||
|
||||
|
||||
class TestTableDisplayDecoration:
|
||||
|
|
@ -1084,8 +1094,11 @@ class TestTableRendersThroughHTTP:
|
|||
resp = self._client().get("/events")
|
||||
assert resp.status_code == 200
|
||||
cells = _first_row_cells(resp.text)
|
||||
assert cells[1].endswith("UTC") and "2026" in cells[1] # Time non-empty
|
||||
assert cells[4] == self._expected_adapter_display() # Adapter display_name
|
||||
# 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()
|
||||
|
|
@ -1094,8 +1107,9 @@ class TestTableRendersThroughHTTP:
|
|||
resp = self._client().get("/events/rows")
|
||||
assert resp.status_code == 200
|
||||
cells = _first_row_cells(resp.text)
|
||||
assert cells[1].endswith("UTC") and "2026" in cells[1] # Time non-empty
|
||||
assert cells[4] == self._expected_adapter_display() # Adapter display_name
|
||||
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 ------------------
|
||||
|
|
|
|||
113
tests/test_events_pagination.py
Normal file
113
tests/test_events_pagination.py
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
"""Tests for v0.7.3 offset pagination: offset/limit parsing, offset-vs-cursor
|
||||
query shape, and the _build_pagination windowing math. No live DB (captured SQL).
|
||||
"""
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from central.gui import routes
|
||||
|
||||
|
||||
# --- offset / limit parsing -------------------------------------------------
|
||||
|
||||
def test_offset_parse_valid():
|
||||
parsed, err = routes._parse_events_params({"offset": "100", "limit": "50", "time": "all"})
|
||||
assert err is None and parsed["offset"] == 100 and parsed["limit"] == 50
|
||||
|
||||
|
||||
def test_offset_negative_errors():
|
||||
parsed, err = routes._parse_events_params({"offset": "-1", "time": "all"})
|
||||
assert parsed is None and "offset" in err
|
||||
|
||||
|
||||
def test_offset_noninteger_errors():
|
||||
parsed, err = routes._parse_events_params({"offset": "abc", "time": "all"})
|
||||
assert parsed is None and "offset" in err
|
||||
|
||||
|
||||
def test_limit_max_is_250():
|
||||
parsed, err = routes._parse_events_params({"limit": "250", "time": "all"})
|
||||
assert err is None and parsed["limit"] == 250
|
||||
over, err2 = routes._parse_events_params({"limit": "251", "time": "all"})
|
||||
assert over is None and "250" in err2
|
||||
|
||||
|
||||
def test_default_offset_selects_mode():
|
||||
"""GUI passes default_offset=0 -> offset-mode; events.json omits -> cursor-mode."""
|
||||
gui, _ = routes._parse_events_params({"time": "all"}, default_offset=0)
|
||||
assert gui["offset"] == 0
|
||||
api, _ = routes._parse_events_params({"time": "all"})
|
||||
assert api["offset"] is None
|
||||
|
||||
|
||||
# --- query shape (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_offset_mode_query_uses_limit_offset_and_window_count():
|
||||
parsed, _ = routes._parse_events_params({"time": "all"}, default_offset=100)
|
||||
cap = await _capture(parsed)
|
||||
assert "LIMIT $" in cap["query"] and "OFFSET $" in cap["query"]
|
||||
assert "count(*) OVER() AS total_count" in cap["query"]
|
||||
assert 100 in cap["params"] # offset bound as a param
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cursor_mode_query_has_no_offset_no_window_count():
|
||||
parsed, _ = routes._parse_events_params({"time": "all"}) # no offset -> cursor-mode
|
||||
cap = await _capture(parsed)
|
||||
assert "OFFSET" not in cap["query"]
|
||||
assert "count(*) OVER()" not in cap["query"]
|
||||
|
||||
|
||||
# --- _build_pagination math -------------------------------------------------
|
||||
|
||||
def test_build_pagination_basic_windowing():
|
||||
pg = routes._build_pagination(2341, 100, 50)
|
||||
assert pg["page"] == 3 and pg["total_pages"] == 47
|
||||
assert pg["start"] == 101 and pg["end"] == 150
|
||||
assert pg["prev_offset"] == 50 and pg["next_offset"] == 150
|
||||
nums = [p["page"] for p in pg["pages"] if "page" in p]
|
||||
assert 1 in nums and 47 in nums and 3 in nums
|
||||
assert any(p.get("ellipsis") for p in pg["pages"])
|
||||
assert pg["per_page_options"] == [25, 50, 100, 250]
|
||||
|
||||
|
||||
def test_build_pagination_first_page_has_no_prev():
|
||||
pg = routes._build_pagination(120, 0, 50)
|
||||
assert pg["page"] == 1 and pg["prev_offset"] is None and pg["next_offset"] == 50
|
||||
assert pg["start"] == 1 and pg["end"] == 50 and pg["total_pages"] == 3
|
||||
|
||||
|
||||
def test_build_pagination_last_page_has_no_next():
|
||||
pg = routes._build_pagination(120, 100, 50)
|
||||
assert pg["page"] == 3 and pg["next_offset"] is None and pg["end"] == 120
|
||||
|
||||
|
||||
def test_build_pagination_zero_results():
|
||||
pg = routes._build_pagination(0, 0, 50)
|
||||
assert pg["total"] == 0 and pg["total_pages"] == 1
|
||||
assert pg["start"] == 0 and pg["end"] == 0
|
||||
assert pg["prev_offset"] is None and pg["next_offset"] is None
|
||||
|
||||
|
||||
def test_build_pagination_single_page_no_next():
|
||||
pg = routes._build_pagination(30, 0, 50)
|
||||
assert pg["total_pages"] == 1 and pg["next_offset"] is None and pg["end"] == 30
|
||||
Loading…
Add table
Add a link
Reference in a new issue