Implement tab-scoped runs pagination state
This commit is contained in:
parent
dce67ea9e3
commit
c834c3c254
6 changed files with 444 additions and 62 deletions
41
tests/test_tab_state.py
Normal file
41
tests/test_tab_state.py
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from repub.datastar import TabStateStore
|
||||
|
||||
|
||||
def test_tab_state_store_tracks_page_state_per_tab_and_connection_count() -> None:
|
||||
store = TabStateStore()
|
||||
|
||||
store.connect("tab-1")
|
||||
store.connect("tab-1")
|
||||
store.update_page_state(
|
||||
"tab-1",
|
||||
"runs",
|
||||
lambda state: {**state, "completed_page": 2},
|
||||
)
|
||||
|
||||
assert store.get_tab_state("tab-1") == {"runs": {"completed_page": 2}}
|
||||
|
||||
store.disconnect("tab-1")
|
||||
|
||||
assert store.get_tab_state("tab-1") == {"runs": {"completed_page": 2}}
|
||||
|
||||
store.disconnect("tab-1")
|
||||
|
||||
assert store.get_tab_state("tab-1") is None
|
||||
|
||||
|
||||
def test_tab_state_store_cleans_only_stale_disconnected_tabs() -> None:
|
||||
store = TabStateStore(clean_age_threshold=timedelta(hours=24))
|
||||
now = datetime(2026, 3, 31, 12, 0, tzinfo=UTC)
|
||||
|
||||
store.connect("stale-tab", now=now - timedelta(days=2))
|
||||
store.connect("fresh-tab", now=now)
|
||||
|
||||
removed = store.cleanup_stale(now=now)
|
||||
|
||||
assert removed == {"stale-tab"}
|
||||
assert store.get_tab_state("stale-tab") is None
|
||||
assert store.get_tab_state("fresh-tab") == {}
|
||||
|
|
@ -1,12 +1,15 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
|
||||
import pytest
|
||||
|
||||
from repub.components import action_button, status_badge, toggle_field
|
||||
from repub.datastar import RefreshBroker, render_sse_event, render_stream
|
||||
from repub.jobs import load_dashboard_view
|
||||
|
|
@ -26,6 +29,7 @@ from repub.pages.sources import sources_page
|
|||
from repub.web import (
|
||||
create_app,
|
||||
get_refresh_broker,
|
||||
get_tab_state_store,
|
||||
render_create_source,
|
||||
render_dashboard,
|
||||
render_edit_source,
|
||||
|
|
@ -228,8 +232,8 @@ def test_runs_page_renders_clear_completed_button_and_pagination() -> None:
|
|||
assert ">Clear history<" in body
|
||||
assert "Showing" in body
|
||||
assert "21" in body
|
||||
assert 'href="/runs?completed_page=1"' in body
|
||||
assert 'href="/runs?completed_page=2"' in body
|
||||
assert "@post('/actions/runs/completed-page/1')" in body
|
||||
assert "@post('/actions/runs/completed-page/2')" in body
|
||||
assert 'aria-current="page"' in body
|
||||
|
||||
|
||||
|
|
@ -1314,6 +1318,142 @@ def test_render_runs_shows_empty_state_rows(monkeypatch, tmp_path: Path) -> None
|
|||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_runs_pagination_action_updates_only_the_current_tab(
|
||||
monkeypatch, tmp_path: Path
|
||||
) -> None:
|
||||
db_path = tmp_path / "runs-tab-pagination.db"
|
||||
monkeypatch.setenv("REPUBLISHER_DB_PATH", str(db_path))
|
||||
|
||||
async def run() -> None:
|
||||
app = create_app()
|
||||
client = app.test_client()
|
||||
|
||||
source = create_source(
|
||||
name="Paged runs source",
|
||||
slug="paged-runs-source",
|
||||
source_type="feed",
|
||||
notes="",
|
||||
spider_arguments="",
|
||||
enabled=True,
|
||||
cron_minute="*/30",
|
||||
cron_hour="*",
|
||||
cron_day_of_month="*",
|
||||
cron_day_of_week="*",
|
||||
cron_month="*",
|
||||
feed_url="https://example.com/paged-runs.xml",
|
||||
)
|
||||
job = Job.get(Job.source == source)
|
||||
|
||||
for minute in range(21):
|
||||
JobExecution.create(
|
||||
job=job,
|
||||
ended_at=datetime(2026, 3, 30, 12, minute, tzinfo=UTC),
|
||||
running_status=JobExecutionStatus.SUCCEEDED,
|
||||
)
|
||||
|
||||
async with client.request(
|
||||
"/runs?u=shim",
|
||||
method="POST",
|
||||
headers={
|
||||
"Datastar-Request": "true",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
) as first_connection:
|
||||
async with client.request(
|
||||
"/runs?u=shim",
|
||||
method="POST",
|
||||
headers={
|
||||
"Datastar-Request": "true",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
) as second_connection:
|
||||
await first_connection.send(json.dumps({"tabid": "tab-1"}).encode())
|
||||
await second_connection.send(json.dumps({"tabid": "tab-2"}).encode())
|
||||
await first_connection.send_complete()
|
||||
await second_connection.send_complete()
|
||||
|
||||
first_body = (
|
||||
await asyncio.wait_for(first_connection.receive(), timeout=1)
|
||||
).decode()
|
||||
second_body = (
|
||||
await asyncio.wait_for(second_connection.receive(), timeout=1)
|
||||
).decode()
|
||||
|
||||
assert (
|
||||
'href="/runs?completed_page=1" aria-current="page"'
|
||||
not in first_body
|
||||
)
|
||||
assert (
|
||||
'Showing <span class="font-medium text-slate-950">1</span> to '
|
||||
'<span class="font-medium text-slate-950">20</span> of '
|
||||
'<span class="font-medium text-slate-950">21</span> results'
|
||||
) in first_body
|
||||
assert (
|
||||
'Showing <span class="font-medium text-slate-950">1</span> to '
|
||||
'<span class="font-medium text-slate-950">20</span> of '
|
||||
'<span class="font-medium text-slate-950">21</span> results'
|
||||
) in second_body
|
||||
|
||||
response = await client.post(
|
||||
"/actions/runs/completed-page/2",
|
||||
headers={"Datastar-Request": "true"},
|
||||
json={"tabid": "tab-1"},
|
||||
)
|
||||
|
||||
assert response.status_code == 204
|
||||
|
||||
updated_first_body = (
|
||||
await asyncio.wait_for(first_connection.receive(), timeout=1)
|
||||
).decode()
|
||||
|
||||
assert (
|
||||
'Showing <span class="font-medium text-slate-950">21</span> to '
|
||||
'<span class="font-medium text-slate-950">21</span> of '
|
||||
'<span class="font-medium text-slate-950">21</span> results'
|
||||
) in updated_first_body
|
||||
assert 'aria-current="page"' in updated_first_body
|
||||
|
||||
with pytest.raises(asyncio.TimeoutError):
|
||||
await asyncio.wait_for(second_connection.receive(), timeout=0.2)
|
||||
|
||||
await second_connection.disconnect()
|
||||
await first_connection.disconnect()
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_runs_patch_creates_and_cleans_up_tab_state(
|
||||
monkeypatch, tmp_path: Path
|
||||
) -> None:
|
||||
db_path = tmp_path / "runs-tab-state.db"
|
||||
monkeypatch.setenv("REPUBLISHER_DB_PATH", str(db_path))
|
||||
|
||||
async def run() -> None:
|
||||
app = create_app()
|
||||
client = app.test_client()
|
||||
|
||||
async with client.request(
|
||||
"/runs?u=shim",
|
||||
method="POST",
|
||||
headers={
|
||||
"Datastar-Request": "true",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
) as connection:
|
||||
await connection.send(json.dumps({"tabid": "tab-1"}).encode())
|
||||
await connection.send_complete()
|
||||
await asyncio.wait_for(connection.receive(), timeout=1)
|
||||
|
||||
assert get_tab_state_store(app).get_tab_state("tab-1") == {}
|
||||
|
||||
await connection.disconnect()
|
||||
await asyncio.sleep(0)
|
||||
|
||||
assert get_tab_state_store(app).get_tab_state("tab-1") is None
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_render_runs_keeps_queued_execution_in_scheduled_jobs_table(
|
||||
monkeypatch, tmp_path: Path
|
||||
) -> None:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue