mirror of
https://github.com/zvx-echo6/central.git
synced 2026-06-10 11:54:37 +02:00
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>
113 lines
4.1 KiB
Python
113 lines
4.1 KiB
Python
"""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
|