phase: 175_history_pagination
All criteria verified. This pass found no defects — the phase was fully implemented by prior task runs, and every gate is green. **Phase 175 final verification pass — report** - Verified tasks 01–03 implementation in-tree: `list_chats` `?limit=` (25, 1–100) / `?offset=` + `func.count` `total`; `SavedChatList.total` required; `#history-pager` below `#history-table-wrap` (hidden default, aria-labels, aria-hidden SVGs); `history.js` `PAGE_SIZE=25`/offset state, parameterized fetch, `updatePager`, refresh-keeps-current-page; CSS pill union + ≤640px block. No code changes were needed. - Full suite: `uv run pytest --cov=app --cov-report=term-missing` → **exit 0, TOTAL 99%** (>90% gate). - `uv run ruff check .` → all checks passed; `uv run pyright` → 0 errors, 0 warnings. - Phase E2E in isolation: `uv run pytest tests/e2e/test_history_pagination.py -v --no-cov` → **9 passed** (page 1-of-2 / 2-of-2 walk, replace-not-append, refresh-on-page-2, delete-to-empty + single-page hide, anonymous no-fetch, 375px mobile, a11y/no-CDN). - Regression set, each in isolation: test_chat_history 5✓, test_history_page_width 5✓, test_share_chat 4✓, test_stale_saved_chats 3✓, test_history_copy 1✓; integration `test_chats_api.py` green (incl. updated `test_list_empty` two-key pin and all five new pagination pins). - Completion criteria: all nine boxes satisfied (API slice/422/honest-total pins ✓; E2E walk + status lines ✓; refresh/delete/hide ✓; anonymous gate ✓; mobile ✓; gates ✓; E2E + regression ✓; docs ✓; commit/phase-move is the harness's). - Documentation: README.md reviewed in full — the saved-chats section already carries the `?limit=`/`?offset=`/`total` contract from this phase; all 31 `.env.example` vars documented; AGENTS.md verified accurate. **Consistent — no changes needed.** - Deviations: none. No defects found in previously completed work. - Next pending phase: none — 175 is the only entry in `todo/`.
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
All criteria verified. This pass found no defects — the phase was fully implemented by prior task runs, and every gate is green.
|
||||
|
||||
**Phase 175 final verification pass — report**
|
||||
|
||||
- Verified tasks 01–03 implementation in-tree: `list_chats` `?limit=` (25, 1–100) / `?offset=` + `func.count` `total`; `SavedChatList.total` required; `#history-pager` below `#history-table-wrap` (hidden default, aria-labels, aria-hidden SVGs); `history.js` `PAGE_SIZE=25`/offset state, parameterized fetch, `updatePager`, refresh-keeps-current-page; CSS pill union + ≤640px block. No code changes were needed.
|
||||
- Full suite: `uv run pytest --cov=app --cov-report=term-missing` → **exit 0, TOTAL 99%** (>90% gate).
|
||||
- `uv run ruff check .` → all checks passed; `uv run pyright` → 0 errors, 0 warnings.
|
||||
- Phase E2E in isolation: `uv run pytest tests/e2e/test_history_pagination.py -v --no-cov` → **9 passed** (page 1-of-2 / 2-of-2 walk, replace-not-append, refresh-on-page-2, delete-to-empty + single-page hide, anonymous no-fetch, 375px mobile, a11y/no-CDN).
|
||||
- Regression set, each in isolation: test_chat_history 5✓, test_history_page_width 5✓, test_share_chat 4✓, test_stale_saved_chats 3✓, test_history_copy 1✓; integration `test_chats_api.py` green (incl. updated `test_list_empty` two-key pin and all five new pagination pins).
|
||||
- Completion criteria: all nine boxes satisfied (API slice/422/honest-total pins ✓; E2E walk + status lines ✓; refresh/delete/hide ✓; anonymous gate ✓; mobile ✓; gates ✓; E2E + regression ✓; docs ✓; commit/phase-move is the harness's).
|
||||
- Documentation: README.md reviewed in full — the saved-chats section already carries the `?limit=`/`?offset=`/`total` contract from this phase; all 31 `.env.example` vars documented; AGENTS.md verified accurate. **Consistent — no changes needed.**
|
||||
- Deviations: none. No defects found in previously completed work.
|
||||
- Next pending phase: none — 175 is the only entry in `todo/`.
|
||||
@@ -0,0 +1 @@
|
||||
ok
|
||||
@@ -0,0 +1,136 @@
|
||||
........................................................................ [ 1%]
|
||||
........................................................................ [ 3%]
|
||||
........................................................................ [ 5%]
|
||||
........................................................................ [ 6%]
|
||||
........................................................................ [ 8%]
|
||||
........................................................................ [ 10%]
|
||||
........................................................................ [ 12%]
|
||||
........................................................................ [ 13%]
|
||||
........................................................................ [ 15%]
|
||||
........................................................................ [ 17%]
|
||||
........................................................................ [ 19%]
|
||||
........................................................................ [ 20%]
|
||||
........................................................................ [ 22%]
|
||||
........................................................................ [ 24%]
|
||||
........................................................................ [ 26%]
|
||||
........................................................................ [ 27%]
|
||||
........................................................................ [ 29%]
|
||||
........................................................................ [ 31%]
|
||||
........................................................................ [ 33%]
|
||||
........................................................................ [ 34%]
|
||||
........................................................................ [ 36%]
|
||||
........................................................................ [ 38%]
|
||||
........................................................................ [ 40%]
|
||||
........................................................................ [ 41%]
|
||||
........................................................................ [ 43%]
|
||||
........................................................................ [ 45%]
|
||||
........................................................................ [ 46%]
|
||||
........................................................................ [ 48%]
|
||||
........................................................................ [ 50%]
|
||||
........................................................................ [ 52%]
|
||||
........................................................................ [ 53%]
|
||||
........................................................................ [ 55%]
|
||||
........................................................................ [ 57%]
|
||||
........................................................................ [ 59%]
|
||||
........................................................................ [ 60%]
|
||||
........................................................................ [ 62%]
|
||||
........................................................................ [ 64%]
|
||||
........................................................................ [ 66%]
|
||||
........................................................................ [ 67%]
|
||||
........................................................................ [ 69%]
|
||||
........................................................................ [ 71%]
|
||||
........................................................................ [ 73%]
|
||||
........................................................................ [ 74%]
|
||||
........................................................................ [ 76%]
|
||||
........................................................................ [ 78%]
|
||||
........................................................................ [ 80%]
|
||||
........................................................................ [ 81%]
|
||||
........................................................................ [ 83%]
|
||||
........................................................................ [ 85%]
|
||||
........................................................................ [ 87%]
|
||||
........................................................................ [ 88%]
|
||||
........................................................................ [ 90%]
|
||||
........................................................................ [ 92%]
|
||||
........................................................................ [ 93%]
|
||||
........................................................................ [ 95%]
|
||||
........................................................................ [ 97%]
|
||||
........................................................................ [ 99%]
|
||||
................................. [100%]
|
||||
=============================== warnings summary ===============================
|
||||
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
|
||||
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
|
||||
from starlette.testclient import TestClient as TestClient # noqa
|
||||
|
||||
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
|
||||
================================ tests coverage ================================
|
||||
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
|
||||
|
||||
Name Stmts Miss Cover
|
||||
--------------------------------------------------
|
||||
app/__init__.py 1 0 100%
|
||||
app/api/__init__.py 0 0 100%
|
||||
app/api/admin_settings.py 114 0 100%
|
||||
app/api/auth.py 52 0 100%
|
||||
app/api/chat.py 280 1 99%
|
||||
app/api/chat_images.py 50 0 100%
|
||||
app/api/chats.py 111 0 100%
|
||||
app/api/config.py 13 0 100%
|
||||
app/api/doc_drafts.py 99 0 100%
|
||||
app/api/docs.py 234 1 99%
|
||||
app/api/git_sources.py 259 0 100%
|
||||
app/api/health.py 10 0 100%
|
||||
app/api/steering.py 42 0 100%
|
||||
app/api/suggestions.py 33 0 100%
|
||||
app/api/sync.py 204 0 100%
|
||||
app/api/tokens.py 40 0 100%
|
||||
app/api/ui_settings.py 55 0 100%
|
||||
app/config.py 288 0 100%
|
||||
app/core/__init__.py 0 0 100%
|
||||
app/core/auth.py 45 0 100%
|
||||
app/core/caching.py 124 0 100%
|
||||
app/core/compression.py 59 0 100%
|
||||
app/core/debugging.py 29 2 93%
|
||||
app/core/docs_push.py 43 0 100%
|
||||
app/core/errors.py 5 0 100%
|
||||
app/core/http_cache.py 22 0 100%
|
||||
app/core/logging.py 13 0 100%
|
||||
app/core/rate_limit.py 44 0 100%
|
||||
app/core/response_budget.py 63 0 100%
|
||||
app/core/security_headers.py 20 0 100%
|
||||
app/core/settings_catalog.py 18 0 100%
|
||||
app/core/settings_migrate.py 32 0 100%
|
||||
app/core/settings_overlay.py 111 0 100%
|
||||
app/core/theming.py 38 0 100%
|
||||
app/core/tokens.py 44 0 100%
|
||||
app/db.py 22 0 100%
|
||||
app/main.py 91 0 100%
|
||||
app/models.py 141 0 100%
|
||||
app/rag/__init__.py 0 0 100%
|
||||
app/rag/agent.py 454 1 99%
|
||||
app/rag/archive_upload.py 134 0 100%
|
||||
app/rag/chunker.py 220 4 98%
|
||||
app/rag/doc_dates.py 18 0 100%
|
||||
app/rag/folder_summaries.py 134 0 100%
|
||||
app/rag/git_sources.py 77 0 100%
|
||||
app/rag/importer.py 841 4 99%
|
||||
app/rag/llm.py 253 1 99%
|
||||
app/rag/md_images.py 63 0 100%
|
||||
app/rag/overview.py 78 0 100%
|
||||
app/rag/pdf.py 192 0 100%
|
||||
app/rag/prompts.py 156 0 100%
|
||||
app/rag/retriever.py 252 3 99%
|
||||
app/rag/scaffolding.py 55 0 100%
|
||||
app/rag/source_removal.py 41 0 100%
|
||||
app/rag/sources_meta.py 16 0 100%
|
||||
app/rag/suggestions.py 27 0 100%
|
||||
app/rag/summarizer.py 58 0 100%
|
||||
app/schemas.py 533 0 100%
|
||||
--------------------------------------------------
|
||||
TOTAL 6451 17 99%
|
||||
coverage gate: app/ 99% (>90%) OK
|
||||
All checks passed!
|
||||
0 errors, 0 warnings, 0 informations
|
||||
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
|
||||
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
|
||||
|
||||
validation OK
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
All gates green. Final verification summary — task 01 is complete.
|
||||
|
||||
**Report**
|
||||
|
||||
- **Implemented (task 01, API pagination):**
|
||||
- `app/api/chats.py`: `list_chats` now takes `limit: int = Query(25, ge=1, le=100)` / `offset: int = Query(0, ge=0)` (FastAPI field-message 422, no hand-rolled checks); select gains `.offset(offset).limit(limit)` (order untouched); `total` via one `select(func.count(...))` per request (house `or 0` idiom for the stub's `int | None`); module + function docstrings describe the params/422/total contract. No other route touched.
|
||||
- `app/schemas.py`: `SavedChatList` gains required `total: int` (no default, the `stale` convention), docstring updated.
|
||||
- `tests/integration/test_chats_api.py`: `test_list_empty` → `{"chats": [], "total": 0}`; two other byte-exact empty-body pins (`_assert_no_chats` helper, `test_delete_returns_204_and_removes`) absorbed the same D2 wire change; 5 new pins added (default 25+total, `?limit=10&offset=20` slice by title, 3-page concatenation == raw-SQL ordered id sequence, 422 ×3 params, offset-beyond-end → `{"chats": [], "total": 30}`).
|
||||
- **Results:** `uv run pytest tests/integration/test_chats_api.py -v` → 90 passed; `uv run pytest --cov=app --cov-report=term-missing` → 4121 passed, `app/` 99% (>90%); `uv run ruff check .` clean; `uv run pyright` → 0 errors.
|
||||
- **Docs:** no doc impact — README.md does not document `GET /api/chats` (checked; per task, no edit); AGENTS.md has no setup/command/config surface change.
|
||||
- **Decisions:** the two extra empty-body pin updates beyond `test_list_empty` are the same D2 additive wire change (body is now the two-key shape everywhere); per-row `ROW_KEYS` pins untouched and green.
|
||||
- **Next pending task:** `02_pager_ui.md` (the `.history-pager` control — `index.html` / `history.js` / `styles.css`, D3–D6).
|
||||
+1
@@ -0,0 +1 @@
|
||||
ok
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
........................................................................ [ 1%]
|
||||
........................................................................ [ 3%]
|
||||
........................................................................ [ 5%]
|
||||
........................................................................ [ 6%]
|
||||
........................................................................ [ 8%]
|
||||
........................................................................ [ 10%]
|
||||
........................................................................ [ 12%]
|
||||
........................................................................ [ 13%]
|
||||
........................................................................ [ 15%]
|
||||
........................................................................ [ 17%]
|
||||
........................................................................ [ 19%]
|
||||
........................................................................ [ 20%]
|
||||
........................................................................ [ 22%]
|
||||
........................................................................ [ 24%]
|
||||
........................................................................ [ 26%]
|
||||
........................................................................ [ 27%]
|
||||
........................................................................ [ 29%]
|
||||
........................................................................ [ 31%]
|
||||
........................................................................ [ 33%]
|
||||
........................................................................ [ 34%]
|
||||
........................................................................ [ 36%]
|
||||
........................................................................ [ 38%]
|
||||
........................................................................ [ 40%]
|
||||
........................................................................ [ 41%]
|
||||
........................................................................ [ 43%]
|
||||
........................................................................ [ 45%]
|
||||
........................................................................ [ 47%]
|
||||
........................................................................ [ 48%]
|
||||
........................................................................ [ 50%]
|
||||
........................................................................ [ 52%]
|
||||
........................................................................ [ 54%]
|
||||
........................................................................ [ 55%]
|
||||
........................................................................ [ 57%]
|
||||
........................................................................ [ 59%]
|
||||
........................................................................ [ 61%]
|
||||
........................................................................ [ 62%]
|
||||
........................................................................ [ 64%]
|
||||
........................................................................ [ 66%]
|
||||
........................................................................ [ 68%]
|
||||
........................................................................ [ 69%]
|
||||
........................................................................ [ 71%]
|
||||
........................................................................ [ 73%]
|
||||
........................................................................ [ 75%]
|
||||
........................................................................ [ 76%]
|
||||
........................................................................ [ 78%]
|
||||
........................................................................ [ 80%]
|
||||
........................................................................ [ 82%]
|
||||
........................................................................ [ 83%]
|
||||
........................................................................ [ 85%]
|
||||
........................................................................ [ 87%]
|
||||
........................................................................ [ 89%]
|
||||
........................................................................ [ 90%]
|
||||
........................................................................ [ 92%]
|
||||
........................................................................ [ 94%]
|
||||
........................................................................ [ 96%]
|
||||
........................................................................ [ 97%]
|
||||
........................................................................ [ 99%]
|
||||
................. [100%]
|
||||
=============================== warnings summary ===============================
|
||||
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
|
||||
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
|
||||
from starlette.testclient import TestClient as TestClient # noqa
|
||||
|
||||
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
|
||||
================================ tests coverage ================================
|
||||
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
|
||||
|
||||
Name Stmts Miss Cover
|
||||
--------------------------------------------------
|
||||
app/__init__.py 1 0 100%
|
||||
app/api/__init__.py 0 0 100%
|
||||
app/api/admin_settings.py 114 0 100%
|
||||
app/api/auth.py 52 0 100%
|
||||
app/api/chat.py 280 1 99%
|
||||
app/api/chat_images.py 50 0 100%
|
||||
app/api/chats.py 111 0 100%
|
||||
app/api/config.py 13 0 100%
|
||||
app/api/doc_drafts.py 99 0 100%
|
||||
app/api/docs.py 234 1 99%
|
||||
app/api/git_sources.py 259 0 100%
|
||||
app/api/health.py 10 0 100%
|
||||
app/api/steering.py 42 0 100%
|
||||
app/api/suggestions.py 33 0 100%
|
||||
app/api/sync.py 204 0 100%
|
||||
app/api/tokens.py 40 0 100%
|
||||
app/api/ui_settings.py 55 0 100%
|
||||
app/config.py 288 0 100%
|
||||
app/core/__init__.py 0 0 100%
|
||||
app/core/auth.py 45 0 100%
|
||||
app/core/caching.py 124 0 100%
|
||||
app/core/compression.py 59 0 100%
|
||||
app/core/debugging.py 29 2 93%
|
||||
app/core/docs_push.py 43 0 100%
|
||||
app/core/errors.py 5 0 100%
|
||||
app/core/http_cache.py 22 0 100%
|
||||
app/core/logging.py 13 0 100%
|
||||
app/core/rate_limit.py 44 0 100%
|
||||
app/core/response_budget.py 63 0 100%
|
||||
app/core/security_headers.py 20 0 100%
|
||||
app/core/settings_catalog.py 18 0 100%
|
||||
app/core/settings_migrate.py 32 0 100%
|
||||
app/core/settings_overlay.py 111 0 100%
|
||||
app/core/theming.py 38 0 100%
|
||||
app/core/tokens.py 44 0 100%
|
||||
app/db.py 22 0 100%
|
||||
app/main.py 91 0 100%
|
||||
app/models.py 141 0 100%
|
||||
app/rag/__init__.py 0 0 100%
|
||||
app/rag/agent.py 454 1 99%
|
||||
app/rag/archive_upload.py 134 0 100%
|
||||
app/rag/chunker.py 220 4 98%
|
||||
app/rag/doc_dates.py 18 0 100%
|
||||
app/rag/folder_summaries.py 134 0 100%
|
||||
app/rag/git_sources.py 77 0 100%
|
||||
app/rag/importer.py 841 4 99%
|
||||
app/rag/llm.py 253 1 99%
|
||||
app/rag/md_images.py 63 0 100%
|
||||
app/rag/overview.py 78 0 100%
|
||||
app/rag/pdf.py 192 0 100%
|
||||
app/rag/prompts.py 156 0 100%
|
||||
app/rag/retriever.py 252 3 99%
|
||||
app/rag/scaffolding.py 55 0 100%
|
||||
app/rag/source_removal.py 41 0 100%
|
||||
app/rag/sources_meta.py 16 0 100%
|
||||
app/rag/suggestions.py 27 0 100%
|
||||
app/rag/summarizer.py 58 0 100%
|
||||
app/schemas.py 533 0 100%
|
||||
--------------------------------------------------
|
||||
TOTAL 6451 17 99%
|
||||
coverage gate: app/ 99% (>90%) OK
|
||||
All checks passed!
|
||||
0 errors, 0 warnings, 0 informations
|
||||
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
|
||||
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
|
||||
|
||||
validation OK
|
||||
@@ -0,0 +1,13 @@
|
||||
All gates green. Task 02 complete.
|
||||
|
||||
**Report — phase 175, task 02 (pager UI)**
|
||||
|
||||
- **`frontend/index.html`**: `#history-pager` div (Prev / `#history-pager-status` / Next, exact aria-labels, aria-hidden SVGs, ships `hidden`) directly after `.table-wrap#history-table-wrap`, inside `.history-shell`, with the phase-175 house comment.
|
||||
- **`frontend/assets/history.js`**: phase-175 header block; root-scoped pager lookups; `PAGE_SIZE = 25` + `offset`/`total` state; `loadChats(pageOffset)` with the manual `?limit=`/`?offset=` join reading `total` and running `updatePager(total)` on 200 (failure branches untouched); `updatePager` (hidden when `total <= PAGE_SIZE`, "Page N of M", Prev/Next disabled edges); `changePage` landing `Showing X–Y of Z.` in `#history-status`; refresh + re-show listener pass the current `offset` (A5); Prev/Next bound admin-branch only (D5); boot `loadChats(0)`; Delete untouched (D4).
|
||||
- **`frontend/assets/styles.css`**: `.history-pager` flex row (`gap: 0.75rem` — existing token); `.history-pager-btn` selector unions on the pill/`:hover`/`:disabled`/svg-sizing `.history-refresh` rules (no new palette variable — B3); ≤640px block: full-width union + the row stacks (`flex-direction: column; align-items: center`, the `.settings-actions` idiom — required so two `flex-shrink: 0` full-width pills don't side-by-side overflow a phone).
|
||||
- **Existing pins absorbed (intentional D3/D4 wire change)**: `test_history_page.py` (parameterized-fetch count + `loadChats(0)` boot), `test_frontend_router.py` (view-refresh `loadChats(offset)`, `refreshChats` A5, `.history-refresh` union shape).
|
||||
- **New** `tests/unit/test_history_pagination.py` — 15 pins (structure, a11y labels, shipped `hidden`, flex/gap, pill-union presence, ≤640px, no-new-token B3, fetch/total/state/edges/Showing/refresh-keeps-page/root-scoped/D5/D4).
|
||||
- **Results**: `uv run pytest tests/unit/test_history_pagination.py -v` → 15 passed; `uv run pytest --cov=app --cov-report=term-missing` → **4136 passed**, `app/` **99%** (>90%); `uv run ruff check .` → clean; `uv run pyright` → 0 errors.
|
||||
- **Docs**: no doc impact (pager is UI with no setup/command/config surface; README doesn't document `GET /api/chats` — per task criterion).
|
||||
- **Decisions**: ≤640px row-stack added alongside the full-width union (layout-correctness for two full-width pills; all values pre-existing in file); `updatePager` takes the fresh count into module `total` state so the `Showing` line uses the last settled total.
|
||||
- **Next pending task**: `03_e2e_history_pagination.md` (the phase's dedicated Playwright suite + regression set + atomic commit).
|
||||
+1
@@ -0,0 +1 @@
|
||||
ok
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
........................................................................ [ 1%]
|
||||
........................................................................ [ 3%]
|
||||
........................................................................ [ 5%]
|
||||
........................................................................ [ 6%]
|
||||
........................................................................ [ 8%]
|
||||
........................................................................ [ 10%]
|
||||
........................................................................ [ 12%]
|
||||
........................................................................ [ 13%]
|
||||
........................................................................ [ 15%]
|
||||
........................................................................ [ 17%]
|
||||
........................................................................ [ 19%]
|
||||
........................................................................ [ 20%]
|
||||
........................................................................ [ 22%]
|
||||
........................................................................ [ 24%]
|
||||
........................................................................ [ 26%]
|
||||
........................................................................ [ 27%]
|
||||
........................................................................ [ 29%]
|
||||
........................................................................ [ 31%]
|
||||
........................................................................ [ 33%]
|
||||
........................................................................ [ 34%]
|
||||
........................................................................ [ 36%]
|
||||
........................................................................ [ 38%]
|
||||
........................................................................ [ 40%]
|
||||
........................................................................ [ 41%]
|
||||
........................................................................ [ 43%]
|
||||
........................................................................ [ 45%]
|
||||
........................................................................ [ 47%]
|
||||
........................................................................ [ 48%]
|
||||
........................................................................ [ 50%]
|
||||
........................................................................ [ 52%]
|
||||
........................................................................ [ 53%]
|
||||
........................................................................ [ 55%]
|
||||
........................................................................ [ 57%]
|
||||
........................................................................ [ 59%]
|
||||
........................................................................ [ 60%]
|
||||
........................................................................ [ 62%]
|
||||
........................................................................ [ 64%]
|
||||
........................................................................ [ 66%]
|
||||
........................................................................ [ 67%]
|
||||
........................................................................ [ 69%]
|
||||
........................................................................ [ 71%]
|
||||
........................................................................ [ 73%]
|
||||
........................................................................ [ 74%]
|
||||
........................................................................ [ 76%]
|
||||
........................................................................ [ 78%]
|
||||
........................................................................ [ 80%]
|
||||
........................................................................ [ 81%]
|
||||
........................................................................ [ 83%]
|
||||
........................................................................ [ 85%]
|
||||
........................................................................ [ 87%]
|
||||
........................................................................ [ 88%]
|
||||
........................................................................ [ 90%]
|
||||
........................................................................ [ 92%]
|
||||
........................................................................ [ 94%]
|
||||
........................................................................ [ 95%]
|
||||
........................................................................ [ 97%]
|
||||
........................................................................ [ 99%]
|
||||
................................ [100%]
|
||||
=============================== warnings summary ===============================
|
||||
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
|
||||
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
|
||||
from starlette.testclient import TestClient as TestClient # noqa
|
||||
|
||||
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
|
||||
================================ tests coverage ================================
|
||||
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
|
||||
|
||||
Name Stmts Miss Cover
|
||||
--------------------------------------------------
|
||||
app/__init__.py 1 0 100%
|
||||
app/api/__init__.py 0 0 100%
|
||||
app/api/admin_settings.py 114 0 100%
|
||||
app/api/auth.py 52 0 100%
|
||||
app/api/chat.py 280 1 99%
|
||||
app/api/chat_images.py 50 0 100%
|
||||
app/api/chats.py 111 0 100%
|
||||
app/api/config.py 13 0 100%
|
||||
app/api/doc_drafts.py 99 0 100%
|
||||
app/api/docs.py 234 1 99%
|
||||
app/api/git_sources.py 259 0 100%
|
||||
app/api/health.py 10 0 100%
|
||||
app/api/steering.py 42 0 100%
|
||||
app/api/suggestions.py 33 0 100%
|
||||
app/api/sync.py 204 0 100%
|
||||
app/api/tokens.py 40 0 100%
|
||||
app/api/ui_settings.py 55 0 100%
|
||||
app/config.py 288 0 100%
|
||||
app/core/__init__.py 0 0 100%
|
||||
app/core/auth.py 45 0 100%
|
||||
app/core/caching.py 124 0 100%
|
||||
app/core/compression.py 59 0 100%
|
||||
app/core/debugging.py 29 2 93%
|
||||
app/core/docs_push.py 43 0 100%
|
||||
app/core/errors.py 5 0 100%
|
||||
app/core/http_cache.py 22 0 100%
|
||||
app/core/logging.py 13 0 100%
|
||||
app/core/rate_limit.py 44 0 100%
|
||||
app/core/response_budget.py 63 0 100%
|
||||
app/core/security_headers.py 20 0 100%
|
||||
app/core/settings_catalog.py 18 0 100%
|
||||
app/core/settings_migrate.py 32 0 100%
|
||||
app/core/settings_overlay.py 111 0 100%
|
||||
app/core/theming.py 38 0 100%
|
||||
app/core/tokens.py 44 0 100%
|
||||
app/db.py 22 0 100%
|
||||
app/main.py 91 0 100%
|
||||
app/models.py 141 0 100%
|
||||
app/rag/__init__.py 0 0 100%
|
||||
app/rag/agent.py 454 1 99%
|
||||
app/rag/archive_upload.py 134 0 100%
|
||||
app/rag/chunker.py 220 4 98%
|
||||
app/rag/doc_dates.py 18 0 100%
|
||||
app/rag/folder_summaries.py 134 0 100%
|
||||
app/rag/git_sources.py 77 0 100%
|
||||
app/rag/importer.py 841 4 99%
|
||||
app/rag/llm.py 253 1 99%
|
||||
app/rag/md_images.py 63 0 100%
|
||||
app/rag/overview.py 78 0 100%
|
||||
app/rag/pdf.py 192 0 100%
|
||||
app/rag/prompts.py 156 0 100%
|
||||
app/rag/retriever.py 252 3 99%
|
||||
app/rag/scaffolding.py 55 0 100%
|
||||
app/rag/source_removal.py 41 0 100%
|
||||
app/rag/sources_meta.py 16 0 100%
|
||||
app/rag/suggestions.py 27 0 100%
|
||||
app/rag/summarizer.py 58 0 100%
|
||||
app/schemas.py 533 0 100%
|
||||
--------------------------------------------------
|
||||
TOTAL 6451 17 99%
|
||||
coverage gate: app/ 99% (>90%) OK
|
||||
All checks passed!
|
||||
0 errors, 0 warnings, 0 informations
|
||||
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
|
||||
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
|
||||
|
||||
validation OK
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
All changes are in the working tree, index untouched (the `D`/`complete/` entries are the harness's own moves of tasks 01/02). Everything is green.
|
||||
|
||||
## Report
|
||||
|
||||
**Implemented (task 03 — phase 175 E2E):**
|
||||
- `tests/e2e/test_history_pagination.py` — the phase's dedicated Playwright suite (9 tests): 30-row page-1 state (25 rows, "Page 1 of 2", disabled edge, "Showing 1–25 of 30."), Next→page 2 (5 rows, replaced-not-appended, order pins first/last page), Prev→page 1 round trip, Refresh-on-page-2 stays (A5), delete-all-5-on-page-2 (inline two-step confirm pin, empty state, pager NOT recomputed per D4, fresh total hides it per A4), 10-row single-page hide, anonymous gate (table+pager hidden, no `/api/chats` in the request log), 375px mobile (stacked full-width buttons, in-card table scroll), a11y light pass (real-Tab 3px focus walk, aria-labels/aria-hidden, live region, computed-style contrast, no-CDN). Fixture: public `POST`/`PUT /api/chats`, distinctive titles, controlled re-Save loop for deterministic `updated_at desc` order, `finally` cleanup + a `TRUNCATE saved_chats` autouse reset (known-total pins; the `test_history_page_width.py` precedent).
|
||||
- **Defect fix in task 02's JS:** task 03's spec pins "Showing 1–25 of 30." at the initial load, but `loadChats` announced nothing on the first load — the settled-load `Showing` line now lands in `loadChats` (gated on `total > PAGE_SIZE`, so single-page/empty behavior is unchanged); matching static pin added to `tests/unit/test_history_pagination.py`.
|
||||
|
||||
**Gates (all green):**
|
||||
- `uv run pytest tests/e2e/test_history_pagination.py -v --no-cov` — 9 passed (in isolation)
|
||||
- Regression set in isolation: `test_chat_history.py` 5, `test_history_page_width.py` 5, `test_share_chat.py` 4, `test_stale_saved_chats.py` 3, `test_history_copy.py` 1, `tests/integration/test_chats_api.py` 90 — all passed, unchanged
|
||||
- `uv run pytest` — 4137 passed; `uv run pytest --cov=app --cov-report=term-missing` — app/ 99% (>90%); `uv run ruff check .` + `uv run pyright` — clean
|
||||
|
||||
**Docs:** README.md Saved-chats section now documents the pager + `?limit=`/`?offset=`/`total` (README didn't document the endpoint shape otherwise; full README sweep — pages table, 79-var config reference, auth/token sections — found no other inconsistencies). AGENTS.md: verified consistent, no change needed.
|
||||
|
||||
**Decisions:** no commit / no phase-dir move — the harness owns the atomic commit and phase move (per binding rules); the task file's commit step is superseded.
|
||||
|
||||
**Next pending task:** none in `175_history_pagination/` — phase 175 is complete, awaiting the harness's commit + move.
|
||||
+1
@@ -0,0 +1 @@
|
||||
ok
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
........................................................................ [ 1%]
|
||||
........................................................................ [ 3%]
|
||||
........................................................................ [ 5%]
|
||||
........................................................................ [ 6%]
|
||||
........................................................................ [ 8%]
|
||||
........................................................................ [ 10%]
|
||||
........................................................................ [ 12%]
|
||||
........................................................................ [ 13%]
|
||||
........................................................................ [ 15%]
|
||||
........................................................................ [ 17%]
|
||||
........................................................................ [ 19%]
|
||||
........................................................................ [ 20%]
|
||||
........................................................................ [ 22%]
|
||||
........................................................................ [ 24%]
|
||||
........................................................................ [ 26%]
|
||||
........................................................................ [ 27%]
|
||||
........................................................................ [ 29%]
|
||||
........................................................................ [ 31%]
|
||||
........................................................................ [ 33%]
|
||||
........................................................................ [ 34%]
|
||||
........................................................................ [ 36%]
|
||||
........................................................................ [ 38%]
|
||||
........................................................................ [ 40%]
|
||||
........................................................................ [ 41%]
|
||||
........................................................................ [ 43%]
|
||||
........................................................................ [ 45%]
|
||||
........................................................................ [ 46%]
|
||||
........................................................................ [ 48%]
|
||||
........................................................................ [ 50%]
|
||||
........................................................................ [ 52%]
|
||||
........................................................................ [ 53%]
|
||||
........................................................................ [ 55%]
|
||||
........................................................................ [ 57%]
|
||||
........................................................................ [ 59%]
|
||||
........................................................................ [ 60%]
|
||||
........................................................................ [ 62%]
|
||||
........................................................................ [ 64%]
|
||||
........................................................................ [ 66%]
|
||||
........................................................................ [ 67%]
|
||||
........................................................................ [ 69%]
|
||||
........................................................................ [ 71%]
|
||||
........................................................................ [ 73%]
|
||||
........................................................................ [ 74%]
|
||||
........................................................................ [ 76%]
|
||||
........................................................................ [ 78%]
|
||||
........................................................................ [ 80%]
|
||||
........................................................................ [ 81%]
|
||||
........................................................................ [ 83%]
|
||||
........................................................................ [ 85%]
|
||||
........................................................................ [ 87%]
|
||||
........................................................................ [ 88%]
|
||||
........................................................................ [ 90%]
|
||||
........................................................................ [ 92%]
|
||||
........................................................................ [ 93%]
|
||||
........................................................................ [ 95%]
|
||||
........................................................................ [ 97%]
|
||||
........................................................................ [ 99%]
|
||||
................................. [100%]
|
||||
=============================== warnings summary ===============================
|
||||
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
|
||||
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
|
||||
from starlette.testclient import TestClient as TestClient # noqa
|
||||
|
||||
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
|
||||
================================ tests coverage ================================
|
||||
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
|
||||
|
||||
Name Stmts Miss Cover
|
||||
--------------------------------------------------
|
||||
app/__init__.py 1 0 100%
|
||||
app/api/__init__.py 0 0 100%
|
||||
app/api/admin_settings.py 114 0 100%
|
||||
app/api/auth.py 52 0 100%
|
||||
app/api/chat.py 280 1 99%
|
||||
app/api/chat_images.py 50 0 100%
|
||||
app/api/chats.py 111 0 100%
|
||||
app/api/config.py 13 0 100%
|
||||
app/api/doc_drafts.py 99 0 100%
|
||||
app/api/docs.py 234 1 99%
|
||||
app/api/git_sources.py 259 0 100%
|
||||
app/api/health.py 10 0 100%
|
||||
app/api/steering.py 42 0 100%
|
||||
app/api/suggestions.py 33 0 100%
|
||||
app/api/sync.py 204 0 100%
|
||||
app/api/tokens.py 40 0 100%
|
||||
app/api/ui_settings.py 55 0 100%
|
||||
app/config.py 288 0 100%
|
||||
app/core/__init__.py 0 0 100%
|
||||
app/core/auth.py 45 0 100%
|
||||
app/core/caching.py 124 0 100%
|
||||
app/core/compression.py 59 0 100%
|
||||
app/core/debugging.py 29 2 93%
|
||||
app/core/docs_push.py 43 0 100%
|
||||
app/core/errors.py 5 0 100%
|
||||
app/core/http_cache.py 22 0 100%
|
||||
app/core/logging.py 13 0 100%
|
||||
app/core/rate_limit.py 44 0 100%
|
||||
app/core/response_budget.py 63 0 100%
|
||||
app/core/security_headers.py 20 0 100%
|
||||
app/core/settings_catalog.py 18 0 100%
|
||||
app/core/settings_migrate.py 32 0 100%
|
||||
app/core/settings_overlay.py 111 0 100%
|
||||
app/core/theming.py 38 0 100%
|
||||
app/core/tokens.py 44 0 100%
|
||||
app/db.py 22 0 100%
|
||||
app/main.py 91 0 100%
|
||||
app/models.py 141 0 100%
|
||||
app/rag/__init__.py 0 0 100%
|
||||
app/rag/agent.py 454 1 99%
|
||||
app/rag/archive_upload.py 134 0 100%
|
||||
app/rag/chunker.py 220 4 98%
|
||||
app/rag/doc_dates.py 18 0 100%
|
||||
app/rag/folder_summaries.py 134 0 100%
|
||||
app/rag/git_sources.py 77 0 100%
|
||||
app/rag/importer.py 841 4 99%
|
||||
app/rag/llm.py 253 1 99%
|
||||
app/rag/md_images.py 63 0 100%
|
||||
app/rag/overview.py 78 0 100%
|
||||
app/rag/pdf.py 192 0 100%
|
||||
app/rag/prompts.py 156 0 100%
|
||||
app/rag/retriever.py 252 3 99%
|
||||
app/rag/scaffolding.py 55 0 100%
|
||||
app/rag/source_removal.py 41 0 100%
|
||||
app/rag/sources_meta.py 16 0 100%
|
||||
app/rag/suggestions.py 27 0 100%
|
||||
app/rag/summarizer.py 58 0 100%
|
||||
app/schemas.py 533 0 100%
|
||||
--------------------------------------------------
|
||||
TOTAL 6451 17 99%
|
||||
coverage gate: app/ 99% (>90%) OK
|
||||
All checks passed!
|
||||
0 errors, 0 warnings, 0 informations
|
||||
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
|
||||
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
|
||||
|
||||
validation OK
|
||||
@@ -206,7 +206,11 @@ List, edit, or remove them here — no conversation required.
|
||||
### Saved chats (`/history.html`)
|
||||
|
||||
Every conversation is saved automatically. Click a title to return to that
|
||||
chat. Admin-only view.
|
||||
chat. Admin-only view. The list is paginated server-side (25 rows per
|
||||
page) — with more than 25, a Prev / “Page N of M” / Next pager appears
|
||||
under the table to walk it, and **Refresh** re-fetches the current page.
|
||||
`GET /api/chats` carries the same contract: `?limit=` (default 25,
|
||||
clamped 1–100) / `?offset=` and a full `total` on every response.
|
||||
|
||||
### Access tokens (`/tokens.html`)
|
||||
|
||||
|
||||
+41
-14
@@ -27,9 +27,15 @@ payload is the exact ``bor.chat.v1`` localStorage record shape
|
||||
(phase 14 — raw text, never HTML), so a saved chat restores
|
||||
pixel-identical through the existing ``renderStoredMessage`` path.
|
||||
|
||||
Routes: ``GET`` (list, latest activity first — no payloads, but the
|
||||
phase-51 ``share_url`` and the phase-53 ``stale`` flag so the
|
||||
History's Share and Stale columns render without a second fetch),
|
||||
Routes: ``GET`` (list — paginated, phase 175 task 01: ``limit``
|
||||
(default 25, clamped 1–100) + ``offset`` (default 0); out-of-range
|
||||
values 422 via FastAPI's field-message validation — no hand-rolled
|
||||
checks — and the body reports the full ``total`` (one small count
|
||||
query per request) so a client can walk the pages; rows are one
|
||||
PAGE in the History page's table order, latest activity first
|
||||
(``updated_at desc, id desc``); no payloads, but the phase-51
|
||||
``share_url`` and the phase-53 ``stale`` flag so the History's
|
||||
Share and Stale columns render without a second fetch),
|
||||
``POST`` (create — auto-title from the first question
|
||||
when no ``title`` is supplied; (phase 51, task 02) ``share: true`` sets
|
||||
the token in the SAME commit — the save-then-share contract;
|
||||
@@ -74,9 +80,9 @@ from __future__ import annotations
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from sqlalchemy import select, text
|
||||
from sqlalchemy import func, select, text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import get_settings
|
||||
@@ -173,20 +179,41 @@ def _to_row(row: SavedChat, current_version: int) -> SavedChatRow:
|
||||
dependencies=[Depends(require_admin)], # management surface (phase 55)
|
||||
)
|
||||
def list_chats(
|
||||
limit: int = Query(25, ge=1, le=100), # noqa: B008
|
||||
offset: int = Query(0, ge=0), # noqa: B008
|
||||
db: Session = Depends(get_db), # noqa: B008
|
||||
) -> SavedChatList:
|
||||
"""All saved chats, latest activity first (``updated_at desc, id
|
||||
desc``) — the History page's table order, admin-only (the owner's
|
||||
History surface; phase 55 moved the gate per-route). Each row
|
||||
carries the
|
||||
phase-53 ``stale`` flag: the current generation is read ONCE per
|
||||
request (one PK read of the seeded row) and compared against every
|
||||
row's stamp in the serializer helpers — no per-row queries."""
|
||||
"""One PAGE of the saved chats (phase 175, task 01), latest
|
||||
activity first (``updated_at desc, id desc``) — the History
|
||||
page's table order, admin-only (the owner's History surface;
|
||||
phase 55 moved the gate per-route).
|
||||
|
||||
``limit`` (default 25, clamped 1–100) and ``offset`` (default 0)
|
||||
are FastAPI-validated: ``limit=0`` / ``limit=101`` / ``offset=-1``
|
||||
422 with the house field-message contract (no hand-rolled checks).
|
||||
A bare ``GET`` is the first 25 rows; an offset beyond the end is
|
||||
honest — an empty ``chats`` list with the real ``total``.
|
||||
|
||||
``total`` is the FULL count across all saved chats: one small
|
||||
``count`` query per request (two small queries total; the
|
||||
once-per-request ``current_sources_version`` read is untouched),
|
||||
so a client can walk the pages and know when it has seen them
|
||||
all. Each row carries the phase-53 ``stale`` flag: the current
|
||||
generation is read ONCE per request (one PK read of the seeded
|
||||
row) and compared against every row's stamp in the serializer
|
||||
helpers — no per-row queries."""
|
||||
current_version = current_sources_version(db)
|
||||
rows = db.scalars(
|
||||
select(SavedChat).order_by(SavedChat.updated_at.desc(), SavedChat.id.desc())
|
||||
select(SavedChat)
|
||||
.order_by(SavedChat.updated_at.desc(), SavedChat.id.desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
).all()
|
||||
return SavedChatList(chats=[_to_row(row, current_version) for row in rows])
|
||||
total = db.scalar(select(func.count(SavedChat.id))) or 0
|
||||
return SavedChatList(
|
||||
chats=[_to_row(row, current_version) for row in rows],
|
||||
total=total,
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=SavedChatOut, status_code=201)
|
||||
|
||||
+10
-2
@@ -1723,10 +1723,18 @@ class SavedChatRow(BaseModel):
|
||||
|
||||
|
||||
class SavedChatList(BaseModel):
|
||||
"""``GET /api/chats`` response: saved chats, latest activity first
|
||||
(``updated_at desc, id desc``)."""
|
||||
"""``GET /api/chats`` response: one PAGE of saved chats (phase
|
||||
175, task 01 — ``limit``/``offset``), latest activity first
|
||||
(``updated_at desc, id desc``).
|
||||
|
||||
``total`` is the FULL count across all saved chats (not the
|
||||
page's length) — one small count query per request; the wire
|
||||
shape always carries it."""
|
||||
|
||||
chats: list[SavedChatRow]
|
||||
#: Required (no default): the API must always compute the full
|
||||
#: count server-side — there is no wire shape without it.
|
||||
total: int
|
||||
|
||||
|
||||
class SharedChatOut(BaseModel):
|
||||
|
||||
+168
-9
@@ -112,6 +112,47 @@
|
||||
* — try again.` on a non-2xx), so EVERY caller of a failed load —
|
||||
* the mount's first load, a re-show, the button — sees the outcome
|
||||
* (the §7.4 never-stale contract; a silent empty table is gone).
|
||||
*
|
||||
* Phase 175 (task 02) — the list pager (TODO.md L3, owner 2026-10-10:
|
||||
* "Need pagination on the history page - it gets long fast"):
|
||||
*
|
||||
* • the load is parameterized: `loadChats(pageOffset)` fetches
|
||||
* `/api/chats?limit=<PAGE_SIZE>&offset=<pageOffset>` (the manual
|
||||
* string join — the file's house style) and reads the body's
|
||||
* `total` (the server's full count) into the pager via
|
||||
* `updatePager(total)`. Module state: `PAGE_SIZE` (25 — the
|
||||
* server's default page; A1 — no size picker in the UI, the E2E
|
||||
* suite catches a drift) and the current page's `offset`
|
||||
* (starts at 0 — the view opens on page 1).
|
||||
* • `updatePager(total)` runs on EVERY 200 (a 0-row fetch included
|
||||
* — the deleted-behind-us race): `total <= PAGE_SIZE` → the
|
||||
* pager is hidden (A4 — no dead "Page 1 of 1" control; it
|
||||
* disappears with the empty state); otherwise it shows, the
|
||||
* status reads `Page N of M`, Prev is disabled on page 1, Next
|
||||
* on the last page (the real `disabled` — the Refresh idiom;
|
||||
* the edges make the click clamping trivial). A FAILED load
|
||||
* (non-2xx / network) leaves the pager as it was — the
|
||||
* previous state is the last known truth.
|
||||
* • Prev/Next (bound in the ADMIN branch only — the SAME gate as
|
||||
* Refresh, D5; the anonymous branch never touches the pager)
|
||||
* move the offset by PAGE_SIZE, re-run the re-entrant load (the
|
||||
* rows are REPLACED, never appended), and land the never-stale
|
||||
* `Showing <first>–<last> of <total>.` line in #history-status.
|
||||
* Refresh (the button AND the re-show listener) re-fetches the
|
||||
* CURRENT page — `loadChats(offset)`, never 0 (A5). A local
|
||||
* Delete does NOT touch the pager or the offset — the next load
|
||||
* (a page click or a Refresh) corrects it (D4).
|
||||
*
|
||||
* • a settled load lands its OWN never-stale `Showing …` line
|
||||
* (task 03): the mount is fire-and-forget (the house-style
|
||||
* `loadChats(0);` line), so the announcement lives in
|
||||
* `loadChats` itself, where the settled `total` is in hand. It
|
||||
* lands ONLY while the pager is visible (total > PAGE_SIZE):
|
||||
* a one-page list IS the whole list (A4 — no redundant line),
|
||||
* and the 0-row branch shows the empty state (nothing to show).
|
||||
* A page change's identical line overwrites it (changePage),
|
||||
* and Refresh's `Saved chats refreshed.` overwrites it on the
|
||||
* refresh path.
|
||||
*/
|
||||
|
||||
import { fetchIsAdmin } from "./header.js";
|
||||
@@ -126,6 +167,16 @@ export async function mount(root) {
|
||||
// Phase 77 (task 03): the explicit refresh control — the page-head
|
||||
// button (outside the table wrap, so the empty state never hides it).
|
||||
const refreshBtn = root.querySelector("#history-refresh");
|
||||
// Phase 175 (task 02, D3): the list pager — under the table card
|
||||
// (Prev / "Page N of M" / Next). It SHIPS hidden in the markup;
|
||||
// only the ADMIN branch's load lifecycle (updatePager) reveals it,
|
||||
// and only while total > PAGE_SIZE (A4 — no dead "Page 1 of 1"
|
||||
// control). The anonymous branch never touches it (D5 — no dead
|
||||
// control beside the gate).
|
||||
const pagerEl = root.querySelector("#history-pager");
|
||||
const pagerPrevBtn = root.querySelector("#history-pager-prev");
|
||||
const pagerNextBtn = root.querySelector("#history-pager-next");
|
||||
const pagerStatusEl = root.querySelector("#history-pager-status");
|
||||
|
||||
/* Action feedback — the role="status" live region above the table
|
||||
(the "never stale" contract: every row action lands a line here,
|
||||
@@ -499,8 +550,67 @@ export async function mount(root) {
|
||||
if (emptyRow) emptyRow.hidden = false;
|
||||
}
|
||||
|
||||
/* GET /api/chats → render the rows (latest activity first — the
|
||||
server's order). A 0-row fetch shows the empty-state row.
|
||||
/* Phase 175 (task 02, D4): the pager's state. PAGE_SIZE matches
|
||||
the server's default page (A1: no size picker in the UI — the
|
||||
E2E suite catches a limit drift); `offset` is the current
|
||||
page's offset (it starts at 0 — the view opens on page 1);
|
||||
`total` is the server's last full count — the "of N" truth (a
|
||||
failed load leaves it; the previous state is the last known
|
||||
truth). */
|
||||
const PAGE_SIZE = 25;
|
||||
let offset = 0;
|
||||
let total = 0;
|
||||
|
||||
/* Phase 175 (task 02, D3): the pager's single source of truth —
|
||||
the load's `updatePager(total)` runs it on EVERY 200 (a 0-row
|
||||
fetch included — the deleted-behind-us race):
|
||||
• `total <= PAGE_SIZE` → the pager is hidden (A4 — no dead
|
||||
"Page 1 of 1" control; it disappears with the empty state);
|
||||
• otherwise it shows, #history-pager-status reads `Page N of M`,
|
||||
Prev is disabled on page 1, Next on the last page (the real
|
||||
`disabled` attribute — the Refresh idiom; the edges make the
|
||||
click clamping trivial).
|
||||
A FAILED load (non-2xx / network) never reaches here — the
|
||||
pager keeps its previous state, the last known truth. */
|
||||
function updatePager(count) {
|
||||
total = count;
|
||||
if (!pagerEl) return;
|
||||
const pages = Math.ceil(total / PAGE_SIZE);
|
||||
if (total <= PAGE_SIZE) {
|
||||
pagerEl.hidden = true; // A4 — a one-page list never shows a dead control
|
||||
return;
|
||||
}
|
||||
pagerEl.hidden = false;
|
||||
if (pagerStatusEl) {
|
||||
pagerStatusEl.textContent =
|
||||
"Page " + (offset / PAGE_SIZE + 1) + " of " + pages;
|
||||
}
|
||||
if (pagerPrevBtn) pagerPrevBtn.disabled = offset === 0;
|
||||
if (pagerNextBtn) pagerNextBtn.disabled = offset + PAGE_SIZE >= total;
|
||||
}
|
||||
|
||||
/* Phase 175 (task 02, D4): a page change — the Prev/Next click has
|
||||
ALREADY moved the module's offset by PAGE_SIZE (the button's
|
||||
disabled edge made it a legal move), this re-runs the
|
||||
re-entrant load (rows are REPLACED, never appended) and, on
|
||||
success, lands the never-stale `Showing <first>–<last> of
|
||||
<total>.` line in #history-status. A failed load keeps the
|
||||
pager (loadChats' own error line is the feedback — the §7.4
|
||||
contract). */
|
||||
async function changePage() {
|
||||
const ok = await loadChats(offset);
|
||||
if (ok) {
|
||||
announce(
|
||||
"Showing " + (offset + 1) + "–" +
|
||||
Math.min(offset + PAGE_SIZE, total) + " of " + total + ".",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/* GET /api/chats?limit=<PAGE_SIZE>&offset=<pageOffset> (phase 175:
|
||||
the parameterized fetch) → render the rows (latest activity
|
||||
first — the server's order). A 0-row fetch shows the
|
||||
empty-state row.
|
||||
Re-entrant (phase 77 task 01): a re-show re-run must REPLACE the
|
||||
list, not append a duplicate set — the data rows (every <tr>
|
||||
EXCEPT the hidden #history-empty-row, which the load itself
|
||||
@@ -510,8 +620,13 @@ export async function mount(root) {
|
||||
"try again.") — and the load RETURNS the outcome: true when the
|
||||
fetch settled (a 0-row fetch is a SUCCESS — the empty state is
|
||||
the honest view), false on non-2xx / network error, so the
|
||||
refresh button's handler can land its own success line. */
|
||||
async function loadChats() {
|
||||
refresh button's handler can land its own success line. A failed
|
||||
load leaves the pager as it was (the previous state is the last
|
||||
known truth).
|
||||
Phase 175 (task 02): the 200 path reads the body's `total` (the
|
||||
server's full count) and runs updatePager — the pager tracks
|
||||
every settled load. */
|
||||
async function loadChats(pageOffset) {
|
||||
if (tbody) {
|
||||
for (const tr of tbody.querySelectorAll("tr")) {
|
||||
if (tr !== emptyRow) tr.remove();
|
||||
@@ -520,7 +635,10 @@ export async function mount(root) {
|
||||
if (emptyRow) emptyRow.hidden = true;
|
||||
let r;
|
||||
try {
|
||||
r = await fetch("/api/chats");
|
||||
/* Phase 175 (task 02, D4): the parameterized fetch — the
|
||||
manual string join (the file's house style): the 25-row page
|
||||
(PAGE_SIZE — the server's default) at the given offset. */
|
||||
r = await fetch("/api/chats?limit=" + PAGE_SIZE + "&offset=" + pageOffset);
|
||||
} catch {
|
||||
announce("Couldn't load saved chats — is the app reachable?");
|
||||
showEmptyState();
|
||||
@@ -531,7 +649,8 @@ export async function mount(root) {
|
||||
showEmptyState();
|
||||
return false;
|
||||
}
|
||||
const { chats } = await r.json();
|
||||
const { chats, total } = await r.json();
|
||||
updatePager(total); // phase 175: the pager tracks the fresh total (a 0-row fetch included — the deleted-behind-us race)
|
||||
if (!chats.length) {
|
||||
showEmptyState();
|
||||
return true;
|
||||
@@ -539,6 +658,21 @@ export async function mount(root) {
|
||||
for (const chat of chats) {
|
||||
tbody.appendChild(makeRow(chat));
|
||||
}
|
||||
/* Phase 175 (task 03, the never-stale contract's first-load
|
||||
half): a settled load lands its own `Showing <first>–<last>
|
||||
of <total>.` line (the same formula a page change lands —
|
||||
changePage's line overwrites it with the identical text;
|
||||
Refresh's `Saved chats refreshed.` overwrites it on the
|
||||
refresh path). It lands ONLY while the pager is visible
|
||||
(total > PAGE_SIZE): a one-page list IS the whole list
|
||||
(A4 — no redundant line) and the 0-row branch above never
|
||||
reaches here (the empty state is the honest view). */
|
||||
if (total > PAGE_SIZE) {
|
||||
announce(
|
||||
"Showing " + (pageOffset + 1) + "–" +
|
||||
Math.min(pageOffset + PAGE_SIZE, total) + " of " + total + ".",
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -549,7 +683,10 @@ export async function mount(root) {
|
||||
async function refreshChats() {
|
||||
let ok = false;
|
||||
try {
|
||||
ok = await loadChats();
|
||||
/* Phase 175 (task 02, A5): refresh re-fetches the CURRENT
|
||||
page — the module's offset, never 0 (a page-2 refresh must
|
||||
land page-2 rows, not reset the list). */
|
||||
ok = await loadChats(offset);
|
||||
} finally {
|
||||
if (refreshBtn) refreshBtn.disabled = false;
|
||||
}
|
||||
@@ -581,7 +718,7 @@ export async function mount(root) {
|
||||
the listener can only ever re-run a load the mount already did. */
|
||||
let started = false;
|
||||
root.addEventListener("bor:view-refresh", () => {
|
||||
if (started) loadChats();
|
||||
if (started) loadChats(offset); // phase 175 (A5): the re-show re-fetches the CURRENT page (never resets to 0)
|
||||
});
|
||||
/* Phase 77 (task 03): the explicit Refresh control (TODO.md L3) —
|
||||
the button's own lifecycle: click → disable (no double-fire while
|
||||
@@ -595,6 +732,28 @@ export async function mount(root) {
|
||||
void refreshChats();
|
||||
});
|
||||
}
|
||||
/* Phase 175 (task 02, D5): the pager's Prev / Next — bound HERE,
|
||||
in the admin branch only (the SAME gate as Refresh; the
|
||||
anonymous branch never touches the pager — it keeps its
|
||||
markup-shipped hidden state, D5). Each click moves the
|
||||
module's offset by PAGE_SIZE in its direction (the button's
|
||||
disabled edge made it a legal move — clamping is trivial) and
|
||||
re-runs the re-entrant load; on success the `Showing …` line
|
||||
lands in the live region (changePage). A local Delete does NOT
|
||||
touch the pager or the offset — the next load (a page click or
|
||||
a Refresh) corrects it (D4). */
|
||||
if (pagerPrevBtn) {
|
||||
pagerPrevBtn.addEventListener("click", () => {
|
||||
offset -= PAGE_SIZE;
|
||||
void changePage();
|
||||
});
|
||||
}
|
||||
if (pagerNextBtn) {
|
||||
pagerNextBtn.addEventListener("click", () => {
|
||||
offset += PAGE_SIZE;
|
||||
void changePage();
|
||||
});
|
||||
}
|
||||
started = true;
|
||||
loadChats();
|
||||
loadChats(0); // phase 175: the view opens on page 1 (offset 0)
|
||||
}
|
||||
|
||||
@@ -4010,7 +4010,7 @@ a.answer-image:hover { text-decoration: underline; } /* flat — the caption und
|
||||
gap: 0.75rem 1.5rem;
|
||||
}
|
||||
#view-history .page-head .page-head-title { min-width: 0; }
|
||||
.history-refresh {
|
||||
.history-refresh, .history-pager-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -4028,12 +4028,38 @@ a.answer-image:hover { text-decoration: underline; } /* flat — the caption und
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.history-refresh:hover { background: var(--brand-hover); color: var(--bg); }
|
||||
.history-refresh:disabled { opacity: 0.6; cursor: wait; }
|
||||
.history-refresh:hover, .history-pager-btn:hover { background: var(--brand-hover); color: var(--bg); }
|
||||
.history-refresh:disabled, .history-pager-btn:disabled { opacity: 0.6; cursor: wait; }
|
||||
/* The refresh glyph is hidden on desktop (the label carries the
|
||||
pill); below 640px it joins the visible label in the full-width
|
||||
pill (the <=640 block — the icon-only squeeze is gone). */
|
||||
.history-refresh svg { width: 16px; height: 16px; display: none; }
|
||||
pill (the <=640 block — the icon-only squeeze is gone). The
|
||||
phase-175 pager buttons join the rule (the selector union — the
|
||||
same glyph language: visible label at every width, the glyph is
|
||||
the mobile companion). */
|
||||
.history-refresh svg, .history-pager-btn svg { width: 16px; height: 16px; display: none; }
|
||||
|
||||
/* ---------- History pager (phase 175, task 02 — TODO.md L3) ----------
|
||||
"Need pagination on the history page - it gets long fast" (owner
|
||||
2026-10-10). Root cause: the saved-chat list used to fetch and
|
||||
render EVERY row; the server now ships a 25-row page + the full
|
||||
count, and this row is the walk control — Prev / "Page N of M" /
|
||||
Next, sitting UNDER the table card (index.html, phase 175).
|
||||
Token reuse (B3): the buttons join the .history-refresh pill
|
||||
through the selector unions above (the solid brand pill, the
|
||||
:disabled opacity, the svg sizing — NO new palette variable), and
|
||||
the row's gap is the page's OWN 0.75rem (the #view-history
|
||||
.page-head row gap — no new spacing token). Hidden by default
|
||||
(the markup ships `hidden`): history.js's updatePager() reveals
|
||||
the row only while `total > PAGE_SIZE` (A4 — no dead "Page 1 of
|
||||
1" control; it disappears with the empty state). The status span
|
||||
needs no rule beyond the inherited text. Below 640px the row
|
||||
stacks (the .settings-actions idiom) and the pills go full width
|
||||
(the .history-refresh mobile idiom — the ≤640px block below). */
|
||||
.history-pager {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
/* ---------- History page (phase 50) ----------
|
||||
/history.html: the admin-only saved-chats list (task 04). The
|
||||
@@ -6486,8 +6512,14 @@ a.answer-image:hover { text-decoration: underline; } /* flat — the caption und
|
||||
.sync-btn { width: 100%; }
|
||||
.sync-cancel-btn { width: 100%; } /* phase 134: the Cancel pill stacks full-width under the Sync pill (the row already wraps) */
|
||||
.sync-label { max-width: none; flex: 1 1 auto; min-width: 0; }
|
||||
.history-refresh { width: 100%; }
|
||||
.history-refresh svg { display: block; }
|
||||
.history-refresh, .history-pager-btn { width: 100%; }
|
||||
.history-refresh svg, .history-pager-btn svg { display: block; }
|
||||
/* Phase 175 (task 02, D6): the pager row STACKS (the
|
||||
.settings-actions idiom) so the full-width Prev / Next pills
|
||||
don't side-by-side overflow the phone width (the pill rule's
|
||||
flex-shrink: 0 would keep each at 100%); the "Page N of M"
|
||||
status sits between them (the 0.75rem gap goes vertical). */
|
||||
.history-pager { flex-direction: column; align-items: center; }
|
||||
}
|
||||
|
||||
/* Phase 46: prefers-reduced-motion stills the mobile menu — no
|
||||
|
||||
@@ -1061,6 +1061,26 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!-- Phase 175 (TODO.md L3): the list pager — visible only when
|
||||
there is more than one page (total > the 25-row page size —
|
||||
history.js toggles `hidden`), so a one-page list never shows a
|
||||
dead control. The buttons reuse the .history-refresh pill
|
||||
language (the house inline-SVG glyph + visible label +
|
||||
aria-label; the ≤640px full-width block joins
|
||||
.history-refresh). #history-pager-status carries "Page N of M";
|
||||
page-change outcome lines land in #history-status (the existing
|
||||
live region). -->
|
||||
<div class="history-pager" id="history-pager" hidden>
|
||||
<button type="button" class="history-pager-btn" id="history-pager-prev" aria-label="Previous page of saved chats">
|
||||
<svg class="history-pager-icon" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M15 18l-6-6 6-6"/></svg>
|
||||
<span class="history-pager-label">Prev</span>
|
||||
</button>
|
||||
<span class="history-pager-status" id="history-pager-status"></span>
|
||||
<button type="button" class="history-pager-btn" id="history-pager-next" aria-label="Next page of saved chats">
|
||||
<span class="history-pager-label">Next</span>
|
||||
<svg class="history-pager-icon" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18l6-6-6-6"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -0,0 +1,700 @@
|
||||
"""Phase 175 E2E (Playwright): the History page's list pager.
|
||||
|
||||
TODO.md L3 (owner 2026-10-10): "Need pagination on the history page
|
||||
- it gets long fast" — the saved-chat list is now paginated
|
||||
SERVER-side (``GET /api/chats`` takes ``?limit=`` (default 25,
|
||||
clamped 1–100) / ``?offset=`` and reports the full ``total`` on
|
||||
every response), and the view walks it through the ``.history-pager``
|
||||
control under the table card (Prev / "Page N of M" / Next — the
|
||||
``.history-refresh`` pill language; hidden while the list is a single
|
||||
page, A4; Refresh keeps the current page, A5; the anonymous gate is
|
||||
untouched, D5; the ≤640px full-width block, D6).
|
||||
|
||||
Run in isolation (the DB must be up: ``podman compose up -d db``):
|
||||
|
||||
uv run pytest tests/e2e/test_history_pagination.py -v --no-cov
|
||||
|
||||
The contract under test:
|
||||
|
||||
* **page 1 of 2 (a 30-row fixture)** — the table holds 25 rows (the
|
||||
25-row page at offset 0), the pager is visible, ``#history-pager-
|
||||
status`` reads "Page 1 of 2", Prev ``disabled`` / Next enabled, and
|
||||
the live region carries "Showing 1–25 of 30." (the never-stale
|
||||
first-load line — the settled load announces its own page);
|
||||
* **Next → page 2** — the 5 remaining rows (rows are REPLACED, never
|
||||
appended — the 25 page-1 rows are gone), "Page 2 of 2", Next
|
||||
``disabled`` / Prev enabled, "Showing 26–30 of 30.";
|
||||
* **Prev → page 1** — back to 25 rows, "Page 1 of 2", Prev
|
||||
``disabled``, "Showing 1–25 of 30.";
|
||||
* **Refresh on page 2** — re-fetches page 2 (A5 — never resets to
|
||||
page 1): still 5 rows + "Page 2 of 2" and "Saved chats
|
||||
refreshed." lands;
|
||||
* **delete-to-empty on the last page** — the inline two-step confirm
|
||||
(the owner-locked no-native-dialog pin — the "Delete? [Yes] [No]"
|
||||
pair appearing in place is the contract) deletes all five page-2
|
||||
rows: the empty state appears, and the pager is NOT recomputed on
|
||||
the local delete (D4 — it still reads "Page 2 of 2", the last
|
||||
known truth); the next load (Prev) corrects it with the fresh
|
||||
``total`` (25 → single page → the pager hides, A4);
|
||||
* **single page** — a 10-row list: all ten rows, the pager fully
|
||||
hidden (A4 — no dead "Page 1 of 1" control);
|
||||
* **anonymous** — the gate shows, the table AND the pager stay
|
||||
hidden, and NO ``GET /api/chats`` request appears in the log (the
|
||||
house request-log pin — the router 403s them);
|
||||
* **≤640px** — the pager row stacks and the buttons go full width
|
||||
(the ``.history-refresh`` mobile idiom, D6) while the table still
|
||||
scrolls inside its card.
|
||||
|
||||
DB isolation: the pagination pins are EXACT (25 of 30, 10 of 10,
|
||||
"Page N of M"), so every test starts from an EMPTY ``saved_chats``
|
||||
(the ``test_history_page_width.py`` TRUNCATE house precedent — the
|
||||
state is under test, not a byproduct; the KB tables are never
|
||||
touched — the suite never drives the chat page). Fixture rows are
|
||||
created through the PUBLIC ``POST /api/chats`` write surface with
|
||||
DISTINCTIVE auto-titles, re-Saved (``PUT /api/chats/<id>`` — the
|
||||
public upsert, ``onupdate`` bumps ``updated_at``) in a controlled
|
||||
loop (a short sleep between bumps — Postgres' ``now()`` is µs-
|
||||
resolution, the HTTP round-trip ms; the sleep makes the
|
||||
``updated_at desc`` order FULLY deterministic) so the last re-Saved
|
||||
chat is row 1, and they are deleted in a ``finally`` (admin cookie —
|
||||
the ``test_chat_history.py`` idiom). The order is pinned by the first
|
||||
row of page 1 (the last re-Saved) and the first / last rows of the
|
||||
last page (the earliest re-Saved) — never the full middle order.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from playwright.sync_api import Page, expect
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.db import SessionLocal
|
||||
from e2e.auth_helpers import login
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clean_saved_chats(db_ready: None) -> None:
|
||||
"""Every test here pins an EXACT total (25 of 30, 10 of 10 —
|
||||
"Page N of M"), so the shared dev DB's leftover rows from other
|
||||
suites would break it: each test starts from an EMPTY
|
||||
``saved_chats`` (the ``test_history_page_width.py`` TRUNCATE
|
||||
house precedent — the state is under test, not a byproduct).
|
||||
The KB tables are never touched — the suite never drives the
|
||||
chat page."""
|
||||
with SessionLocal() as db:
|
||||
db.execute(text("TRUNCATE saved_chats"))
|
||||
db.commit()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixture helpers (the test_chat_history.py DB-isolation idiom, adapted
|
||||
# to a known-total fixture: the suite's pins are exact, so the rows
|
||||
# are created through the public API with distinctive auto-titles,
|
||||
# order-controlled, and deleted in a finally)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _title(i: int, n: int) -> str:
|
||||
"""The fixture row's distinctive auto-title (the first user
|
||||
message — the auto-title convention: whitespace-collapsed,
|
||||
120-char cap)."""
|
||||
return f"Pagination probe {i} of {n} — what is the capital of France?"
|
||||
|
||||
|
||||
def _make_chat(app_url: str, title: str) -> str:
|
||||
"""POST /api/chats (the PUBLIC write surface, phase 55): the
|
||||
auto-title IS the first user message, so the title goes in as
|
||||
the question; the minimal house conversation shape (one user +
|
||||
one brain reply)."""
|
||||
r = httpx.post(
|
||||
f"{app_url}/api/chats",
|
||||
json={
|
||||
"messages": [
|
||||
{"who": "user", "text": title},
|
||||
{"who": "brain", "text": "That's a good question."},
|
||||
]
|
||||
},
|
||||
timeout=10,
|
||||
)
|
||||
assert r.status_code == 201, r.text
|
||||
return r.json()["id"]
|
||||
|
||||
|
||||
def _re_save(app_url: str, chat_id: str, title: str) -> None:
|
||||
"""PUT /api/chats/<id> (the public re-Save upsert): a full
|
||||
message replacement that bumps ``updated_at``
|
||||
(``onupdate=func.now()``) — the controlled order bump."""
|
||||
r = httpx.put(
|
||||
f"{app_url}/api/chats/{chat_id}",
|
||||
json={
|
||||
"messages": [
|
||||
{"who": "user", "text": title},
|
||||
{"who": "brain", "text": "That's a good question."},
|
||||
]
|
||||
},
|
||||
timeout=10,
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
|
||||
def _make_chats(app_url: str, n: int) -> list[str]:
|
||||
"""n fixture conversations (distinctive auto-titles), then the
|
||||
controlled re-Save loop — a short sleep between bumps makes the
|
||||
``updated_at desc`` order FULLY deterministic, so the last
|
||||
re-Saved chat is row 1. Returns the ids in re-Save order (index
|
||||
0 = the OLDEST = the last row of the last page)."""
|
||||
ids: list[str] = []
|
||||
for i in range(1, n + 1):
|
||||
ids.append(_make_chat(app_url, _title(i, n)))
|
||||
for i, chat_id in enumerate(ids, start=1):
|
||||
time.sleep(0.02)
|
||||
_re_save(app_url, chat_id, _title(i, n))
|
||||
return ids
|
||||
|
||||
|
||||
def _admin_cookies(page: Page) -> dict[str, str]:
|
||||
"""The signed session cookies the browser holds after a form
|
||||
login — used to call the admin API with plain httpx (the
|
||||
``test_chat_history.py`` idiom)."""
|
||||
return {
|
||||
c["name"]: c["value"]
|
||||
for c in page.context.cookies()
|
||||
if "name" in c and "value" in c
|
||||
}
|
||||
|
||||
|
||||
def _delete_all(app_url: str, cookies: dict[str, str], ids: list[str]) -> None:
|
||||
"""Best-effort row cleanup (a 403 — not signed in yet — or a 404
|
||||
— already deleted — is fine; the next test's TRUNCATE covers
|
||||
any leak)."""
|
||||
for chat_id in ids:
|
||||
httpx.delete(f"{app_url}/api/chats/{chat_id}", timeout=10, cookies=cookies)
|
||||
|
||||
|
||||
def _settle_admin_history(page: Page, app_url: str, rows: int) -> None:
|
||||
"""Real form login onto /history.html + wait for the view's
|
||||
first load to have settled: the N data rows (the title links)
|
||||
IS the settle signal — the pager's state and the first-load
|
||||
"Showing" line land in the same settled load (updatePager runs
|
||||
before the row append; the announcement after it)."""
|
||||
login(page, app_url, next="/history.html")
|
||||
expect(page).to_have_url(app_url + "/history.html", timeout=30_000)
|
||||
expect(
|
||||
page.locator("#history-tbody a.history-title-link")
|
||||
).to_have_count(rows, timeout=15_000)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Contrast helpers (copied from test_responsive_polish.py — suites
|
||||
# stay self-contained; only auth_helpers is imported)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _rgb(value: str) -> tuple[int, int, int]:
|
||||
match = re.match(r"^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)", value)
|
||||
assert match, f"unparsable color: {value!r}"
|
||||
return int(match.group(1)), int(match.group(2)), int(match.group(3))
|
||||
|
||||
|
||||
def _rel_luminance(rgb: tuple[int, int, int]) -> float:
|
||||
def chan(c: int) -> float:
|
||||
s = c / 255
|
||||
return s / 12.92 if s <= 0.04045 else ((s + 0.055) / 1.055) ** 2.4
|
||||
|
||||
r, g, b = (chan(c) for c in rgb)
|
||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b
|
||||
|
||||
|
||||
def contrast_ratio(fg: str, bg: str) -> float:
|
||||
l1, l2 = _rel_luminance(_rgb(fg)), _rel_luminance(_rgb(bg))
|
||||
if l1 < l2:
|
||||
l1, l2 = l2, l1
|
||||
return (l1 + 0.05) / (l2 + 0.05)
|
||||
|
||||
|
||||
def _assert_aa(pair: Any, label: str) -> None:
|
||||
fg, bg = str(pair[0]), str(pair[1])
|
||||
ratio = contrast_ratio(fg, bg)
|
||||
assert ratio >= 4.5, (
|
||||
f"contrast {label}: {fg} on {bg} = {ratio:.2f}:1 (< 4.5:1)"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Page 1 of 2 for 30 rows: the 25-row page, the pager's visible
|
||||
# state, the never-stale first-load line
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_pager_shows_page_1_of_2_for_30_rows(
|
||||
page: Page, app_url: str, clean_saved_chats: None
|
||||
) -> None:
|
||||
page.set_default_timeout(30_000)
|
||||
ids = _make_chats(app_url, 30)
|
||||
_settle_admin_history(page, app_url, 25)
|
||||
try:
|
||||
# The table holds the first 25 of 30 (the 25-row page)…
|
||||
expect(page.locator("#history-pager")).to_be_visible()
|
||||
expect(page.locator("#history-pager-status")).to_have_text(
|
||||
"Page 1 of 2"
|
||||
)
|
||||
expect(page.locator("#history-pager-prev")).to_be_disabled()
|
||||
expect(page.locator("#history-pager-next")).to_be_enabled()
|
||||
# …and the live region carries the first-load line (the
|
||||
# settled load announces its own page — task 03).
|
||||
expect(page.locator("#history-status")).to_have_text(
|
||||
"Showing 1–25 of 30."
|
||||
)
|
||||
# Order pinned by the first row of page 1 (the last
|
||||
# re-Saved — the newest).
|
||||
expect(
|
||||
page.locator("#history-tbody a.history-title-link").first
|
||||
).to_have_text(_title(30, 30))
|
||||
finally:
|
||||
_delete_all(app_url, _admin_cookies(page), ids)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Next → page 2: the 5 rows (replaced, never appended), the
|
||||
# disabled edge, the live-region line
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_next_walks_to_page_2(
|
||||
page: Page, app_url: str, clean_saved_chats: None
|
||||
) -> None:
|
||||
page.set_default_timeout(30_000)
|
||||
ids = _make_chats(app_url, 30)
|
||||
_settle_admin_history(page, app_url, 25)
|
||||
try:
|
||||
page.locator("#history-pager-next").click()
|
||||
# The 25 page-1 rows are GONE (replaced, never appended —
|
||||
# an append would leave 30; the count IS the pin)…
|
||||
expect(
|
||||
page.locator("#history-tbody a.history-title-link")
|
||||
).to_have_count(5, timeout=15_000)
|
||||
expect(page.locator("#history-pager-status")).to_have_text(
|
||||
"Page 2 of 2"
|
||||
)
|
||||
expect(page.locator("#history-pager-next")).to_be_disabled()
|
||||
expect(page.locator("#history-pager-prev")).to_be_enabled()
|
||||
expect(page.locator("#history-status")).to_have_text(
|
||||
"Showing 26–30 of 30."
|
||||
)
|
||||
# …reinforced: the newest page-1 title (row 1) is not on
|
||||
# the page at all.
|
||||
expect(
|
||||
page.locator(
|
||||
f'#history-tbody a.history-title-link:text-is("{_title(30, 30)}")'
|
||||
)
|
||||
).to_have_count(0)
|
||||
# Order: the first row of the last page (the 5th re-Saved —
|
||||
# row 26) …
|
||||
expect(
|
||||
page.locator("#history-tbody a.history-title-link").first
|
||||
).to_have_text(_title(5, 30))
|
||||
# …and the last row is the earliest re-Saved (row 30).
|
||||
expect(
|
||||
page.locator("#history-tbody a.history-title-link").last
|
||||
).to_have_text(_title(1, 30))
|
||||
finally:
|
||||
_delete_all(app_url, _admin_cookies(page), ids)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Prev → page 1: the round trip (25 rows again, the edge back,
|
||||
# the line)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_prev_walks_back_to_page_1(
|
||||
page: Page, app_url: str, clean_saved_chats: None
|
||||
) -> None:
|
||||
page.set_default_timeout(30_000)
|
||||
ids = _make_chats(app_url, 30)
|
||||
_settle_admin_history(page, app_url, 25)
|
||||
try:
|
||||
page.locator("#history-pager-next").click()
|
||||
expect(
|
||||
page.locator("#history-tbody a.history-title-link")
|
||||
).to_have_count(5, timeout=15_000)
|
||||
page.locator("#history-pager-prev").click()
|
||||
expect(
|
||||
page.locator("#history-tbody a.history-title-link")
|
||||
).to_have_count(25, timeout=15_000)
|
||||
expect(page.locator("#history-pager-status")).to_have_text(
|
||||
"Page 1 of 2"
|
||||
)
|
||||
expect(page.locator("#history-pager-prev")).to_be_disabled()
|
||||
expect(page.locator("#history-pager-next")).to_be_enabled()
|
||||
expect(page.locator("#history-status")).to_have_text(
|
||||
"Showing 1–25 of 30."
|
||||
)
|
||||
# Order round-trips: the first row is the last re-Saved
|
||||
# again.
|
||||
expect(
|
||||
page.locator("#history-tbody a.history-title-link").first
|
||||
).to_have_text(_title(30, 30))
|
||||
finally:
|
||||
_delete_all(app_url, _admin_cookies(page), ids)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. Refresh on page 2 stays on page 2 (A5 — never resets to
|
||||
# page 1)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_refresh_on_page_2_stays_on_page_2(
|
||||
page: Page, app_url: str, clean_saved_chats: None
|
||||
) -> None:
|
||||
page.set_default_timeout(30_000)
|
||||
ids = _make_chats(app_url, 30)
|
||||
_settle_admin_history(page, app_url, 25)
|
||||
try:
|
||||
page.locator("#history-pager-next").click()
|
||||
expect(
|
||||
page.locator("#history-tbody a.history-title-link")
|
||||
).to_have_count(5, timeout=15_000)
|
||||
# The Refresh lifecycle: click → disabled while in flight →
|
||||
# re-enabled in the finally — waiting for the re-enable IS
|
||||
# the settle gate (the house idiom).
|
||||
page.locator("#history-refresh").click()
|
||||
expect(page.locator("#history-refresh")).to_be_enabled(
|
||||
timeout=15_000
|
||||
)
|
||||
# A5: the re-fetch landed page 2, not page 1 — still the 5
|
||||
# page-2 rows + "Page 2 of 2" (a reset to page 1 would show
|
||||
# 25 rows + "Page 1 of 2" — the pins fail it).
|
||||
expect(
|
||||
page.locator("#history-tbody a.history-title-link")
|
||||
).to_have_count(5)
|
||||
expect(page.locator("#history-pager-status")).to_have_text(
|
||||
"Page 2 of 2"
|
||||
)
|
||||
expect(page.locator("#history-pager-next")).to_be_disabled()
|
||||
expect(page.locator("#history-status")).to_have_text(
|
||||
"Saved chats refreshed."
|
||||
)
|
||||
# The page-2 order is intact (the 5th re-Saved first).
|
||||
expect(
|
||||
page.locator("#history-tbody a.history-title-link").first
|
||||
).to_have_text(_title(5, 30))
|
||||
finally:
|
||||
_delete_all(app_url, _admin_cookies(page), ids)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. Delete-to-empty on the last page: the inline two-step confirm
|
||||
# (no native dialog), the empty state, the pager NOT recomputed
|
||||
# on the local delete (D4), the next load's fresh total hides
|
||||
# it (A4)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_delete_all_rows_of_page_2_then_prev_single_page_hides_pager(
|
||||
page: Page, app_url: str, clean_saved_chats: None
|
||||
) -> None:
|
||||
page.set_default_timeout(30_000)
|
||||
ids = _make_chats(app_url, 30)
|
||||
_settle_admin_history(page, app_url, 25)
|
||||
try:
|
||||
page.locator("#history-pager-next").click()
|
||||
expect(
|
||||
page.locator("#history-tbody a.history-title-link")
|
||||
).to_have_count(5, timeout=15_000)
|
||||
# Delete all five page-2 rows through the inline two-step
|
||||
# confirm — the owner-locked no-native-dialog pin: the
|
||||
# "Delete? [Yes] [No]" pair appearing IN PLACE is the
|
||||
# contract (a real window.confirm would hang Playwright).
|
||||
for remaining in (4, 3, 2, 1, 0):
|
||||
tr = page.locator(
|
||||
"#history-tbody tr",
|
||||
has=page.locator("a.history-title-link"),
|
||||
).first
|
||||
expect(tr).to_be_visible()
|
||||
tr.locator("button.history-delete").click()
|
||||
expect(tr.locator(".history-confirm-yes")).to_be_visible()
|
||||
expect(tr.locator(".history-confirm-no")).to_be_visible()
|
||||
tr.locator(".history-confirm-yes").click()
|
||||
expect(
|
||||
page.locator("#history-tbody a.history-title-link")
|
||||
).to_have_count(remaining)
|
||||
# The last row's deletion reveals the empty state…
|
||||
expect(page.locator("#history-empty-row")).to_be_visible()
|
||||
# …and the pager is NOT recomputed on the local delete
|
||||
# (D4 — the last known truth: still "Page 2 of 2", the
|
||||
# server's 30-row count from the page-2 load; only the next
|
||||
# load corrects it).
|
||||
expect(page.locator("#history-pager")).to_be_visible()
|
||||
expect(page.locator("#history-pager-status")).to_have_text(
|
||||
"Page 2 of 2"
|
||||
)
|
||||
expect(page.locator("#history-pager-prev")).to_be_enabled()
|
||||
expect(page.locator("#history-pager-next")).to_be_disabled()
|
||||
# The next load (Prev) corrects the pager with the fresh
|
||||
# total: 25 rows (the page-1 rows — the page-2 ones are
|
||||
# deleted) → single page → the pager hides (A4).
|
||||
page.locator("#history-pager-prev").click()
|
||||
expect(
|
||||
page.locator("#history-tbody a.history-title-link")
|
||||
).to_have_count(25, timeout=15_000)
|
||||
expect(page.locator("#history-pager")).to_be_hidden()
|
||||
expect(page.locator("#history-status")).to_have_text(
|
||||
"Showing 1–25 of 25."
|
||||
)
|
||||
finally:
|
||||
_delete_all(app_url, _admin_cookies(page), ids)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6. Single page: the pager is fully hidden (A4 — no dead "Page 1
|
||||
# of 1" control)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_single_page_hides_the_pager(
|
||||
page: Page, app_url: str, clean_saved_chats: None
|
||||
) -> None:
|
||||
page.set_default_timeout(30_000)
|
||||
ids = _make_chats(app_url, 10)
|
||||
_settle_admin_history(page, app_url, 10)
|
||||
try:
|
||||
# The whole list is on the page (10 of 10)…
|
||||
expect(
|
||||
page.locator("#history-tbody a.history-title-link")
|
||||
).to_have_count(10)
|
||||
# …and the pager is fully hidden (the container's `hidden`
|
||||
# takes the whole row away — the buttons with it).
|
||||
expect(page.locator("#history-pager")).to_be_hidden()
|
||||
expect(page.locator("#history-pager-prev")).to_be_hidden()
|
||||
expect(page.locator("#history-pager-next")).to_be_hidden()
|
||||
# Order: the first row is the last re-Saved (the newest).
|
||||
expect(
|
||||
page.locator("#history-tbody a.history-title-link").first
|
||||
).to_have_text(_title(10, 10))
|
||||
finally:
|
||||
_delete_all(app_url, _admin_cookies(page), ids)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 7. Anonymous: the gate, the table AND the pager hidden, and NO
|
||||
# /api/chats request in the log (D5)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_anonymous_sees_gate_and_never_fetches(
|
||||
page: Page, app_url: str, clean_saved_chats: None
|
||||
) -> None:
|
||||
page.set_default_timeout(30_000)
|
||||
|
||||
# A fresh context is anonymous by construction (no login).
|
||||
requests: list[str] = []
|
||||
page.on("request", lambda r: requests.append(r.url))
|
||||
page.goto(app_url + "/history.html")
|
||||
# Settled anonymous state (the whoami round-trip has landed)…
|
||||
expect(page.locator("#history-gate")).to_be_visible(timeout=15_000)
|
||||
# …the table is wrapped away AND the pager stays hidden (the
|
||||
# markup-shipped state — the anonymous branch never touches
|
||||
# it, D5 — no dead control beside the gate).
|
||||
expect(page.locator("#history-table-wrap")).to_be_hidden()
|
||||
expect(page.locator("#history-pager")).to_be_hidden()
|
||||
# …and the view NEVER calls /api/chats (the router 403s
|
||||
# anonymous) — a short settle wait so the claim is made on a
|
||||
# settled page (the house log-pin idiom).
|
||||
page.wait_for_timeout(500)
|
||||
assert not any("/api/chats" in u for u in requests), (
|
||||
f"the anonymous History page must not fetch /api/chats: {requests}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 8. ≤640px (D6): the pager row stacks, the buttons go full width
|
||||
# (the .history-refresh mobile idiom), the table still scrolls
|
||||
# inside its card
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_pager_mobile_375px(
|
||||
page: Page, app_url: str, clean_saved_chats: None
|
||||
) -> None:
|
||||
page.set_default_timeout(30_000)
|
||||
page.set_viewport_size({"width": 375, "height": 720})
|
||||
ids = _make_chats(app_url, 30)
|
||||
_settle_admin_history(page, app_url, 25)
|
||||
try:
|
||||
expect(page.locator("#history-pager")).to_be_visible()
|
||||
pager = page.locator("#history-pager").bounding_box()
|
||||
assert pager is not None, "the pager must render at 375px"
|
||||
# The D6 block: the row STACKS (the .settings-actions idiom)
|
||||
# and the buttons go FULL WIDTH (the .history-refresh mobile
|
||||
# idiom) — each button's width is the pager's (±1px
|
||||
# subpixel; the pills are `width: 100%` of the stacked row).
|
||||
direction = page.evaluate(
|
||||
"() => getComputedStyle("
|
||||
"document.getElementById('history-pager')).flexDirection"
|
||||
)
|
||||
assert direction == "column", (
|
||||
f"the pager row must stack at 375px (got {direction!r})"
|
||||
)
|
||||
for sel in ("#history-pager-prev", "#history-pager-next"):
|
||||
box = page.locator(sel).bounding_box()
|
||||
assert box is not None, f"{sel} must render at 375px"
|
||||
assert abs(box["width"] - pager["width"]) <= 1, (
|
||||
f"{sel} must be full width: {box['width']:.1f}px vs the "
|
||||
f"pager's {pager['width']:.1f}px"
|
||||
)
|
||||
assert pager["x"] + pager["width"] <= 375 + 1, (
|
||||
f"the pager's right edge ({pager['x'] + pager['width']:.1f}) "
|
||||
f"reaches/past the 375px viewport"
|
||||
)
|
||||
# No page-level horizontal scroll (the table's own overflow-x
|
||||
# scroll is the mobile scroll)…
|
||||
scroll_width = page.evaluate(
|
||||
"() => document.scrollingElement.scrollWidth"
|
||||
)
|
||||
assert scroll_width <= 375, (
|
||||
f"page-level horizontal scroll at 375px "
|
||||
f"(scrollWidth {scroll_width})"
|
||||
)
|
||||
# …and the 640px table still scrolls INSIDE its card (the
|
||||
# phase-86 contract the D6 block must not break).
|
||||
report = page.evaluate(
|
||||
"() => { const el = document.getElementById("
|
||||
"'history-table-wrap'); "
|
||||
"return { scroll: el.scrollWidth, client: el.clientWidth }; }"
|
||||
)
|
||||
assert report["scroll"] > report["client"], (
|
||||
f"the 640px table must scroll inside its card at 375px "
|
||||
f"(scrollWidth {report['scroll']} <= clientWidth "
|
||||
f"{report['client']})"
|
||||
)
|
||||
finally:
|
||||
_delete_all(app_url, _admin_cookies(page), ids)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 9. UI Structure Check (AGENTS.md rule 5) + no CDN (rule 6)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_pager_a11y_and_no_cdn_basics(
|
||||
page: Page, app_url: str, clean_saved_chats: None
|
||||
) -> None:
|
||||
page.set_default_timeout(30_000)
|
||||
_make_chats(app_url, 30)
|
||||
_settle_admin_history(page, app_url, 25)
|
||||
expect(page.locator("#history-pager")).to_be_visible()
|
||||
|
||||
# Standard app frame (PLAN §7.2).
|
||||
expect(page.locator("header.app-header")).to_have_count(1)
|
||||
expect(page.locator('nav[aria-label="Primary"]')).to_have_count(1)
|
||||
expect(page.locator("main#main")).to_have_count(1)
|
||||
expect(page.locator("footer.app-footer")).to_have_count(1)
|
||||
expect(page.locator(".skip-link")).to_have_count(1)
|
||||
|
||||
# The pager buttons' accessible names (the aria-labels — what
|
||||
# the accessibility tree exposes; the visible "Prev" / "Next"
|
||||
# labels stay in both) and the decorative SVGs (aria-hidden).
|
||||
assert page.locator("#history-pager-prev").get_attribute("aria-label") == (
|
||||
"Previous page of saved chats"
|
||||
), "the Prev button's accessible name"
|
||||
assert page.locator("#history-pager-next").get_attribute("aria-label") == (
|
||||
"Next page of saved chats"
|
||||
), "the Next button's accessible name"
|
||||
assert (
|
||||
page.locator("#history-pager-prev .history-pager-icon")
|
||||
.get_attribute("aria-hidden")
|
||||
== "true"
|
||||
), "the Prev chevron is decorative (aria-hidden)"
|
||||
assert (
|
||||
page.locator("#history-pager-next .history-pager-icon")
|
||||
.get_attribute("aria-hidden")
|
||||
== "true"
|
||||
), "the Next chevron is decorative (aria-hidden)"
|
||||
# The live region that announces the page lines (the
|
||||
# never-stale contract).
|
||||
assert page.locator("#history-status").get_attribute("role") == "status"
|
||||
assert (
|
||||
page.locator("#history-status").get_attribute("aria-live")
|
||||
== "polite"
|
||||
), "#history-status must be a polite live region"
|
||||
|
||||
# The pager button reaches the 3px :focus-visible outline via a
|
||||
# REAL keyboard Tab walk (the phase-171 walk shape: the skip
|
||||
# link is focused first — the document's FIRST tabbable
|
||||
# element; every move after it is a real Tab keypress). The
|
||||
# walk crosses the header, the Refresh pill, the table region
|
||||
# (tabindex=0) and the 25 row's controls (title link + share
|
||||
# Create link + Delete — 3 tabbables per row) before it lands
|
||||
# on the pager's Next (Prev is `disabled` on page 1 — not
|
||||
# tabbable).
|
||||
page.evaluate("() => document.querySelector('a.skip-link').focus()")
|
||||
seen: list[str] = []
|
||||
reached = False
|
||||
for _ in range(150):
|
||||
page.keyboard.press("Tab")
|
||||
active = page.evaluate(
|
||||
"() => (document.activeElement && document.activeElement.id)"
|
||||
" || ''"
|
||||
)
|
||||
seen.append(active)
|
||||
if active == "history-pager-next":
|
||||
reached = True
|
||||
break
|
||||
assert reached, (
|
||||
f"the walk never reached the pager's Next control "
|
||||
f"(seen={seen[-12:]})"
|
||||
)
|
||||
outline = page.evaluate(
|
||||
"() => getComputedStyle(document.activeElement).outlineWidth"
|
||||
)
|
||||
assert outline == "3px", (
|
||||
f"focus-visible outline missing on the pager button: {outline!r}"
|
||||
)
|
||||
|
||||
# Contrast (the existing tokens — the phase-08 AA palette,
|
||||
# computed-style spot check): the "Page N of M" status text on
|
||||
# the page bg (the inherited text color), and the button ink on
|
||||
# the brand pill.
|
||||
pairs = page.evaluate(
|
||||
"""() => {
|
||||
const cs = (sel, prop) =>
|
||||
getComputedStyle(document.querySelector(sel))[prop];
|
||||
const root = getComputedStyle(document.documentElement);
|
||||
const body = getComputedStyle(document.body);
|
||||
const bg =
|
||||
root.backgroundColor !== "rgba(0, 0, 0, 0)"
|
||||
? root.backgroundColor
|
||||
: (body.backgroundColor !== "rgba(0, 0, 0, 0)"
|
||||
? body.backgroundColor
|
||||
: "rgb(0, 0, 0)");
|
||||
return {
|
||||
status: [cs("#history-pager-status", "color"), bg],
|
||||
next_btn: [
|
||||
cs("#history-pager-next", "color"),
|
||||
cs("#history-pager-next", "backgroundColor"),
|
||||
],
|
||||
};
|
||||
}"""
|
||||
)
|
||||
_assert_aa(pairs["status"], "pager status text on the page bg")
|
||||
_assert_aa(pairs["next_btn"], "pager button ink on brand")
|
||||
|
||||
# No CDN (rule 6): no https:// asset tags; every script/link
|
||||
# ref is same-origin or a data: URI (the house check).
|
||||
html = page.content()
|
||||
assert 'src="https://' not in html and 'href="https://' not in html
|
||||
refs = page.evaluate(
|
||||
"""() => [...document.querySelectorAll("script[src], link[href]")]
|
||||
.map((el) => el.src || el.href)"""
|
||||
)
|
||||
assert refs, "expected local asset references"
|
||||
for ref in refs:
|
||||
assert ref.startswith(app_url) or ref.startswith("data:"), (
|
||||
f"non-local asset reference: {ref}"
|
||||
)
|
||||
@@ -9,7 +9,11 @@ surface (list / detail / delete / unshare) stays admin-only (guests get
|
||||
403 on exactly those four routes). The admin CRUD pins stay green
|
||||
unchanged: the auto-title convention (first
|
||||
user message, whitespace-collapsed, 120-char cap + the no-user-message
|
||||
fallback), the list order (``updated_at desc, id desc``), the
|
||||
fallback), the list order (``updated_at desc, id desc``; phase 175
|
||||
task 01: server-side pagination — ``limit`` (default 25, clamped
|
||||
1–100) / ``offset`` + the full ``total`` on every response; out-of-
|
||||
range params 422; an offset beyond the end → an empty list with the
|
||||
honest ``total``), the
|
||||
full-payload round-trip (a ``bor.chat.v1``-shaped brain record carrying
|
||||
``sources``/``thinking``/``tools``/``stopped``/``failed``/``error``
|
||||
survives losslessly),
|
||||
@@ -244,7 +248,7 @@ def _expect(records: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
|
||||
|
||||
def _assert_no_chats(admin_client: TestClient) -> None:
|
||||
assert admin_client.get("/api/chats").json() == {"chats": []}
|
||||
assert admin_client.get("/api/chats").json() == {"chats": [], "total": 0}
|
||||
|
||||
|
||||
# ---------- guest (no session): the public write surface (phase 55,
|
||||
@@ -518,7 +522,7 @@ def test_create_rejects_title_over_500(admin_client: TestClient) -> None:
|
||||
def test_list_empty(admin_client: TestClient) -> None:
|
||||
r = admin_client.get("/api/chats")
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"chats": []}
|
||||
assert r.json() == {"chats": [], "total": 0}
|
||||
|
||||
|
||||
def test_list_orders_by_updated_at_desc(admin_client: TestClient, db: Session) -> None:
|
||||
@@ -560,6 +564,111 @@ def test_list_reports_message_count(admin_client: TestClient) -> None:
|
||||
assert [c["message_count"] for c in body["chats"]] == [2]
|
||||
|
||||
|
||||
# ---------- list — pagination (phase 175, task 01) ----------
|
||||
|
||||
|
||||
def _seed_staggered_chats(db: Session, n: int) -> list[str]:
|
||||
"""``n`` saved chats with distinctive titles and DISTINCT,
|
||||
staggered ``updated_at`` (the
|
||||
``test_list_orders_by_updated_at_desc`` idiom, scaled up): the
|
||||
ordered list is fully determined by ``updated_at desc`` (the
|
||||
uuid4 id tie-break never fires). Returns the titles in ORDERED
|
||||
order (newest first); ``clean_chats`` truncates around the test."""
|
||||
base = datetime.now(UTC)
|
||||
db.add_all(
|
||||
[
|
||||
SavedChat(
|
||||
title=f"page175 row {i:02d}",
|
||||
messages=[_user(f"question {i}")],
|
||||
updated_at=base + timedelta(hours=i), # i=0 oldest
|
||||
)
|
||||
for i in range(n)
|
||||
]
|
||||
)
|
||||
db.commit()
|
||||
return [f"page175 row {i:02d}" for i in range(n - 1, -1, -1)]
|
||||
|
||||
|
||||
def _ordered_chat_ids(db: Session) -> list[str]:
|
||||
"""The full ordered id sequence (``updated_at desc, id desc``)
|
||||
via raw SQL — the ground truth a page concatenation is checked
|
||||
against (bypassing the API itself)."""
|
||||
return [
|
||||
str(r[0])
|
||||
for r in db.execute(
|
||||
text(
|
||||
"SELECT id FROM saved_chats ORDER BY updated_at DESC, "
|
||||
"id DESC"
|
||||
)
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def test_list_default_page_is_25_and_reports_total(
|
||||
admin_client: TestClient, db: Session
|
||||
) -> None:
|
||||
ordered = _seed_staggered_chats(db, 30)
|
||||
r = admin_client.get("/api/chats")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert set(body) == {"chats", "total"}
|
||||
assert body["total"] == 30 # the FULL count, not the page length
|
||||
assert len(body["chats"]) == 25
|
||||
# The page is the FIRST 25 of the full ordered list
|
||||
# (``updated_at desc`` — the staggered fixture pins the order).
|
||||
assert [c["title"] for c in body["chats"]] == ordered[:25]
|
||||
for c in body["chats"]:
|
||||
assert set(c) == ROW_KEYS
|
||||
uuid.UUID(c["id"])
|
||||
|
||||
|
||||
def test_list_limit_offset_slice(admin_client: TestClient, db: Session) -> None:
|
||||
ordered = _seed_staggered_chats(db, 30)
|
||||
r = admin_client.get("/api/chats", params={"limit": 10, "offset": 20})
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["total"] == 30
|
||||
# Exactly the LAST 10 of the ordered list (the fixture's ten
|
||||
# oldest), pinned by title.
|
||||
assert [c["title"] for c in body["chats"]] == ordered[20:]
|
||||
|
||||
|
||||
def test_list_order_preserved_across_pages(
|
||||
admin_client: TestClient, db: Session
|
||||
) -> None:
|
||||
_seed_staggered_chats(db, 30)
|
||||
pages: list[dict[str, Any]] = []
|
||||
for offset in (0, 10, 20):
|
||||
body = admin_client.get(
|
||||
"/api/chats", params={"limit": 10, "offset": offset}
|
||||
).json()
|
||||
assert body["total"] == 30
|
||||
pages.extend(body["chats"])
|
||||
# The three pages concatenated == the full ordered id sequence
|
||||
# (the raw-SQL ground truth) — no duplicates, no gaps.
|
||||
assert [c["id"] for c in pages] == _ordered_chat_ids(db)
|
||||
assert len({c["id"] for c in pages}) == 30
|
||||
|
||||
|
||||
def test_list_rejects_out_of_range_params(admin_client: TestClient) -> None:
|
||||
# The house fixed-422 contract (FastAPI field-message validation
|
||||
# — no hand-rolled checks): one request per case, status
|
||||
# asserted.
|
||||
for params in ({"limit": 0}, {"limit": 101}, {"offset": -1}):
|
||||
assert admin_client.get("/api/chats", params=params).status_code == 422
|
||||
|
||||
|
||||
def test_list_offset_beyond_total_returns_empty_with_total(
|
||||
admin_client: TestClient, db: Session
|
||||
) -> None:
|
||||
_seed_staggered_chats(db, 30)
|
||||
r = admin_client.get("/api/chats", params={"offset": 100})
|
||||
assert r.status_code == 200
|
||||
# An honest count, not an error: the list is empty but ``total``
|
||||
# still reports the fixture's 30 rows.
|
||||
assert r.json() == {"chats": [], "total": 30}
|
||||
|
||||
|
||||
# ---------- get ----------
|
||||
|
||||
|
||||
@@ -708,7 +817,7 @@ def test_delete_returns_204_and_removes(admin_client: TestClient, db: Session) -
|
||||
|
||||
assert admin_client.delete(f"/api/chats/{created['id']}").status_code == 204
|
||||
assert admin_client.get(f"/api/chats/{created['id']}").status_code == 404
|
||||
assert admin_client.get("/api/chats").json() == {"chats": []}
|
||||
assert admin_client.get("/api/chats").json() == {"chats": [], "total": 0}
|
||||
assert db.scalars(select(SavedChat)).all() == []
|
||||
|
||||
|
||||
|
||||
@@ -516,11 +516,12 @@ def test_active_view_reclick_dispatches_refresh_not_bare_return() -> None:
|
||||
def test_history_view_listens_for_view_refresh_in_admin_branch_only() -> None:
|
||||
"""Phase 77: the History view re-fetches on a user-initiated
|
||||
re-show — history.js registers a ``bor:view-refresh`` listener on
|
||||
the view's root that re-runs the (now re-entrant) ``loadChats()``.
|
||||
The listener is armed only AFTER the whoami gate passes: anonymous
|
||||
shows the gate and never fetches (the phase-50 contract the story
|
||||
E2E pins), and the ``started`` flag means the listener can only
|
||||
re-run a load the mount already made."""
|
||||
the view's root that re-runs the (now re-entrant) load. Phase 175:
|
||||
the re-show re-fetches the CURRENT page — ``loadChats(offset)``,
|
||||
never 0 (A5). The listener is armed only AFTER the whoami gate
|
||||
passes: anonymous shows the gate and never fetches (the phase-50
|
||||
contract the story E2E pins), and the ``started`` flag means the
|
||||
listener can only re-run a load the mount already made."""
|
||||
history_js = (ASSETS / "history.js").read_text(encoding="utf-8")
|
||||
assert 'addEventListener("bor:view-refresh"' in history_js, (
|
||||
"history.js must listen for the refresh event on the view root"
|
||||
@@ -530,20 +531,21 @@ def test_history_view_listens_for_view_refresh_in_admin_branch_only() -> None:
|
||||
assert 0 <= gate < listener, (
|
||||
"the listener is armed only in the ADMIN branch (after the gate)"
|
||||
)
|
||||
assert re.search(r"if \(started\)\s+loadChats\(\)", history_js), (
|
||||
"the listener is gated on the first load (started)"
|
||||
assert re.search(r"if \(started\)\s+loadChats\(offset\)", history_js), (
|
||||
"the listener is gated on the first load (started) — and "
|
||||
"re-fetches the CURRENT page (phase 175, A5: never resets to 0)"
|
||||
)
|
||||
# Re-entrancy: a re-load drops the data rows (except the hidden
|
||||
# empty-state row) before fetching — the list is replaced, not
|
||||
# duplicated.
|
||||
load = history_js.find("async function loadChats()")
|
||||
load = history_js.find("async function loadChats(pageOffset)")
|
||||
assert load != -1, "loadChats must exist"
|
||||
load_body = history_js[load : history_js.find("\n }", load)]
|
||||
assert "tr !== emptyRow" in load_body and "tr.remove()" in load_body, (
|
||||
"loadChats must remove the data rows (the empty row stays) first"
|
||||
)
|
||||
clear_i = load_body.find("tr !== emptyRow")
|
||||
fetch_i = load_body.find('fetch("/api/chats")')
|
||||
fetch_i = load_body.find('fetch("/api/chats?limit="')
|
||||
assert 0 <= clear_i < fetch_i, "the row clearing precedes the fetch"
|
||||
|
||||
|
||||
@@ -763,12 +765,15 @@ def test_history_refresh_button_binding_admin_only_with_outcome_lines() -> None:
|
||||
fn = js.find("async function refreshChats()")
|
||||
assert fn != -1, "refreshChats must exist"
|
||||
fn_body = js[fn:js.find("\n }", fn)]
|
||||
assert "loadChats()" in fn_body, "the button re-runs the (re-entrant) load"
|
||||
assert "loadChats(offset)" in fn_body, (
|
||||
"the button re-runs the (re-entrant) load — the CURRENT page "
|
||||
"(phase 175, A5: a page-2 refresh never resets to page 1)"
|
||||
)
|
||||
assert "finally" in fn_body and "refreshBtn.disabled = false" in fn_body, (
|
||||
"the button re-enables on success AND failure (the finally)"
|
||||
)
|
||||
assert '"Saved chats refreshed."' in fn_body, "the exact success line"
|
||||
load = js.find("async function loadChats()")
|
||||
load = js.find("async function loadChats(pageOffset)")
|
||||
load_body = js[load:js.find("\n }", load)]
|
||||
assert "Couldn't load saved chats — is the app reachable?" in load_body, (
|
||||
"the network-error line lives in loadChats (every caller sees it)"
|
||||
@@ -788,20 +793,33 @@ def test_history_refresh_button_css_reuses_the_new_chat_language() -> None:
|
||||
The page-head flex row is SCOPED to #view-history (the other four
|
||||
views' page-heads are untouched). Below 640px the pill goes full
|
||||
width with the label visible (the icon-only squeeze is gone —
|
||||
the glyph joins the label)."""
|
||||
the glyph joins the label). Phase 175: the pager buttons join
|
||||
the rule (the selector union — this pin names the union shape,
|
||||
so the pill language stays pinned on .history-refresh itself)."""
|
||||
css = _asset("styles.css")
|
||||
block = re.search(r"\.history-refresh \{([\s\S]*?)\n\}", css)
|
||||
block = re.search(
|
||||
r"\.history-refresh(?:,\s*\.history-pager-btn)? \{([\s\S]*?)\n\}", css
|
||||
)
|
||||
assert block, "styles.css must style .history-refresh"
|
||||
body = block.group(1)
|
||||
assert "background: var(--brand)" in body, "the .new-chat-btn brand fill"
|
||||
assert "color: var(--bg)" in body, "--bg text on --brand (5.2:1, AA)"
|
||||
assert "min-height: 44px" in body, "the comfortable touch target"
|
||||
assert "border-radius: 999px" in body and "border: 0" in body, "the pill"
|
||||
assert ".history-refresh:hover { background: var(--brand-hover); color: var(--bg); }" in css
|
||||
assert ".history-refresh:disabled { opacity: 0.6; cursor: wait; }" in css, (
|
||||
assert (
|
||||
".history-refresh:hover, .history-pager-btn:hover {"
|
||||
" background: var(--brand-hover); color: var(--bg); }"
|
||||
) in css
|
||||
assert (
|
||||
".history-refresh:disabled, .history-pager-btn:disabled {"
|
||||
" opacity: 0.6; cursor: wait; }"
|
||||
) in css, (
|
||||
"the in-flight disabled state is dimmed (the house language)"
|
||||
)
|
||||
assert ".history-refresh svg { width: 16px; height: 16px; display: none; }" in css, (
|
||||
assert (
|
||||
".history-refresh svg, .history-pager-btn svg {"
|
||||
" width: 16px; height: 16px; display: none; }"
|
||||
) in css, (
|
||||
"desktop: the label carries the pill (the glyph is hidden)"
|
||||
)
|
||||
row = re.search(r"#view-history \.page-head \{([\s\S]*?)\n\}", css)
|
||||
@@ -811,10 +829,10 @@ def test_history_refresh_button_css_reuses_the_new_chat_language() -> None:
|
||||
mobile = re.search(r"@media \(max-width: 640px\) \{([\s\S]*?)\n\}\n", css)
|
||||
assert mobile, "the 640px media query must exist"
|
||||
mbody = mobile.group(1)
|
||||
assert ".history-refresh { width: 100%; }" in mbody, (
|
||||
assert ".history-refresh, .history-pager-btn { width: 100%; }" in mbody, (
|
||||
"full width below 640px (the icon-only squeeze is gone)"
|
||||
)
|
||||
assert ".history-refresh svg { display: block; }" in mbody, (
|
||||
assert ".history-refresh svg, .history-pager-btn svg { display: block; }" in mbody, (
|
||||
"the glyph joins the visible label below 640px"
|
||||
)
|
||||
|
||||
|
||||
@@ -325,14 +325,17 @@ def test_anonymous_boot_makes_no_chats_request() -> None:
|
||||
zero extra), hides the table, shows the gate, and RETURNS — no
|
||||
``/api/chats`` request on the wire (the router 403s anonymous; the
|
||||
story E2E pins the request log). Only the admin path reaches
|
||||
``loadChats()``. The single ``fetch("/api/chats")`` in the file
|
||||
lives in loadChats."""
|
||||
``loadChats(pageOffset)``. The single list fetch in the file —
|
||||
phase 175's parameterized form (``fetch("/api/chats?limit=" +
|
||||
PAGE_SIZE + "&offset=" + pageOffset)``) — lives in loadChats."""
|
||||
js = _js()
|
||||
assert js.count('fetch("/api/chats")') == 1, (
|
||||
assert js.count('fetch("/api/chats?limit="') == 1, (
|
||||
"exactly ONE list fetch — the anonymous path must never add one"
|
||||
)
|
||||
load = _fn(js, "loadChats")
|
||||
assert 'fetch("/api/chats")' in load, "the list fetch lives in loadChats"
|
||||
assert (
|
||||
'fetch("/api/chats?limit=" + PAGE_SIZE + "&offset=" + pageOffset)' in load
|
||||
), "the parameterized list fetch lives in loadChats (phase 175)"
|
||||
|
||||
# Phase 76 (task 03): the view module never boots the shell's
|
||||
# header — no CALL to the header boot (a docstring may name it;
|
||||
@@ -359,10 +362,11 @@ def test_anonymous_boot_makes_no_chats_request() -> None:
|
||||
assert "fetch(" not in branch, "the anonymous branch must not fetch anything"
|
||||
assert "tableWrap.hidden = true" in branch
|
||||
assert "gateEl.hidden = false" in branch
|
||||
# The admin path: the gate hides, then the list loads.
|
||||
# The admin path: the gate hides, then the list loads on page 1
|
||||
# (phase 175: the view opens on page 1 — offset 0).
|
||||
after = js[js.find("return;", gate_i):]
|
||||
assert "gateEl.hidden = true" in after
|
||||
assert "loadChats();" in after
|
||||
assert "loadChats(0);" in after
|
||||
|
||||
|
||||
def test_admin_load_renders_rows_or_empty_state() -> None:
|
||||
|
||||
@@ -0,0 +1,593 @@
|
||||
"""Unit: the History page's list pager (phase 175, task 02).
|
||||
|
||||
TODO.md L3 (owner 2026-10-10): "Need pagination on the history
|
||||
page - it gets long fast" — the saved-chat list is now paginated
|
||||
SERVER-side (task 01: ``GET /api/chats`` takes ``?limit=`` /
|
||||
``?offset=`` and reports the full ``total`` on every response), and
|
||||
this module pins the view's pager — the ``.history-pager`` control
|
||||
under the table card: Prev / "Page N of M" / Next, reusing the
|
||||
``.history-refresh`` pill language (D3: NO new palette variable, B3;
|
||||
no new spacing token) and walking the pages through the re-entrant
|
||||
load (D4: refresh-keeps-current-page, A5; the anonymous gate
|
||||
untouched, D5; the ≤640px full-width block, D6).
|
||||
|
||||
This module pins the source-level contract a silent regression would
|
||||
break; the browser behavior (the 30-row page walk, the status lines,
|
||||
the anonymous request log, the mobile pin) is E2E-gated by the
|
||||
phase-175 story suite (``tests/e2e/test_history_pagination.py``,
|
||||
task 03).
|
||||
|
||||
Pinned:
|
||||
|
||||
* ``index.html``: ``#history-pager`` sits in ``#view-history`` and
|
||||
AFTER ``#history-table-wrap`` (the pager is under the table card,
|
||||
not in the page-head — a direct child of the ``.history-shell``
|
||||
container, following the table card), both buttons carry their
|
||||
exact ``aria-label``s, both inline-SVG chevrons carry
|
||||
``aria-hidden="true"``, the visible "Prev" / "Next" labels ship,
|
||||
and the div ships ``hidden`` (the hidden-when-single-page default
|
||||
the JS flips);
|
||||
* ``styles.css``: the ``.history-pager`` rule carries
|
||||
``display: flex`` + ``gap: 0.75rem`` (the page's OWN 0.75rem —
|
||||
no new spacing token); ``.history-pager-btn`` is covered by the
|
||||
pill / ``:disabled`` / svg-sizing declarations (the
|
||||
``.history-refresh`` pill language — pinned by PRESENCE in the
|
||||
declarations, not by a particular CSS authoring choice); the
|
||||
≤640px block takes the pager buttons full width (the
|
||||
``.history-refresh`` mobile idiom) and stacks the row (the
|
||||
``.settings-actions`` idiom); and NO new token: every ``var(--x)``
|
||||
used in any pager rule is already declared in the file's ``:root``
|
||||
block (the B3 pin);
|
||||
* ``history.js``: the parameterized fetch URL (the manual
|
||||
``/api/chats?limit=…&offset=…`` join — the file's house style),
|
||||
the ``total`` read from the JSON body, ``PAGE_SIZE = 25`` (the
|
||||
server's default — A1, no size picker in the UI; the E2E suite
|
||||
catches a drift), the hidden-when-single-page branch
|
||||
(``total <= PAGE_SIZE``), the two ``disabled`` edge lines, the
|
||||
``Showing <first>–<last> of <total>.`` status-line format (the
|
||||
page-change line AND the first-load line — the settled load
|
||||
announces its own page while the pager is visible, task 03), the
|
||||
refresh path passing the current ``offset`` (A5 — a page-2
|
||||
refresh never resets to page 1), the local Delete leaving the
|
||||
pager and the offset alone (D4 — the next load corrects it), the
|
||||
pager lookups scoped to ``root``, and the D5 pin: the anonymous
|
||||
branch never touches the pager (it keeps its markup-shipped
|
||||
hidden state and never fetches).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
|
||||
SHELL_HTML = FRONTEND / "index.html"
|
||||
STYLES_CSS = FRONTEND / "assets" / "styles.css"
|
||||
HISTORY_JS = FRONTEND / "assets" / "history.js"
|
||||
|
||||
|
||||
def _read(path: Path) -> str:
|
||||
return path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _strip_comments(text: str, kind: str) -> str:
|
||||
"""The text without its comments — a comment's tag-like / rule-like
|
||||
prose (an ``<!-- … <id> … -->`` in the markup, a ``/* … */``
|
||||
between CSS rules) must not leak into the tag / selector walk."""
|
||||
if kind == "html":
|
||||
return re.sub(r"<!--.*?-->", "", text, flags=re.S)
|
||||
return re.sub(r"/\*.*?\*/", "", text, flags=re.S)
|
||||
|
||||
|
||||
def _tag_span(html: str, open_tag: str, tag: str) -> str:
|
||||
"""The substring from an opening <tag …> to its matching </tag>
|
||||
(balanced tag counting — the test_git_sources_card_rhythm.py
|
||||
brace-counting helper, applied to a named tag; comments are
|
||||
stripped first so a comment's tag-like prose can't unbalance the
|
||||
count)."""
|
||||
html = _strip_comments(html, "html")
|
||||
start = html.index(open_tag)
|
||||
depth = 0
|
||||
for m in re.finditer(rf"<{tag}\b|</{tag}>", html[start:]):
|
||||
if m.group(0) == f"</{tag}>":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return html[start : start + m.end()]
|
||||
else:
|
||||
depth += 1
|
||||
raise AssertionError(f"unbalanced {tag}s from {open_tag!r}")
|
||||
|
||||
|
||||
def _direct_children_tags(html: str) -> list[str]:
|
||||
"""The element tags that open at DEPTH 1 (direct children) of the
|
||||
substring (the leading <div> is depth 0 — the counted element).
|
||||
Void elements (input/br/img/… — this markup uses non-self-closing
|
||||
<input …>) change no depth."""
|
||||
VOID = {"input", "br", "img", "hr", "meta", "link", "source", "wbr"}
|
||||
html = _strip_comments(html, "html")
|
||||
out: list[str] = []
|
||||
depth = 1 # we are inside the counted div
|
||||
for m in re.finditer(r"</?[a-zA-Z][^>]*?>", html):
|
||||
tag = m.group(0)
|
||||
mname = re.match(r"</?([a-zA-Z]+)", tag)
|
||||
name = mname.group(1).lower() if mname else ""
|
||||
if tag.startswith("</"):
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
break
|
||||
elif name not in VOID and not tag.endswith("/>"):
|
||||
if depth == 1:
|
||||
out.append(tag)
|
||||
depth += 1
|
||||
return out
|
||||
|
||||
|
||||
def _css_rules(css: str) -> list[tuple[str, str]]:
|
||||
"""Every (selector-list, body) pair in the file — top-level AND
|
||||
inside @media blocks alike (the small-regex house style, with a
|
||||
single brace-walk so the ≤640px block's inner rules are pinned
|
||||
too). Selector lists and bodies are whitespace-normalized."""
|
||||
out: list[tuple[str, str]] = []
|
||||
|
||||
def walk(text: str) -> None:
|
||||
i = 0
|
||||
n = len(text)
|
||||
while i < n:
|
||||
brace = text.find("{", i)
|
||||
if brace == -1:
|
||||
return
|
||||
# the selector list is the slice's NON-comment text (a
|
||||
# /* … */ comment between rules must not leak into it —
|
||||
# it would mask the @media at-rule and pollute the
|
||||
# selector):
|
||||
selector = " ".join(
|
||||
re.sub(r"/\*.*?\*/", " ", text[i:brace], flags=re.S).split()
|
||||
)
|
||||
depth = 1
|
||||
j = brace + 1
|
||||
while j < n and depth:
|
||||
if text[j] == "{":
|
||||
depth += 1
|
||||
elif text[j] == "}":
|
||||
depth -= 1
|
||||
j += 1
|
||||
body = re.sub(r"/\*.*?\*/", " ", text[brace + 1 : j - 1], flags=re.S)
|
||||
if selector.startswith("@media"):
|
||||
walk(body)
|
||||
elif selector:
|
||||
out.append((selector, " ".join(body.split())))
|
||||
i = j
|
||||
|
||||
walk(css)
|
||||
return out
|
||||
|
||||
|
||||
def _root_block(css: str) -> str:
|
||||
"""The file's :root block (balanced-brace slice)."""
|
||||
m = re.search(r"^:root\s*\{", css, re.M)
|
||||
assert m is not None, "the :root block must exist"
|
||||
start = m.end() - 1
|
||||
depth = 0
|
||||
for i in range(start, len(css)):
|
||||
if css[i] == "{":
|
||||
depth += 1
|
||||
elif css[i] == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return css[start : i + 1]
|
||||
raise AssertionError("unbalanced :root block")
|
||||
|
||||
|
||||
def _js() -> str:
|
||||
return _read(HISTORY_JS)
|
||||
|
||||
|
||||
def _fn(js: str, name: str) -> str:
|
||||
"""The source of the first ``function <name>`` in history.js
|
||||
(brace-balanced — the test_history_page.py house helper; since
|
||||
phase 76 task 03 the functions live inside ``mount(root)``)."""
|
||||
fn = js.find(f"function {name}")
|
||||
assert fn != -1, f"{name} must be defined in history.js"
|
||||
open_idx = js.find("{", fn)
|
||||
depth = 0
|
||||
for i in range(open_idx, len(js)):
|
||||
c = js[i]
|
||||
if c == "{":
|
||||
depth += 1
|
||||
elif c == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return js[fn : i + 1]
|
||||
raise AssertionError(f"unbalanced braces in {name}")
|
||||
|
||||
|
||||
# ---------- index.html: the pager's structure ----------
|
||||
|
||||
|
||||
def test_pager_div_sits_below_the_table_card_in_the_view() -> None:
|
||||
"""D3 (the structural pin): #history-pager sits in #view-history
|
||||
and AFTER #history-table-wrap — a direct child of the
|
||||
.history-shell container, following the table card (the pager is
|
||||
under the table, not in the page-head). If a future refactor
|
||||
moves the pager to the page-head, this pin names it."""
|
||||
html = _read(SHELL_HTML)
|
||||
view = _tag_span(
|
||||
html, '<section class="view" id="view-history"', "section"
|
||||
)
|
||||
wrap_open = (
|
||||
'<div class="table-wrap history-table-wrap" id="history-table-wrap"'
|
||||
)
|
||||
assert wrap_open in view, "the table card must be in the History view"
|
||||
pager_open = '<div class="history-pager" id="history-pager"'
|
||||
assert pager_open in view, "the pager must be in the History view"
|
||||
assert view.index(pager_open) > view.index(wrap_open), (
|
||||
"the pager must sit AFTER the table card (under it, not in "
|
||||
"the page-head)"
|
||||
)
|
||||
# A direct child of the .history-shell container, after the wrap:
|
||||
shell_open = '<div class="container history-shell">'
|
||||
assert shell_open in view, "the .history-shell container must be in the view"
|
||||
shell = _tag_span(view, shell_open, "div")[len(shell_open):]
|
||||
children = _direct_children_tags(shell)
|
||||
pagers = [t for t in children if 'id="history-pager"' in t]
|
||||
wraps = [t for t in children if 'id="history-table-wrap"' in t]
|
||||
assert len(pagers) == 1, (
|
||||
f"exactly ONE #history-pager direct child of the shell: {children}"
|
||||
)
|
||||
assert len(wraps) == 1, "exactly ONE #history-table-wrap direct child"
|
||||
assert children.index(pagers[0]) > children.index(wraps[0]), (
|
||||
"the pager must follow the table card among the shell's children"
|
||||
)
|
||||
|
||||
|
||||
def test_pager_buttons_carry_their_aria_labels_and_hidden_svgs() -> None:
|
||||
"""D3: both buttons carry their EXACT aria-labels (the accessible
|
||||
name the mobile squeeze relies on — the label is visible at every
|
||||
width, the aria-label keeps it in both), both inline-SVG
|
||||
chevrons carry aria-hidden="true" (decorative — the accessible
|
||||
name is the label's), and the visible "Prev" / "Next" labels
|
||||
ship (the house inline-SVG glyph + visible label + aria-label)."""
|
||||
html = _read(SHELL_HTML)
|
||||
pager = _tag_span(
|
||||
html, '<div class="history-pager" id="history-pager"', "div"
|
||||
)
|
||||
assert pager.count('aria-label="Previous page of saved chats"') == 1, (
|
||||
"the Prev button's exact aria-label (once)"
|
||||
)
|
||||
assert pager.count('aria-label="Next page of saved chats"') == 1, (
|
||||
"the Next button's exact aria-label (once)"
|
||||
)
|
||||
assert 'class="history-pager-icon"' in pager, "the house inline-SVG glyph"
|
||||
assert pager.count('aria-hidden="true"') == 2, (
|
||||
"both SVG chevrons are aria-hidden (decorative)"
|
||||
)
|
||||
assert re.search(r'<span class="history-pager-label">Prev</span>', pager)
|
||||
assert re.search(r'<span class="history-pager-label">Next</span>', pager)
|
||||
|
||||
|
||||
def test_pager_div_ships_hidden_with_an_empty_status() -> None:
|
||||
"""A4: the div SHIPS hidden — the hidden-when-single-page
|
||||
default; history.js's updatePager() is the only thing that
|
||||
reveals it (and only while total > PAGE_SIZE). A markup without
|
||||
`hidden` would show a dead "Page 1 of 1" control on a one-page
|
||||
list. The status span ships empty (the JS writes "Page N of M")."""
|
||||
html = _read(SHELL_HTML)
|
||||
m = re.search(r'<div [^>]*id="history-pager"[^>]*>', html)
|
||||
assert m is not None, "the #history-pager div must ship in the markup"
|
||||
tag = m.group(0)
|
||||
assert " hidden" in tag or tag.rstrip(">").endswith("hidden"), (
|
||||
f"the pager div must ship hidden: {tag}"
|
||||
)
|
||||
assert (
|
||||
'<span class="history-pager-status" id="history-pager-status"></span>'
|
||||
in html
|
||||
), "the status span ships empty (the JS writes `Page N of M`)"
|
||||
|
||||
|
||||
# ---------- styles.css: the pager's rules ----------
|
||||
|
||||
|
||||
def test_pager_rule_is_the_flex_row_with_the_page_gap() -> None:
|
||||
"""D3: the .history-pager row rule — display: flex,
|
||||
align-items: center, gap: 0.75rem (the page's OWN 0.75rem — the
|
||||
#view-history .page-head row gap; any other gap value is a new
|
||||
spacing token and fails — no new spacing value)."""
|
||||
rules = _css_rules(_read(STYLES_CSS))
|
||||
row = [b for s, b in rules if s == ".history-pager"]
|
||||
assert row, "the top-level .history-pager row rule must exist"
|
||||
body = row[0]
|
||||
assert "display: flex" in body, body
|
||||
assert "align-items: center" in body, body
|
||||
gaps = re.findall(r"gap\s*:\s*([^;]+)", body)
|
||||
assert gaps == ["0.75rem"], f"gap regression (a new spacing token): {gaps}"
|
||||
|
||||
|
||||
def test_pager_buttons_reuse_the_refresh_pill_declarations() -> None:
|
||||
"""D3 (the B3 pin's structural half): .history-pager-btn is
|
||||
covered by the pill / :disabled / svg-sizing declarations — the
|
||||
.history-refresh pill language (pinned by PRESENCE in the
|
||||
declarations, not by a particular CSS authoring choice — the
|
||||
selector union or a duplication both pass)."""
|
||||
rules = _css_rules(_read(STYLES_CSS))
|
||||
# The pill (the desktop .history-refresh pill language — the
|
||||
# brand fill, the ≥44px target, the 999px shape, the borderless
|
||||
# solid pill):
|
||||
pill = [
|
||||
b
|
||||
for s, b in rules
|
||||
if ".history-pager-btn" in s
|
||||
and ":disabled" not in s
|
||||
and "svg" not in s
|
||||
and ":hover" not in s
|
||||
]
|
||||
assert pill, "the pill rule must carry .history-pager-btn"
|
||||
all_pill = " ; ".join(pill)
|
||||
for decl in (
|
||||
"display: inline-flex",
|
||||
"min-height: 44px",
|
||||
"border-radius: 999px",
|
||||
"border: 0",
|
||||
"background: var(--brand)",
|
||||
"color: var(--bg)",
|
||||
):
|
||||
assert decl in all_pill, f"the pill declaration is missing: {decl}"
|
||||
# The :disabled idiom (the in-flight dimming — the Refresh idiom;
|
||||
# the Prev/Next edges use the real `disabled` attribute):
|
||||
dis = [b for s, b in rules if ".history-pager-btn:disabled" in s]
|
||||
assert dis, "the :disabled rule must carry .history-pager-btn:disabled"
|
||||
dis_all = " ; ".join(dis)
|
||||
assert "opacity: 0.6" in dis_all and "cursor: wait" in dis_all, dis_all
|
||||
# The svg sizing (the glyph hidden on desktop — visible in the
|
||||
# ≤640px block; the .history-refresh idiom):
|
||||
svg = [b for s, b in rules if ".history-pager-btn svg" in s]
|
||||
assert svg, "the svg-sizing rule must carry .history-pager-btn svg"
|
||||
assert any("width: 16px" in b and "height: 16px" in b for b in svg), svg
|
||||
|
||||
|
||||
def test_le640_pager_buttons_go_full_width_and_the_row_stacks() -> None:
|
||||
"""D6: the ≤640px block — the pager buttons join the
|
||||
.history-refresh full-width idiom (width: 100% + the svg glyph
|
||||
visible), and the row STACKS (the .settings-actions idiom) so the
|
||||
two full-width pills don't side-by-side overflow the phone width
|
||||
(the pill rule's flex-shrink: 0 would keep each at 100%)."""
|
||||
rules = _css_rules(_read(STYLES_CSS))
|
||||
fw = [b for s, b in rules if ".history-pager-btn" in s and "width: 100%" in b]
|
||||
assert fw, "the ≤640px block must take .history-pager-btn full width"
|
||||
svg = [
|
||||
b
|
||||
for s, b in rules
|
||||
if ".history-pager-btn svg" in s and "display: block" in b
|
||||
]
|
||||
assert svg, (
|
||||
"the ≤640px block must show the pager glyph (the .history-refresh "
|
||||
"svg idiom)"
|
||||
)
|
||||
stack = [
|
||||
b for s, b in rules if s == ".history-pager" and "flex-direction: column" in b
|
||||
]
|
||||
assert stack, (
|
||||
"the ≤640px block must stack the pager row (the full-width "
|
||||
"pills must not side-by-side overflow the phone width)"
|
||||
)
|
||||
|
||||
|
||||
def test_pager_rules_introduce_no_new_tokens() -> None:
|
||||
"""B3: no new palette variable — every var(--x) used in ANY pager
|
||||
rule (the row rule, the pill union, the :disabled / svg rules —
|
||||
desktop AND the ≤640px block) is already declared in the file's
|
||||
:root block. (The row rule itself uses no tokens — the gap is a
|
||||
plain 0.75rem — but the pin covers the union declarations too, so
|
||||
a future `var(--new)` rides no pager change undetected.)"""
|
||||
css = _read(STYLES_CSS)
|
||||
declared = set(re.findall(r"(--[a-z0-9-]+)\s*:", _root_block(css)))
|
||||
used: set[str] = set()
|
||||
for s, b in _css_rules(css):
|
||||
if "history-pager" in s:
|
||||
used.update(re.findall(r"var\(\s*(--[a-z0-9-]+)\s*\)", b))
|
||||
missing = used - declared
|
||||
assert not missing, (
|
||||
f"pager rules use undeclared tokens (a new palette variable, B3): "
|
||||
f"{missing}"
|
||||
)
|
||||
|
||||
|
||||
# ---------- history.js: the pager's load contract ----------
|
||||
|
||||
|
||||
def test_fetch_is_parameterized_with_limit_and_offset() -> None:
|
||||
"""D4: loadChats(pageOffset) fetches the 25-row page at the given
|
||||
offset — the manual string join (the file's house style) — and
|
||||
reads the body's `total` (the server's full count) into the pager
|
||||
(updatePager, on the 200 path). The failed-load branches keep the
|
||||
house error lines + the empty state (a failed load leaves the
|
||||
pager as it was — the previous state is the last known truth)."""
|
||||
js = _js()
|
||||
assert "async function loadChats(pageOffset)" in js, (
|
||||
"the load must be parameterized on the page's offset"
|
||||
)
|
||||
load = _fn(js, "loadChats")
|
||||
assert re.search(
|
||||
r'fetch\("/api/chats\?limit="\s*\+\s*PAGE_SIZE\s*\+\s*"&offset="\s*\+\s*pageOffset\)',
|
||||
load,
|
||||
), "the fetch must be the manual /api/chats?limit=…&offset=… join"
|
||||
assert re.search(r"const \{ chats, total \} = await r\.json\(\);", load), (
|
||||
"the 200 path must read `total` from the JSON body"
|
||||
)
|
||||
assert "updatePager(total)" in load, (
|
||||
"the 200 path must run the pager on the fresh total"
|
||||
)
|
||||
# The failure branches keep the house error lines (unchanged):
|
||||
assert "Couldn't load saved chats — is the app reachable?" in load
|
||||
assert "Couldn't load saved chats — try again." in load
|
||||
assert load.count("showEmptyState()") == 3, (
|
||||
"network failure, non-2xx and a 0-row list all show the empty state"
|
||||
)
|
||||
|
||||
|
||||
def test_settled_load_lands_its_own_showing_line_for_the_visible_pager() -> None:
|
||||
"""D4 (task 03, the first-load half of the never-stale
|
||||
contract): a settled load announces its own page — the mount is
|
||||
fire-and-forget (the house-style ``loadChats(0);`` line), so the
|
||||
announcement lives in ``loadChats`` itself, where the settled
|
||||
``total`` is in hand. It lands ONLY while the pager is visible
|
||||
(total > PAGE_SIZE): a one-page list IS the whole list (A4 — no
|
||||
redundant line) and the 0-row branch shows the empty state
|
||||
(nothing to show)."""
|
||||
js = _js()
|
||||
fn = _fn(js, "loadChats")
|
||||
assert re.search(r"if \(total > PAGE_SIZE\)", fn), (
|
||||
"the first-load line must land only while the pager is "
|
||||
"visible (A4 — no redundant line)"
|
||||
)
|
||||
assert re.search(
|
||||
r'"Showing "\s*\+\s*\(pageOffset \+ 1\)\s*\+\s*"–"', fn
|
||||
), "the line starts `Showing <first>–` (the en-dash, the settled page)"
|
||||
assert re.search(r"Math\.min\(pageOffset \+ PAGE_SIZE, total\)", fn), (
|
||||
"the last row of the page is min(pageOffset + PAGE_SIZE, total)"
|
||||
)
|
||||
assert re.search(r'" of "\s*\+\s*total\s*\+\s*"\."', fn), (
|
||||
"the line ends ` of <total>.`"
|
||||
)
|
||||
|
||||
|
||||
def test_pager_state_is_the_25_row_page_and_the_offset() -> None:
|
||||
"""A1: PAGE_SIZE = 25 matches the server's default page (no size
|
||||
picker in the UI — the E2E suite catches a drift); the module
|
||||
keeps the current page's offset (it starts at 0 — the view opens
|
||||
on page 1)."""
|
||||
js = _js()
|
||||
assert "const PAGE_SIZE = 25;" in js, "PAGE_SIZE must be the 25-row page"
|
||||
assert "let offset = 0;" in js, "the module keeps the current page's offset"
|
||||
|
||||
|
||||
def test_update_pager_hides_when_single_page_and_edges_disable() -> None:
|
||||
"""D3/A4: updatePager — `total <= PAGE_SIZE` → the pager is
|
||||
hidden (no dead "Page 1 of 1" control; it disappears with the
|
||||
empty state); otherwise it shows, the status reads `Page N of M`,
|
||||
Prev is disabled on page 1, Next on the last page (the real
|
||||
`disabled` attribute — the Refresh idiom; the edges make the
|
||||
click clamping trivial)."""
|
||||
js = _js()
|
||||
fn = _fn(js, "updatePager")
|
||||
assert "Math.ceil(total / PAGE_SIZE)" in fn, "the page count is the ceil"
|
||||
assert "total <= PAGE_SIZE" in fn, "the hidden-when-single-page branch"
|
||||
assert "pagerEl.hidden = true" in fn, "the branch hides the pager"
|
||||
assert "pagerEl.hidden = false" in fn, "the multi-page state shows it"
|
||||
assert re.search(
|
||||
r'"Page "\s*\+\s*\(offset / PAGE_SIZE \+ 1\)\s*\+\s*" of "\s*\+\s*pages',
|
||||
fn,
|
||||
), "the status reads `Page N of M`"
|
||||
# The two disabled edge lines:
|
||||
assert "pagerPrevBtn.disabled = offset === 0" in fn, (
|
||||
"Prev is disabled on page 1"
|
||||
)
|
||||
assert "pagerNextBtn.disabled = offset + PAGE_SIZE >= total" in fn, (
|
||||
"Next is disabled on the last page"
|
||||
)
|
||||
|
||||
|
||||
def test_page_change_lands_the_showing_line_in_the_live_region() -> None:
|
||||
"""D4 (the never-stale contract): a page change re-runs the
|
||||
re-entrant load (rows are REPLACED, never appended) and, on
|
||||
success, lands `Showing <first>–<last> of <total>.` in
|
||||
#history-status (the existing live region — announce()). A failed
|
||||
load keeps the pager (no Showing line; loadChats' own error line
|
||||
is the feedback)."""
|
||||
js = _js()
|
||||
fn = _fn(js, "changePage")
|
||||
assert "loadChats(offset)" in fn, "the change re-runs the re-entrant load"
|
||||
assert re.search(r'"Showing "\s*\+\s*\(offset \+ 1\)\s*\+\s*"–"', fn), (
|
||||
"the line starts `Showing <first>–` (the en-dash)"
|
||||
)
|
||||
assert "Math.min(offset + PAGE_SIZE, total)" in fn, (
|
||||
"the last row of the page is min(offset + PAGE_SIZE, total)"
|
||||
)
|
||||
assert re.search(r'" of "\s*\+\s*total\s*\+\s*"."', fn), (
|
||||
"the line ends ` of <total>.`"
|
||||
)
|
||||
assert "announce(" in fn, "the line lands in the live region (announce)"
|
||||
|
||||
|
||||
def test_refresh_and_resend_keep_the_current_page_and_boot_on_page_1() -> None:
|
||||
"""A5: refreshChats re-fetches the CURRENT page — the module's
|
||||
offset, never 0 (a page-2 refresh must land page-2 rows); the
|
||||
re-show listener does the same; the mount boots on page 1
|
||||
(offset 0). The Prev/Next handlers move the offset by PAGE_SIZE
|
||||
(the disabled edges make clamping trivial)."""
|
||||
js = _js()
|
||||
fn = _fn(js, "refreshChats")
|
||||
assert "loadChats(offset)" in fn, "refresh re-fetches the CURRENT page"
|
||||
assert "loadChats(0)" not in fn, "refresh must never reset to page 1"
|
||||
assert re.search(r"if \(started\)\s+loadChats\(offset\)", js), (
|
||||
"the re-show listener re-fetches the CURRENT page (A5)"
|
||||
)
|
||||
assert re.search(r"^\s*loadChats\(0\);", js, re.M), (
|
||||
"the mount boots on page 1 (the view opens on page 1)"
|
||||
)
|
||||
assert "offset -= PAGE_SIZE" in js, "Prev moves the offset back a page"
|
||||
assert "offset += PAGE_SIZE" in js, "Next moves the offset forward a page"
|
||||
|
||||
|
||||
def test_pager_lookups_are_scoped_to_root() -> None:
|
||||
"""Phase 76's view-module contract: every DOM lookup scopes to
|
||||
root (the view's section) — the four pager elements included (the
|
||||
view ids stay unique across the shell; the scoped lookups keep
|
||||
the module honest and testable)."""
|
||||
js = _js()
|
||||
for sel in (
|
||||
'root.querySelector("#history-pager")',
|
||||
'root.querySelector("#history-pager-prev")',
|
||||
'root.querySelector("#history-pager-next")',
|
||||
'root.querySelector("#history-pager-status")',
|
||||
):
|
||||
assert sel in js, f"the pager lookup must be scoped to root: {sel}"
|
||||
|
||||
|
||||
def test_pager_is_bound_in_the_admin_branch_only() -> None:
|
||||
"""D5: the whoami gate is unchanged — the anonymous branch hides
|
||||
the table, shows the gate, hides Refresh, and RETURNS without
|
||||
touching the pager (it keeps its markup-shipped hidden state) and
|
||||
without a /api/chats request (the E2E pins the request log). The
|
||||
Prev/Next bindings — like Refresh's — are armed ONLY after the
|
||||
gate passes, in the admin branch."""
|
||||
js = _js()
|
||||
gate_i = js.find("if (!(await fetchIsAdmin()))")
|
||||
assert gate_i != -1, "the whoami gate must run in mount"
|
||||
branch = js[gate_i : js.find("return;", gate_i)]
|
||||
for name in ("pagerEl", "pagerPrevBtn", "pagerNextBtn", "pagerStatusEl"):
|
||||
assert name not in branch, (
|
||||
f"the anonymous branch must not touch {name} (D5 — the pager "
|
||||
"keeps its markup-shipped hidden state)"
|
||||
)
|
||||
assert "fetch(" not in branch, (
|
||||
"the anonymous branch must not fetch anything"
|
||||
)
|
||||
# The bindings are armed in the admin branch (after the return):
|
||||
after = js[js.find("return;", gate_i):]
|
||||
assert "pagerPrevBtn.addEventListener" in after, (
|
||||
"Prev is bound in the admin branch only (the same gate as Refresh)"
|
||||
)
|
||||
assert "pagerNextBtn.addEventListener" in after, (
|
||||
"Next is bound in the admin branch only (the same gate as Refresh)"
|
||||
)
|
||||
assert "loadChats(0);" in after, "the admin path boots the list on page 1"
|
||||
|
||||
|
||||
def test_local_delete_does_not_touch_the_pager_or_the_offset() -> None:
|
||||
"""D4: a local Delete removes the row (the empty state reappears
|
||||
when it was the last one) but does NOT recompute the pager or the
|
||||
offset — the pager's `total` stays the server's last truth; the
|
||||
next load (a page click or a Refresh) corrects it."""
|
||||
js = _js()
|
||||
fn = _fn(js, "confirmDelete")
|
||||
for name in (
|
||||
"pagerEl",
|
||||
"pagerPrevBtn",
|
||||
"pagerNextBtn",
|
||||
"pagerStatusEl",
|
||||
"offset",
|
||||
"updatePager",
|
||||
):
|
||||
assert name not in fn, (
|
||||
f"confirmDelete must not touch {name} (D4 — the next load "
|
||||
"corrects the pager)"
|
||||
)
|
||||
Reference in New Issue
Block a user