phase: 149_sync_progress_box
All verification complete. The `ink-soft`/`--surface` pair the box uses is already pinned AA by the existing theme E2E suites (8.2:1 dark, ~6.9:1 light). Everything is green — no fixes were needed.
**Phase 149 final verification report**
- Verified (no re-work needed): `#sync-progress` box wiring (single `setSyncLabel` writer with `running` flag; `is-running` class travels with the spin; aria-label owned by the writer), the panel/icon-only-button CSS, and the 5-test dedicated E2E; all 6 pre-existing sync E2Es' running pins migrated to the box, settled pins untouched.
- Dedicated E2E: `uv run pytest tests/e2e/test_sync_progress_box.py -v --no-cov` → **5 passed in 198.6 s** (isolation, slow-proxy mock; under the 600 s budget).
- Full suite: `uv run pytest --cov=app --cov-report=term-missing` → **3463 passed, app/ coverage 99%** (>90% gate).
- Migrated sync E2Es, each in isolation: `test_sync_button` 3 passed, `test_sync_cancel` 3, `test_sync_chunk_progress` 2, `test_sync_label_mobile` 4, `test_sync_summary_visibility` 3, `test_sync_upload_progress` 4 — all green.
- Lint/types: `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors.
- Criteria: in-flight spinner-only button + full untruncated wrapped box (desktop & mobile 390×844, `scrollWidth ≤ clientWidth`) ✓; settle byte-identical ("Synced HH:MM" + `3 added`, box hidden, aria restored) ✓; `#sync-result` sole live region (box `aria-hidden`), mid-run reload re-attaches ✓; WCAG (AA contrast both themes via the pinned ink-soft/surface pair, focus-visible, reduced-motion kill intact at styles.css L2201) ✓; cancel/409/error/upload/status API unchanged (green suites above) ✓.
- Documentation: README.md reviewed in full (sync-states paragraph now matches the shipped UI; env-var table matches `app/config.py` and covers all `.env.example` vars; pages/commands verified) and AGENTS.md reviewed in full — **verified consistent, no changes needed**.
- Notable: no defects found in this or prior work; no live infrastructure touched.
- Next pending phase: none — `todo/` will be empty once the harness moves 149 (only 149 was pending).
This commit is contained in:
@@ -0,0 +1,13 @@
|
|||||||
|
All verification complete. The `ink-soft`/`--surface` pair the box uses is already pinned AA by the existing theme E2E suites (8.2:1 dark, ~6.9:1 light). Everything is green — no fixes were needed.
|
||||||
|
|
||||||
|
**Phase 149 final verification report**
|
||||||
|
|
||||||
|
- Verified (no re-work needed): `#sync-progress` box wiring (single `setSyncLabel` writer with `running` flag; `is-running` class travels with the spin; aria-label owned by the writer), the panel/icon-only-button CSS, and the 5-test dedicated E2E; all 6 pre-existing sync E2Es' running pins migrated to the box, settled pins untouched.
|
||||||
|
- Dedicated E2E: `uv run pytest tests/e2e/test_sync_progress_box.py -v --no-cov` → **5 passed in 198.6 s** (isolation, slow-proxy mock; under the 600 s budget).
|
||||||
|
- Full suite: `uv run pytest --cov=app --cov-report=term-missing` → **3463 passed, app/ coverage 99%** (>90% gate).
|
||||||
|
- Migrated sync E2Es, each in isolation: `test_sync_button` 3 passed, `test_sync_cancel` 3, `test_sync_chunk_progress` 2, `test_sync_label_mobile` 4, `test_sync_summary_visibility` 3, `test_sync_upload_progress` 4 — all green.
|
||||||
|
- Lint/types: `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors.
|
||||||
|
- Criteria: in-flight spinner-only button + full untruncated wrapped box (desktop & mobile 390×844, `scrollWidth ≤ clientWidth`) ✓; settle byte-identical ("Synced HH:MM" + `3 added`, box hidden, aria restored) ✓; `#sync-result` sole live region (box `aria-hidden`), mid-run reload re-attaches ✓; WCAG (AA contrast both themes via the pinned ink-soft/surface pair, focus-visible, reduced-motion kill intact at styles.css L2201) ✓; cancel/409/error/upload/status API unchanged (green suites above) ✓.
|
||||||
|
- Documentation: README.md reviewed in full (sync-states paragraph now matches the shipped UI; env-var table matches `app/config.py` and covers all `.env.example` vars; pages/commands verified) and AGENTS.md reviewed in full — **verified consistent, no changes needed**.
|
||||||
|
- Notable: no defects found in this or prior work; no live infrastructure touched.
|
||||||
|
- Next pending phase: none — `todo/` will be empty once the harness moves 149 (only 149 was pending).
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
........................................................................ [ 2%]
|
||||||
|
........................................................................ [ 4%]
|
||||||
|
........................................................................ [ 6%]
|
||||||
|
........................................................................ [ 8%]
|
||||||
|
........................................................................ [ 10%]
|
||||||
|
........................................................................ [ 12%]
|
||||||
|
........................................................................ [ 14%]
|
||||||
|
........................................................................ [ 16%]
|
||||||
|
........................................................................ [ 18%]
|
||||||
|
........................................................................ [ 20%]
|
||||||
|
........................................................................ [ 22%]
|
||||||
|
........................................................................ [ 24%]
|
||||||
|
........................................................................ [ 27%]
|
||||||
|
........................................................................ [ 29%]
|
||||||
|
........................................................................ [ 31%]
|
||||||
|
........................................................................ [ 33%]
|
||||||
|
........................................................................ [ 35%]
|
||||||
|
........................................................................ [ 37%]
|
||||||
|
........................................................................ [ 39%]
|
||||||
|
........................................................................ [ 41%]
|
||||||
|
........................................................................ [ 43%]
|
||||||
|
........................................................................ [ 45%]
|
||||||
|
........................................................................ [ 47%]
|
||||||
|
........................................................................ [ 49%]
|
||||||
|
........................................................................ [ 51%]
|
||||||
|
........................................................................ [ 54%]
|
||||||
|
........................................................................ [ 56%]
|
||||||
|
........................................................................ [ 58%]
|
||||||
|
........................................................................ [ 60%]
|
||||||
|
........................................................................ [ 62%]
|
||||||
|
........................................................................ [ 64%]
|
||||||
|
........................................................................ [ 66%]
|
||||||
|
........................................................................ [ 68%]
|
||||||
|
........................................................................ [ 70%]
|
||||||
|
........................................................................ [ 72%]
|
||||||
|
........................................................................ [ 74%]
|
||||||
|
........................................................................ [ 76%]
|
||||||
|
........................................................................ [ 79%]
|
||||||
|
........................................................................ [ 81%]
|
||||||
|
........................................................................ [ 83%]
|
||||||
|
........................................................................ [ 85%]
|
||||||
|
........................................................................ [ 87%]
|
||||||
|
........................................................................ [ 89%]
|
||||||
|
........................................................................ [ 91%]
|
||||||
|
........................................................................ [ 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/auth.py 52 0 100%
|
||||||
|
app/api/chat.py 270 1 99%
|
||||||
|
app/api/chat_images.py 50 0 100%
|
||||||
|
app/api/chats.py 110 0 100%
|
||||||
|
app/api/config.py 13 0 100%
|
||||||
|
app/api/doc_drafts.py 99 0 100%
|
||||||
|
app/api/docs.py 196 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 183 0 100%
|
||||||
|
app/api/tokens.py 40 0 100%
|
||||||
|
app/api/ui_settings.py 55 0 100%
|
||||||
|
app/config.py 265 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/debugging.py 29 2 93%
|
||||||
|
app/core/docs_push.py 39 0 100%
|
||||||
|
app/core/errors.py 5 0 100%
|
||||||
|
app/core/logging.py 13 0 100%
|
||||||
|
app/core/rate_limit.py 44 0 100%
|
||||||
|
app/core/security_headers.py 20 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 68 0 100%
|
||||||
|
app/models.py 135 0 100%
|
||||||
|
app/rag/__init__.py 0 0 100%
|
||||||
|
app/rag/agent.py 431 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 744 4 99%
|
||||||
|
app/rag/llm.py 246 1 99%
|
||||||
|
app/rag/md_images.py 63 0 100%
|
||||||
|
app/rag/overview.py 71 0 100%
|
||||||
|
app/rag/pdf.py 124 0 100%
|
||||||
|
app/rag/prompts.py 155 0 100%
|
||||||
|
app/rag/retriever.py 248 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 443 0 100%
|
||||||
|
--------------------------------------------------
|
||||||
|
TOTAL 5609 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
|
||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
Everything is verified and green. Task 01 is complete.
|
||||||
|
|
||||||
|
**Report — Phase 149, Task 01 (`01_progress_box_wiring.md`)**
|
||||||
|
|
||||||
|
- **HTML:** `#sync-progress` box (`.sync-progress-text` span) inserted between the `.page-head` block and `#sync-result` — ship-hidden, `aria-hidden` (presentational; `#sync-result` stays the sole aria-live announcer).
|
||||||
|
- **sources.js (single-writer wiring):** `setSyncLabel` gains `running=false` — writes the SAME full untruncated label into the box, reveals it IFF a run is in flight, and owns the button's `aria-label` (full live label while running, `"Sync sources"` restored on every settle). `enterSyncRunningState` passes `running=true` + adds `is-running` (with the spin, its old aria write removed); `settleSyncButton` removes `is-running` (with the spin). Covers all 5 running entries (poll ticks, 202/409 click, both re-attaches) and every settle.
|
||||||
|
- **Unit tests:** pins moved to the new shape + new HTML markup pin + node-harness rows gain box text/hidden, `is-running`/`is-spinning`, aria-label, with a new behavioral test (box visible IFF running, exact full label, restored at settle).
|
||||||
|
- **E2E migration (six sync suites):** running-state label pins → `#sync-progress` (regexes target `#sync-progress-text` — the div's innerText carries newline artifacts); count `to_be_visible` → box visibility; `test_sync_label_mobile` tests 1–3 reworked box-based (full string + viewport-fit + `is-running`), `_settle` gains the box-hidden pin; all settled/idle + `#sync-result` pins untouched.
|
||||||
|
- **Docs:** README "States" bullet updated (button = spinner, detail box under the row); no AGENTS.md impact.
|
||||||
|
|
||||||
|
**Results:** `uv run pytest --cov=app` → **3463 passed, 99% coverage** (gate >90%); `uv run ruff check .` clean; `uv run pyright` 0 errors; all 11 sync-related E2E files green in isolation (`--no-cov`), incl. the six migrated suites.
|
||||||
|
|
||||||
|
**Notable:** (1) `test_sync_label_mobile.py` test 4 (pre-phase-128 CSS clip reproduction) stays green now, but task 02's `.sync-btn.is-running .sync-label{display:none}` (higher specificity) will make the injected rules lose — task 02 must migrate/drop it. (2) Owner appended 2 new TODO.md items mid-session (image-embed sync status) — untouched; likely a future phase.
|
||||||
|
|
||||||
|
**Next pending task:** `02_progress_box_styles.md` (box panel CSS, icon-only running button, mobile, AA/motion gates).
|
||||||
+120
@@ -0,0 +1,120 @@
|
|||||||
|
........................................................................ [ 2%]
|
||||||
|
........................................................................ [ 4%]
|
||||||
|
........................................................................ [ 6%]
|
||||||
|
........................................................................ [ 8%]
|
||||||
|
........................................................................ [ 10%]
|
||||||
|
........................................................................ [ 12%]
|
||||||
|
........................................................................ [ 14%]
|
||||||
|
........................................................................ [ 16%]
|
||||||
|
........................................................................ [ 18%]
|
||||||
|
........................................................................ [ 20%]
|
||||||
|
........................................................................ [ 22%]
|
||||||
|
........................................................................ [ 24%]
|
||||||
|
........................................................................ [ 27%]
|
||||||
|
........................................................................ [ 29%]
|
||||||
|
........................................................................ [ 31%]
|
||||||
|
........................................................................ [ 33%]
|
||||||
|
........................................................................ [ 35%]
|
||||||
|
........................................................................ [ 37%]
|
||||||
|
........................................................................ [ 39%]
|
||||||
|
........................................................................ [ 41%]
|
||||||
|
........................................................................ [ 43%]
|
||||||
|
........................................................................ [ 45%]
|
||||||
|
........................................................................ [ 47%]
|
||||||
|
........................................................................ [ 49%]
|
||||||
|
........................................................................ [ 51%]
|
||||||
|
........................................................................ [ 54%]
|
||||||
|
........................................................................ [ 56%]
|
||||||
|
........................................................................ [ 58%]
|
||||||
|
........................................................................ [ 60%]
|
||||||
|
........................................................................ [ 62%]
|
||||||
|
........................................................................ [ 64%]
|
||||||
|
........................................................................ [ 66%]
|
||||||
|
........................................................................ [ 68%]
|
||||||
|
........................................................................ [ 70%]
|
||||||
|
........................................................................ [ 72%]
|
||||||
|
........................................................................ [ 74%]
|
||||||
|
........................................................................ [ 76%]
|
||||||
|
........................................................................ [ 79%]
|
||||||
|
........................................................................ [ 81%]
|
||||||
|
........................................................................ [ 83%]
|
||||||
|
........................................................................ [ 85%]
|
||||||
|
........................................................................ [ 87%]
|
||||||
|
........................................................................ [ 89%]
|
||||||
|
........................................................................ [ 91%]
|
||||||
|
........................................................................ [ 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/auth.py 52 0 100%
|
||||||
|
app/api/chat.py 270 1 99%
|
||||||
|
app/api/chat_images.py 50 0 100%
|
||||||
|
app/api/chats.py 110 0 100%
|
||||||
|
app/api/config.py 13 0 100%
|
||||||
|
app/api/doc_drafts.py 99 0 100%
|
||||||
|
app/api/docs.py 196 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 183 0 100%
|
||||||
|
app/api/tokens.py 40 0 100%
|
||||||
|
app/api/ui_settings.py 55 0 100%
|
||||||
|
app/config.py 265 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/debugging.py 29 2 93%
|
||||||
|
app/core/docs_push.py 39 0 100%
|
||||||
|
app/core/errors.py 5 0 100%
|
||||||
|
app/core/logging.py 13 0 100%
|
||||||
|
app/core/rate_limit.py 44 0 100%
|
||||||
|
app/core/security_headers.py 20 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 68 0 100%
|
||||||
|
app/models.py 135 0 100%
|
||||||
|
app/rag/__init__.py 0 0 100%
|
||||||
|
app/rag/agent.py 431 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 744 4 99%
|
||||||
|
app/rag/llm.py 246 1 99%
|
||||||
|
app/rag/md_images.py 63 0 100%
|
||||||
|
app/rag/overview.py 71 0 100%
|
||||||
|
app/rag/pdf.py 124 0 100%
|
||||||
|
app/rag/prompts.py 155 0 100%
|
||||||
|
app/rag/retriever.py 248 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 443 0 100%
|
||||||
|
--------------------------------------------------
|
||||||
|
TOTAL 5609 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 green. Task complete.
|
||||||
|
|
||||||
|
**Implemented (task 02 — box styling):**
|
||||||
|
- `.sync-progress` panel in `frontend/assets/styles.css`: house bordered-panel language (`--surface` fill, 1px `--line` border, `--radius` radius, `0.75rem 1rem` padding, `min-height: 4rem` ≈ 2 lines). No `margin-top` — the `.sources-shell` flex gap (1.25rem) *is* the house spacing; a margin would double it (documented in the CSS comment)
|
||||||
|
- `.sync-progress-text`: `display: block`, `var(--mono)`, `0.8rem` (the `.sync-result` size), house `1.55` line-height, `var(--ink-soft)` (pinned identity pair on `--surface`: 8.2:1 built-in dark, theme gate keeps all themes ≥4.5:1), `overflow-wrap: anywhere` — long paths WRAP, never clip
|
||||||
|
- `.sync-btn.is-running .sync-label { display: none }` — icon-only footprint (16px icon centered in a ~47×44 pill; mobile keeps the full-width 44px bar via the pre-existing `width: 100%` rule); viewport-agnostic, defined once. Hover/`:disabled`/`:focus-visible`/`prefers-reduced-motion` untouched (they live on the button; the spinner kill at L2202 stands)
|
||||||
|
- Mobile media query: verified no changes needed (box is a block in the 72rem container — full-width at 360–390px; no dead mobile label rules — idle label still renders, ellipsis rules kept)
|
||||||
|
- **Defect fix (task 01 work):** `test_sync_label_mobile.py::test_pre_phase_css_reproduces_the_clip` went vacuous — its injected pre-phase rules (0,1,0) lost to the new (0,2,0) hide rule, leaving the parts `display:none`. The counterfactual injection set now also neutralizes the phase-149 hide rule (it didn't exist pre-phase), restoring the clip-geometry pin
|
||||||
|
|
||||||
|
**Validation — 100% green:**
|
||||||
|
- `uv run pytest --cov=app --cov-report=term` → 3463 passed, `app/` coverage 99% (>90%)
|
||||||
|
- All 11 sync/upload E2E files pass in isolation: test_sync_button (3), test_sync_label_mobile (4), test_sync_cancel (3), test_sync_chunk_progress (2), test_sync_summary_visibility (3), test_sync_upload_progress (4), test_sync_changed_only (3), test_sync_model_down (4), test_force_push_resync (2), test_archive_upload_sources (5), test_upload_no_scan (3)
|
||||||
|
- `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors, 0 warnings
|
||||||
|
- Visual verification: 8 screenshots (desktop/mobile × dark/light × idle/running) — bordered mono panel with wrapped long path, icon-only spinner button, "Sync sources" pill restored at idle, AA contrast both themes
|
||||||
|
|
||||||
|
**Docs:** no doc impact (feature-level README text stays accurate; no commands/env/API/convention changes)
|
||||||
|
|
||||||
|
**Next pending task:** `03_e2e_progress_box.md` (the dedicated in-flight/settled Playwright suite)
|
||||||
+120
@@ -0,0 +1,120 @@
|
|||||||
|
........................................................................ [ 2%]
|
||||||
|
........................................................................ [ 4%]
|
||||||
|
........................................................................ [ 6%]
|
||||||
|
........................................................................ [ 8%]
|
||||||
|
........................................................................ [ 10%]
|
||||||
|
........................................................................ [ 12%]
|
||||||
|
........................................................................ [ 14%]
|
||||||
|
........................................................................ [ 16%]
|
||||||
|
........................................................................ [ 18%]
|
||||||
|
........................................................................ [ 20%]
|
||||||
|
........................................................................ [ 22%]
|
||||||
|
........................................................................ [ 24%]
|
||||||
|
........................................................................ [ 27%]
|
||||||
|
........................................................................ [ 29%]
|
||||||
|
........................................................................ [ 31%]
|
||||||
|
........................................................................ [ 33%]
|
||||||
|
........................................................................ [ 35%]
|
||||||
|
........................................................................ [ 37%]
|
||||||
|
........................................................................ [ 39%]
|
||||||
|
........................................................................ [ 41%]
|
||||||
|
........................................................................ [ 43%]
|
||||||
|
........................................................................ [ 45%]
|
||||||
|
........................................................................ [ 47%]
|
||||||
|
........................................................................ [ 49%]
|
||||||
|
........................................................................ [ 51%]
|
||||||
|
........................................................................ [ 54%]
|
||||||
|
........................................................................ [ 56%]
|
||||||
|
........................................................................ [ 58%]
|
||||||
|
........................................................................ [ 60%]
|
||||||
|
........................................................................ [ 62%]
|
||||||
|
........................................................................ [ 64%]
|
||||||
|
........................................................................ [ 66%]
|
||||||
|
........................................................................ [ 68%]
|
||||||
|
........................................................................ [ 70%]
|
||||||
|
........................................................................ [ 72%]
|
||||||
|
........................................................................ [ 74%]
|
||||||
|
........................................................................ [ 76%]
|
||||||
|
........................................................................ [ 79%]
|
||||||
|
........................................................................ [ 81%]
|
||||||
|
........................................................................ [ 83%]
|
||||||
|
........................................................................ [ 85%]
|
||||||
|
........................................................................ [ 87%]
|
||||||
|
........................................................................ [ 89%]
|
||||||
|
........................................................................ [ 91%]
|
||||||
|
........................................................................ [ 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/auth.py 52 0 100%
|
||||||
|
app/api/chat.py 270 1 99%
|
||||||
|
app/api/chat_images.py 50 0 100%
|
||||||
|
app/api/chats.py 110 0 100%
|
||||||
|
app/api/config.py 13 0 100%
|
||||||
|
app/api/doc_drafts.py 99 0 100%
|
||||||
|
app/api/docs.py 196 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 183 0 100%
|
||||||
|
app/api/tokens.py 40 0 100%
|
||||||
|
app/api/ui_settings.py 55 0 100%
|
||||||
|
app/config.py 265 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/debugging.py 29 2 93%
|
||||||
|
app/core/docs_push.py 39 0 100%
|
||||||
|
app/core/errors.py 5 0 100%
|
||||||
|
app/core/logging.py 13 0 100%
|
||||||
|
app/core/rate_limit.py 44 0 100%
|
||||||
|
app/core/security_headers.py 20 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 68 0 100%
|
||||||
|
app/models.py 135 0 100%
|
||||||
|
app/rag/__init__.py 0 0 100%
|
||||||
|
app/rag/agent.py 431 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 744 4 99%
|
||||||
|
app/rag/llm.py 246 1 99%
|
||||||
|
app/rag/md_images.py 63 0 100%
|
||||||
|
app/rag/overview.py 71 0 100%
|
||||||
|
app/rag/pdf.py 124 0 100%
|
||||||
|
app/rag/prompts.py 155 0 100%
|
||||||
|
app/rag/retriever.py 248 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 443 0 100%
|
||||||
|
--------------------------------------------------
|
||||||
|
TOTAL 5609 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
|
||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
All gates green. Final report:
|
||||||
|
|
||||||
|
**Implemented**
|
||||||
|
- `tests/e2e/test_sync_progress_box.py` — dedicated phase-149 E2E (5 tests, module app with `BOR_GIT_SOURCES=file://` fixture repo + own `BOR_SOURCES_DIR` + slow-LLM proxy, 1.5s): spinner-only button while running (`is-running`, `is-spinning`, `#sync-label` hidden, `aria-label` == box text, Cancel visible, box `aria-hidden`); box carries the EXACT full untruncated long-path label (wrap proof `scrollWidth <= clientWidth`, announcer `#sync-result` + `aria-label` equal, contrast ≥4.5:1) with the phase-129 `· chunk n/m` part pinned in the box over the slow embed window; mobile 390×844 box full-width + icon-only full-width button + wrapped full path; settle restores the byte-identical pill (`Synced HH:MM`, `aria-label` "Sync sources", `3 added`, box hidden); mid-run reload re-attaches with the box visible.
|
||||||
|
|
||||||
|
**Test / lint / coverage**
|
||||||
|
- `uv run pytest tests/e2e/test_sync_progress_box.py -v --no-cov` → **5 passed** in isolation (×2 runs, ~205s)
|
||||||
|
- `uv run pytest --cov=app` → **3463 passed**, app coverage **99%**
|
||||||
|
- `uv run ruff check . && uv run pyright` → **clean**
|
||||||
|
- All 8 pre-existing sync E2Es green: button (3), cancel (3), chunk_progress (2), label_mobile (4), summary_visibility (3), upload_progress (4), model_down (4), changed_only (3)
|
||||||
|
|
||||||
|
**Docs** — no doc impact (README's sync section was already updated by tasks 01/02; AGENTS.md unaffected).
|
||||||
|
|
||||||
|
**Decisions** — mobile button full-width reference is the `.page-head` card content width (its containing block), not the `.container`; added a failed-terminal diagnostic print in the boundary wait (surfaced a test-cascade, not an app defect — both post-fix full runs fully green).
|
||||||
|
|
||||||
|
**Next pending task** — none for phase 149 (task 03 was the last; harness moves the phase on completion).
|
||||||
+120
@@ -0,0 +1,120 @@
|
|||||||
|
........................................................................ [ 2%]
|
||||||
|
........................................................................ [ 4%]
|
||||||
|
........................................................................ [ 6%]
|
||||||
|
........................................................................ [ 8%]
|
||||||
|
........................................................................ [ 10%]
|
||||||
|
........................................................................ [ 12%]
|
||||||
|
........................................................................ [ 14%]
|
||||||
|
........................................................................ [ 16%]
|
||||||
|
........................................................................ [ 18%]
|
||||||
|
........................................................................ [ 20%]
|
||||||
|
........................................................................ [ 22%]
|
||||||
|
........................................................................ [ 24%]
|
||||||
|
........................................................................ [ 27%]
|
||||||
|
........................................................................ [ 29%]
|
||||||
|
........................................................................ [ 31%]
|
||||||
|
........................................................................ [ 33%]
|
||||||
|
........................................................................ [ 35%]
|
||||||
|
........................................................................ [ 37%]
|
||||||
|
........................................................................ [ 39%]
|
||||||
|
........................................................................ [ 41%]
|
||||||
|
........................................................................ [ 43%]
|
||||||
|
........................................................................ [ 45%]
|
||||||
|
........................................................................ [ 47%]
|
||||||
|
........................................................................ [ 49%]
|
||||||
|
........................................................................ [ 51%]
|
||||||
|
........................................................................ [ 54%]
|
||||||
|
........................................................................ [ 56%]
|
||||||
|
........................................................................ [ 58%]
|
||||||
|
........................................................................ [ 60%]
|
||||||
|
........................................................................ [ 62%]
|
||||||
|
........................................................................ [ 64%]
|
||||||
|
........................................................................ [ 66%]
|
||||||
|
........................................................................ [ 68%]
|
||||||
|
........................................................................ [ 70%]
|
||||||
|
........................................................................ [ 72%]
|
||||||
|
........................................................................ [ 74%]
|
||||||
|
........................................................................ [ 76%]
|
||||||
|
........................................................................ [ 79%]
|
||||||
|
........................................................................ [ 81%]
|
||||||
|
........................................................................ [ 83%]
|
||||||
|
........................................................................ [ 85%]
|
||||||
|
........................................................................ [ 87%]
|
||||||
|
........................................................................ [ 89%]
|
||||||
|
........................................................................ [ 91%]
|
||||||
|
........................................................................ [ 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/auth.py 52 0 100%
|
||||||
|
app/api/chat.py 270 1 99%
|
||||||
|
app/api/chat_images.py 50 0 100%
|
||||||
|
app/api/chats.py 110 0 100%
|
||||||
|
app/api/config.py 13 0 100%
|
||||||
|
app/api/doc_drafts.py 99 0 100%
|
||||||
|
app/api/docs.py 196 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 183 0 100%
|
||||||
|
app/api/tokens.py 40 0 100%
|
||||||
|
app/api/ui_settings.py 55 0 100%
|
||||||
|
app/config.py 265 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/debugging.py 29 2 93%
|
||||||
|
app/core/docs_push.py 39 0 100%
|
||||||
|
app/core/errors.py 5 0 100%
|
||||||
|
app/core/logging.py 13 0 100%
|
||||||
|
app/core/rate_limit.py 44 0 100%
|
||||||
|
app/core/security_headers.py 20 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 68 0 100%
|
||||||
|
app/models.py 135 0 100%
|
||||||
|
app/rag/__init__.py 0 0 100%
|
||||||
|
app/rag/agent.py 431 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 744 4 99%
|
||||||
|
app/rag/llm.py 246 1 99%
|
||||||
|
app/rag/md_images.py 63 0 100%
|
||||||
|
app/rag/overview.py 71 0 100%
|
||||||
|
app/rag/pdf.py 124 0 100%
|
||||||
|
app/rag/prompts.py 155 0 100%
|
||||||
|
app/rag/retriever.py 248 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 443 0 100%
|
||||||
|
--------------------------------------------------
|
||||||
|
TOTAL 5609 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
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
**What was fixed**
|
||||||
|
- No code defects found: the prior rejection was a missing final report (the harness's own a1 validation passed — coverage gate OK, ruff, pyright). Re-verified everything and tightened the README settle sentence (was "idle 'Sync sources' pill returns"; now byte-accurate: "Synced HH:MM" pill after success with the counts line, retry-ready "Sync sources" after failure/cancel — matching the E2E-pinned `settleSyncButton`/`applySyncSuccess` behavior).
|
||||||
|
|
||||||
|
**Test / lint / coverage (all re-run this pass)**
|
||||||
|
- `uv run pytest --cov=app --cov-report=term-missing` → **3463 passed**, app coverage **99%** (>90% gate)
|
||||||
|
- `uv run ruff check . && uv run pyright` → **clean** (0 errors)
|
||||||
|
- `uv run pytest tests/e2e/test_sync_progress_box.py -v --no-cov` → **5 passed** in isolation (198s)
|
||||||
|
- Pre-existing sync E2Es (button/cancel/chunk_progress/label_mobile/summary_visibility/upload_progress/model_down/changed_only): **26 passed** (run on this same tree; only README changed since)
|
||||||
|
|
||||||
|
**Docs** — README.md settle contract corrected (doc-only); AGENTS.md re-checked, no sync-UI content, no changes.
|
||||||
|
|
||||||
|
**Notable decisions** — README edit is documentation-only (not served by the app, no test impact); the phase-149 sync section otherwise verified accurate against the E2E pins (spinner-only button, `aria-hidden` box, A4 single live region, 409/cancel contracts).
|
||||||
|
|
||||||
|
**Next pending task** — none; task 03 is the last task of phase 149 (harness completes the phase).
|
||||||
+120
@@ -0,0 +1,120 @@
|
|||||||
|
........................................................................ [ 2%]
|
||||||
|
........................................................................ [ 4%]
|
||||||
|
........................................................................ [ 6%]
|
||||||
|
........................................................................ [ 8%]
|
||||||
|
........................................................................ [ 10%]
|
||||||
|
........................................................................ [ 12%]
|
||||||
|
........................................................................ [ 14%]
|
||||||
|
........................................................................ [ 16%]
|
||||||
|
........................................................................ [ 18%]
|
||||||
|
........................................................................ [ 20%]
|
||||||
|
........................................................................ [ 22%]
|
||||||
|
........................................................................ [ 24%]
|
||||||
|
........................................................................ [ 27%]
|
||||||
|
........................................................................ [ 29%]
|
||||||
|
........................................................................ [ 31%]
|
||||||
|
........................................................................ [ 33%]
|
||||||
|
........................................................................ [ 35%]
|
||||||
|
........................................................................ [ 37%]
|
||||||
|
........................................................................ [ 39%]
|
||||||
|
........................................................................ [ 41%]
|
||||||
|
........................................................................ [ 43%]
|
||||||
|
........................................................................ [ 45%]
|
||||||
|
........................................................................ [ 47%]
|
||||||
|
........................................................................ [ 49%]
|
||||||
|
........................................................................ [ 51%]
|
||||||
|
........................................................................ [ 54%]
|
||||||
|
........................................................................ [ 56%]
|
||||||
|
........................................................................ [ 58%]
|
||||||
|
........................................................................ [ 60%]
|
||||||
|
........................................................................ [ 62%]
|
||||||
|
........................................................................ [ 64%]
|
||||||
|
........................................................................ [ 66%]
|
||||||
|
........................................................................ [ 68%]
|
||||||
|
........................................................................ [ 70%]
|
||||||
|
........................................................................ [ 72%]
|
||||||
|
........................................................................ [ 74%]
|
||||||
|
........................................................................ [ 76%]
|
||||||
|
........................................................................ [ 79%]
|
||||||
|
........................................................................ [ 81%]
|
||||||
|
........................................................................ [ 83%]
|
||||||
|
........................................................................ [ 85%]
|
||||||
|
........................................................................ [ 87%]
|
||||||
|
........................................................................ [ 89%]
|
||||||
|
........................................................................ [ 91%]
|
||||||
|
........................................................................ [ 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/auth.py 52 0 100%
|
||||||
|
app/api/chat.py 270 1 99%
|
||||||
|
app/api/chat_images.py 50 0 100%
|
||||||
|
app/api/chats.py 110 0 100%
|
||||||
|
app/api/config.py 13 0 100%
|
||||||
|
app/api/doc_drafts.py 99 0 100%
|
||||||
|
app/api/docs.py 196 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 183 0 100%
|
||||||
|
app/api/tokens.py 40 0 100%
|
||||||
|
app/api/ui_settings.py 55 0 100%
|
||||||
|
app/config.py 265 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/debugging.py 29 2 93%
|
||||||
|
app/core/docs_push.py 39 0 100%
|
||||||
|
app/core/errors.py 5 0 100%
|
||||||
|
app/core/logging.py 13 0 100%
|
||||||
|
app/core/rate_limit.py 44 0 100%
|
||||||
|
app/core/security_headers.py 20 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 68 0 100%
|
||||||
|
app/models.py 135 0 100%
|
||||||
|
app/rag/__init__.py 0 0 100%
|
||||||
|
app/rag/agent.py 431 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 744 4 99%
|
||||||
|
app/rag/llm.py 246 1 99%
|
||||||
|
app/rag/md_images.py 63 0 100%
|
||||||
|
app/rag/overview.py 71 0 100%
|
||||||
|
app/rag/pdf.py 124 0 100%
|
||||||
|
app/rag/prompts.py 155 0 100%
|
||||||
|
app/rag/retriever.py 248 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 443 0 100%
|
||||||
|
--------------------------------------------------
|
||||||
|
TOTAL 5609 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
|
||||||
@@ -326,14 +326,21 @@ whole git-source refresh in one click:
|
|||||||
rows, not the whole table); an unchanged re-sync instead gap-fills
|
rows, not the whole table); an unchanged re-sync instead gap-fills
|
||||||
any missing rows (`only_missing`).
|
any missing rows (`only_missing`).
|
||||||
|
|
||||||
- States: the button shows **Syncing…** with a spinner while polling
|
- States: while polling `GET /api/sync/status` every 2 s, the button
|
||||||
`GET /api/sync/status` every 2 s. During the import phase the label
|
becomes the spinner (icon only — its label parts are hidden while a
|
||||||
carries the live file, its `(n/m)` file position, and — while a file's
|
run is in flight, and its accessible name is the full live label),
|
||||||
embed batch is in flight — the pinned `· chunk n/m` embed position
|
and the full live detail — "Syncing… <file> (n/m)" during the import
|
||||||
(batch granularity: the first chunk of the in-flight batch over the
|
phase (the live file, its `(n/m)` file position, and — while a file's
|
||||||
file's total content chunks); the full untruncated label also rides
|
embed batch is in flight — the pinned `· chunk n/m` embed position,
|
||||||
the button title and the aria-live result line. No client-side
|
batch granularity: the first chunk of the in-flight batch over the
|
||||||
timeout — a clone + embed can legitimately take minutes.
|
file's total content chunks) — shows in a text box under the button
|
||||||
|
row, wrapping (never clipped); the same untruncated label rides the
|
||||||
|
button title and the aria-live result line (the sole live region —
|
||||||
|
the box is presentational, `aria-hidden`). On settle the box hides
|
||||||
|
and the button leaves the running state — the "Synced HH:MM" pill
|
||||||
|
after a success (the counts line lands on the result line), the
|
||||||
|
retry-ready "Sync sources" pill after a failure or cancel. No
|
||||||
|
client-side timeout — a clone + embed can legitimately take minutes.
|
||||||
- One sync at a time: a second trigger while a run is in flight gets a
|
- One sync at a time: a second trigger while a run is in flight gets a
|
||||||
`409`.
|
`409`.
|
||||||
- **Cancel**: while a run is in flight, a **Cancel** button appears
|
- **Cancel**: while a run is in flight, a **Cancel** button appears
|
||||||
|
|||||||
@@ -1 +1,4 @@
|
|||||||
# TODO
|
# TODO
|
||||||
|
|
||||||
|
- [ ] The sync status on the RAG page gets "stuck" when it's embedding images. For example, when syncing test_sources/podman-systemd.pdf the status was stuck on `Syncing… podman-systemd.unit — Podman documentation/podman-systemd.unit — Podman documentation.pdf (2540/2540) · chunk 125/126` for several minutes for some reason
|
||||||
|
- [ ] For the same podman pdf document, it extracted a series of blank white images for some reason. There don't appear to be any blank images in the document, is this a bug?
|
||||||
@@ -361,6 +361,31 @@
|
|||||||
* "Sync cancelled", never a stuck running state). The bor:sync-status
|
* "Sync cancelled", never a stuck running state). The bor:sync-status
|
||||||
* event shape is unchanged — the cancelled status object flows through
|
* event shape is unchanged — the cancelled status object flows through
|
||||||
* emitSyncStatus exactly like every other state.
|
* emitSyncStatus exactly like every other state.
|
||||||
|
*
|
||||||
|
* Phase 149 (task 01) — the sync detail box + the icon-only running
|
||||||
|
* button: the owner's report was that the full live label (a long
|
||||||
|
* source/relative/path + the counts + the chunk part) is clipped in
|
||||||
|
* the pill even on desktop — the mobile solution (stuffing the parts
|
||||||
|
* in the button) had become the permanent surface. The fix: while a
|
||||||
|
* sync/upload run is in flight, the button shows ONLY the spinning
|
||||||
|
* icon (the `is-running` class — task 02's CSS hides the label parts;
|
||||||
|
* the button IS the loading spinner, exactly the owner's ask), and a
|
||||||
|
* NEW bordered text box under the button row (#sync-progress, the
|
||||||
|
* presentational `aria-hidden` panel) shows the SAME full untruncated
|
||||||
|
* live label — wrapping, never clipped, breathing room. #sync-result
|
||||||
|
* REMAINS the sole aria-live announcer (one live region, no double
|
||||||
|
* announce — the box is a visual duplicate for sighted users). The
|
||||||
|
* wiring rides the SINGLE writer: setSyncLabel gains the `running`
|
||||||
|
* flag (true on the running entry — the poll ticks, the 202/409
|
||||||
|
* click, both re-attach entries; the settle default false) and writes
|
||||||
|
* the full label into the box + reveals/hides it, and owns the
|
||||||
|
* button's accessible name (full live label while running, the ship
|
||||||
|
* value "Sync sources" restored on every settle). The `is-running`
|
||||||
|
* class travels with the spin: added in enterSyncRunningState, removed
|
||||||
|
* in settleSyncButton (every terminal + both idles pass through it).
|
||||||
|
* The settle rendering ("Synced HH:MM" + the counts line), the Cancel
|
||||||
|
* control, the 409 double-trigger, the error banner/modal, and the
|
||||||
|
* sync status API are all UNCHANGED.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { fetchIsAdmin } from "./header.js";
|
import { fetchIsAdmin } from "./header.js";
|
||||||
@@ -695,7 +720,11 @@ export async function mount(root) {
|
|||||||
* import phase after the file's first batch)), #sync-cancel-btn
|
* import phase after the file's first batch)), #sync-cancel-btn
|
||||||
* (the Cancel control — phase 134 task 02, ship-hidden, revealed
|
* (the Cancel control — phase 134 task 02, ship-hidden, revealed
|
||||||
* only while a sync run is in flight), #sync-icon (the
|
* only while a sync run is in flight), #sync-icon (the
|
||||||
* spinner icon), #sync-result (aria-live result line),
|
* spinner icon), #sync-result (aria-live result line — the SOLE
|
||||||
|
* live region), #sync-progress (Phase 149 task 01: the full
|
||||||
|
* untruncated live label's detail box under the button row —
|
||||||
|
* presentational, aria-hidden, visible only while a run is in
|
||||||
|
* flight; the .sync-progress-text span carries the label),
|
||||||
* #sync-error-banner / #sync-error-text (error banner).
|
* #sync-error-banner / #sync-error-text (error banner).
|
||||||
*/
|
*/
|
||||||
const syncBtn = root.querySelector("#sync-btn");
|
const syncBtn = root.querySelector("#sync-btn");
|
||||||
@@ -709,6 +738,15 @@ export async function mount(root) {
|
|||||||
const syncLabelCount = syncLabel ? syncLabel.querySelector(".sync-label-count") : null;
|
const syncLabelCount = syncLabel ? syncLabel.querySelector(".sync-label-count") : null;
|
||||||
const syncLabelChunk = syncLabel ? syncLabel.querySelector(".sync-label-chunk") : null;
|
const syncLabelChunk = syncLabel ? syncLabel.querySelector(".sync-label-chunk") : null;
|
||||||
const syncIcon = syncBtn ? syncBtn.querySelector(".sync-icon") : null;
|
const syncIcon = syncBtn ? syncBtn.querySelector(".sync-icon") : null;
|
||||||
|
// Phase 149 (task 01): the sync detail box — the full UNTRUNCATED
|
||||||
|
// live label in a bordered panel under the button row, visible ONLY
|
||||||
|
// while a run is in flight (setSyncLabel reveals/hides it). The box
|
||||||
|
// is presentational (aria-hidden): #sync-result stays the SOLE
|
||||||
|
// aria-live announcer (one live region, no double announce).
|
||||||
|
const syncProgress = root.querySelector("#sync-progress");
|
||||||
|
const syncProgressText = syncProgress
|
||||||
|
? syncProgress.querySelector(".sync-progress-text")
|
||||||
|
: null;
|
||||||
const syncResult = root.querySelector("#sync-result");
|
const syncResult = root.querySelector("#sync-result");
|
||||||
const syncErrorBanner = root.querySelector("#sync-error-banner");
|
const syncErrorBanner = root.querySelector("#sync-error-banner");
|
||||||
const syncErrorText = root.querySelector("#sync-error-text");
|
const syncErrorText = root.querySelector("#sync-error-text");
|
||||||
@@ -755,8 +793,20 @@ export async function mount(root) {
|
|||||||
* them are the CSS gap). textContent only — the house rule, no
|
* them are the CSS gap). textContent only — the house rule, no
|
||||||
* HTML strings. Every label state goes through here (the running
|
* HTML strings. Every label state goes through here (the running
|
||||||
* builders, the idle / settle labels, the 202/409 click, the
|
* builders, the idle / settle labels, the 202/409 click, the
|
||||||
* re-attach). */
|
* re-attach).
|
||||||
function setSyncLabel(mainText, countText = "", chunkText = "") {
|
* Phase 149 (task 01): the fourth parameter — `running` (the
|
||||||
|
* in-flight flag; the callsites are the ONLY decision sites: the
|
||||||
|
* running entry passes true, the settle passes the default false).
|
||||||
|
* The box (#sync-progress) gets the SAME untruncated `full` string
|
||||||
|
* (textContent only) and is visible IFF `running` — the detail box
|
||||||
|
* rides the writer, so it can never disagree with the button
|
||||||
|
* title / aria / the announcer. The button's accessible name rides
|
||||||
|
* here too: while running the icon-only button (task 02 hides the
|
||||||
|
* label parts) reads the FULL live label (the title already carries
|
||||||
|
* it for hover); every non-running call restores the ship value
|
||||||
|
* "Sync sources" (the static HTML attribute is the idle truth —
|
||||||
|
* the JS only overwrites/restores it, never removes it). */
|
||||||
|
function setSyncLabel(mainText, countText = "", chunkText = "", running = false) {
|
||||||
if (syncLabelMain) syncLabelMain.textContent = mainText;
|
if (syncLabelMain) syncLabelMain.textContent = mainText;
|
||||||
if (syncLabelCount) {
|
if (syncLabelCount) {
|
||||||
if (countText) {
|
if (countText) {
|
||||||
@@ -782,6 +832,15 @@ export async function mount(root) {
|
|||||||
(chunkText ? ` ${chunkText}` : "");
|
(chunkText ? ` ${chunkText}` : "");
|
||||||
if (syncBtn) syncBtn.title = full; // A4: the untruncated label rides the title, always
|
if (syncBtn) syncBtn.title = full; // A4: the untruncated label rides the title, always
|
||||||
if (syncResult) syncResult.textContent = full; // A4: the announcer hears the whole thing
|
if (syncResult) syncResult.textContent = full; // A4: the announcer hears the whole thing
|
||||||
|
// Phase 149 (task 01): the detail box — the SAME untruncated label
|
||||||
|
// (one source, two surfaces: sighted users read the box, the
|
||||||
|
// announcer hears it), visible only while a run is in flight.
|
||||||
|
if (syncProgressText) syncProgressText.textContent = full;
|
||||||
|
if (syncProgress) syncProgress.hidden = !running;
|
||||||
|
// The running button is the spinner (task 02 hides the label
|
||||||
|
// parts) — its accessible name is the full live label; every
|
||||||
|
// settle restores the ship value (see the function docstring).
|
||||||
|
if (syncBtn) syncBtn.setAttribute("aria-label", running ? full : "Sync sources");
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Phase 64 (task 04) + Phase 128 (task 01, the PARTS rework): the
|
/* Phase 64 (task 04) + Phase 128 (task 01, the PARTS rework): the
|
||||||
@@ -894,7 +953,13 @@ export async function mount(root) {
|
|||||||
* same way (absent from the bare builders — the upload job and the
|
* same way (absent from the bare builders — the upload job and the
|
||||||
* 202/409 click — so the "" default keeps the part hidden there).
|
* 202/409 click — so the "" default keeps the part hidden there).
|
||||||
* Same never-stale mechanics as before (disabled, aria-busy,
|
* Same never-stale mechanics as before (disabled, aria-busy,
|
||||||
* spinning icon, no is-error).
|
* spinning icon, no is-error). Phase 149 (task 01): the writer call
|
||||||
|
* passes `running = true` — the detail box reveals with the full
|
||||||
|
* label, and the button's accessible name becomes the full live
|
||||||
|
* label (the icon-only running button keeps a name; the writer
|
||||||
|
* owns the aria write, the entry no longer sets it) — plus the
|
||||||
|
* `is-running` class, which travels with the spin (task 02's CSS
|
||||||
|
* makes the running button icon-only).
|
||||||
*/
|
*/
|
||||||
function enterSyncRunningState(kind, currentFile, done, total, status) {
|
function enterSyncRunningState(kind, currentFile, done, total, status) {
|
||||||
if (!syncBtn) return;
|
if (!syncBtn) return;
|
||||||
@@ -904,10 +969,15 @@ export async function mount(root) {
|
|||||||
kind === "sync" && status
|
kind === "sync" && status
|
||||||
? fmtSyncPhaseLabel(status)
|
? fmtSyncPhaseLabel(status)
|
||||||
: fmtSyncLabel(kind, currentFile, done, total);
|
: fmtSyncLabel(kind, currentFile, done, total);
|
||||||
setSyncLabel(main, count, chunk); // the parts + the A4 untruncated title / announcer
|
// the parts + the A4 untruncated title / announcer + the phase-149
|
||||||
syncBtn.setAttribute("aria-label", "Sync sources");
|
// detail box (revealed, full label) + the running aria-label
|
||||||
|
setSyncLabel(main, count, chunk, true);
|
||||||
syncBtn.classList.remove("is-error");
|
syncBtn.classList.remove("is-error");
|
||||||
if (syncIcon) syncIcon.classList.add("is-spinning");
|
if (syncIcon) syncIcon.classList.add("is-spinning");
|
||||||
|
// Phase 149 (task 01): the icon-only running button — the class
|
||||||
|
// and the spin travel together (one in-flight state; the settle
|
||||||
|
// removes both).
|
||||||
|
syncBtn.classList.add("is-running");
|
||||||
// Phase 134 (task 02): the Cancel control is visible + enabled
|
// Phase 134 (task 02): the Cancel control is visible + enabled
|
||||||
// ONLY while a SYNC run is in flight — the upload job is NOT
|
// ONLY while a SYNC run is in flight — the upload job is NOT
|
||||||
// cancellable in this phase (its run keeps the control hidden).
|
// cancellable in this phase (its run keeps the control hidden).
|
||||||
@@ -927,10 +997,17 @@ export async function mount(root) {
|
|||||||
syncBtn.setAttribute("aria-label", "Sync sources");
|
syncBtn.setAttribute("aria-label", "Sync sources");
|
||||||
syncBtn.classList.remove("is-error");
|
syncBtn.classList.remove("is-error");
|
||||||
if (syncIcon) syncIcon.classList.remove("is-spinning");
|
if (syncIcon) syncIcon.classList.remove("is-spinning");
|
||||||
|
// Phase 149 (task 01): the class travels with the spin — EVERY
|
||||||
|
// settle (every terminal + both idles pass through here) leaves
|
||||||
|
// the running state.
|
||||||
|
syncBtn.classList.remove("is-running");
|
||||||
// Phase 128 (task 01): the idle / settle labels ("Sync sources",
|
// Phase 128 (task 01): the idle / settle labels ("Sync sources",
|
||||||
// "Synced HH:MM") go through the parts writer — count hidden
|
// "Synced HH:MM") go through the parts writer — count hidden
|
||||||
// (absent), the label's full string rides the title + #sync-result
|
// (absent), the label's full string rides the title + #sync-result
|
||||||
// (A4, as with every label state).
|
// (A4, as with every label state). Phase 149 (task 01): the
|
||||||
|
// default `running = false` keeps the detail box hidden in every
|
||||||
|
// non-running state (idle, settled, error) and re-restores the
|
||||||
|
// button's idle accessible name.
|
||||||
setSyncLabel(label);
|
setSyncLabel(label);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2236,6 +2236,50 @@ a.answer-image:hover { text-decoration: underline; } /* flat — the caption und
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
font-family: var(--mono); /* the same pinned-part language as the count (position reads as data) */
|
font-family: var(--mono); /* the same pinned-part language as the count (position reads as data) */
|
||||||
}
|
}
|
||||||
|
/* Phase 149 (task 02): the sync detail box — the house's bordered panel
|
||||||
|
language (the .page-head card: --surface fill, 1px --line border,
|
||||||
|
the house radius; --line is decorative, no contrast duty, like every
|
||||||
|
other house border). It sits in the .sources-shell flex column whose
|
||||||
|
1.25rem gap IS the house spacing above and below — no margin-top
|
||||||
|
(it would double the gap). min-height ≈ 2 lines (0.8rem mono at the
|
||||||
|
body 1.55 line-height, + the 0.75rem block padding + the 2px border,
|
||||||
|
border-box) so even a one-line label reads as a calm panel with
|
||||||
|
breathing room. The box is hidden-attr-driven (the global [hidden]
|
||||||
|
!important rule keeps it display:none at idle — it exists only while
|
||||||
|
a run is in flight). Mobile: a block in the same .container rides the
|
||||||
|
100%-width phone shell — full width with no override (task 03's E2E
|
||||||
|
asserts it at 360px). */
|
||||||
|
.sync-progress {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
min-height: 4rem;
|
||||||
|
}
|
||||||
|
.sync-progress-text {
|
||||||
|
display: block;
|
||||||
|
font-family: var(--mono); /* the .sync-result mono language (position reads as data) */
|
||||||
|
font-size: 0.8rem; /* the .sync-result size */
|
||||||
|
line-height: 1.55; /* the house body line-height */
|
||||||
|
color: var(--ink-soft); /* the .sync-result ink — a PINNED identity pair on --surface
|
||||||
|
(8.2:1 on the built-in dark, AA in both built-ins; the
|
||||||
|
theme gate keeps any custom theme at >= 4.5:1) */
|
||||||
|
overflow-wrap: anywhere; /* THE POINT of the box — a long source/relative/path WRAPS
|
||||||
|
inside the panel; the pre-phase pill ellipsis is retired
|
||||||
|
for the live detail (never clipped) */
|
||||||
|
}
|
||||||
|
/* Phase 149 (task 02): the running button IS the loading spinner (the
|
||||||
|
owner's ask) — the label parts hide via the .is-running class
|
||||||
|
(sources.js adds/removes it exactly where the icon's .is-spinning
|
||||||
|
travels). The pill's padding/border stay, so the control never
|
||||||
|
collapses: the 16px icon centers in a ~47x44px pill (desktop) or the
|
||||||
|
full-width 44px bar (mobile — the pre-phase .sync-btn { width: 100% }
|
||||||
|
rule keeps the big in-flight target, viewport-agnostic rule defined
|
||||||
|
ONCE here). :hover, :disabled, and :focus-visible are untouched —
|
||||||
|
they live on the .sync-btn element, and hiding the label span touches
|
||||||
|
neither. The idle "Sync sources" label (and the mobile ellipsis rules
|
||||||
|
for it) render exactly as pre-phase when the class is absent. */
|
||||||
|
.sync-btn.is-running .sync-label { display: none; }
|
||||||
/* Last-result announcer — soft ink, small mono */
|
/* Last-result announcer — soft ink, small mono */
|
||||||
.sync-result {
|
.sync-result {
|
||||||
display: block;
|
display: block;
|
||||||
|
|||||||
@@ -482,6 +482,17 @@
|
|||||||
to pull the latest and re-import.
|
to pull the latest and re-import.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Phase 149 (task 01): the sync detail box — the full UNTRUNCATED live
|
||||||
|
label ("Syncing… <source/rel/path> (n/m) · chunk n/m") in a bordered
|
||||||
|
panel under the button row, visible ONLY while a sync/upload run is
|
||||||
|
in flight (sources.js reveals/hides it through the single setSyncLabel
|
||||||
|
writer). Presentational (aria-hidden) — #sync-result stays the SOLE
|
||||||
|
aria-live announcer (one live region, no double announce). The button
|
||||||
|
itself is the spinner while running (task 02's .is-running CSS hides
|
||||||
|
the label parts). -->
|
||||||
|
<div class="sync-progress" id="sync-progress" hidden aria-hidden="true">
|
||||||
|
<span class="sync-progress-text" id="sync-progress-text"></span>
|
||||||
|
</div>
|
||||||
<!-- #sync-result is the aria-live announcer: the last sync
|
<!-- #sync-result is the aria-live announcer: the last sync
|
||||||
result ("N added · …") when a sync settles, and — phase 64 —
|
result ("N added · …") when a sync settles, and — phase 64 —
|
||||||
the LIVE file label while a sync runs ("Syncing… <file>
|
the LIVE file label while a sync runs ("Syncing… <file>
|
||||||
|
|||||||
@@ -11,7 +11,11 @@ The story gate runs the **real** sync path end to end — a real
|
|||||||
network), a real import against the mock LLM, a real KB-overview
|
network), a real import against the mock LLM, a real KB-overview
|
||||||
regeneration — plus the admin-only visibility and the full button
|
regeneration — plus the admin-only visibility and the full button
|
||||||
lifecycle (§7.4: "Syncing…" → "Synced HH:MM" + counts, the idempotent
|
lifecycle (§7.4: "Syncing…" → "Synced HH:MM" + counts, the idempotent
|
||||||
second run, the 409 double trigger).
|
second run, the 409 double trigger). Phase 149 (task 01): the
|
||||||
|
RUNNING-state pins (the 202's "Syncing…") ride the detail box
|
||||||
|
(``#sync-progress`` — the running label left the pill; the button is
|
||||||
|
the spinner), while every SETTLED pin (the idle "Sync sources", the
|
||||||
|
"Synced HH:MM" label + counts line) is unchanged.
|
||||||
|
|
||||||
Per-module app env (the E2E conftest pattern, module-scoped): this
|
Per-module app env (the E2E conftest pattern, module-scoped): this
|
||||||
story's app boots with ``BOR_GIT_SOURCES=file://<fixture repo>`` and
|
story's app boots with ``BOR_GIT_SOURCES=file://<fixture repo>`` and
|
||||||
@@ -246,11 +250,12 @@ def test_anonymous_sees_no_button(page: Page, app_url: str, db_ready: None) -> N
|
|||||||
|
|
||||||
|
|
||||||
def test_admin_sync_lifecycle(page: Page, app_url: str, db_ready: None) -> None:
|
def test_admin_sync_lifecycle(page: Page, app_url: str, db_ready: None) -> None:
|
||||||
"""AC 2 + 4 — click → "Syncing…" (disabled) → "Synced HH:MM" + the
|
"""AC 2 + 4 — click → "Syncing…" (disabled; phase 149: the
|
||||||
counts, against the REAL pipeline (git clone of the ``file://``
|
running label is in the detail box — the button is the spinner)
|
||||||
fixture → import with prune → KB overview regeneration, mock LLM).
|
→ "Synced HH:MM" + the counts, against the REAL pipeline (git
|
||||||
Then the idempotent second run: fast-forward pull + sha256 hash
|
clone of the ``file://`` fixture → import with prune → KB overview
|
||||||
skip → "0 added · 1 unchanged"."""
|
regeneration, mock LLM). Then the idempotent second run:
|
||||||
|
fast-forward pull + sha256 hash skip → "0 added · 1 unchanged"."""
|
||||||
login(page, app_url) # lands on /sources.html (the button's home)
|
login(page, app_url) # lands on /sources.html (the button's home)
|
||||||
|
|
||||||
btn = page.locator("#sync-btn")
|
btn = page.locator("#sync-btn")
|
||||||
@@ -260,7 +265,9 @@ def test_admin_sync_lifecycle(page: Page, app_url: str, db_ready: None) -> None:
|
|||||||
# --- run 1: clone + import + overview --------------------------------
|
# --- run 1: clone + import + overview --------------------------------
|
||||||
btn.click()
|
btn.click()
|
||||||
expect(btn).to_be_disabled()
|
expect(btn).to_be_disabled()
|
||||||
expect(page.locator("#sync-label")).to_have_text("Syncing…")
|
# Phase 149 (task 01): the running detail rides the box under the
|
||||||
|
# button row (the button is the spinner) — the 202's bare label.
|
||||||
|
expect(page.locator("#sync-progress")).to_have_text("Syncing…")
|
||||||
expect(page.locator("#sync-label")).to_have_text(SYNCED_LABEL, timeout=SYNC_TIMEOUT_MS)
|
expect(page.locator("#sync-label")).to_have_text(SYNCED_LABEL, timeout=SYNC_TIMEOUT_MS)
|
||||||
expect(btn).to_be_enabled() # never stale — re-enabled at the terminal state
|
expect(btn).to_be_enabled() # never stale — re-enabled at the terminal state
|
||||||
# The fixture doc (one A9-format file) is the only change.
|
# The fixture doc (one A9-format file) is the only change.
|
||||||
@@ -282,7 +289,7 @@ def test_admin_sync_lifecycle(page: Page, app_url: str, db_ready: None) -> None:
|
|||||||
# --- run 2: idempotent pull + hash skip -------------------------------
|
# --- run 2: idempotent pull + hash skip -------------------------------
|
||||||
btn.click()
|
btn.click()
|
||||||
expect(btn).to_be_disabled()
|
expect(btn).to_be_disabled()
|
||||||
expect(page.locator("#sync-label")).to_have_text("Syncing…")
|
expect(page.locator("#sync-progress")).to_have_text("Syncing…")
|
||||||
expect(page.locator("#sync-label")).to_have_text(SYNCED_LABEL, timeout=SYNC_TIMEOUT_MS)
|
expect(page.locator("#sync-label")).to_have_text(SYNCED_LABEL, timeout=SYNC_TIMEOUT_MS)
|
||||||
expect(btn).to_be_enabled()
|
expect(btn).to_be_enabled()
|
||||||
# Nothing re-embedded (sha256 delta) — the no-op run announces the
|
# Nothing re-embedded (sha256 delta) — the no-op run announces the
|
||||||
|
|||||||
@@ -497,10 +497,12 @@ def test_admin_cancel_lifecycle(page: Page, app_url: str, db_ready: None) -> Non
|
|||||||
btn.click()
|
btn.click()
|
||||||
# The 202 enters the running state SYNCHRONOUSLY (the click handler
|
# The 202 enters the running state SYNCHRONOUSLY (the click handler
|
||||||
#'s 202 branch, before the first 2 s poll tick): the main button
|
#'s 202 branch, before the first 2 s poll tick): the main button
|
||||||
# disables + "Syncing…", and the Cancel control reveals + enables
|
# disables, the detail box reveals with "Syncing…" (phase 149 —
|
||||||
# (enterSyncRunningState, kind "sync").
|
# the running label rides the box; the button is the spinner),
|
||||||
|
# and the Cancel control reveals + enables (enterSyncRunningState,
|
||||||
|
# kind "sync").
|
||||||
expect(btn).to_be_disabled()
|
expect(btn).to_be_disabled()
|
||||||
expect(page.locator("#sync-label")).to_have_text("Syncing…")
|
expect(page.locator("#sync-progress")).to_have_text("Syncing…")
|
||||||
expect(cancel).to_be_visible()
|
expect(cancel).to_be_visible()
|
||||||
expect(cancel).to_be_enabled()
|
expect(cancel).to_be_enabled()
|
||||||
|
|
||||||
@@ -553,7 +555,7 @@ def test_admin_cancel_lifecycle(page: Page, app_url: str, db_ready: None) -> Non
|
|||||||
# --- run 2: the re-sync is clean --------------------------------------
|
# --- run 2: the re-sync is clean --------------------------------------
|
||||||
btn.click()
|
btn.click()
|
||||||
expect(btn).to_be_disabled()
|
expect(btn).to_be_disabled()
|
||||||
expect(page.locator("#sync-label")).to_have_text("Syncing…")
|
expect(page.locator("#sync-progress")).to_have_text("Syncing…")
|
||||||
expect(cancel).to_be_visible() # the fresh run is cancellable again
|
expect(cancel).to_be_visible() # the fresh run is cancellable again
|
||||||
expect(page.locator("#sync-label")).to_have_text(
|
expect(page.locator("#sync-label")).to_have_text(
|
||||||
SYNCED_LABEL, timeout=RESYNC_TIMEOUT_MS
|
SYNCED_LABEL, timeout=RESYNC_TIMEOUT_MS
|
||||||
|
|||||||
@@ -688,10 +688,12 @@ def _start_sync(page: Page, app_url: str, sync_dir: Path) -> None:
|
|||||||
# the phase-64 contract): disabled, aria-busy, spinning icon, and
|
# the phase-64 contract): disabled, aria-busy, spinning icon, and
|
||||||
# the label's MAIN part alone ("Syncing…" — the 202 click carries
|
# the label's MAIN part alone ("Syncing…" — the 202 click carries
|
||||||
# no status, so both pinned parts hide, phase 128/129 writers).
|
# no status, so both pinned parts hide, phase 128/129 writers).
|
||||||
|
# Phase 149 (task 01): the running detail rides the box — the 202's
|
||||||
|
# bare label is in #sync-progress (the button is the spinner).
|
||||||
expect(btn).to_be_disabled()
|
expect(btn).to_be_disabled()
|
||||||
expect(btn).to_have_attribute("aria-busy", "true")
|
expect(btn).to_have_attribute("aria-busy", "true")
|
||||||
expect(btn.locator(".sync-icon")).to_have_class(re.compile(r"\bis-spinning\b"))
|
expect(btn.locator(".sync-icon")).to_have_class(re.compile(r"\bis-spinning\b"))
|
||||||
expect(page.locator("#sync-label")).to_have_text("Syncing…")
|
expect(page.locator("#sync-progress")).to_have_text("Syncing…")
|
||||||
expect(page.locator(".sync-label-count")).to_be_hidden()
|
expect(page.locator(".sync-label-count")).to_be_hidden()
|
||||||
expect(page.locator(".sync-label-chunk")).to_be_hidden()
|
expect(page.locator(".sync-label-chunk")).to_be_hidden()
|
||||||
expect(page.locator("#sync-error-banner")).to_be_hidden()
|
expect(page.locator("#sync-error-banner")).to_be_hidden()
|
||||||
|
|||||||
+295
-234
@@ -1,8 +1,7 @@
|
|||||||
"""Phase 128 task 03 E2E (Playwright, mock-only): the RUNNING sync label
|
"""Phase 128 task 03 E2E (Playwright, mock-only): the RUNNING sync
|
||||||
is fully visible at a phone viewport — the file path ellipsizes to fit
|
detail is fully visible at a phone viewport — in BOTH long spans
|
||||||
and the ``(n/m)`` count span sits in its own non-shrinking part that is
|
(import + summaries) — while desktop keeps the pre-phase full
|
||||||
NEVER clipped — in BOTH long spans (import + summaries), while desktop
|
strings.
|
||||||
keeps the pre-phase strings and the ``min(16rem, 40vw)`` cap.
|
|
||||||
|
|
||||||
The dedicated story suite for ``128_sync_label_mobile`` (A16 — one
|
The dedicated story suite for ``128_sync_label_mobile`` (A16 — one
|
||||||
Playwright file per phase, run in isolation): the owner's report was
|
Playwright file per phase, run in isolation): the owner's report was
|
||||||
@@ -10,11 +9,40 @@ that ``Syncing… name/of/file/here (2/3)`` "doesn't show up on mobile" —
|
|||||||
pre-phase, ``#sync-label`` was ONE inline-block span (whole label
|
pre-phase, ``#sync-label`` was ONE inline-block span (whole label
|
||||||
string, one ellipsis) whose mobile media query LIFTED the
|
string, one ellipsis) whose mobile media query LIFTED the
|
||||||
``min(16rem, 40vw)`` cap (``max-width: none``), so the ellipsized tail
|
``min(16rem, 40vw)`` cap (``max-width: none``), so the ellipsized tail
|
||||||
— where ``(n/m)`` lives — was clipped away on a phone. Phase 128 splits
|
— where ``(n/m)`` lives — was clipped away on a phone. Phase 128 split
|
||||||
the label into two parts (``.sync-label-main`, the shrinkable
|
the label into two parts (``.sync-label-main`, the shrinkable
|
||||||
prefix + file/folder, + ``.sync-label-count`, the pinned position) so
|
prefix + file/folder, + ``.sync-label-count`, the pinned position) so
|
||||||
the count is never the ellipsis's sacrifice.
|
the count is never the ellipsis's sacrifice.
|
||||||
|
|
||||||
|
Phase 149 (task 01) RE-POINTED the running-state pins: the owner's
|
||||||
|
follow-up was that the full live label (a long
|
||||||
|
``source/relative/path`` + the counts + the chunk part) is clipped
|
||||||
|
in the pill even on desktop, and the fix moves the running detail
|
||||||
|
out of the pill entirely — while a run is in flight the button is
|
||||||
|
the spinner (the ``is-running`` class; task 02's CSS hides the label
|
||||||
|
parts), and a detail box under the button row (``#sync-progress``)
|
||||||
|
carries the SAME full untruncated label — wrapping, never clipped,
|
||||||
|
``#sync-result`` staying the sole aria-live announcer. Tests 1–3
|
||||||
|
therefore pin the BOX: visible while the run is in flight, its text
|
||||||
|
the pre-phase byte-identical full string ("Syncing… <full
|
||||||
|
source/relative/path> (n/4)" / "Summarizing folders… <folder>
|
||||||
|
(n/3)" + the phase-129 "· chunk n/m" while a batch is in flight),
|
||||||
|
inside the viewport at 390 px and 1280 px, the button in its
|
||||||
|
running state, and the ``title`` carrying the same full string
|
||||||
|
(A4 — one source, two surfaces). The SETTLED pins (the phase-32
|
||||||
|
"Synced HH:MM" label, the count span hidden + empty, the box hidden)
|
||||||
|
are UNCHANGED. Test 4 (the pre-phase-128 rules' clip reproduction)
|
||||||
|
pins the old markup/CSS counterfactually — the label parts still
|
||||||
|
receive their text under any CSS (the writer is unchanged), so it
|
||||||
|
holds on the DOM text + injected rules regardless of the phase-149
|
||||||
|
box. The injected set ALSO neutralizes the phase-149 hide rule
|
||||||
|
(``.sync-btn.is-running .sync-label { display: none }`` — task 02's
|
||||||
|
specificity (0,2,0) would beat the injected (0,1,0) ``.sync-label``
|
||||||
|
rule and leave the parts display:none, making the geometry vacuous);
|
||||||
|
pre-phase, that rule simply did not exist, so the faithful
|
||||||
|
pre-phase counterfactual restores the parts' inline-block display
|
||||||
|
at matching specificity, last in the cascade.
|
||||||
|
|
||||||
Run in isolation (DB must be up: ``podman compose up -d db``):
|
Run in isolation (DB must be up: ``podman compose up -d db``):
|
||||||
|
|
||||||
uv run pytest tests/e2e/test_sync_label_mobile.py -v --no-cov
|
uv run pytest tests/e2e/test_sync_label_mobile.py -v --no-cov
|
||||||
@@ -56,38 +84,34 @@ Test → observable mapping (Playwright Mapping Rule):
|
|||||||
1. ``test_mobile_label_fits_viewport_with_count_in_import`` — admin
|
1. ``test_mobile_label_fits_viewport_with_count_in_import`` — admin
|
||||||
page on the sources view at viewport **390×844** (mobile); click
|
page on the sources view at viewport **390×844** (mobile); click
|
||||||
**Sync sources**; while the import phase is running (the page's
|
**Sync sources**; while the import phase is running (the page's
|
||||||
own 2 s poll): the rendered ``#sync-label``'s bounding box is
|
own 2 s poll): the detail box (``#sync-progress``) is VISIBLE and
|
||||||
INSIDE the viewport (left ≥ 0, right ≤ 390 — the bug's direct
|
an atomic snapshot of: its text the FULL untruncated label
|
||||||
negation), and the ``.sync-label-count`` part is VISIBLE (non-null
|
``Syncing… <full source/relative/path> (n/4)`` (+ the phase-129
|
||||||
box) and NOT clipped — its right edge sits at or inside the label's
|
"· chunk n/m" part while a batch is in flight — optional, the
|
||||||
right edge and the button's right edge, with text matching
|
pre-first-batch frame has none) with the ``(n/4)`` count IN the
|
||||||
``\\(\\d+/\\d+\\)`` once a file is in flight; ``.sync-label-main``
|
text (never clipped — nothing is ellipsized away), the box's
|
||||||
carries ``Syncing… <file>`` — the FULL source/relative/path
|
bounding box INSIDE the viewport (left ≥ 0, right ≤ 390 — the
|
||||||
(visually ellipsized, but the DOM text and the button ``title``
|
bug's direct negation), the button in its running state
|
||||||
are untruncated, A4). Phase 129: the label's THIRD pinned part —
|
(``is-running`` — the spinner), and the button ``title``
|
||||||
``.sync-label-chunk``, "· chunk n/m" (the in-flight file's
|
carrying the SAME full string (A4 — one source, two surfaces).
|
||||||
embed-batch position, batch granularity) — renders while a file's
|
The run then settles (the phase-32 terminal shape; the box hides).
|
||||||
embed batch is in flight (``chunks_total > 0``) and rides the
|
|
||||||
untruncated ``title`` too; the snapshot accepts it (optional —
|
|
||||||
the pre-first-batch frame has no chunk part yet; the phase-129
|
|
||||||
dedicated suite pins it strictly).
|
|
||||||
2. ``test_mobile_label_shows_summaries_count`` — same mobile
|
2. ``test_mobile_label_shows_summaries_count`` — same mobile
|
||||||
viewport; through to the summaries phase: ``.sync-label-main``
|
viewport; through to the summaries phase: the box text matches
|
||||||
matches ``Summarizing folders…`` (the folder part optional — the
|
``Summarizing folders… [source/folder] (n/3)`` (the folder part
|
||||||
phase's first poll may precede the first hook), the
|
optional — the phase's first poll may precede the first hook — the
|
||||||
``.sync-label-count`` part matches ``\\(\\d+/3\\)`` and stays
|
``(n/3)`` count always present, IN the text) with the same
|
||||||
visible (same clip pins as test 1); on settle the button leaves
|
box-geometry pins as test 1 (inside the viewport), the ``title``
|
||||||
the running label (the phase-32 terminal ``Synced HH:MM``) and the
|
carrying the same full string (A4); on settle the button leaves
|
||||||
count span is hidden + empty.
|
the running label (the phase-32 terminal ``Synced HH:MM``), the
|
||||||
|
count span is hidden + empty, and the box hides.
|
||||||
3. ``test_desktop_label_unchanged`` — viewport 1280×800; the same
|
3. ``test_desktop_label_unchanged`` — viewport 1280×800; the same
|
||||||
sync: during import the full rendered label text (main + count +
|
sync: during import the box text matches ``Syncing… <full
|
||||||
optional phase-129 chunk concatenation) matches ``Syncing… <full
|
source/relative/path> (n/m)`` (+ the phase-129 chunk part while a
|
||||||
source/relative/path> (n/m)`` (the pre-phase byte-identical
|
batch is in flight) — the pre-phase byte-identical FULL string
|
||||||
string, the chunk part appended while a batch is in flight) and
|
(phase 149 moved it from the pill's parts to the box — the text
|
||||||
the label's width respects the ``min(16rem, 40vw)`` cap (bounding
|
is unchanged), the box inside the viewport, the button in its
|
||||||
box width ≤ 256 px + slack — the shrinkable main part absorbs
|
running state (``is-running``), and the ``title`` carrying the
|
||||||
the extra pinned part); the ``title`` carries the same
|
same untruncated string (A4).
|
||||||
untruncated string (A4).
|
|
||||||
4. ``test_pre_phase_css_reproduces_the_clip`` — the pin check: the
|
4. ``test_pre_phase_css_reproduces_the_clip`` — the pin check: the
|
||||||
SAME mobile run with the pre-phase-128 ``.sync-label`` rules
|
SAME mobile run with the pre-phase-128 ``.sync-label`` rules
|
||||||
re-injected (CSSOM ``insertRule`` into the origin's own linked
|
re-injected (CSSOM ``insertRule`` into the origin's own linked
|
||||||
@@ -183,21 +207,26 @@ FILE_RE = r"synclabel/(?:alpha|bravo)/(?:alpha|bravo)-[ab]\.md"
|
|||||||
#: strings, unit-pinned in tests/unit/test_frontend_sync_upload.py):
|
#: strings, unit-pinned in tests/unit/test_frontend_sync_upload.py):
|
||||||
#: main "Syncing… <full source/relative/path>" + count "(n/4)" —
|
#: main "Syncing… <full source/relative/path>" + count "(n/4)" —
|
||||||
#: ``done`` starts at 1 (the importer counts the file as current
|
#: ``done`` starts at 1 (the importer counts the file as current
|
||||||
#: BEFORE indexing it), so ``n`` ∈ 1..4.
|
#: BEFORE indexing it), so ``n`` ∈ 1..4. (Test 4 — the pre-phase
|
||||||
|
#: rules' clip reproduction — pins the PARTS' DOM text directly.)
|
||||||
IMPORT_MAIN_RE = re.compile(rf"^Syncing… {FILE_RE}$")
|
IMPORT_MAIN_RE = re.compile(rf"^Syncing… {FILE_RE}$")
|
||||||
IMPORT_COUNT_RE = re.compile(r"^\([1-4]/4\)$")
|
IMPORT_COUNT_RE = re.compile(r"^\([1-4]/4\)$")
|
||||||
#: The pre-phase byte-identical FULL string (main + " " + count — the
|
#: The pre-phase byte-identical FULL string (main + " " + count — the
|
||||||
#: single span's text before phase 128; the A4 title keeps it), plus
|
#: single span's text before phase 128; the A4 title keeps it), plus
|
||||||
#: the OPTIONAL phase-129 chunk part (" · chunk n/m" — the in-flight
|
#: the OPTIONAL phase-129 chunk part (" · chunk n/m" — the in-flight
|
||||||
#: file's embed-batch position, batch granularity) the title carries
|
#: file's embed-batch position, batch granularity). Phase 149 (task
|
||||||
#: while a file's embed batch is in flight.
|
#: 01): the box (#sync-progress) carries this EXACT string while the
|
||||||
|
#: import is in flight (tests 1 + 3) — the title carries it too
|
||||||
|
#: (A4 — one source, two surfaces).
|
||||||
IMPORT_FULL_RE = re.compile(rf"^Syncing… {FILE_RE} \([1-4]/4\)(?: · chunk \d+/\d+)?$")
|
IMPORT_FULL_RE = re.compile(rf"^Syncing… {FILE_RE} \([1-4]/4\)(?: · chunk \d+/\d+)?$")
|
||||||
|
|
||||||
#: The summaries label's PARTS (phase 98 task 02, unit-pinned): main
|
#: The summaries label's FULL string (phase 98 task 02, unit-pinned):
|
||||||
#: "Summarizing folders… [source/folder]" (the folder part omitted
|
#: "Summarizing folders… [source/folder] (n/3)" (the folder part
|
||||||
#: while ``current_summary`` is null) + count "(n/3)".
|
#: omitted while ``current_summary`` is null — the phase's first poll
|
||||||
SUMMARY_MAIN_RE = re.compile(r"^Summarizing folders…(?: \S+)?$")
|
#: may precede the first hook — the (n/3) count always present). Phase
|
||||||
SUMMARY_COUNT_RE = re.compile(r"^\(\d+/3\)$")
|
#: 149 (task 01): the box carries this EXACT string while the
|
||||||
|
#: summaries phase is in flight (test 2) — the title carries it too.
|
||||||
|
SUMMARY_FULL_RE = re.compile(r"^Summarizing folders…(?: \S+)? \(\d+/3\)$")
|
||||||
|
|
||||||
#: "Synced HH:MM" — the local-time last-result label (sources.js's
|
#: "Synced HH:MM" — the local-time last-result label (sources.js's
|
||||||
#: fmtSyncTime), any hour/minute (the test_sync_button.py pattern).
|
#: fmtSyncTime), any hour/minute (the test_sync_button.py pattern).
|
||||||
@@ -209,17 +238,15 @@ SYNCED_LABEL = re.compile(r"Synced \d{1,2}:\d{2}$")
|
|||||||
IDLE_OR_SETTLED = re.compile(r"^(Sync sources|Synced \d{1,2}:\d{2})$")
|
IDLE_OR_SETTLED = re.compile(r"^(Sync sources|Synced \d{1,2}:\d{2})$")
|
||||||
|
|
||||||
#: The viewports (the task's mapping): the phone (the reported bug)
|
#: The viewports (the task's mapping): the phone (the reported bug)
|
||||||
#: and the desktop (the unchanged-contract leg).
|
#: and the desktop (the unchanged-contract leg). Phase 149 (task 01):
|
||||||
|
#: the geometry pins now apply to the DETAIL BOX (a block in the
|
||||||
|
#: 72rem container — it can only shrink toward the container's
|
||||||
|
#: content width, never overflow the viewport); the old pill-label
|
||||||
|
#: cap (min(16rem, 40vw)) applied to the ellipsized pill label, which
|
||||||
|
#: is no longer the running surface (the button is the spinner).
|
||||||
MOBILE_VIEWPORT: ViewportSize = {"width": 390, "height": 844}
|
MOBILE_VIEWPORT: ViewportSize = {"width": 390, "height": 844}
|
||||||
DESKTOP_VIEWPORT: ViewportSize = {"width": 1280, "height": 800}
|
DESKTOP_VIEWPORT: ViewportSize = {"width": 1280, "height": 800}
|
||||||
|
|
||||||
#: The desktop cap: ``min(16rem, 40vw)`` = 256 px at the 16 px :root
|
|
||||||
#: (16rem) and the 1280 px viewport (40vw = 512 px) — the bounding
|
|
||||||
#: box width may not exceed the cap (a small slack covers float +
|
|
||||||
#: border rounding).
|
|
||||||
DESKTOP_CAP_PX = 256
|
|
||||||
CAP_SLACK_PX = 8
|
|
||||||
|
|
||||||
#: Generous settle budgets: a changed sync against the slowed LLM is
|
#: Generous settle budgets: a changed sync against the slowed LLM is
|
||||||
#: ≈ 15–18 s; the UI's 2 s poll settles at most one tick after the
|
#: ≈ 15–18 s; the UI's 2 s poll settles at most one tick after the
|
||||||
#: terminal state lands.
|
#: terminal state lands.
|
||||||
@@ -234,27 +261,127 @@ def _md(title: str, body: str) -> str:
|
|||||||
# The in-browser snapshot functions (Playwright Mapping Rule — the
|
# The in-browser snapshot functions (Playwright Mapping Rule — the
|
||||||
# rendered DOM is the observable).
|
# rendered DOM is the observable).
|
||||||
#
|
#
|
||||||
# Each returns null while the awaited label state is absent, and an
|
# Phase 149 (task 01): the RUNNING detail rides the detail box —
|
||||||
# ATOMIC geometry snapshot once it is present: the part texts, the
|
# #sync-progress (visible IFF a run is in flight) with the FULL
|
||||||
# button title, and the getBoundingClientRect boxes of the label, the
|
# untruncated label in its .sync-progress-text span. Each snapshot
|
||||||
# count part, and the button — read in the SAME in-browser evaluation
|
# returns null while the awaited box state is absent, and an ATOMIC
|
||||||
# that verified the label state, so the numbers can never straddle a
|
# snapshot once it is present: the box's text + the button's title +
|
||||||
# 2 s poll tick (the parts + the title are written together in one
|
# is-running flag + the getBoundingClientRect boxes of the box and
|
||||||
# JS task by setSyncLabel).
|
# the button — read in the SAME in-browser evaluation that verified
|
||||||
|
# the box state, so the numbers can never straddle a 2 s poll tick
|
||||||
|
# (the box text + the title are written together in one JS task by
|
||||||
|
# the single setSyncLabel writer — A4: one source, two surfaces).
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
#: Test 1/3 — the in-flight IMPORT label: main carries "Syncing…
|
#: Test 1/3 — the in-flight IMPORT detail: the box is VISIBLE and its
|
||||||
#: <full source/relative/path>" (the untruncated A4 text — the visual
|
#: text is the FULL untruncated label — "Syncing… <full
|
||||||
#: ellipsis never touches textContent), the count part is VISIBLE
|
#: source/relative/path> (n/4)" (+ the optional phase-129 " · chunk
|
||||||
#: (not ``hidden``) with the "(n/4)" position, and the button title
|
#: n/m" part while a file's embed batch is in flight — the
|
||||||
#: carries the full untruncated label (main + " " + count, A4). Phase
|
#: pre-first-batch frame has none; the phase-129 dedicated suite pins
|
||||||
#: 129: the THIRD pinned part — .sync-label-chunk ("· chunk n/m", the
|
#: it strictly) — the count is IN the text (never clipped — nothing
|
||||||
#: in-flight file's embed-batch position) — rides the title too while
|
#: is ellipsized away), the button is in its running state
|
||||||
#: a file's embed batch is in flight (chunks_total > 0): the title
|
#: (is-running — the spinner), and the title carries the SAME full
|
||||||
#: check accepts main + " " + count (+ " " + chunk when the part is
|
#: string (A4).
|
||||||
#: rendered) — optional, since the pre-first-batch frame has no chunk
|
_JS_IMPORT_BOX_SNAPSHOT = """
|
||||||
#: part (the phase-129 dedicated suite pins the part strictly).
|
() => {
|
||||||
_JS_IMPORT_SNAPSHOT = """
|
const box = document.querySelector('#sync-progress');
|
||||||
|
const textEl = document.querySelector('#sync-progress-text');
|
||||||
|
const btn = document.querySelector('#sync-btn');
|
||||||
|
if (!box || !textEl || !btn || box.hidden) return null;
|
||||||
|
const t = textEl.textContent;
|
||||||
|
const reFull = new RegExp(
|
||||||
|
"^Syncing… synclabel/(?:alpha|bravo)/(?:alpha|bravo)-[ab]\\\\.md " +
|
||||||
|
"\\\\([1-4]/4\\\\)(?: · chunk \\\\d+/\\\\d+)?$"
|
||||||
|
);
|
||||||
|
if (!reFull.test(t)) return null;
|
||||||
|
const title = btn.getAttribute('title');
|
||||||
|
if (title !== t) return null;
|
||||||
|
const rect = (el) => {
|
||||||
|
const b = el.getBoundingClientRect();
|
||||||
|
return { left: b.left, right: b.right, width: b.width, height: b.height };
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
boxText: t,
|
||||||
|
title,
|
||||||
|
isRunning: btn.classList.contains('is-running'),
|
||||||
|
boxRect: rect(box),
|
||||||
|
btnRect: rect(btn),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
#: Test 2 — the SUMMARIES detail: the box is VISIBLE and its text is
|
||||||
|
#: the full "Summarizing folders… [source/folder] (n/3)" (the folder
|
||||||
|
#: part optional — the phase's first poll may precede the first hook —
|
||||||
|
#: the (n/3) count always present, IN the text), the button is in its
|
||||||
|
#: running state, and the title carries the SAME full string (A4).
|
||||||
|
_JS_SUMMARY_BOX_SNAPSHOT = """
|
||||||
|
() => {
|
||||||
|
const box = document.querySelector('#sync-progress');
|
||||||
|
const textEl = document.querySelector('#sync-progress-text');
|
||||||
|
const btn = document.querySelector('#sync-btn');
|
||||||
|
if (!box || !textEl || !btn || box.hidden) return null;
|
||||||
|
const t = textEl.textContent;
|
||||||
|
const reFull = /^Summarizing folders…(?: \\S+)? \\(\\d+\\/3\\)$/;
|
||||||
|
if (!reFull.test(t)) return null;
|
||||||
|
const title = btn.getAttribute('title');
|
||||||
|
if (title !== t) return null;
|
||||||
|
const rect = (el) => {
|
||||||
|
const b = el.getBoundingClientRect();
|
||||||
|
return { left: b.left, right: b.right, width: b.width, height: b.height };
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
boxText: t,
|
||||||
|
title,
|
||||||
|
isRunning: btn.classList.contains('is-running'),
|
||||||
|
boxRect: rect(box),
|
||||||
|
btnRect: rect(btn),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
#: The pre-phase-128 ``.sync-label`` geometry, re-injected for test 4
|
||||||
|
#: (the bug reproduction): the base rule's inline-block + single
|
||||||
|
#: ellipsis with the mobile media query's lifted cap (``max-width:
|
||||||
|
#: none``) — the label sizes to its FULL content and its ONE ellipsis
|
||||||
|
#: owns the whole tail, so the count (now riding a child span inside
|
||||||
|
#: that ellipsized box) is clipped away with it. The parts' own rules
|
||||||
|
#: are neutralized (no flex row, no per-part ellipsis, the pre-phase
|
||||||
|
#: non-mono count font). Phase 149 (task 02): the set ALSO
|
||||||
|
#: neutralizes the phase-149 hide rule — ``.sync-btn.is-running
|
||||||
|
#: .sync-label { display: none }`` (0,2,0) would beat the injected
|
||||||
|
#: (0,1,0) ``.sync-label`` rule and leave the parts display:none, so
|
||||||
|
#: the counterfactual restores the parts' pre-phase inline-block
|
||||||
|
#: display at matching specificity (last in the cascade it wins);
|
||||||
|
#: pre-phase, the hide rule simply did not exist. The rules are
|
||||||
|
#: complete rule strings — they go in via CSSOM ``insertRule``
|
||||||
|
#: (appended to the origin's own linked stylesheet, so the cascade
|
||||||
|
#: position is "last wins" like an injected ``<style>`` tag — which
|
||||||
|
#: the A20 CSP would block). The probe measured this override
|
||||||
|
#: reproduces the owner's report at 390 px with the repo's fonts (the
|
||||||
|
#: count's box fully past the label's clip edge and past the pill's
|
||||||
|
#: right edge).
|
||||||
|
_PRE_PHASE_SYNC_RULES = (
|
||||||
|
".sync-label { display: inline-block; align-items: initial; gap: 0; "
|
||||||
|
"max-width: none; min-width: 0; overflow: hidden; "
|
||||||
|
"text-overflow: ellipsis; white-space: nowrap; }",
|
||||||
|
".sync-label-main { min-width: 0; overflow: visible; text-overflow: clip; }",
|
||||||
|
".sync-label-count { flex: 0 1 auto; font-family: inherit; }",
|
||||||
|
".sync-btn.is-running .sync-label { display: inline-block; }",
|
||||||
|
)
|
||||||
|
|
||||||
|
#: Test 4 ONLY — the PARTS-level in-flight IMPORT snapshot (the
|
||||||
|
#: counterfactual clip reproduction): the writer still lands the label
|
||||||
|
#: PARTS (main + count + optional chunk) in the DOM for every running
|
||||||
|
#: label (phase 149's box reads the same writer output), so under the
|
||||||
|
#: re-injected pre-phase-128 CSS the parts' texts + geometry are the
|
||||||
|
#: observable — the count part's box runs past the label's clip edge
|
||||||
|
#: (painted away) + off the pill's right edge (the bug). Reads the
|
||||||
|
#: parts' textContent + hidden flags, the button title (A4), and the
|
||||||
|
#: getBoundingClientRect boxes of the label, the count part, and the
|
||||||
|
#: button — atomically (one in-browser evaluation, so the numbers
|
||||||
|
#: never straddle a 2 s poll tick).
|
||||||
|
_JS_PARTS_IMPORT_SNAPSHOT = """
|
||||||
() => {
|
() => {
|
||||||
const label = document.querySelector('#sync-label');
|
const label = document.querySelector('#sync-label');
|
||||||
const btn = document.querySelector('#sync-btn');
|
const btn = document.querySelector('#sync-btn');
|
||||||
@@ -287,61 +414,6 @@ _JS_IMPORT_SNAPSHOT = """
|
|||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
#: Test 2 — the SUMMARIES label: main carries "Summarizing folders…
|
|
||||||
#: [source/folder]" (the folder part optional — the phase's first
|
|
||||||
#: poll may precede the first hook), the count part is visible with
|
|
||||||
#: the "(n/3)" position, and the title carries the full untruncated
|
|
||||||
#: label (A4).
|
|
||||||
_JS_SUMMARY_SNAPSHOT = """
|
|
||||||
() => {
|
|
||||||
const label = document.querySelector('#sync-label');
|
|
||||||
const btn = document.querySelector('#sync-btn');
|
|
||||||
if (!label || !btn) return null;
|
|
||||||
const main = label.querySelector('.sync-label-main');
|
|
||||||
const count = label.querySelector('.sync-label-count');
|
|
||||||
if (!main || !count || count.hidden) return null;
|
|
||||||
const reMain = /^Summarizing folders…(?: \\S+)?$/;
|
|
||||||
const reCount = /^\\(\\d+\\/3\\)$/;
|
|
||||||
if (!reMain.test(main.textContent) || !reCount.test(count.textContent)) return null;
|
|
||||||
const title = btn.getAttribute('title');
|
|
||||||
if (title !== main.textContent + ' ' + count.textContent) return null;
|
|
||||||
const rect = (el) => {
|
|
||||||
const b = el.getBoundingClientRect();
|
|
||||||
return { left: b.left, right: b.right, width: b.width, height: b.height };
|
|
||||||
};
|
|
||||||
return {
|
|
||||||
mainText: main.textContent,
|
|
||||||
countText: count.textContent,
|
|
||||||
title,
|
|
||||||
labelRect: rect(label),
|
|
||||||
countRect: rect(count),
|
|
||||||
btnRect: rect(btn),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
|
|
||||||
#: The pre-phase-128 ``.sync-label`` geometry, re-injected for test 4
|
|
||||||
#: (the bug reproduction): the base rule's inline-block + single
|
|
||||||
#: ellipsis with the mobile media query's lifted cap (``max-width:
|
|
||||||
#: none``) — the label sizes to its FULL content and its ONE ellipsis
|
|
||||||
#: owns the whole tail, so the count (now riding a child span inside
|
|
||||||
#: that ellipsized box) is clipped away with it. The parts' own rules
|
|
||||||
#: are neutralized (no flex row, no per-part ellipsis, the pre-phase
|
|
||||||
#: non-mono count font). The rules are complete rule strings — they
|
|
||||||
#: go in via CSSOM ``insertRule`` (appended to the origin's own
|
|
||||||
#: linked stylesheet, so the cascade position is "last wins" like an
|
|
||||||
#: injected ``<style>`` tag — which the A20 CSP would block). The
|
|
||||||
#: probe measured this override reproduces the owner's report at
|
|
||||||
#: 390 px with the repo's fonts (the count's box fully past the
|
|
||||||
#: label's clip edge and past the pill's right edge).
|
|
||||||
_PRE_PHASE_SYNC_RULES = (
|
|
||||||
".sync-label { display: inline-block; align-items: initial; gap: 0; "
|
|
||||||
"max-width: none; min-width: 0; overflow: hidden; "
|
|
||||||
"text-overflow: ellipsis; white-space: nowrap; }",
|
|
||||||
".sync-label-main { min-width: 0; overflow: visible; text-overflow: clip; }",
|
|
||||||
".sync-label-count { flex: 0 1 auto; font-family: inherit; }",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Fixtures
|
# Fixtures
|
||||||
@@ -563,12 +635,12 @@ def _start_sync(page: Page, app_url: str, sync_dir: Path) -> None:
|
|||||||
|
|
||||||
# The click's immediate state (the 202 moment — the bare prefix,
|
# The click's immediate state (the 202 moment — the bare prefix,
|
||||||
# the phase-64 contract): disabled, aria-busy, spinning icon, and
|
# the phase-64 contract): disabled, aria-busy, spinning icon, and
|
||||||
# the label's MAIN part alone (the count part is hidden — its text
|
# the detail box revealed with the bare label (phase 149 — the
|
||||||
# is empty, so the parts' concatenation is exactly "Syncing…").
|
# running detail rides the box; the button is the spinner).
|
||||||
expect(btn).to_be_disabled()
|
expect(btn).to_be_disabled()
|
||||||
expect(btn).to_have_attribute("aria-busy", "true")
|
expect(btn).to_have_attribute("aria-busy", "true")
|
||||||
expect(btn.locator(".sync-icon")).to_have_class(re.compile(r"\bis-spinning\b"))
|
expect(btn.locator(".sync-icon")).to_have_class(re.compile(r"\bis-spinning\b"))
|
||||||
expect(page.locator("#sync-label")).to_have_text("Syncing…")
|
expect(page.locator("#sync-progress")).to_have_text("Syncing…")
|
||||||
expect(page.locator("#sync-error-banner")).to_be_hidden()
|
expect(page.locator("#sync-error-banner")).to_be_hidden()
|
||||||
|
|
||||||
|
|
||||||
@@ -607,34 +679,32 @@ def _settle(page: Page, app_url: str) -> None:
|
|||||||
count = page.locator(".sync-label-count")
|
count = page.locator(".sync-label-count")
|
||||||
expect(count).to_be_hidden()
|
expect(count).to_be_hidden()
|
||||||
expect(count).to_have_text("")
|
expect(count).to_have_text("")
|
||||||
|
# Phase 149 (task 01): the detail box is run-scoped — the settle
|
||||||
|
# (the writer's default running = false) hides it again.
|
||||||
|
expect(page.locator("#sync-progress")).to_be_hidden()
|
||||||
expect(page.locator("#sync-error-banner")).to_be_hidden()
|
expect(page.locator("#sync-error-banner")).to_be_hidden()
|
||||||
|
|
||||||
|
|
||||||
def _assert_count_not_clipped(snap: dict[str, Any], viewport_w: int) -> None:
|
def _assert_box_fits_viewport(snap: dict[str, Any], viewport_w: int) -> None:
|
||||||
"""The phase 128 geometry contract for a rendered running label at
|
"""The phase 149 geometry contract for the rendered running detail
|
||||||
the given viewport: the label box is INSIDE the viewport (the bug's
|
at the given viewport: the detail box is VISIBLE (a non-degenerate
|
||||||
direct negation), the count part is VISIBLE (a non-degenerate box),
|
box) and INSIDE the viewport (left ≥ 0, right ≤ the viewport
|
||||||
and the count is NOT clipped — its right edge sits at or inside
|
width — the owner's clipped-detail report, directly negated). The
|
||||||
the label's right edge (the label's own clip boundary) and the
|
box is a block in the 72rem centered container, so it can only
|
||||||
button's right edge (the pill). The 1 px slack covers float
|
shrink toward the container's content width — it never overflows
|
||||||
rounding only — the layout nests the boxes exactly."""
|
the viewport — and its text is the FULL untruncated label (the
|
||||||
label, count, btn = snap["labelRect"], snap["countRect"], snap["btnRect"]
|
"(n/m)" count is IN the text; the wrap that keeps long paths
|
||||||
assert label["left"] >= 0, f"label runs off the left edge: {label}"
|
on-screen is task 02's overflow-wrap: anywhere, pinned by the
|
||||||
assert label["right"] <= viewport_w, (
|
phase-149 dedicated suite). The 1 px slack covers float rounding
|
||||||
f"label runs off the right edge of the {viewport_w}px viewport: {label}"
|
only."""
|
||||||
|
box = snap["boxRect"]
|
||||||
|
assert box["width"] > 0 and box["height"] > 0, (
|
||||||
|
f"the detail box has no visible box: {box}"
|
||||||
)
|
)
|
||||||
assert count["width"] > 0 and count["height"] > 0, (
|
assert box["left"] >= 0, f"the detail box runs off the left edge: {box}"
|
||||||
f"the count part has no visible box: {count}"
|
assert box["right"] <= viewport_w + 1, (
|
||||||
)
|
f"the detail box runs off the right edge of the {viewport_w}px "
|
||||||
assert count["left"] >= label["left"] - 1, (
|
f"viewport: {box}"
|
||||||
f"the count part sits outside the label: {label} {count}"
|
|
||||||
)
|
|
||||||
assert count["right"] <= label["right"] + 1, (
|
|
||||||
f"the count part is clipped by the label (its box runs past the "
|
|
||||||
f"label's clip edge): label={label} count={count}"
|
|
||||||
)
|
|
||||||
assert count["right"] <= btn["right"] + 1, (
|
|
||||||
f"the count part runs off the pill: btn={btn} count={count}"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -650,47 +720,43 @@ def test_mobile_label_fits_viewport_with_count_in_import(
|
|||||||
"""A fresh admin page on the sources view at a PHONE viewport
|
"""A fresh admin page on the sources view at a PHONE viewport
|
||||||
(390×844) clicks **Sync sources**; while the import phase is
|
(390×844) clicks **Sync sources**; while the import phase is
|
||||||
running (the page's own 2 s poll renders it — the span outlives
|
running (the page's own 2 s poll renders it — the span outlives
|
||||||
the poll by ≥ 2× under the slow proxy), the rendered ``#sync-label``
|
the poll by ≥ 2× under the slow proxy), the DETAIL BOX
|
||||||
is an atomic snapshot of: the label box INSIDE the viewport (left ≥
|
(``#sync-progress`` — phase 149: the running detail left the
|
||||||
0, right ≤ 390 — the bug's direct negation), the ``.sync-label-count``
|
pill; the button is the spinner) is an atomic snapshot of:
|
||||||
part VISIBLE with its "(n/4)" position and its right edge at or
|
VISIBLE with the FULL untruncated label ``Syncing… <full
|
||||||
inside the label's and the button's right edges (NEVER clipped —
|
source/relative/path> (n/4)`` (+ the phase-129 "· chunk n/m" part
|
||||||
the pre-phase geometry fails exactly these pins, test 4
|
while a batch is in flight — optional) with the ``(n/4)`` count IN
|
||||||
reproduces it), and the ``.sync-label-main`` part carrying
|
the text (NEVER clipped — nothing is ellipsized away), the box's
|
||||||
``Syncing… <full source/relative/path>`` — the file part may be
|
bounding box INSIDE the viewport (left ≥ 0, right ≤ 390 — the
|
||||||
VISUALLY ellipsized, but the DOM text is untruncated and the button
|
bug's direct negation), the button in its running state
|
||||||
``title`` carries the FULL label (main + " " + count, A4). The run
|
(``is-running``), and the button ``title`` carrying the SAME full
|
||||||
then settles (the phase-32 terminal shape)."""
|
string (A4 — one source, two surfaces). The run then settles (the
|
||||||
|
phase-32 terminal shape; the box hides)."""
|
||||||
page.set_viewport_size(MOBILE_VIEWPORT)
|
page.set_viewport_size(MOBILE_VIEWPORT)
|
||||||
_start_sync(page, app_url, synclabel_dir)
|
_start_sync(page, app_url, synclabel_dir)
|
||||||
|
|
||||||
# The page's own 2 s poll renders the in-flight import label; the
|
# The page's own 2 s poll renders the in-flight import detail in
|
||||||
# atomic in-browser snapshot pins its text + geometry together.
|
# the box; the atomic in-browser snapshot pins its text + geometry
|
||||||
# (The Playwright sync API wraps a non-primitive result in a
|
# together. (The Playwright sync API wraps a non-primitive result
|
||||||
# JSHandle — .json_value() unwraps the snapshot dict.)
|
# in a JSHandle — .json_value() unwraps the snapshot dict.)
|
||||||
snap = page.wait_for_function(
|
snap = page.wait_for_function(
|
||||||
_JS_IMPORT_SNAPSHOT, timeout=SETTLE_TIMEOUT_MS
|
_JS_IMPORT_BOX_SNAPSHOT, timeout=SETTLE_TIMEOUT_MS
|
||||||
).json_value()
|
).json_value()
|
||||||
|
|
||||||
# The parts (the phase-64 byte-identical strings, unit-pinned):
|
# The FULL untruncated label — the complete
|
||||||
# main = "Syncing… <full source/relative/path>" (untruncated — the
|
# source/relative/path, the (n/4) count IN the text (never
|
||||||
# visual ellipsis never touches textContent), count = "(n/4)".
|
# clipped), the optional phase-129 chunk part.
|
||||||
assert IMPORT_MAIN_RE.match(snap["mainText"]), snap["mainText"]
|
assert IMPORT_FULL_RE.match(snap["boxText"]), snap["boxText"]
|
||||||
assert IMPORT_COUNT_RE.match(snap["countText"]), snap["countText"]
|
# A4: the button title carries the SAME untruncated label (the
|
||||||
# A4: the button title carries the UNTRUNCATED label — the full
|
# in-browser check already required title == box text; pin it here
|
||||||
# path, not the ellipsized visual tail (the in-browser check
|
# too — one source, two surfaces).
|
||||||
# already required title == main + " " + count; pin it here too).
|
assert snap["title"] == snap["boxText"], snap
|
||||||
# Phase 129: the pinned chunk part ("· chunk n/m") rides the title
|
# Phase 149: the running button is the spinner (is-running rides
|
||||||
# while a file's embed batch is in flight (optional — the
|
# the spin — the detail is the box's, not the pill's).
|
||||||
# pre-first-batch frame has none; the phase-129 dedicated suite
|
assert snap["isRunning"] is True, snap
|
||||||
# pins it strictly).
|
# The geometry contract (the fix): the box is visible and inside
|
||||||
full = snap["mainText"] + " " + snap["countText"]
|
# the viewport.
|
||||||
if snap["chunkText"]:
|
_assert_box_fits_viewport(snap, MOBILE_VIEWPORT["width"])
|
||||||
full += " " + snap["chunkText"]
|
|
||||||
assert snap["title"] == full, snap
|
|
||||||
# The geometry contract (the fix): label inside the viewport, the
|
|
||||||
# count visible and never clipped.
|
|
||||||
_assert_count_not_clipped(snap, MOBILE_VIEWPORT["width"])
|
|
||||||
|
|
||||||
_settle(page, app_url)
|
_settle(page, app_url)
|
||||||
|
|
||||||
@@ -707,24 +773,26 @@ def test_mobile_label_shows_summaries_count(
|
|||||||
"""Same phone viewport; the sync runs through to the SUMMARIES
|
"""Same phone viewport; the sync runs through to the SUMMARIES
|
||||||
phase (the post-import span where the file count sits still — the
|
phase (the post-import span where the file count sits still — the
|
||||||
owner's "the number pauses" leg): the page's own 2 s poll renders
|
owner's "the number pauses" leg): the page's own 2 s poll renders
|
||||||
``.sync-label-main`` as ``Summarizing folders… [source/folder]``
|
the DETAIL BOX (``#sync-progress`` — phase 149) with
|
||||||
(the folder part optional — the phase's first poll may precede the
|
``Summarizing folders… [source/folder] (n/3)`` (the folder part
|
||||||
first hook) with the VISIBLE ``.sync-label-count`` part matching
|
optional — the phase's first poll may precede the first hook — the
|
||||||
``(n/3)`` (the 3 candidate folders) — never clipped (the same pins
|
``(n/3)`` count of the 3 candidate folders always present, IN the
|
||||||
as test 1) — and the title carrying the full untruncated label
|
text — never clipped) with the same box-geometry pins as test 1
|
||||||
(A4). On settle the button leaves the running label (the phase-32
|
(inside the viewport), the button in its running state, and the
|
||||||
terminal ``Synced HH:MM``) and the count span is hidden + empty."""
|
title carrying the SAME full string (A4). On settle the button
|
||||||
|
leaves the running label (the phase-32 terminal ``Synced HH:MM``),
|
||||||
|
the count span is hidden + empty, and the box hides."""
|
||||||
page.set_viewport_size(MOBILE_VIEWPORT)
|
page.set_viewport_size(MOBILE_VIEWPORT)
|
||||||
_start_sync(page, app_url, synclabel_dir)
|
_start_sync(page, app_url, synclabel_dir)
|
||||||
|
|
||||||
snap = page.wait_for_function(
|
snap = page.wait_for_function(
|
||||||
_JS_SUMMARY_SNAPSHOT, timeout=SETTLE_TIMEOUT_MS
|
_JS_SUMMARY_BOX_SNAPSHOT, timeout=SETTLE_TIMEOUT_MS
|
||||||
).json_value()
|
).json_value()
|
||||||
|
|
||||||
assert SUMMARY_MAIN_RE.match(snap["mainText"]), snap["mainText"]
|
assert SUMMARY_FULL_RE.match(snap["boxText"]), snap["boxText"]
|
||||||
assert SUMMARY_COUNT_RE.match(snap["countText"]), snap["countText"]
|
assert snap["title"] == snap["boxText"], snap
|
||||||
assert snap["title"] == snap["mainText"] + " " + snap["countText"], snap
|
assert snap["isRunning"] is True, snap
|
||||||
_assert_count_not_clipped(snap, MOBILE_VIEWPORT["width"])
|
_assert_box_fits_viewport(snap, MOBILE_VIEWPORT["width"])
|
||||||
|
|
||||||
_settle(page, app_url)
|
_settle(page, app_url)
|
||||||
|
|
||||||
@@ -738,43 +806,34 @@ def test_mobile_label_shows_summaries_count(
|
|||||||
def test_desktop_label_unchanged(
|
def test_desktop_label_unchanged(
|
||||||
page: Page, app_url: str, synclabel_dir: Path, db_ready: None
|
page: Page, app_url: str, synclabel_dir: Path, db_ready: None
|
||||||
) -> None:
|
) -> None:
|
||||||
"""A DESKTOP viewport (1280×800) keeps the pre-phase contract:
|
"""A DESKTOP viewport (1280×800) keeps the pre-phase contract —
|
||||||
during import the FULL rendered label text — the main + count
|
the TEXT: during import the detail box (``#sync-progress`` —
|
||||||
parts' concatenation (main + " " + count, the CSS gap standing in
|
phase 149 moved the running detail out of the pill to the box;
|
||||||
for the old single span's space) — matches ``Syncing… <full
|
the owner's report was that the full label was clipped in the
|
||||||
source/relative/path> (n/m)``: the pre-phase byte-identical string
|
pill even on desktop) carries ``Syncing… <full
|
||||||
(the parts rework changed the output SHAPE, not the text). The
|
source/relative/path> (n/m)`` (+ the phase-129 chunk part while a
|
||||||
label's width respects the ``min(16rem, 40vw)`` cap — 256 px at
|
batch is in flight): the pre-phase byte-identical FULL string
|
||||||
this viewport (16 px :root) — with the count part pinned at the
|
(phase 149 changed the SURFACE, not the text). The box is inside
|
||||||
right edge and the main part ellipsized (the same cap/ellipsis the
|
the viewport, the button is in its running state (``is-running``
|
||||||
pre-phase label had), and the button ``title`` carries the same
|
— the spinner), and the button ``title`` carries the same
|
||||||
untruncated string (A4)."""
|
untruncated string (A4)."""
|
||||||
page.set_viewport_size(DESKTOP_VIEWPORT)
|
page.set_viewport_size(DESKTOP_VIEWPORT)
|
||||||
_start_sync(page, app_url, synclabel_dir)
|
_start_sync(page, app_url, synclabel_dir)
|
||||||
|
|
||||||
snap = page.wait_for_function(
|
snap = page.wait_for_function(
|
||||||
_JS_IMPORT_SNAPSHOT, timeout=SETTLE_TIMEOUT_MS
|
_JS_IMPORT_BOX_SNAPSHOT, timeout=SETTLE_TIMEOUT_MS
|
||||||
).json_value()
|
).json_value()
|
||||||
|
|
||||||
# The pre-phase byte-identical FULL string (main + " " + count +
|
# The pre-phase byte-identical FULL string ("Syncing… <full
|
||||||
# the phase-129 chunk part while a batch is in flight):
|
# source/relative/path> (n/m)" + " · chunk n/m" while a batch is
|
||||||
# "Syncing… <full source/relative/path> (n/m)" (+ " · chunk n/m").
|
# in flight) — now in the box, untruncated and wrapping.
|
||||||
full = snap["mainText"] + " " + snap["countText"]
|
assert IMPORT_FULL_RE.match(snap["boxText"]), snap["boxText"]
|
||||||
if snap["chunkText"]:
|
assert snap["title"] == snap["boxText"], snap
|
||||||
full += " " + snap["chunkText"]
|
# Phase 149: the running button is the spinner (is-running);
|
||||||
assert IMPORT_FULL_RE.match(full), full
|
# the count is IN the box's full text (the never-clipped pin —
|
||||||
assert snap["title"] == full, snap
|
# nothing is ellipsized away on desktop either).
|
||||||
# The count part is visible and pinned inside the label (no
|
assert snap["isRunning"] is True, snap
|
||||||
# clipping on desktop either — the cap ellipsizes the MAIN part
|
_assert_box_fits_viewport(snap, DESKTOP_VIEWPORT["width"])
|
||||||
# only).
|
|
||||||
count, label = snap["countRect"], snap["labelRect"]
|
|
||||||
assert count["width"] > 0 and count["height"] > 0, count
|
|
||||||
assert count["right"] <= label["right"] + 1, f"label={label} count={count}"
|
|
||||||
# The cap: min(16rem, 40vw) = 256 px (16 px :root, 1280 px
|
|
||||||
# viewport) — the bounding box width may not exceed it (+ slack).
|
|
||||||
assert label["width"] <= DESKTOP_CAP_PX + CAP_SLACK_PX, (
|
|
||||||
f"the label box exceeds the min(16rem, 40vw) cap: {label}"
|
|
||||||
)
|
|
||||||
|
|
||||||
_settle(page, app_url)
|
_settle(page, app_url)
|
||||||
|
|
||||||
@@ -791,9 +850,11 @@ def test_pre_phase_css_reproduces_the_clip(
|
|||||||
"""The same mobile run with the PRE-PHASE-128 ``.sync-label`` rules
|
"""The same mobile run with the PRE-PHASE-128 ``.sync-label`` rules
|
||||||
re-injected (CSSOM ``insertRule`` into the origin's own linked
|
re-injected (CSSOM ``insertRule`` into the origin's own linked
|
||||||
stylesheet — inline-block, the cap lifted, one ellipsis over the
|
stylesheet — inline-block, the cap lifted, one ellipsis over the
|
||||||
whole label, the count riding the ellipsized tail): the reported
|
whole label, the count riding the ellipsized tail; the injected
|
||||||
bug comes back geometrically — the count part's box extends past
|
set also neutralizes the phase-149 ``is-running`` hide rule, which
|
||||||
the label's clip edge (its right edge outside the label's right
|
pre-phase did not exist): the reported bug comes back geometrically —
|
||||||
|
the count part's box extends past the label's clip edge (its right
|
||||||
|
edge outside the label's right
|
||||||
edge, so the label's ``overflow: hidden`` paints it away) and past
|
edge, so the label's ``overflow: hidden`` paints it away) and past
|
||||||
the pill's right edge (it runs off the button). This is the
|
the pill's right edge (it runs off the button). This is the
|
||||||
geometry tests 1–2's "count not clipped" pins reject — the suite's
|
geometry tests 1–2's "count not clipped" pins reject — the suite's
|
||||||
@@ -822,7 +883,7 @@ def test_pre_phase_css_reproduces_the_clip(
|
|||||||
)
|
)
|
||||||
|
|
||||||
snap = page.wait_for_function(
|
snap = page.wait_for_function(
|
||||||
_JS_IMPORT_SNAPSHOT, timeout=SETTLE_TIMEOUT_MS
|
_JS_PARTS_IMPORT_SNAPSHOT, timeout=SETTLE_TIMEOUT_MS
|
||||||
).json_value()
|
).json_value()
|
||||||
|
|
||||||
assert IMPORT_MAIN_RE.match(snap["mainText"]), snap["mainText"]
|
assert IMPORT_MAIN_RE.match(snap["mainText"]), snap["mainText"]
|
||||||
|
|||||||
@@ -0,0 +1,996 @@
|
|||||||
|
"""Phase 149 task 03 E2E (Playwright, mock-only): the owner's ask, pinned
|
||||||
|
end to end — while a sync run is in flight, the **button is the loading
|
||||||
|
spinner** (``is-running`` hides the label parts; the spinning icon is the
|
||||||
|
whole control) and a NEW bordered text box directly under the button row
|
||||||
|
(``#sync-progress``) carries the **EXACT full untruncated live label** —
|
||||||
|
``Syncing… <source/relative/path> (n/m) · chunk n/m`` — wrapping, never
|
||||||
|
clipping (the long ``source/relative/path`` that overflows the pill is the
|
||||||
|
fixture's whole reason to exist). At settle the box hides, the pill is
|
||||||
|
restored byte-identical to pre-phase (``Synced HH:MM`` + the ``fmtSyncResult``
|
||||||
|
counts line on ``#sync-result``), and ``#sync-result`` stayed the SOLE
|
||||||
|
aria-live announcer the whole time (the box is presentational,
|
||||||
|
``aria-hidden``).
|
||||||
|
|
||||||
|
The dedicated story suite for ``149_sync_progress_box`` (A16 — one
|
||||||
|
Playwright file per phase, run in isolation). Tasks 01/02 built the box +
|
||||||
|
its wiring + its CSS; this suite is the behavioral proof through the REAL
|
||||||
|
sync path: a real ``git clone`` of a local ``file://`` fixture repo, a real
|
||||||
|
import, the slowed mock LLM so the in-flight state is observable.
|
||||||
|
|
||||||
|
Run in isolation (DB must be up: ``podman compose up -d db``; git on PATH —
|
||||||
|
a documented environment prerequisite, phase 28):
|
||||||
|
|
||||||
|
uv run pytest tests/e2e/test_sync_progress_box.py -v --no-cov
|
||||||
|
|
||||||
|
MOCK-ONLY suite: ``E2E_REAL_LLM=1`` is not supported — the gate is the
|
||||||
|
deterministic mock plus the phase-64 ``tests/e2e/slow_llm.py`` delay proxy
|
||||||
|
(``SLOW_DELAY_S = "1.5"`` — the proxy delays EVERY LLM call, embeds
|
||||||
|
included) in front of the mock: each in-flight state outlives the UI's
|
||||||
|
2 s status poll by ≫ 1×, so every rendered label is observed
|
||||||
|
deterministically, not as a race.
|
||||||
|
|
||||||
|
The suite runs ONE module app (the phase-79 full-inventory pattern — it
|
||||||
|
binds its own port) over the shared E2E Postgres: the SLOW app with
|
||||||
|
``BOR_GIT_SOURCES=file://<fixture repo>`` (the env fallback — the autouse
|
||||||
|
truncate keeps the ``git_sources`` table empty, so the env list IS the
|
||||||
|
effective source) + its own ``BOR_SOURCES_DIR`` for the checkout.
|
||||||
|
|
||||||
|
Timing fixture: a changed sync of the 3-file fixture is ≈ 2 model probes
|
||||||
|
+ 3 files × (1 content-embed batch + 1 summary chat + 1 summary-chunk
|
||||||
|
embed) + 1 overview + 7 folder summaries ≈ 19 LLM calls ≈ 30–35 s under
|
||||||
|
the proxy. The LONG-path file is the FIRST of the three (the sorted walk —
|
||||||
|
``notes/a-…`` < ``notes/short-…``), so its import span (7–8 embed batches
|
||||||
|
× 1.5 s + the two summary calls ≈ 15 s) outlives the 2 s poll ~7× — the
|
||||||
|
full-path + chunk-part windows the assertions observe are ≫ the poll
|
||||||
|
period (deterministic, not races).
|
||||||
|
|
||||||
|
KB fixture — a real git repo (host temp dir via ``tmp_path_factory``; the
|
||||||
|
app runs on the same host) with EXACTLY THREE markdown files:
|
||||||
|
|
||||||
|
* ``notes/a-rather-deeply-nested-folder/structure/for/the/sync/
|
||||||
|
fixture-document.md`` — the LONG relative path (the clipping trigger the
|
||||||
|
owner hit: a ``source/relative/path`` that overflows the pre-phase pill
|
||||||
|
at desktop width), and a multi-chunk body (7 single-paragraph sections
|
||||||
|
— one chunk apiece at the code-default 2000-char target, so the embed
|
||||||
|
span is many 1.5 s token-budgeted batches: the wide in-flight window);
|
||||||
|
* ``notes/short-one.md`` + ``notes/short-two.md`` — short paths, one chunk
|
||||||
|
each (the sync has several files → the ``(n/3)`` count part and the
|
||||||
|
per-file chunk restart are exercised too).
|
||||||
|
|
||||||
|
The source name is the checkout's directory basename (the ``repo_name``
|
||||||
|
rule, phase 28): ``progressbox`` — so the in-flight label's file part is
|
||||||
|
``progressbox/notes/a-rather-deeply-nested-folder/structure/for/the/sync/
|
||||||
|
fixture-document.md``.
|
||||||
|
|
||||||
|
Autouse cleanup (the phase-96 pattern): before each test wait for no
|
||||||
|
running sync on the app, then truncate the shared registry + KB tables
|
||||||
|
(chunks/documents/query_log/steering_notes/kb_overview/git_sources/
|
||||||
|
folder_summaries) — each test runs its own sync of the env-fallback source;
|
||||||
|
after each test the same truncate. The checkout in ``BOR_SOURCES_DIR``
|
||||||
|
survives across tests on purpose (the sync's pull is a fast no-op after
|
||||||
|
the first clone).
|
||||||
|
|
||||||
|
Test → observable mapping (Playwright Mapping Rule — the rendered DOM is
|
||||||
|
the observable; every in-flight snapshot is ONE in-browser evaluation that
|
||||||
|
verifies the awaited label state and reads the numbers together, so they
|
||||||
|
can never straddle a 2 s poll tick — the single ``setSyncLabel`` writer
|
||||||
|
updates the box + the button's aria-label/title + ``#sync-result`` in one
|
||||||
|
JS task):
|
||||||
|
|
||||||
|
1. ``test_running_button_is_spinner_only`` — click **Sync sources**; poll
|
||||||
|
until ``#sync-progress`` is visible (a run is in flight): ``#sync-btn``
|
||||||
|
carries ``is-running`` AND the icon ``is-spinning`` (the spinner IS the
|
||||||
|
button), ``#sync-label`` is NOT visible (the label parts are hidden —
|
||||||
|
the button is the icon), the button's ``aria-label`` EQUALS the box's
|
||||||
|
current text (written by the same call — never stale), the box is
|
||||||
|
``aria-hidden`` (presentational — ``#sync-result`` is the sole live
|
||||||
|
region), the button is disabled + ``aria-busy`` (the pre-phase running
|
||||||
|
mechanics), and the Cancel button is visible (the phase-134 contract
|
||||||
|
unchanged).
|
||||||
|
2. ``test_box_carries_the_full_untruncated_label`` — during the
|
||||||
|
in-flight window of the LONG-path file (poll until the box text is
|
||||||
|
``Syncing… <full long path> (1/3)`` — the path string IN FULL, the
|
||||||
|
pre-phase ellipsis would have truncated it): the box's
|
||||||
|
``scrollWidth <= clientWidth`` AND the text's ``scrollWidth <=
|
||||||
|
clientWidth`` (the wrap engaged — nothing is clipped), ``#sync-result``
|
||||||
|
(the announcer) carries the SAME full string (A4 — one source, two
|
||||||
|
surfaces), the button's ``aria-label`` equals it too, and the box ink
|
||||||
|
on the panel background clears WCAG 2.1 AA (≥ 4.5:1). Then a retry loop
|
||||||
|
over the slow embed window pins the phase-129 chunk part IN THE BOX
|
||||||
|
(``Syncing… <full long path> (1/3) · chunk n/m`` — the part is only
|
||||||
|
present while an embed batch is in flight; the proxy's 1.5 s delay
|
||||||
|
makes the ~15 s window ≫ the 2 s poll).
|
||||||
|
3. ``test_mobile_box_full_width`` — a fresh page at the house mobile
|
||||||
|
viewport (390×844 — the ``test_sync_label_mobile.py`` constant): the
|
||||||
|
same in-flight assertions — the box is FULL WIDTH (its width ≈ the
|
||||||
|
container's content width minus the container's padding — no override
|
||||||
|
needed, the block rides the 72rem container), no horizontal clipping
|
||||||
|
(``scrollWidth <= clientWidth``), the icon-only button keeps the
|
||||||
|
mobile full-width rule, the label wraps (the full long path is a
|
||||||
|
substring — the old mobile-stuffing ellipsis is gone).
|
||||||
|
4. ``test_settled_state_restores_the_pill`` — in flight first (the box is
|
||||||
|
visible), then let the run settle (the house settle idiom): the box is
|
||||||
|
HIDDEN (run-scoped only), the button shows the settle label
|
||||||
|
``Synced HH:MM`` (the pre-phase byte-identical settle copy) with the
|
||||||
|
count + chunk parts hidden and empty, ``is-running`` / ``is-spinning``
|
||||||
|
are gone, the ``aria-label`` is back to ``"Sync sources"``, and the
|
||||||
|
counts line on ``#sync-result`` matches the pre-phase ``fmtSyncResult``
|
||||||
|
shape (``N added``).
|
||||||
|
5. ``test_reattach_shows_the_box`` — in flight (the box visible), RELOAD
|
||||||
|
the page (the admin session cookie persists): the load-time re-attach
|
||||||
|
(``initSyncButton``) finds the RUNNING sync → the box is VISIBLE with
|
||||||
|
the current label and the button is icon-only again (the never-stale
|
||||||
|
contract — a page load mid-run shows the truth, not an idle pill).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from collections.abc import Iterator
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
from playwright.sync_api import Page, ViewportSize, expect
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
from app.config import Settings as _Settings
|
||||||
|
from app.db import SessionLocal
|
||||||
|
from e2e.auth_helpers import login
|
||||||
|
from e2e.conftest import (
|
||||||
|
ADMIN_PASSWORD,
|
||||||
|
MOCK_PORT,
|
||||||
|
SESSION_SECRET,
|
||||||
|
USE_REAL_LLM,
|
||||||
|
_wait_http,
|
||||||
|
)
|
||||||
|
|
||||||
|
REPO = Path(__file__).resolve().parents[2]
|
||||||
|
|
||||||
|
# Phase 79 (task 04, full inventory): the conftest session app owns its
|
||||||
|
# port in a combined run — this module's app binds its own port
|
||||||
|
# instead (a same-port second uvicorn dies on bind and would drive the
|
||||||
|
# wrong server). Env-overridable.
|
||||||
|
APP_PORT = int(os.environ.get("E2E_APP_PORT_SYNCPROGRESS", "8166"))
|
||||||
|
APP_URL = f"http://127.0.0.1:{APP_PORT}"
|
||||||
|
|
||||||
|
#: The slow-LLM proxy's port (the conftest's mock LLM stays on
|
||||||
|
#: MOCK_PORT — the proxy fronts it).
|
||||||
|
SLOW_PORT = int(os.environ.get("E2E_SLOW_LLM_PORT_SYNCPROGRESS", "8908"))
|
||||||
|
SLOW_URL = f"http://127.0.0.1:{SLOW_PORT}"
|
||||||
|
|
||||||
|
#: Per-LLM-request delay on the proxy (the phase-64/98 rationale,
|
||||||
|
#: module docstring): the proxy delays EVERY call — embeds included —
|
||||||
|
#: so the in-flight windows outlive the UI's 2 s poll by ≫ 1×.
|
||||||
|
SLOW_DELAY_S = "1.5"
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Fixture constants (deterministic, token-controlled)
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#: The fixture repo's directory name — the source name the import
|
||||||
|
#: records for it (the ``repo_name`` basename rule, phase 28).
|
||||||
|
SOURCE = "progressbox"
|
||||||
|
|
||||||
|
#: The LONG relative path — the clipping trigger the owner hit (a
|
||||||
|
#: ``source/relative/path`` that overflows the pre-phase pill even on
|
||||||
|
#: desktop) — and the two short-path siblings (the sync has several
|
||||||
|
#: files: the ``(n/3)`` count part + the per-file chunk restart).
|
||||||
|
LONG_REL = (
|
||||||
|
"notes/a-rather-deeply-nested-folder/structure/for/the/sync/"
|
||||||
|
"fixture-document.md"
|
||||||
|
)
|
||||||
|
SHORT_RELS = ("notes/short-one.md", "notes/short-two.md")
|
||||||
|
N_FILES = 3
|
||||||
|
|
||||||
|
#: The in-flight label's file part — the status endpoint's
|
||||||
|
#: ``current_file`` = ``<source>/<rel>`` (the sync's progress hook),
|
||||||
|
#: verbatim. The long file is the FIRST of the three (the sorted walk —
|
||||||
|
#: ``notes/a-…`` < ``notes/short-…``), so its count part is ``(1/3)``.
|
||||||
|
LONG_PATH = f"{SOURCE}/{LONG_REL}"
|
||||||
|
|
||||||
|
#: The long file's shape (module docstring): an H1 preamble (one chunk)
|
||||||
|
#: + 7 single-paragraph sections (~1500 chars each — one chunk apiece at
|
||||||
|
#: the code-default 2000-char target) → ~8 chunks → ~8 token-budgeted
|
||||||
|
#: embed batches × 1.5 s (the wide in-flight window the assertions
|
||||||
|
#: observe — deterministic, not a race).
|
||||||
|
N_LONG_SECTIONS = 7
|
||||||
|
|
||||||
|
SHORT_MD = (
|
||||||
|
"# Short fixture {n}\n\nA tiny sync fixture note with a short path "
|
||||||
|
"({n}).\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
#: The house mobile viewport (the ``test_sync_label_mobile.py``
|
||||||
|
#: constant — the phone-width leg of the owner's ask).
|
||||||
|
MOBILE_VIEWPORT: ViewportSize = {"width": 390, "height": 844}
|
||||||
|
|
||||||
|
#: "Synced HH:MM" — the local-time last-result label (sources.js's
|
||||||
|
#: fmtSyncTime), any hour/minute (the test_sync_button.py pattern).
|
||||||
|
SYNCED_LABEL = re.compile(r"Synced \d{1,2}:\d{2}$")
|
||||||
|
#: The settled boot label — idle ("Sync sources") or the RE-ATTACHED
|
||||||
|
#: terminal from a previous test's sync (the module app is shared, its
|
||||||
|
#: in-memory sync state survives the test boundary, and the boot
|
||||||
|
#: re-attach renders the last result).
|
||||||
|
IDLE_OR_SETTLED = re.compile(r"^(Sync sources|Synced \d{1,2}:\d{2})$")
|
||||||
|
|
||||||
|
#: Generous settle budgets: a changed sync against the slowed LLM is
|
||||||
|
#: ≈ 30–35 s; the UI's 2 s poll settles at most one tick after the
|
||||||
|
#: terminal state lands.
|
||||||
|
SETTLE_TIMEOUT_MS = 120_000
|
||||||
|
|
||||||
|
#: The module's wall budget: five full 3-file syncs against the slowed
|
||||||
|
#: mock (≈ 30–35 s each) must stay well under this — a breach is a
|
||||||
|
#: regression canary for the sync pipeline's speed, not a flake
|
||||||
|
#: tolerance.
|
||||||
|
WALL_BUDGET_S = 600.0
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# The in-browser snapshot functions (Playwright Mapping Rule — the
|
||||||
|
# rendered DOM is the observable).
|
||||||
|
#
|
||||||
|
# Each returns null while the awaited label state is absent, and an
|
||||||
|
# ATOMIC snapshot once it is present: the box's text + the button's
|
||||||
|
# aria-label / classes + the geometry — read in the SAME in-browser
|
||||||
|
# evaluation that verified the state, so the numbers can never straddle
|
||||||
|
# a 2 s poll tick (the single setSyncLabel writer updates the box, the
|
||||||
|
# button, and the announcer together in one JS task — A4: one source,
|
||||||
|
# two surfaces).
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#: Tests 1 + 5 — ANY in-flight state (a run is in flight): the box is
|
||||||
|
#: VISIBLE with non-empty text, and the button is in its running state
|
||||||
|
#: (is-running + the spinning icon + the hidden label parts + the full
|
||||||
|
#: live label as aria-label + disabled + aria-busy) with the Cancel
|
||||||
|
#: control visible (the phase-134 contract). The box's aria-hidden
|
||||||
|
#: attribute is read too (presentational — the one-live-region pin).
|
||||||
|
_JS_RUNNING_SNAPSHOT = """
|
||||||
|
() => {
|
||||||
|
const box = document.querySelector('#sync-progress');
|
||||||
|
const textEl = document.querySelector('#sync-progress-text');
|
||||||
|
const btn = document.querySelector('#sync-btn');
|
||||||
|
const label = document.querySelector('#sync-label');
|
||||||
|
const icon = btn ? btn.querySelector('.sync-icon') : null;
|
||||||
|
const cancel = document.querySelector('#sync-cancel-btn');
|
||||||
|
if (!box || !textEl || !btn || !label || !icon || !cancel) return null;
|
||||||
|
if (box.hidden || !textEl.textContent) return null;
|
||||||
|
const cs = (el) => getComputedStyle(el);
|
||||||
|
return {
|
||||||
|
boxText: textEl.textContent,
|
||||||
|
ariaHidden: box.getAttribute('aria-hidden'),
|
||||||
|
ariaLabel: btn.getAttribute('aria-label'),
|
||||||
|
isRunning: btn.classList.contains('is-running'),
|
||||||
|
isSpinning: icon.classList.contains('is-spinning'),
|
||||||
|
labelHidden: cs(label).display === 'none',
|
||||||
|
cancelVisible: !cancel.hidden && cs(cancel).display !== 'none',
|
||||||
|
btnDisabled: btn.disabled,
|
||||||
|
ariaBusy: btn.getAttribute('aria-busy'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
#: Test 2, wait A — the in-flight window of the LONG-path file: the box
|
||||||
|
#: is VISIBLE and its text is the FULL label for the long file —
|
||||||
|
#: "Syncing… <full long path> (1/3)" (+ the optional phase-129
|
||||||
|
#: " · chunk n/m" part while an embed batch is in flight — the
|
||||||
|
#: pre-first-batch frame has none) — the complete
|
||||||
|
# source/relative/path IN the text (the pre-phase ellipsis would have
|
||||||
|
#: truncated it). Returns the text + the wrap proof (scrollWidth vs
|
||||||
|
#: clientWidth of box and text) + the announcer + the aria-label + the
|
||||||
|
#: computed ink/panel colors (the AA pin).
|
||||||
|
_JS_LONG_FILE_SNAPSHOT = """
|
||||||
|
(longPath) => {
|
||||||
|
const box = document.querySelector('#sync-progress');
|
||||||
|
const textEl = document.querySelector('#sync-progress-text');
|
||||||
|
const btn = document.querySelector('#sync-btn');
|
||||||
|
const result = document.querySelector('#sync-result');
|
||||||
|
if (!box || !textEl || !btn || !result || box.hidden) return null;
|
||||||
|
const t = textEl.textContent;
|
||||||
|
const prefix = 'Syncing… ' + longPath + ' (';
|
||||||
|
if (!t.startsWith(prefix)) return null;
|
||||||
|
const tail = t.slice(prefix.length);
|
||||||
|
if (!/^\\d+\\/3\\)( · chunk \\d+\\/\\d+)?$/.test(tail)) return null;
|
||||||
|
const cs = (el) => getComputedStyle(el);
|
||||||
|
return {
|
||||||
|
boxText: t,
|
||||||
|
resultText: result.textContent,
|
||||||
|
ariaLabel: btn.getAttribute('aria-label'),
|
||||||
|
boxScrollWidth: box.scrollWidth,
|
||||||
|
boxClientWidth: box.clientWidth,
|
||||||
|
textScrollWidth: textEl.scrollWidth,
|
||||||
|
textClientWidth: textEl.clientWidth,
|
||||||
|
ink: cs(textEl).color,
|
||||||
|
bg: cs(box).backgroundColor,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
#: Test 2, wait B — the phase-129 chunk part IN THE BOX during the slow
|
||||||
|
#: embed window: the box text is "Syncing… <full long path> (1/3) ·
|
||||||
|
#: chunk n/m" (the part is only present while an in-flight file's embed
|
||||||
|
#: batch is in flight — the proxy's 1.5 s delay makes the ~15 s
|
||||||
|
#: long-file window ≫ the 2 s poll, so the retry loop resolves
|
||||||
|
#: deterministically).
|
||||||
|
_JS_LONG_FILE_CHUNK_SNAPSHOT = """
|
||||||
|
(longPath) => {
|
||||||
|
const box = document.querySelector('#sync-progress');
|
||||||
|
const textEl = document.querySelector('#sync-progress-text');
|
||||||
|
if (!box || !textEl || box.hidden) return null;
|
||||||
|
const t = textEl.textContent;
|
||||||
|
if (!t.startsWith('Syncing… ' + longPath + ' (1/3) · chunk ')) return null;
|
||||||
|
const m = t.match(/· chunk (\\d+)\\/(\\d+)$/);
|
||||||
|
if (!m) return null;
|
||||||
|
return {
|
||||||
|
boxText: t,
|
||||||
|
chunkN: Number(m[1]),
|
||||||
|
chunkM: Number(m[2]),
|
||||||
|
boxScrollWidth: box.scrollWidth,
|
||||||
|
boxClientWidth: box.clientWidth,
|
||||||
|
textScrollWidth: textEl.scrollWidth,
|
||||||
|
textClientWidth: textEl.clientWidth,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
#: Test 3 — the mobile (390×844) in-flight window of the long file: the
|
||||||
|
#: box text is the full label (the long path IN the text), the box is
|
||||||
|
#: FULL WIDTH (its bounding-box width ≈ the container's content width —
|
||||||
|
#: the block rides the 72rem container, no override), no horizontal
|
||||||
|
#: clipping (scrollWidth <= clientWidth), the icon-only button keeps the
|
||||||
|
#: mobile full-width rule (its width:100% resolves against its
|
||||||
|
#: containing block — the .page-head card's content width, NOT the
|
||||||
|
#: container's), and the label parts are hidden.
|
||||||
|
_JS_MOBILE_SNAPSHOT = """
|
||||||
|
(longPath) => {
|
||||||
|
const box = document.querySelector('#sync-progress');
|
||||||
|
const textEl = document.querySelector('#sync-progress-text');
|
||||||
|
const btn = document.querySelector('#sync-btn');
|
||||||
|
const label = document.querySelector('#sync-label');
|
||||||
|
const icon = btn ? btn.querySelector('.sync-icon') : null;
|
||||||
|
const container = document.querySelector('#view-rag .container');
|
||||||
|
const head = document.querySelector('#view-rag .page-head');
|
||||||
|
if (!box || !textEl || !btn || !label || !icon || !container || !head) return null;
|
||||||
|
if (box.hidden) return null;
|
||||||
|
const t = textEl.textContent;
|
||||||
|
if (!t.startsWith('Syncing… ' + longPath + ' (')) return null;
|
||||||
|
const cs = (el) => getComputedStyle(el);
|
||||||
|
const rect = (el) => {
|
||||||
|
const b = el.getBoundingClientRect();
|
||||||
|
return { left: b.left, right: b.right, width: b.width, height: b.height };
|
||||||
|
};
|
||||||
|
// clientWidth already excludes the border — content = clientWidth
|
||||||
|
// minus the horizontal padding only.
|
||||||
|
const contentWidth = (el) => {
|
||||||
|
const s = cs(el);
|
||||||
|
return el.clientWidth - parseFloat(s.paddingLeft) - parseFloat(s.paddingRight);
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
boxText: t,
|
||||||
|
isRunning: btn.classList.contains('is-running'),
|
||||||
|
isSpinning: icon.classList.contains('is-spinning'),
|
||||||
|
labelHidden: cs(label).display === 'none',
|
||||||
|
boxRect: rect(box),
|
||||||
|
btnRect: rect(btn),
|
||||||
|
containerContentWidth: contentWidth(container),
|
||||||
|
headContentWidth: contentWidth(head),
|
||||||
|
boxScrollWidth: box.scrollWidth,
|
||||||
|
boxClientWidth: box.clientWidth,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fixtures
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _git(cwd: Path, *args: str) -> None:
|
||||||
|
"""Run git in *cwd*; a non-zero exit fails the fixture loudly."""
|
||||||
|
proc = subprocess.run(["git", *args], cwd=cwd, capture_output=True, text=True)
|
||||||
|
if proc.returncode != 0:
|
||||||
|
raise AssertionError(f"git {' '.join(args)} failed: {proc.stderr.strip()}")
|
||||||
|
|
||||||
|
|
||||||
|
def _commit(repo: Path, message: str) -> None:
|
||||||
|
"""A fixture commit (never signed — the house ``commit.gpgsign``
|
||||||
|
flag, the test_sync_button.py idiom)."""
|
||||||
|
_git(
|
||||||
|
repo,
|
||||||
|
"-c", "user.email=e@x", "-c", "user.name=t",
|
||||||
|
"-c", "commit.gpgsign=false",
|
||||||
|
"commit", "-qm", message,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _long_md() -> str:
|
||||||
|
"""The long-path fixture doc (see the module docstring): an H1
|
||||||
|
preamble (one chunk) + 7 single-paragraph sections (~1500 chars
|
||||||
|
each — one chunk apiece at the code-default 2000-char target — no
|
||||||
|
randomness, so the chunk count is stable run to run)."""
|
||||||
|
parts = ["# Long-path fixture document\n"]
|
||||||
|
parts.append(
|
||||||
|
"Preamble for the deeply nested sync fixture document. This "
|
||||||
|
"paragraph sits under the H1 and chunks on its own.\n"
|
||||||
|
)
|
||||||
|
for k in range(1, N_LONG_SECTIONS + 1):
|
||||||
|
parts.append(f"\n## Nesting {k}\n")
|
||||||
|
base = (
|
||||||
|
f"Nesting paragraph {k} of the long-path sync fixture carries "
|
||||||
|
"deterministic filler for the chunker: alpha bravo charlie "
|
||||||
|
"delta echo foxtrot golf hotel india juliet kilo lima "
|
||||||
|
)
|
||||||
|
parts.append((base * 30)[:1500] + "\n\n")
|
||||||
|
return "".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def progressbox_repo(tmp_path_factory: pytest.TempPathFactory) -> Path:
|
||||||
|
"""A real git repo the sync must clone over ``file://`` (see the
|
||||||
|
module docstring): the long-path doc (the clipping trigger,
|
||||||
|
multi-chunk body) + the two short-path docs. The directory NAME is
|
||||||
|
the source name (the ``repo_name`` basename rule, phase 28).
|
||||||
|
``tmp_path`` is function-scoped while the module-scoped app fixture
|
||||||
|
needs the repo for the module's lifetime, so it is built under
|
||||||
|
``tmp_path_factory`` via real ``git`` subprocess calls."""
|
||||||
|
root = tmp_path_factory.mktemp("bor_syncprogress")
|
||||||
|
repo = root / SOURCE
|
||||||
|
long_file = repo / LONG_REL
|
||||||
|
long_file.parent.mkdir(parents=True)
|
||||||
|
long_file.write_text(_long_md(), encoding="utf-8")
|
||||||
|
for i, rel in enumerate(SHORT_RELS, start=1):
|
||||||
|
(repo / rel).write_text(SHORT_MD.format(n=i), encoding="utf-8")
|
||||||
|
_git(repo, "init", "-q")
|
||||||
|
_git(repo, "add", "-A")
|
||||||
|
_commit(repo, "one")
|
||||||
|
assert (repo / ".git").is_dir()
|
||||||
|
assert len(list(repo.rglob("*.md"))) == N_FILES
|
||||||
|
return repo
|
||||||
|
|
||||||
|
|
||||||
|
def _app_env(
|
||||||
|
base_url: str, repo: Path, tmp_path_factory: pytest.TempPathFactory
|
||||||
|
) -> dict[str, str]:
|
||||||
|
"""The per-module app env (the conftest pattern): the ``base_url``
|
||||||
|
picks the LLM (the slow proxy in front of the mock), and
|
||||||
|
``BOR_GIT_SOURCES`` is this suite's own ``file://`` fixture repo —
|
||||||
|
the sync's SUBJECT (the env fallback: the autouse truncate keeps the
|
||||||
|
``git_sources`` table empty, so the env list is the effective
|
||||||
|
source). The env var also overrides the owner's local (gitignored)
|
||||||
|
``.env`` ``BOR_GIT_SOURCES`` (process env ranks above the file)."""
|
||||||
|
env = dict(os.environ)
|
||||||
|
env.pop("DEBUGPY", None)
|
||||||
|
env["BOR_ENVIRONMENT"] = "e2e"
|
||||||
|
env["BOR_STATIC_DIR"] = str(REPO / "frontend")
|
||||||
|
env["BOR_LLM_BASE_URL"] = (
|
||||||
|
"https://aipi.reeseapps.com/v1" if USE_REAL_LLM else base_url
|
||||||
|
)
|
||||||
|
# Mock-calibrated gate values (conftest pattern): the floors ride
|
||||||
|
# WITH the 0.30 threshold — a floor above it fails the startup
|
||||||
|
# validator (phase 112/113).
|
||||||
|
env["BOR_RELEVANCE_THRESHOLD"] = "0.30"
|
||||||
|
env["BOR_LEXICAL_SUPPORT_FLOOR"] = "0.15"
|
||||||
|
env["BOR_SOURCE_USEFULNESS_FLOOR"] = "0.15"
|
||||||
|
# Phase 67: instant retry waits + the code-default budget (the
|
||||||
|
# conftest leak-guard pattern).
|
||||||
|
env["BOR_LLM_RETRY_DELAY"] = "0"
|
||||||
|
env["BOR_LLM_RETRIES"] = str(_Settings.model_fields["llm_retries"].default)
|
||||||
|
env.setdefault(
|
||||||
|
"BOR_DATABASE_URL",
|
||||||
|
"postgresql+psycopg://reese:reese@localhost:5432/brain_of_reese",
|
||||||
|
)
|
||||||
|
# Phase 16: admin auth must be set or create_app() refuses to boot.
|
||||||
|
env["BOR_ADMIN_PASSWORD"] = ADMIN_PASSWORD
|
||||||
|
env["BOR_SESSION_SECRET"] = SESSION_SECRET
|
||||||
|
# The sync's subject (see the docstring) + its own checkout dir.
|
||||||
|
env["BOR_GIT_SOURCES"] = f"file://{repo}"
|
||||||
|
env["BOR_SOURCES_DIR"] = str(tmp_path_factory.mktemp("bor_checkouts"))
|
||||||
|
# Leak guards (conftest pattern): an operator's local (gitignored)
|
||||||
|
# .env cannot leak corpus-specific settings into the app under test.
|
||||||
|
env["BOR_DOCS_REPO"] = ""
|
||||||
|
env["BOR_SUGGESTIONS"] = json.dumps(
|
||||||
|
_Settings.model_fields["suggestions"].default
|
||||||
|
)
|
||||||
|
env["BOR_INPUT_PLACEHOLDER"] = _Settings.model_fields["input_placeholder"].default
|
||||||
|
env["BOR_FOOTER_TEXT"] = _Settings.model_fields["footer_text"].default
|
||||||
|
return env
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def slow_llm(mock_llm: int) -> Iterator[int]:
|
||||||
|
"""The delay-injecting reverse proxy in front of the mock LLM
|
||||||
|
(``tests/e2e/slow_llm.py``) — the timing fixture for the whole
|
||||||
|
suite: it delays EVERY LLM call, embeds included, so the in-flight
|
||||||
|
windows outlive the UI's 2 s poll by ≫ 1× (see ``SLOW_DELAY_S``)."""
|
||||||
|
env = dict(os.environ)
|
||||||
|
env.pop("DEBUGPY", None)
|
||||||
|
env["SLOW_LLM_DELAY_S"] = SLOW_DELAY_S
|
||||||
|
env["E2E_MOCK_PORT"] = str(MOCK_PORT)
|
||||||
|
proc = subprocess.Popen(
|
||||||
|
[sys.executable, "-m", "uvicorn", "tests.e2e.slow_llm:app",
|
||||||
|
"--host", "127.0.0.1", "--port", str(SLOW_PORT), "--log-level", "warning"],
|
||||||
|
cwd=REPO,
|
||||||
|
env=env,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
_wait_http(f"{SLOW_URL}/v1/models")
|
||||||
|
yield SLOW_PORT
|
||||||
|
finally:
|
||||||
|
proc.terminate()
|
||||||
|
try:
|
||||||
|
proc.wait(timeout=10)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
proc.kill()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def app_server(
|
||||||
|
mock_llm: int,
|
||||||
|
slow_llm: int,
|
||||||
|
progressbox_repo: Path,
|
||||||
|
tmp_path_factory: pytest.TempPathFactory,
|
||||||
|
) -> Iterator[str]:
|
||||||
|
"""The SLOW app under test (the whole suite):
|
||||||
|
``BOR_LLM_BASE_URL`` is the slow proxy in front of the mock (the
|
||||||
|
timing fixture), ``BOR_GIT_SOURCES`` the ``file://`` fixture repo
|
||||||
|
(the sync's subject — see the module docstring), and
|
||||||
|
``BOR_SOURCES_DIR`` its own checkout dir. ``progressbox_repo`` is a
|
||||||
|
dependency only for the fixture ordering (the repo exists before
|
||||||
|
the first test)."""
|
||||||
|
env = _app_env(f"{SLOW_URL}/v1", progressbox_repo, tmp_path_factory)
|
||||||
|
proc = subprocess.Popen(
|
||||||
|
[sys.executable, "-m", "uvicorn", "app.main:app",
|
||||||
|
"--host", "127.0.0.1", "--port", str(APP_PORT), "--log-level", "warning"],
|
||||||
|
cwd=REPO,
|
||||||
|
env=env,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
_wait_http(f"{APP_URL}/api/health")
|
||||||
|
yield APP_URL
|
||||||
|
finally:
|
||||||
|
proc.terminate()
|
||||||
|
try:
|
||||||
|
proc.wait(timeout=10)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
proc.kill()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def app_url(app_server: str) -> str:
|
||||||
|
return app_server
|
||||||
|
|
||||||
|
|
||||||
|
def _truncate_all() -> None:
|
||||||
|
"""Fresh registry + KB (the E2E isolation pattern): the E2E suites
|
||||||
|
share one Postgres, so a leftover git_sources row would outrank
|
||||||
|
this app's ``BOR_GIT_SOURCES`` env fallback (effective_sources
|
||||||
|
prefers the DB list) and a leftover document would pollute the file
|
||||||
|
counts the sync pins exactly."""
|
||||||
|
with SessionLocal() as db:
|
||||||
|
db.execute(
|
||||||
|
text(
|
||||||
|
"TRUNCATE chunks, documents, query_log, steering_notes, "
|
||||||
|
"kb_overview, git_sources, folder_summaries"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.commit() # without the commit the TRUNCATE rolls back (the house pattern)
|
||||||
|
|
||||||
|
|
||||||
|
def _wait_sync_settled(base_url: str, timeout_s: float = 180.0) -> None:
|
||||||
|
"""No background sync may leak across tests (the run state lives in
|
||||||
|
the app's memory, and a still-running run would keep importing into
|
||||||
|
the NEXT test's truncated KB): wait for the app's sync status to be
|
||||||
|
non-running BEFORE the truncate. Own admin session (the endpoint is
|
||||||
|
admin-only) — usually a no-op: the tests settle only after their
|
||||||
|
run's terminal state."""
|
||||||
|
with httpx.Client(base_url=base_url, timeout=5.0) as client:
|
||||||
|
r = client.post("/api/login", json={"password": ADMIN_PASSWORD})
|
||||||
|
assert r.status_code == 204, r.text
|
||||||
|
deadline = time.monotonic() + timeout_s
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
r = client.get("/api/sync/status")
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
body = r.json()
|
||||||
|
if body["state"] != "running":
|
||||||
|
# Diagnostic: a FAILED terminal between tests is a real
|
||||||
|
# anomaly for this suite (every sync here must succeed)
|
||||||
|
# — surface the error text so a cascade is diagnosable
|
||||||
|
# instead of showing up as a later test's blocked click.
|
||||||
|
if body["state"] == "failed":
|
||||||
|
print(f"sync progress box: terminal FAILED at a test "
|
||||||
|
f"boundary: {body.get('error')}")
|
||||||
|
return
|
||||||
|
time.sleep(0.2)
|
||||||
|
raise AssertionError(f"a sync was still running at a test boundary: {base_url}")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _clean(app_url: str, db_ready: None) -> Iterator[None]:
|
||||||
|
"""Per-test KB isolation (the phase-96 cleanup pattern): the
|
||||||
|
shared E2E Postgres is truncated before AND after every test —
|
||||||
|
each test runs its own sync of the env-fallback source, so the KB
|
||||||
|
rows of the fixture source are exactly this test's own. The
|
||||||
|
pre-wait keeps a leaked run from the previous test (the module app
|
||||||
|
is shared) from racing the truncate."""
|
||||||
|
_wait_sync_settled(app_url)
|
||||||
|
_truncate_all()
|
||||||
|
yield
|
||||||
|
_truncate_all()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module", autouse=True)
|
||||||
|
def _wall_time_guard() -> Iterator[None]:
|
||||||
|
"""The module's wall-time budget: the whole module — app boot +
|
||||||
|
FIVE full 3-file syncs against the slowed mock LLM — stays under
|
||||||
|
``WALL_BUDGET_S``. A breach is a regression canary (the sync
|
||||||
|
pipeline slowed), not a flake tolerance."""
|
||||||
|
start = time.monotonic()
|
||||||
|
yield
|
||||||
|
elapsed = time.monotonic() - start
|
||||||
|
assert elapsed < WALL_BUDGET_S, (
|
||||||
|
f"the module took {elapsed:.1f}s — over the {WALL_BUDGET_S:.0f}s wall "
|
||||||
|
"budget (the five small-fixture syncs against the slowed mock LLM "
|
||||||
|
"are a speed canary)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_rgb(css_color: str) -> tuple[int, int, int]:
|
||||||
|
"""A computed ``rgb(r, g, b)`` / ``rgba(…)`` color → the triple."""
|
||||||
|
m = re.match(r"rgba?\(\s*(\d+)[,\s]+(\d+)[,\s]+(\d+)", css_color)
|
||||||
|
assert m, f"not a computable rgb color: {css_color!r}"
|
||||||
|
return (int(m.group(1)), int(m.group(2)), int(m.group(3)))
|
||||||
|
|
||||||
|
|
||||||
|
def _wcag_luminance(rgb: tuple[float, float, float]) -> float:
|
||||||
|
"""WCAG 2.1 relative luminance (the same math the theme suites run)."""
|
||||||
|
|
||||||
|
def lin(c: float) -> float:
|
||||||
|
s = c / 255
|
||||||
|
return s / 12.92 if s <= 0.03928 else ((s + 0.055) / 1.055) ** 2.4
|
||||||
|
|
||||||
|
r, g, b = rgb
|
||||||
|
return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b)
|
||||||
|
|
||||||
|
|
||||||
|
def _wcag_ratio(
|
||||||
|
fg: tuple[float, float, float], bg: tuple[float, float, float]
|
||||||
|
) -> float:
|
||||||
|
l1, l2 = _wcag_luminance(fg), _wcag_luminance(bg)
|
||||||
|
return (max(l1, l2) + 0.05) / (min(l1, l2) + 0.05)
|
||||||
|
|
||||||
|
|
||||||
|
def _start_sync(page: Page, app_url: str) -> None:
|
||||||
|
"""Land the admin on the sources view, wait for the settled boot
|
||||||
|
label (the phase-64 pattern — also proves the boot re-attach
|
||||||
|
completed, so no later re-render can clobber the running state),
|
||||||
|
then click **Sync sources** and pin the click's immediate 202
|
||||||
|
state: the button IS the spinner (disabled, aria-busy,
|
||||||
|
``is-running``, the spinning icon) and the box is revealed with
|
||||||
|
the bare label (phase 149 — the running detail rides the box)."""
|
||||||
|
login(page, app_url) # lands on /sources.html (the button's home)
|
||||||
|
|
||||||
|
btn = page.locator("#sync-btn")
|
||||||
|
expect(btn).to_be_visible(timeout=30_000)
|
||||||
|
expect(page.locator("#sync-label")).to_have_text(IDLE_OR_SETTLED)
|
||||||
|
expect(btn).to_be_enabled()
|
||||||
|
|
||||||
|
btn.click()
|
||||||
|
|
||||||
|
# The click's immediate state (the 202 moment — the bare prefix,
|
||||||
|
# the phase-64 contract): the button is the loading spinner and
|
||||||
|
# the box carries the bare label (phase 149 — one source, two
|
||||||
|
# surfaces).
|
||||||
|
expect(btn).to_be_disabled()
|
||||||
|
expect(btn).to_have_attribute("aria-busy", "true")
|
||||||
|
expect(btn).to_have_class(re.compile(r"\bis-running\b"))
|
||||||
|
expect(btn.locator(".sync-icon")).to_have_class(re.compile(r"\bis-spinning\b"))
|
||||||
|
expect(page.locator("#sync-progress")).to_have_text("Syncing…")
|
||||||
|
expect(page.locator("#sync-error-banner")).to_be_hidden()
|
||||||
|
|
||||||
|
|
||||||
|
def _wait_terminal(app_url: str, timeout_s: float = 120.0) -> dict[str, Any]:
|
||||||
|
"""Poll the status endpoint (own admin session) until the UI-
|
||||||
|
triggered run reaches a terminal state — the source of truth for
|
||||||
|
the settle (the UI's 2 s poll renders it at most one tick later).
|
||||||
|
A FAILED terminal fails the test with the terminal body (the
|
||||||
|
``detail`` / error text) — never an opaque label timeout."""
|
||||||
|
with httpx.Client(base_url=app_url, timeout=30.0) as client:
|
||||||
|
r = client.post("/api/login", json={"password": ADMIN_PASSWORD})
|
||||||
|
assert r.status_code == 204, r.text
|
||||||
|
deadline = time.monotonic() + timeout_s
|
||||||
|
body: dict[str, Any] = {}
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
r = client.get("/api/sync/status")
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
body = r.json()
|
||||||
|
if body["state"] in ("success", "failed"):
|
||||||
|
assert body["state"] == "success", body
|
||||||
|
return body
|
||||||
|
time.sleep(0.2)
|
||||||
|
raise AssertionError(f"the run never reached a terminal state: {body}")
|
||||||
|
|
||||||
|
|
||||||
|
def _settle(page: Page, app_url: str) -> None:
|
||||||
|
"""The phase-32 success settle (the pre-phase byte-identical
|
||||||
|
terminal shape, preserved by phase 149): the run reaches a SUCCESS
|
||||||
|
terminal at the endpoint, then the button shows "Synced HH:MM"
|
||||||
|
(re-enabled, the running state fully cleared), the box is HIDDEN
|
||||||
|
(run-scoped), and no error."""
|
||||||
|
_wait_terminal(app_url)
|
||||||
|
expect(page.locator("#sync-label")).to_have_text(SYNCED_LABEL, timeout=30_000)
|
||||||
|
btn = page.locator("#sync-btn")
|
||||||
|
expect(btn).to_be_enabled()
|
||||||
|
expect(btn).not_to_have_attribute("aria-busy")
|
||||||
|
expect(btn).not_to_have_class(re.compile(r"\bis-running\b"))
|
||||||
|
expect(btn.locator(".sync-icon")).not_to_have_class(re.compile(r"\bis-spinning\b"))
|
||||||
|
expect(page.locator("#sync-progress")).to_be_hidden()
|
||||||
|
expect(page.locator("#sync-error-banner")).to_be_hidden()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 1. In flight: the button is the spinner only (no visible label text),
|
||||||
|
# the aria-label carries the full live label, and Cancel rides along
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_running_button_is_spinner_only(
|
||||||
|
page: Page, app_url: str, db_ready: None
|
||||||
|
) -> None:
|
||||||
|
"""A fresh admin page on ``/sources.html`` clicks **Sync sources**;
|
||||||
|
the page's OWN 2 s poll keeps the run in flight long enough (the
|
||||||
|
slow proxy guarantees a ≫ 30 s window) that the in-flight state is
|
||||||
|
observed deterministically: ``#sync-btn`` carries ``is-running``
|
||||||
|
AND the icon ``is-spinning`` (the spinner IS the loading state),
|
||||||
|
``#sync-label`` is NOT visible (the label parts are hidden — the
|
||||||
|
button is the icon, at any viewport), the button's ``aria-label``
|
||||||
|
EQUALS the box's current text (written by the same single-writer
|
||||||
|
call — never stale), the box is ``aria-hidden`` (presentational —
|
||||||
|
``#sync-result`` remains the sole aria-live region), the running
|
||||||
|
mechanics (disabled + ``aria-busy``) are preserved, and the Cancel
|
||||||
|
button is visible (the phase-134 contract unchanged)."""
|
||||||
|
_start_sync(page, app_url)
|
||||||
|
|
||||||
|
# Poll (the generous timeout — the slow proxy's in-flight window is
|
||||||
|
# ~30 s) until a run is in flight: the box is visible + non-empty.
|
||||||
|
snap = page.wait_for_function(
|
||||||
|
_JS_RUNNING_SNAPSHOT, timeout=SETTLE_TIMEOUT_MS
|
||||||
|
).json_value()
|
||||||
|
|
||||||
|
# The button is the loading spinner (the owner's ask).
|
||||||
|
assert snap["isRunning"] is True, snap
|
||||||
|
assert snap["isSpinning"] is True, snap
|
||||||
|
# The label parts are hidden — the button is the icon (no visible
|
||||||
|
# label text).
|
||||||
|
assert snap["labelHidden"] is True, snap
|
||||||
|
assert page.locator("#sync-label").is_visible() is False
|
||||||
|
assert page.locator("#sync-progress").is_visible()
|
||||||
|
# The icon-only button keeps an accessible name — the FULL live
|
||||||
|
# label, equal to the box's text (the same writer call wrote both).
|
||||||
|
assert snap["boxText"], snap
|
||||||
|
assert snap["ariaLabel"] == snap["boxText"], snap
|
||||||
|
# The box is presentational — #sync-result is the SOLE live region.
|
||||||
|
assert snap["ariaHidden"] == "true", snap
|
||||||
|
# The pre-phase running mechanics are preserved.
|
||||||
|
assert snap["btnDisabled"] is True, snap
|
||||||
|
assert snap["ariaBusy"] == "true", snap
|
||||||
|
# The Cancel control rides the running state (phase 134 — unchanged).
|
||||||
|
assert snap["cancelVisible"] is True, snap
|
||||||
|
|
||||||
|
_settle(page, app_url)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 2. The box carries the EXACT full untruncated label (long path
|
||||||
|
# included — wrapped, never clipped), the announcer carries the same
|
||||||
|
# string, and the chunk part appears in the box during the embed
|
||||||
|
# window
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_box_carries_the_full_untruncated_label(
|
||||||
|
page: Page, app_url: str, db_ready: None
|
||||||
|
) -> None:
|
||||||
|
"""During the in-flight window of the LONG-path file (poll until the
|
||||||
|
box text is ``Syncing… <full long path> (1/3)`` — the slow proxy
|
||||||
|
makes the ~15 s span deterministic): the box's text contains the
|
||||||
|
FULL label shape with the complete ``source/relative/path`` as a
|
||||||
|
substring (the pre-phase ellipsis would have truncated it — the
|
||||||
|
wrap means nothing is dropped), the box did NOT overflow
|
||||||
|
horizontally (``scrollWidth <= clientWidth`` — the wrap engaged)
|
||||||
|
neither as a panel nor in its text, ``#sync-result`` (the
|
||||||
|
announcer) carries the SAME full string (A4 — one source, two
|
||||||
|
surfaces), the button's ``aria-label`` equals it, and the box ink
|
||||||
|
on the panel clears WCAG 2.1 AA (≥ 4.5:1). Then a retry loop over
|
||||||
|
the slow embed window pins the phase-129 chunk part IN THE BOX
|
||||||
|
(``· chunk n/m`` — the part is only present while an embed batch is
|
||||||
|
in flight; the proxy's 1.5 s delay makes the window wide)."""
|
||||||
|
_start_sync(page, app_url)
|
||||||
|
|
||||||
|
# Wait A — the in-flight window of the long file (any frame of it —
|
||||||
|
# the pre-first-batch frame has no chunk part yet).
|
||||||
|
snap = page.wait_for_function(
|
||||||
|
_JS_LONG_FILE_SNAPSHOT, arg=LONG_PATH, timeout=SETTLE_TIMEOUT_MS
|
||||||
|
).json_value()
|
||||||
|
|
||||||
|
# The FULL label shape — the complete long path IN the text (the
|
||||||
|
# clipping trigger, untruncated).
|
||||||
|
expected_prefix = f"Syncing… {LONG_PATH} (1/3)"
|
||||||
|
assert snap["boxText"].startswith(expected_prefix), snap["boxText"]
|
||||||
|
assert LONG_PATH in snap["boxText"], snap["boxText"]
|
||||||
|
# The wrap engaged — no horizontal overflow (the whole point of the
|
||||||
|
# box: the path WRAPS, it is never clipped).
|
||||||
|
assert snap["boxScrollWidth"] <= snap["boxClientWidth"], snap
|
||||||
|
assert snap["textScrollWidth"] <= snap["textClientWidth"], snap
|
||||||
|
# A4: the announcer carries the SAME full string (one source, two
|
||||||
|
# surfaces) — and the icon-only button's accessible name does too.
|
||||||
|
assert snap["resultText"] == snap["boxText"], snap
|
||||||
|
assert snap["ariaLabel"] == snap["boxText"], snap
|
||||||
|
# WCAG 2.1 AA: the box's mono ink on its panel background ≥ 4.5:1
|
||||||
|
# (the theme's pinned identity pair — observed, not assumed).
|
||||||
|
ratio = _wcag_ratio(_parse_rgb(snap["ink"]), _parse_rgb(snap["bg"]))
|
||||||
|
assert ratio >= 4.5, f"{snap['ink']} on {snap['bg']}: {ratio:.2f}:1 < 4.5:1 (AA)"
|
||||||
|
|
||||||
|
# Wait B — the phase-129 chunk part IN THE BOX (the retry loop over
|
||||||
|
# the slow embed window — the part is only present while an embed
|
||||||
|
# batch is in flight; the ~15 s long-file window outlives the 2 s
|
||||||
|
# poll by ≫ 1×).
|
||||||
|
snap2 = page.wait_for_function(
|
||||||
|
_JS_LONG_FILE_CHUNK_SNAPSHOT, arg=LONG_PATH, timeout=SETTLE_TIMEOUT_MS
|
||||||
|
).json_value()
|
||||||
|
assert snap2["chunkN"] >= 1, snap2
|
||||||
|
assert snap2["chunkM"] >= 1, snap2
|
||||||
|
# The longer label (with the chunk part) still wraps — no clipping.
|
||||||
|
assert snap2["boxScrollWidth"] <= snap2["boxClientWidth"], snap2
|
||||||
|
assert snap2["textScrollWidth"] <= snap2["textClientWidth"], snap2
|
||||||
|
|
||||||
|
_settle(page, app_url)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 3. Mobile (390×844): the box is full-width, the button is icon-only
|
||||||
|
# (the mobile full-width rule), and the label wraps
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_mobile_box_full_width(page: Page, app_url: str, db_ready: None) -> None:
|
||||||
|
"""A fresh page at the house MOBILE viewport (390×844) clicks
|
||||||
|
**Sync sources**; during the in-flight window of the LONG-path
|
||||||
|
file: the box is FULL WIDTH (its bounding-box width ≈ the
|
||||||
|
container's content width — a block in the same container rides
|
||||||
|
the 100%-width phone shell with no override), no horizontal
|
||||||
|
clipping (``scrollWidth <= clientWidth``), the button is
|
||||||
|
icon-only (``is-running`` + the spinning icon, the label parts
|
||||||
|
hidden) and keeps the mobile full-width rule, and the label wraps
|
||||||
|
(the full long path is a substring — the old mobile-stuffing
|
||||||
|
ellipsis is gone)."""
|
||||||
|
page.set_viewport_size(MOBILE_VIEWPORT)
|
||||||
|
_start_sync(page, app_url)
|
||||||
|
|
||||||
|
snap = page.wait_for_function(
|
||||||
|
_JS_MOBILE_SNAPSHOT, arg=LONG_PATH, timeout=SETTLE_TIMEOUT_MS
|
||||||
|
).json_value()
|
||||||
|
|
||||||
|
# The label wraps — the full long path is IN the box's text (the
|
||||||
|
# old mobile-stuffing ellipsis is gone).
|
||||||
|
assert snap["boxText"].startswith(f"Syncing… {LONG_PATH} ("), snap["boxText"]
|
||||||
|
assert LONG_PATH in snap["boxText"], snap["boxText"]
|
||||||
|
# The wrap engaged — no horizontal clipping at the phone width.
|
||||||
|
assert snap["boxScrollWidth"] <= snap["boxClientWidth"], snap
|
||||||
|
# The box is FULL WIDTH: its width ≈ the container's content width
|
||||||
|
# (the 2px slack covers float rounding only).
|
||||||
|
assert abs(snap["boxRect"]["width"] - snap["containerContentWidth"]) <= 2, snap
|
||||||
|
# …and it is inside the viewport (the owner's clipped-detail
|
||||||
|
# report, directly negated).
|
||||||
|
assert snap["boxRect"]["left"] >= 0, snap["boxRect"]
|
||||||
|
assert snap["boxRect"]["right"] <= MOBILE_VIEWPORT["width"] + 1, snap["boxRect"]
|
||||||
|
# The button is icon-only (the spinner) and keeps the mobile
|
||||||
|
# full-width rule: its width:100% resolves against its containing
|
||||||
|
# block — the .page-head card's content width (the card's padding
|
||||||
|
# + border inset it from the container).
|
||||||
|
assert snap["isRunning"] is True, snap
|
||||||
|
assert snap["isSpinning"] is True, snap
|
||||||
|
assert snap["labelHidden"] is True, snap
|
||||||
|
assert abs(snap["btnRect"]["width"] - snap["headContentWidth"]) <= 2, snap
|
||||||
|
|
||||||
|
_settle(page, app_url)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 4. Settle: the box hides, the "Synced HH:MM" pill is restored
|
||||||
|
# byte-identical, and the counts line is unchanged
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_settled_state_restores_the_pill(
|
||||||
|
page: Page, app_url: str, db_ready: None
|
||||||
|
) -> None:
|
||||||
|
"""A sync is triggered and observed IN FLIGHT (the box is visible —
|
||||||
|
so the settle below is a real transition, not a no-op), then let to
|
||||||
|
settle (the house settle idiom): the box is HIDDEN (run-scoped
|
||||||
|
only), the button shows the settle label ``Synced HH:MM`` (the
|
||||||
|
pre-phase byte-identical settle copy) with the label parts
|
||||||
|
restored — the count + chunk parts hidden and empty —,
|
||||||
|
``is-running`` / ``is-spinning`` are gone, the ``aria-label`` is
|
||||||
|
back to ``"Sync sources"`` (the ship value), and the counts line
|
||||||
|
on ``#sync-result`` matches the pre-phase ``fmtSyncResult`` shape
|
||||||
|
(``N added`` — the 3 fixture files)."""
|
||||||
|
_start_sync(page, app_url)
|
||||||
|
|
||||||
|
# In flight first (the box is visible + the button is the spinner).
|
||||||
|
page.wait_for_function(_JS_RUNNING_SNAPSHOT, timeout=SETTLE_TIMEOUT_MS).json_value()
|
||||||
|
|
||||||
|
# The settle (the house idiom): the terminal lands at the endpoint,
|
||||||
|
# then the UI's 2 s poll renders the phase-32 terminal shape.
|
||||||
|
_wait_terminal(app_url)
|
||||||
|
expect(page.locator("#sync-label")).to_have_text(SYNCED_LABEL, timeout=30_000)
|
||||||
|
|
||||||
|
# The pill is restored (the pre-phase byte-identical settle copy).
|
||||||
|
expect(page.locator("#sync-label")).to_be_visible()
|
||||||
|
btn = page.locator("#sync-btn")
|
||||||
|
expect(btn).to_be_enabled()
|
||||||
|
expect(btn).not_to_have_attribute("aria-busy")
|
||||||
|
expect(btn).to_have_attribute("aria-label", "Sync sources")
|
||||||
|
expect(btn).not_to_have_class(re.compile(r"\bis-running\b"))
|
||||||
|
expect(btn.locator(".sync-icon")).not_to_have_class(re.compile(r"\bis-spinning\b"))
|
||||||
|
# The box is run-scoped: hidden at settle.
|
||||||
|
expect(page.locator("#sync-progress")).to_be_hidden()
|
||||||
|
# The label parts are restored — the settle label carries no count
|
||||||
|
# or chunk part.
|
||||||
|
count = page.locator(".sync-label-count")
|
||||||
|
expect(count).to_be_hidden()
|
||||||
|
expect(count).to_have_text("")
|
||||||
|
chunk = page.locator(".sync-label-chunk")
|
||||||
|
expect(chunk).to_be_hidden()
|
||||||
|
expect(chunk).to_have_text("")
|
||||||
|
# The counts line is unchanged (the pre-phase fmtSyncResult pin).
|
||||||
|
expect(page.locator("#sync-result")).to_have_text(f"{N_FILES} added")
|
||||||
|
expect(page.locator("#sync-error-banner")).to_be_hidden()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 5. A page load mid-run re-attaches with the box visible (never stale)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_reattach_shows_the_box(page: Page, app_url: str, db_ready: None) -> None:
|
||||||
|
"""A sync is triggered and observed IN FLIGHT (the box is visible),
|
||||||
|
then the page is RELOADED (the admin session cookie persists): the
|
||||||
|
load-time re-attach (``initSyncButton``) finds the RUNNING sync →
|
||||||
|
the box is VISIBLE with the current label and the button is
|
||||||
|
icon-only again (``is-running`` + the spinning icon, the label
|
||||||
|
parts hidden, the ``aria-label`` equal to the box's text, the
|
||||||
|
Cancel control visible) — the never-stale contract: a page load
|
||||||
|
mid-run shows the truth, not an idle pill."""
|
||||||
|
_start_sync(page, app_url)
|
||||||
|
|
||||||
|
# In flight first (the pre-reload state: the box is visible).
|
||||||
|
page.wait_for_function(_JS_RUNNING_SNAPSHOT, timeout=SETTLE_TIMEOUT_MS).json_value()
|
||||||
|
|
||||||
|
# Reload mid-run — the run keeps going on the server; the admin
|
||||||
|
# session (the cookie) survives the reload.
|
||||||
|
page.reload(wait_until="domcontentloaded")
|
||||||
|
|
||||||
|
# The re-attach: the box is visible with the CURRENT label and the
|
||||||
|
# button is the spinner again (any in-flight state — the run is
|
||||||
|
# ~30 s and the reload lands within a few seconds of the start).
|
||||||
|
snap = page.wait_for_function(
|
||||||
|
_JS_RUNNING_SNAPSHOT, timeout=SETTLE_TIMEOUT_MS
|
||||||
|
).json_value()
|
||||||
|
assert snap["isRunning"] is True, snap
|
||||||
|
assert snap["isSpinning"] is True, snap
|
||||||
|
assert snap["labelHidden"] is True, snap
|
||||||
|
assert snap["boxText"], snap
|
||||||
|
assert snap["ariaLabel"] == snap["boxText"], snap
|
||||||
|
assert snap["cancelVisible"] is True, snap
|
||||||
|
|
||||||
|
_settle(page, app_url)
|
||||||
@@ -727,16 +727,17 @@ def test_sync_button_names_the_summary_phase(
|
|||||||
page: Page, app_url: str, syncsum_dir: Path, db_ready: None
|
page: Page, app_url: str, syncsum_dir: Path, db_ready: None
|
||||||
) -> None:
|
) -> None:
|
||||||
"""A fresh admin page on ``/sources.html`` clicks **Sync sources**;
|
"""A fresh admin page on ``/sources.html`` clicks **Sync sources**;
|
||||||
the page's OWN 2 s poll renders ``#sync-label`` as
|
the page's OWN 2 s poll renders the summaries label —
|
||||||
``Summarizing folders… [source/folder]`` + the pinned ``(n/3)``
|
``Summarizing folders… [source/folder] (n/3)`` (the folder part is
|
||||||
count span (phase 128: the label is PARTS — the folder part is
|
optional — the phase's first poll may precede the first hook) — in
|
||||||
optional — the phase's first poll may precede the first hook — and
|
the detail box (phase 149: the running label rides #sync-progress
|
||||||
the button ``title`` carries the full untruncated string, main +
|
under the button row; the button is the spinner) with the button
|
||||||
" " + count, A4); on settle the button shows the terminal
|
``title`` carrying the same full untruncated string (A4 — one
|
||||||
``Synced HH:MM`` (the phase-32 contract) and ``#sync-result``
|
source, two surfaces); on settle the box hides, the button shows
|
||||||
carries the counts line. The prelude/import labels stay the
|
the terminal ``Synced HH:MM`` (the phase-32 contract) and
|
||||||
phase-64 shape (the endpoint test pins those phases; the UI pins
|
``#sync-result`` carries the counts line. The prelude/import labels
|
||||||
the new summaries label)."""
|
stay the phase-64 shape (the endpoint test pins those phases; the
|
||||||
|
UI pins the new summaries label)."""
|
||||||
page.set_default_timeout(30_000)
|
page.set_default_timeout(30_000)
|
||||||
_register_source(app_url, str(syncsum_dir))
|
_register_source(app_url, str(syncsum_dir))
|
||||||
login(page, app_url) # lands on /sources.html (the button's home)
|
login(page, app_url) # lands on /sources.html (the button's home)
|
||||||
@@ -759,38 +760,35 @@ def test_sync_button_names_the_summary_phase(
|
|||||||
|
|
||||||
# The click's immediate state (the 202 moment — the bare prefix,
|
# The click's immediate state (the 202 moment — the bare prefix,
|
||||||
# the phase-64 contract): disabled, aria-busy, spinning icon, no
|
# the phase-64 contract): disabled, aria-busy, spinning icon, no
|
||||||
# error…
|
# error, and the detail box revealed with the bare label
|
||||||
|
# (phase 149 — the running label rides the box)…
|
||||||
expect(btn).to_be_disabled()
|
expect(btn).to_be_disabled()
|
||||||
expect(btn).to_have_attribute("aria-busy", "true")
|
expect(btn).to_have_attribute("aria-busy", "true")
|
||||||
expect(btn.locator(".sync-icon")).to_have_class(re.compile(r"\bis-spinning\b"))
|
expect(btn.locator(".sync-icon")).to_have_class(re.compile(r"\bis-spinning\b"))
|
||||||
expect(page.locator("#sync-label")).to_have_text("Syncing…")
|
expect(page.locator("#sync-progress")).to_have_text("Syncing…")
|
||||||
expect(page.locator("#sync-error-banner")).to_be_hidden()
|
expect(page.locator("#sync-error-banner")).to_be_hidden()
|
||||||
|
|
||||||
# UI layer (D2): the page's own 2 s poll names the summaries phase
|
# UI layer (D2): the page's own 2 s poll names the summaries phase
|
||||||
# — the RENDERED label matches the D2 PARTS shape (phase 128:
|
# — the RENDERED label in the detail box (phase 149: #sync-progress
|
||||||
# the MAIN part carries "Summarizing folders… [folder]" — the
|
# — the button is the spinner) matches the D2 shape —
|
||||||
# folder part is optional, the phase's first poll may precede the
|
# "Summarizing folders… [folder] (n/3)" (the folder part is
|
||||||
# first hook — and the pinned COUNT part carries the (n/3)
|
# optional, the phase's first poll may precede the first hook — the
|
||||||
# counts), and the button ``title`` carries the full untruncated
|
# (n/3) counts always present) — and the button ``title`` carries
|
||||||
# string (main + " " + count, A4). ``wait_for_function`` checks
|
# the same full untruncated string (A4 — one source, two surfaces).
|
||||||
# BOTH in ONE in-browser evaluation: a poll tick updates the parts
|
# ``wait_for_function`` checks BOTH in ONE in-browser evaluation:
|
||||||
# + the title together in a single JS task, so the atomic check
|
# a poll tick updates the box + the title together in a single JS
|
||||||
# can never straddle one (the JS regexes are the D2 shapes,
|
# task (the single setSyncLabel writer), so the atomic check can
|
||||||
# unit-pinned in tests/unit/test_frontend_sync_upload.py).
|
# never straddle one (the D2 shapes are unit-pinned in
|
||||||
|
# tests/unit/test_frontend_sync_upload.py).
|
||||||
page.wait_for_function(
|
page.wait_for_function(
|
||||||
"""() => {
|
"""() => {
|
||||||
const label = document.querySelector('#sync-label');
|
const box = document.querySelector('#sync-progress');
|
||||||
|
const textEl = document.querySelector('#sync-progress-text');
|
||||||
const btn = document.querySelector('#sync-btn');
|
const btn = document.querySelector('#sync-btn');
|
||||||
if (!label || !btn) return false;
|
if (!box || !textEl || !btn || box.hidden) return false;
|
||||||
const main = label.querySelector('.sync-label-main');
|
const t = textEl.textContent;
|
||||||
const count = label.querySelector('.sync-label-count');
|
const reFull = /^Summarizing folders…(?: \\S+)? \\(\\d+\\/3\\)$/;
|
||||||
if (!main || !count || count.hidden) return false;
|
return reFull.test(t) && btn.getAttribute('title') === t;
|
||||||
const reMain = /^Summarizing folders…(?: \\S+)?$/;
|
|
||||||
const reCount = /^\\(\\d+\\/3\\)$/;
|
|
||||||
return reMain.test(main.textContent)
|
|
||||||
&& reCount.test(count.textContent)
|
|
||||||
&& btn.getAttribute('title')
|
|
||||||
=== main.textContent + ' ' + count.textContent;
|
|
||||||
}""",
|
}""",
|
||||||
timeout=SETTLE_TIMEOUT_MS,
|
timeout=SETTLE_TIMEOUT_MS,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -41,8 +41,10 @@ is asserted at BOTH layers the task pins:
|
|||||||
non-null ``current_file`` (``source/relative/path``) observed at
|
non-null ``current_file`` (``source/relative/path``) observed at
|
||||||
some tick, the counts advancing, and the file-less ticks (model
|
some tick, the counts advancing, and the file-less ticks (model
|
||||||
probe — A4) preceding the first file tick;
|
probe — A4) preceding the first file tick;
|
||||||
* **UI** — polling the label for the ``Syncing…`` prefix plus a file
|
* **UI** — polling the detail box (``#sync-progress`` — phase 149:
|
||||||
path (generous timeout), which the page's own 2 s poll ticks render.
|
the running label rides the box under the button row, the button is
|
||||||
|
the spinner) for the ``Syncing…`` prefix plus a file path (generous
|
||||||
|
timeout), which the page's own 2 s poll ticks render.
|
||||||
|
|
||||||
The upload archive is built in-test with Python's ``tarfile`` from
|
The upload archive is built in-test with Python's ``tarfile`` from
|
||||||
**25 small ``.md`` files** (``e2e-prog.tar.gz`` → source
|
**25 small ``.md`` files** (``e2e-prog.tar.gz`` → source
|
||||||
@@ -64,9 +66,10 @@ Contract under test:
|
|||||||
documents** (the upload unpacked + registered only) and the sync
|
documents** (the upload unpacked + registered only) and the sync
|
||||||
button settled idle with no error UI; clicking **Sync sources**
|
button settled idle with no error UI; clicking **Sync sources**
|
||||||
then imports the uploaded row with the LIVE "Syncing… <file> (n/m)"
|
then imports the uploaded row with the LIVE "Syncing… <file> (n/m)"
|
||||||
label (both layers — the phase-129 pinned chunk part "· chunk n/m"
|
label (both layers — the phase-149 detail box carries the full
|
||||||
rides the label + title while a file's embed batch is in flight)
|
string, the phase-129 pinned chunk part "· chunk n/m" rides the box
|
||||||
and lands the counts ("N added", the catalog refreshes);
|
+ title while a file's embed batch is in flight) and lands the
|
||||||
|
counts ("N added", the catalog refreshes);
|
||||||
* **upload processing (phase 90 A2)**: the upload button shows the
|
* **upload processing (phase 90 A2)**: the upload button shows the
|
||||||
BARE "Processing…" for the whole background run (no file, no
|
BARE "Processing…" for the whole background run (no file, no
|
||||||
"(n/m)", no title — proven across a held first status GET); every
|
"(n/m)", no title — proven across a held first status GET); every
|
||||||
@@ -76,11 +79,12 @@ Contract under test:
|
|||||||
result line points at the Sync button; the KB stays empty;
|
result line points at the Sync button; the KB stays empty;
|
||||||
* **sync live file (A4)**: a multi-file local source; clicking
|
* **sync live file (A4)**: a multi-file local source; clicking
|
||||||
**Sync sources** on ``/sources.html`` shows "Syncing…" (bare, the
|
**Sync sources** on ``/sources.html`` shows "Syncing…" (bare, the
|
||||||
pre-64 click state) then "Syncing… <file> (n/m)" (both layers — the
|
pre-64 click state) in the detail box (phase 149 — the button is
|
||||||
phase-129 pinned chunk part "· chunk n/m" rides the label + title
|
the spinner) then "Syncing… <file> (n/m)" (both layers — the phase-
|
||||||
while a file's embed batch is in flight), then the pre-64 success
|
129 pinned chunk part "· chunk n/m" rides the box + title while a
|
||||||
settle — "Synced HH:MM" + the counts result line — preserved, plus
|
file's embed batch is in flight), then the pre-64 success settle —
|
||||||
the file in the label;
|
"Synced HH:MM" + the counts result line — preserved, plus the file
|
||||||
|
in the label (the box hides at settle);
|
||||||
* **reload re-attach (A1 + A2, phase 90)**: starting an upload and
|
* **reload re-attach (A1 + A2, phase 90)**: starting an upload and
|
||||||
reloading ``/git-sources.html`` (the sub-second run may still be in
|
reloading ``/git-sources.html`` (the sub-second run may still be in
|
||||||
flight — the boot re-attach enters the bare Processing state — or
|
flight — the boot re-attach enters the bare Processing state — or
|
||||||
@@ -548,9 +552,10 @@ def test_upload_toast_then_navigate_away(
|
|||||||
documents** (phase 90 A1: the upload unpacked + registered only)
|
documents** (phase 90 A1: the upload unpacked + registered only)
|
||||||
and the sync button settled idle with no error UI; then click
|
and the sync button settled idle with no error UI; then click
|
||||||
**Sync sources** (the new leg, phase 90) → the LIVE
|
**Sync sources** (the new leg, phase 90) → the LIVE
|
||||||
"Syncing… <file> (n/m)" label while the sync imports the uploaded
|
"Syncing… <file> (n/m)" detail while the sync imports the uploaded
|
||||||
row (both layers), then the success settle with the counts and the
|
row (phase 149: the box under the button row — the button is the
|
||||||
catalog refresh."""
|
spinner — at both layers), then the success settle with the counts
|
||||||
|
and the catalog refresh (the box hides)."""
|
||||||
page.set_default_timeout(30_000)
|
page.set_default_timeout(30_000)
|
||||||
_admin_git_sources_page(page, app_url)
|
_admin_git_sources_page(page, app_url)
|
||||||
# (A previous test's terminal run may re-render its result line at
|
# (A previous test's terminal run may re-render its result line at
|
||||||
@@ -599,33 +604,34 @@ def test_upload_toast_then_navigate_away(
|
|||||||
|
|
||||||
# The click's immediate state (A4 — the bare prefix until the
|
# The click's immediate state (A4 — the bare prefix until the
|
||||||
# import's first file): disabled, aria-busy, spinning icon, no
|
# import's first file): disabled, aria-busy, spinning icon, no
|
||||||
# error…
|
# error, and the detail box revealed with the bare label
|
||||||
|
# (phase 149 — the running label rides the box)…
|
||||||
expect(btn).to_be_disabled()
|
expect(btn).to_be_disabled()
|
||||||
expect(btn).to_have_attribute("aria-busy", "true")
|
expect(btn).to_have_attribute("aria-busy", "true")
|
||||||
expect(btn.locator(".sync-icon")).to_have_class(re.compile(r"\bis-spinning\b"))
|
expect(btn.locator(".sync-icon")).to_have_class(re.compile(r"\bis-spinning\b"))
|
||||||
expect(page.locator("#sync-label")).to_have_text("Syncing…")
|
expect(page.locator("#sync-progress")).to_have_text("Syncing…")
|
||||||
expect(page.locator("#sync-error-banner")).to_be_hidden()
|
expect(page.locator("#sync-error-banner")).to_be_hidden()
|
||||||
|
|
||||||
# UI layer: the label gains the live file at the page's 2 s poll
|
# UI layer (phase 149 task 01): the RUNNING detail rides the
|
||||||
# tick — the file rides the MAIN part, "(n/m)" the pinned COUNT
|
# detail box — the page's 2 s poll tick reveals the live file in
|
||||||
# span (phase 128: the space between them is the CSS gap, so the
|
# #sync-progress as the FULL untruncated string: "Syncing…
|
||||||
# rendered concatenation carries none — the FULL string with the
|
# <source/rel/path> (n/m)" (+ " · chunk n/m" while a file's embed
|
||||||
# space rides the button title, A4). Phase 129: the pinned CHUNK
|
# batch is in flight — phase 129, batch granularity). The box is
|
||||||
# part ("· chunk n/m" — the file's embed-batch position, batch
|
# the visible running surface — nothing is ellipsized away, the
|
||||||
# granularity) renders while a file's embed batch is in flight —
|
# "(n/m)" count is IN the full string (the never-clipped pin),
|
||||||
# both the rendered label (no space — the CSS gap) and the title
|
# and the button title carries the same full string (A4 — one
|
||||||
# (with the space, A4) carry it.
|
# source, two surfaces).
|
||||||
expect(page.locator("#sync-label")).to_have_text(
|
expect(page.locator("#sync-progress-text")).to_have_text(
|
||||||
re.compile(
|
re.compile(
|
||||||
rf"Syncing… {re.escape(UPLOAD_NAME)}/.+\.md\(\d+/{N_FILES}\)(?:· chunk \d+/\d+)?"
|
rf"^Syncing… {re.escape(UPLOAD_NAME)}/.+\.md \(\d+/{N_FILES}\)"
|
||||||
|
r"(?: · chunk \d+/\d+)?$"
|
||||||
),
|
),
|
||||||
timeout=SETTLE_TIMEOUT_MS,
|
timeout=SETTLE_TIMEOUT_MS,
|
||||||
)
|
)
|
||||||
# Phase 128: the "(n/m)" is NEVER clipped — it sits in its own
|
# The box is the VISIBLE running surface (the count's old
|
||||||
# visible count span, and the button title carries the full
|
# "never clipped" pin now lives in its full string — the "(n/m)"
|
||||||
# untruncated string (main + " " + count [+ " " + chunk, phase
|
# is in the text above).
|
||||||
# 129], A4).
|
expect(page.locator("#sync-progress")).to_be_visible(
|
||||||
expect(page.locator("#sync-label .sync-label-count")).to_be_visible(
|
|
||||||
timeout=SETTLE_TIMEOUT_MS
|
timeout=SETTLE_TIMEOUT_MS
|
||||||
)
|
)
|
||||||
expect(btn).to_have_attribute(
|
expect(btn).to_have_attribute(
|
||||||
@@ -762,12 +768,14 @@ def test_sync_live_file_label(
|
|||||||
page: Page, app_url: str, db_ready: None, sync_local_dir: Path
|
page: Page, app_url: str, db_ready: None, sync_local_dir: Path
|
||||||
) -> None:
|
) -> None:
|
||||||
"""A multi-file local source (the ``test_sync_button.py`` fixture
|
"""A multi-file local source (the ``test_sync_button.py`` fixture
|
||||||
style): on ``/sources.html`` click **Sync sources** → the label
|
style): on ``/sources.html`` click **Sync sources** → the detail
|
||||||
shows "Syncing…" (bare, the pre-64 click state) then "Syncing…
|
box (phase 149 — the running label left the pill; the button is
|
||||||
<file> (n/m)" while running (endpoint layer: ``current_file``
|
the spinner) shows "Syncing…" (bare, the pre-64 click state) then
|
||||||
non-null at running ticks; UI layer: the label poll), then the
|
"Syncing… <file> (n/m)" while running (endpoint layer:
|
||||||
success settle with the counts result line — the pre-phase-64 sync
|
``current_file`` non-null at running ticks; UI layer: the box
|
||||||
UX is preserved, plus the file."""
|
poll), then the success settle with the counts result line (the
|
||||||
|
box hides) — the pre-phase-64 sync UX's settle is preserved, plus
|
||||||
|
the file in the label while running."""
|
||||||
page.set_default_timeout(30_000)
|
page.set_default_timeout(30_000)
|
||||||
_seed_local_source(str(sync_local_dir))
|
_seed_local_source(str(sync_local_dir))
|
||||||
login(page, app_url) # lands on /sources.html (the button's home)
|
login(page, app_url) # lands on /sources.html (the button's home)
|
||||||
@@ -792,33 +800,34 @@ def test_sync_live_file_label(
|
|||||||
|
|
||||||
# The click's immediate state is the pre-64 one (A4 — the bare
|
# The click's immediate state is the pre-64 one (A4 — the bare
|
||||||
# prefix until the import's first file): disabled, aria-busy,
|
# prefix until the import's first file): disabled, aria-busy,
|
||||||
# spinning icon, no error…
|
# spinning icon, no error, and the detail box revealed with the
|
||||||
|
# bare label (phase 149 — the running label rides the box)…
|
||||||
expect(btn).to_be_disabled()
|
expect(btn).to_be_disabled()
|
||||||
expect(btn).to_have_attribute("aria-busy", "true")
|
expect(btn).to_have_attribute("aria-busy", "true")
|
||||||
expect(btn.locator(".sync-icon")).to_have_class(re.compile(r"\bis-spinning\b"))
|
expect(btn.locator(".sync-icon")).to_have_class(re.compile(r"\bis-spinning\b"))
|
||||||
expect(page.locator("#sync-label")).to_have_text("Syncing…")
|
expect(page.locator("#sync-progress")).to_have_text("Syncing…")
|
||||||
expect(page.locator("#sync-error-banner")).to_be_hidden()
|
expect(page.locator("#sync-error-banner")).to_be_hidden()
|
||||||
|
|
||||||
# UI layer: the label gains the live file at the page's 2 s poll
|
# UI layer (phase 149 task 01): the RUNNING detail rides the
|
||||||
# tick — the file rides the MAIN part, "(n/m)" the pinned COUNT
|
# detail box — the page's 2 s poll tick reveals the live file in
|
||||||
# span (phase 128: the space between them is the CSS gap, so the
|
# #sync-progress as the FULL untruncated string: "Syncing…
|
||||||
# rendered concatenation carries none — the FULL string with the
|
# <source/rel/path> (n/m)" (+ " · chunk n/m" while a file's embed
|
||||||
# space rides the button title, A4). Phase 129: the pinned CHUNK
|
# batch is in flight — phase 129, batch granularity). The box is
|
||||||
# part ("· chunk n/m" — the file's embed-batch position, batch
|
# the visible running surface — nothing is ellipsized away, the
|
||||||
# granularity) renders while a file's embed batch is in flight —
|
# "(n/m)" count is IN the full string (the never-clipped pin),
|
||||||
# both the rendered label (no space — the CSS gap) and the title
|
# and the button title carries the same full string (A4 — one
|
||||||
# (with the space, A4) carry it.
|
# source, two surfaces).
|
||||||
expect(page.locator("#sync-label")).to_have_text(
|
expect(page.locator("#sync-progress-text")).to_have_text(
|
||||||
re.compile(
|
re.compile(
|
||||||
rf"Syncing… {re.escape(SYNC_SOURCE_DIR)}/.+\.md\(\d+/{N_FILES}\)(?:· chunk \d+/\d+)?"
|
rf"^Syncing… {re.escape(SYNC_SOURCE_DIR)}/.+\.md \(\d+/{N_FILES}\)"
|
||||||
|
r"(?: · chunk \d+/\d+)?$"
|
||||||
),
|
),
|
||||||
timeout=SETTLE_TIMEOUT_MS,
|
timeout=SETTLE_TIMEOUT_MS,
|
||||||
)
|
)
|
||||||
# Phase 128: the "(n/m)" is NEVER clipped — it sits in its own
|
# The box is the VISIBLE running surface (the count's old
|
||||||
# visible count span, and the button title carries the full
|
# "never clipped" pin now lives in its full string — the "(n/m)"
|
||||||
# untruncated string (main + " " + count [+ " " + chunk, phase
|
# is in the text above).
|
||||||
# 129], A4).
|
expect(page.locator("#sync-progress")).to_be_visible(
|
||||||
expect(page.locator("#sync-label .sync-label-count")).to_be_visible(
|
|
||||||
timeout=SETTLE_TIMEOUT_MS
|
timeout=SETTLE_TIMEOUT_MS
|
||||||
)
|
)
|
||||||
expect(btn).to_have_attribute(
|
expect(btn).to_have_attribute(
|
||||||
|
|||||||
@@ -92,6 +92,23 @@ contract: shown + enabled in EVERY sync-running state (the entry
|
|||||||
shows it for kind "sync"), hidden + disabled for the upload run
|
shows it for kind "sync"), hidden + disabled for the upload run
|
||||||
(the upload job is NOT cancellable in this phase) and after the
|
(the upload job is NOT cancellable in this phase) and after the
|
||||||
settle (settleSyncButton always hides it).
|
settle (settleSyncButton always hides it).
|
||||||
|
|
||||||
|
Phase 149 (task 01) adds the `running` flag to the single writer:
|
||||||
|
setSyncLabel(mainText, countText = "", chunkText = "", running =
|
||||||
|
false) also writes the SAME untruncated full label into the detail
|
||||||
|
box (#sync-progress's .sync-progress-text — textContent only) and
|
||||||
|
reveals the box IFF running (the running entry passes true, the
|
||||||
|
settle passes the default false), and owns the button's accessible
|
||||||
|
name (full live label while running, the ship value "Sync sources"
|
||||||
|
restored on every non-running call). The running entry passes
|
||||||
|
`running = true` + adds the `is-running` class (with the spin), and
|
||||||
|
no longer sets aria-label (the writer owns it); the settle removes
|
||||||
|
`is-running` (with the spin). The node harness rows gain the box's
|
||||||
|
text/hidden pair, the button's `is-running` + the icon's
|
||||||
|
`is-spinning` flags, and the button's aria-label — the behavioral
|
||||||
|
contract: box visible + full label + is-running + spinning + the
|
||||||
|
aria-label equal to the full label in EVERY running state, box
|
||||||
|
hidden + the idle flags + the restored aria-label after the settle.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -256,22 +273,33 @@ def test_fmt_sync_label_returns_the_parts_contract() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_set_sync_label_is_the_single_parts_writer() -> None:
|
def test_set_sync_label_is_the_single_parts_writer() -> None:
|
||||||
"""setSyncLabel(mainText, countText = "", chunkText = "") — Phase
|
"""setSyncLabel(mainText, countText = "", chunkText = "",
|
||||||
128 (task 01) + Phase 129 (task 04): the SINGLE point that writes
|
running = false) — Phase 128 (task 01) + Phase 129 (task 04):
|
||||||
#sync-label. The MAIN part gets mainText (textContent only — the
|
the SINGLE point that writes #sync-label. The MAIN part gets
|
||||||
house rule, no HTML strings); the COUNT part gets countText when
|
mainText (textContent only — the house rule, no HTML strings);
|
||||||
present (textContent + hidden=false — the "(n/m)" position is
|
the COUNT part gets countText when present (textContent +
|
||||||
visible, NEVER clipped) and is emptied + hidden=true when absent;
|
hidden=false — the "(n/m)" position is visible, NEVER clipped)
|
||||||
the CHUNK part (phase 129) gets chunkText under the SAME contract
|
and is emptied + hidden=true when absent; the CHUNK part
|
||||||
(present → the pinned "· chunk n/m" is visible, absent → emptied
|
(phase 129) gets chunkText under the SAME contract (present →
|
||||||
+ hidden=true — the part renders only while a file's embed batch
|
the pinned "· chunk n/m" is visible, absent → emptied +
|
||||||
|
hidden=true — the part renders only while a file's embed batch
|
||||||
is in flight). Then the UNTRUNCATED full label — mainText +
|
is in flight). Then the UNTRUNCATED full label — mainText +
|
||||||
(countText ? " " + countText : "") + (chunkText ? " " + chunkText
|
(countText ? " " + countText : "") + (chunkText ? " " + chunkText
|
||||||
: "") — rides the button title + #sync-result (the aria-live
|
: "") — rides the button title + #sync-result (the aria-live
|
||||||
announcer) (A4 — the screen reader hears the whole "Syncing…
|
announcer) (A4 — the screen reader hears the whole "Syncing…
|
||||||
<file> (2/3) · chunk 14/38")."""
|
<file> (2/3) · chunk 14/38"). Phase 149 (task 01): the fourth
|
||||||
|
parameter `running` (the in-flight flag — the running entry
|
||||||
|
passes true, the settle the default false): the SAME full label
|
||||||
|
lands in the detail box (.sync-progress-text, textContent only)
|
||||||
|
and the box (#sync-progress) is visible IFF running (hidden in
|
||||||
|
every non-running state), and the button's accessible name is
|
||||||
|
the full label while running / the ship value "Sync sources"
|
||||||
|
otherwise (overwrites/restores only — never removes)."""
|
||||||
body = _fn(_js(), "setSyncLabel")
|
body = _fn(_js(), "setSyncLabel")
|
||||||
assert 'function setSyncLabel(mainText, countText = "", chunkText = "")' in body
|
assert (
|
||||||
|
'function setSyncLabel(mainText, countText = "", chunkText = "", running = false)'
|
||||||
|
in body
|
||||||
|
), "phase 149: the in-flight flag is the fourth parameter"
|
||||||
# the parts: textContent only (the house rule), null-guarded refs
|
# the parts: textContent only (the house rule), null-guarded refs
|
||||||
assert "syncLabelMain.textContent = mainText" in body
|
assert "syncLabelMain.textContent = mainText" in body
|
||||||
assert "syncLabelCount.textContent = countText" in body
|
assert "syncLabelCount.textContent = countText" in body
|
||||||
@@ -302,7 +330,13 @@ def test_set_sync_label_is_the_single_parts_writer() -> None:
|
|||||||
i_full = body.find("const full")
|
i_full = body.find("const full")
|
||||||
i_title = body.find("syncBtn.title = full")
|
i_title = body.find("syncBtn.title = full")
|
||||||
i_result = body.find("syncResult.textContent = full")
|
i_result = body.find("syncResult.textContent = full")
|
||||||
assert -1 < i_full < i_title < i_result, ("built once: full → title → announcer")
|
i_box = body.find("syncProgressText.textContent = full")
|
||||||
|
i_hide = body.find("syncProgress.hidden = !running")
|
||||||
|
i_aria = body.find('syncBtn.setAttribute("aria-label", running ? full : "Sync sources")')
|
||||||
|
assert -1 < i_full < i_title < i_result < i_box < i_hide < i_aria, (
|
||||||
|
"built once: full → title → announcer → the detail box (revealed IFF"
|
||||||
|
" running) → the button's accessible name (phase 149)"
|
||||||
|
)
|
||||||
assert "innerHTML" not in body, "the label path is textContent only"
|
assert "innerHTML" not in body, "the label path is textContent only"
|
||||||
|
|
||||||
|
|
||||||
@@ -421,16 +455,25 @@ def test_enter_running_state_is_phase_aware_and_writes_the_parts() -> None:
|
|||||||
# the parts are handed to the single writer (A4 lives in it) —
|
# the parts are handed to the single writer (A4 lives in it) —
|
||||||
# phase 129: the chunk part destructures with a "" default (the
|
# phase 129: the chunk part destructures with a "" default (the
|
||||||
# bare builders — the upload job, the 202/409 click — return no
|
# bare builders — the upload job, the 202/409 click — return no
|
||||||
# chunk part, so the part stays hidden there).
|
# chunk part, so the part stays hidden there); phase 149: the
|
||||||
|
# call passes `running = true` (the detail box reveals + the
|
||||||
|
# full-label aria-label ride in the writer).
|
||||||
assert "const { main, count, chunk = \"\" } =" in body, (
|
assert "const { main, count, chunk = \"\" } =" in body, (
|
||||||
"the chunk part destructures with the '' default (bare builders return none)"
|
"the chunk part destructures with the '' default (bare builders return none)"
|
||||||
)
|
)
|
||||||
i_set = body.find("setSyncLabel(main, count, chunk)")
|
i_set = body.find("setSyncLabel(main, count, chunk, true)")
|
||||||
assert -1 < i_fall < i_set, "the parts are written after the split"
|
assert -1 < i_fall < i_set, "the parts are written after the split (running = true)"
|
||||||
assert body.count("setSyncLabel(") == 1, "one writer call per entry"
|
assert body.count("setSyncLabel(") == 1, "one writer call per entry"
|
||||||
assert "syncBtn.title =" not in body, ("the title write lives in setSyncLabel (A4)")
|
assert "syncBtn.title =" not in body, ("the title write lives in setSyncLabel (A4)")
|
||||||
assert "syncResult" not in body, ("the announcer write lives in setSyncLabel (A4)")
|
assert "syncResult" not in body, ("the announcer write lives in setSyncLabel (A4)")
|
||||||
|
assert 'syncBtn.setAttribute("aria-label"' not in body, (
|
||||||
|
"the aria-label write lives in setSyncLabel (phase 149: the full"
|
||||||
|
" live label while running)"
|
||||||
|
)
|
||||||
assert "syncLabel.textContent" not in body, ("no direct span write — the parts")
|
assert "syncLabel.textContent" not in body, ("no direct span write — the parts")
|
||||||
|
assert "syncBtn.classList.add(\"is-running\")" in body, (
|
||||||
|
"the icon-only running button class is added with the spin (phase 149)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_settle_and_idle_labels_go_through_the_parts_writer() -> None:
|
def test_settle_and_idle_labels_go_through_the_parts_writer() -> None:
|
||||||
@@ -448,6 +491,12 @@ def test_settle_and_idle_labels_go_through_the_parts_writer() -> None:
|
|||||||
assert "function settleSyncButton(label)" in settle
|
assert "function settleSyncButton(label)" in settle
|
||||||
assert "setSyncLabel(label)" in settle, "the settle label goes through the parts writer"
|
assert "setSyncLabel(label)" in settle, "the settle label goes through the parts writer"
|
||||||
assert settle.count("setSyncLabel(") == 1
|
assert settle.count("setSyncLabel(") == 1
|
||||||
|
assert "setSyncLabel(label, true)" not in settle, (
|
||||||
|
"the settle is NEVER a running state — the detail box hides (phase 149)"
|
||||||
|
)
|
||||||
|
assert "syncBtn.classList.remove(\"is-running\")" in settle, (
|
||||||
|
"the running class is removed with the spin on EVERY settle (phase 149)"
|
||||||
|
)
|
||||||
assert "syncLabel.textContent" not in settle, ("no direct span write — the parts")
|
assert "syncLabel.textContent" not in settle, ("no direct span write — the parts")
|
||||||
# the success settle keeps the "Synced HH:MM" copy + the counts line
|
# the success settle keeps the "Synced HH:MM" copy + the counts line
|
||||||
# AFTER the settle (the settle's announcer write is superseded by
|
# AFTER the settle (the settle's announcer write is superseded by
|
||||||
@@ -487,6 +536,39 @@ def test_sync_label_markup_ships_the_hidden_chunk_part() -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_sync_progress_box_markup_ships_under_the_button_row() -> None:
|
||||||
|
"""Phase 149 (task 01): #sync-progress — the sync detail box under
|
||||||
|
the button row (the shell's RAG view): between the .page-head block
|
||||||
|
(the button row + the page sub) and #sync-result. Ships hidden +
|
||||||
|
aria-hidden (the anonymous-safe/idle state — sources.js reveals it
|
||||||
|
through the single setSyncLabel writer only while a run is in
|
||||||
|
flight; presentational, so #sync-result stays the SOLE aria-live
|
||||||
|
announcer — one live region, no double announce). It carries one
|
||||||
|
.sync-progress-text span (id-pinned) — the writer's textContent
|
||||||
|
target, the full untruncated live label."""
|
||||||
|
html = _html()
|
||||||
|
box = re.search(
|
||||||
|
r'<div class="sync-progress" id="sync-progress" hidden aria-hidden="true">\s*'
|
||||||
|
r'<span class="sync-progress-text" id="sync-progress-text"></span>\s*</div>',
|
||||||
|
html,
|
||||||
|
)
|
||||||
|
assert box, "the shell must carry the #sync-progress detail box (phase 149)"
|
||||||
|
# Under the button row, above the announcer: the RAG view's
|
||||||
|
# .page-head block precedes the box, and the box precedes
|
||||||
|
# #sync-result (one live region — the box is presentational).
|
||||||
|
rag = html.find('id="view-rag"')
|
||||||
|
head = html.find('class="page-head"', rag)
|
||||||
|
result = html.find('id="sync-result"', rag)
|
||||||
|
assert -1 < head < box.start() < result, (
|
||||||
|
"the box sits between the RAG view's .page-head block and "
|
||||||
|
"#sync-result (directly under the button row, above the announcer)"
|
||||||
|
)
|
||||||
|
# Ship-hidden + presentational (the reveal is run-scoped, in JS).
|
||||||
|
tag = html[box.start() : html.find(">", box.start())]
|
||||||
|
assert re.search(r"\bhidden\b", tag), "the box ships hidden (idle / anonymous-safe)"
|
||||||
|
assert 'aria-hidden="true"' in tag, ("the box is presentational (no double announce)")
|
||||||
|
|
||||||
|
|
||||||
def test_sync_label_chunk_css_is_the_pinned_part() -> None:
|
def test_sync_label_chunk_css_is_the_pinned_part() -> None:
|
||||||
""".sync-label-chunk (phase 129 task 04): the same pinned-part
|
""".sync-label-chunk (phase 129 task 04): the same pinned-part
|
||||||
language as .sync-label-count — flex 0 0 auto (NEVER clipped),
|
language as .sync-label-count — flex 0 0 auto (NEVER clipped),
|
||||||
@@ -515,15 +597,33 @@ const syncLabelMain = { textContent: "" };
|
|||||||
const syncLabelCount = { textContent: "", hidden: true };
|
const syncLabelCount = { textContent: "", hidden: true };
|
||||||
const syncLabelChunk = { textContent: "", hidden: true };
|
const syncLabelChunk = { textContent: "", hidden: true };
|
||||||
const syncResult = { textContent: "" };
|
const syncResult = { textContent: "" };
|
||||||
|
// Phase 149 (task 01): the detail box — presentational, ship-hidden;
|
||||||
|
// the REAL writer mutates it (full label + the running reveal).
|
||||||
|
const syncProgress = { hidden: true };
|
||||||
|
const syncProgressText = { textContent: "" };
|
||||||
|
// Phase 149 (task 01): class-tracking stubs — the is-running /
|
||||||
|
// is-spinning travel-together contract is behavioral, not source.
|
||||||
const syncBtn = {
|
const syncBtn = {
|
||||||
disabled: false,
|
disabled: false,
|
||||||
title: "",
|
title: "",
|
||||||
attrs: {},
|
attrs: {},
|
||||||
setAttribute(k, v) { this.attrs[k] = v; },
|
setAttribute(k, v) { this.attrs[k] = v; },
|
||||||
removeAttribute(k) { delete this.attrs[k]; },
|
removeAttribute(k) { delete this.attrs[k]; },
|
||||||
classList: { add() {}, remove() {} },
|
classes: new Set(),
|
||||||
|
classList: {
|
||||||
|
add(c) { syncBtn.classes.add(c); },
|
||||||
|
remove(c) { syncBtn.classes.delete(c); },
|
||||||
|
has(c) { return syncBtn.classes.has(c); },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const syncIcon = {
|
||||||
|
classes: new Set(),
|
||||||
|
classList: {
|
||||||
|
add(c) { syncIcon.classes.add(c); },
|
||||||
|
remove(c) { syncIcon.classes.delete(c); },
|
||||||
|
has(c) { return syncIcon.classes.has(c); },
|
||||||
|
},
|
||||||
};
|
};
|
||||||
const syncIcon = { classList: { add() {}, remove() {} } };
|
|
||||||
// Phase 134 (task 02): the Cancel control — ships hidden + disabled
|
// Phase 134 (task 02): the Cancel control — ships hidden + disabled
|
||||||
// (the markup's state); the extracted REAL show/hide helpers mutate
|
// (the markup's state); the extracted REAL show/hide helpers mutate
|
||||||
// it (show also sets the aria-label — the attrs pocket records it).
|
// it (show also sets the aria-label — the attrs pocket records it).
|
||||||
@@ -544,6 +644,11 @@ const show = (tag) => {
|
|||||||
syncLabelCount.textContent, syncLabelCount.hidden,
|
syncLabelCount.textContent, syncLabelCount.hidden,
|
||||||
syncLabelChunk.textContent, syncLabelChunk.hidden,
|
syncLabelChunk.textContent, syncLabelChunk.hidden,
|
||||||
syncCancelBtn.hidden, syncCancelBtn.disabled,
|
syncCancelBtn.hidden, syncCancelBtn.disabled,
|
||||||
|
// Phase 149 (task 01): the detail box (text + hidden), the
|
||||||
|
// is-running / is-spinning flags, the button's accessible name.
|
||||||
|
syncProgressText.textContent, syncProgress.hidden,
|
||||||
|
syncBtn.classes.has('is-running'), syncIcon.classes.has('is-spinning'),
|
||||||
|
syncBtn.attrs['aria-label'] || '',
|
||||||
]));
|
]));
|
||||||
};
|
};
|
||||||
// 1. import, batch in flight (the task's fixture values, 14/38):
|
// 1. import, batch in flight (the task's fixture values, 14/38):
|
||||||
@@ -596,6 +701,34 @@ def _run_node(script: str) -> str:
|
|||||||
return proc.stdout
|
return proc.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def _chunk_harness_rows() -> list:
|
||||||
|
"""The node harness's rows — the REAL extracted label path against
|
||||||
|
stub element refs (the test-document-viewer pattern), one JSON row
|
||||||
|
per state: [tag, title, announcer, main, count, countHidden,
|
||||||
|
chunk, chunkHidden, cancelHidden, cancelDisabled, progressText,
|
||||||
|
progressHidden, btnRunning, iconSpinning, ariaLabel]. Shared by
|
||||||
|
the chunk-part test (phase 129) and the detail-box test
|
||||||
|
(phase 149)."""
|
||||||
|
js = _js()
|
||||||
|
fns = "\n".join(
|
||||||
|
_fn(js, name)
|
||||||
|
for name in (
|
||||||
|
"fmtSyncLabel",
|
||||||
|
"fmtSyncPhaseLabel",
|
||||||
|
"setSyncLabel",
|
||||||
|
# Phase 134 (task 02): the running entry's REAL cancel
|
||||||
|
# show/hide + the REAL settle (step 8) — extracted against
|
||||||
|
# the syncCancelBtn stub.
|
||||||
|
"showSyncCancelBtn",
|
||||||
|
"hideSyncCancelBtn",
|
||||||
|
"enterSyncRunningState",
|
||||||
|
"settleSyncButton",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
script = _CHUNK_NODE_HEAD + fns + _CHUNK_NODE_TAIL
|
||||||
|
return [json.loads(line) for line in _run_node(script).splitlines() if line]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
|
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
|
||||||
def test_chunk_part_renders_only_in_the_import_phase_under_node() -> None:
|
def test_chunk_part_renders_only_in_the_import_phase_under_node() -> None:
|
||||||
"""Behavioral check (node) of the REAL label path — the extracted
|
"""Behavioral check (node) of the REAL label path — the extracted
|
||||||
@@ -620,27 +753,11 @@ test-document-viewer pattern). The pinned "· chunk n/m" part (the
|
|||||||
for the upload run (the upload job is NOT cancellable in this
|
for the upload run (the upload job is NOT cancellable in this
|
||||||
phase) and after the (real) settle.
|
phase) and after the (real) settle.
|
||||||
"""
|
"""
|
||||||
js = _js()
|
rows = _chunk_harness_rows()
|
||||||
fns = "\n".join(
|
|
||||||
_fn(js, name)
|
|
||||||
for name in (
|
|
||||||
"fmtSyncLabel",
|
|
||||||
"fmtSyncPhaseLabel",
|
|
||||||
"setSyncLabel",
|
|
||||||
# Phase 134 (task 02): the running entry's REAL cancel
|
|
||||||
# show/hide + the REAL settle (step 8) — extracted against
|
|
||||||
# the syncCancelBtn stub.
|
|
||||||
"showSyncCancelBtn",
|
|
||||||
"hideSyncCancelBtn",
|
|
||||||
"enterSyncRunningState",
|
|
||||||
"settleSyncButton",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
script = _CHUNK_NODE_HEAD + fns + _CHUNK_NODE_TAIL
|
|
||||||
rows = [json.loads(line) for line in _run_node(script).splitlines() if line]
|
|
||||||
by_tag = {row[0]: row for row in rows}
|
by_tag = {row[0]: row for row in rows}
|
||||||
# row = [tag, title, announcer, main, count, countHidden, chunk,
|
# row = [tag, title, announcer, main, count, countHidden, chunk,
|
||||||
# chunkHidden, cancelHidden, cancelDisabled]
|
# chunkHidden, cancelHidden, cancelDisabled, progressText,
|
||||||
|
# progressHidden, btnRunning, iconSpinning, ariaLabel]
|
||||||
|
|
||||||
# 1. the in-flight import — the chunk part renders, the full
|
# 1. the in-flight import — the chunk part renders, the full
|
||||||
# untruncated label rides the title + the announcer (A4).
|
# untruncated label rides the title + the announcer (A4).
|
||||||
@@ -694,6 +811,79 @@ test-document-viewer pattern). The pinned "· chunk n/m" part (the
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
|
||||||
|
def test_progress_box_and_running_button_contract_under_node() -> None:
|
||||||
|
"""Phase 149 (task 01) — the detail box + the icon-only running
|
||||||
|
button, behaviorally pinned through the SAME node harness (the
|
||||||
|
REAL setSyncLabel / enterSyncRunningState / settleSyncButton
|
||||||
|
against the stub element refs). The contract — the box is
|
||||||
|
visible IFF a run is in flight, and its text is EXACTLY the full
|
||||||
|
untruncated label (one source, two surfaces: the box for sighted
|
||||||
|
users, the announcer for AT):
|
||||||
|
|
||||||
|
* EVERY running state (the in-flight import, the pre-batch
|
||||||
|
import, the prelude, the 202/409 click, the upload run, the
|
||||||
|
overview, the summaries): the box is REVEALED (hidden=false)
|
||||||
|
and carries the full label (the same string as the button
|
||||||
|
title / announcer — the writer's `full`), the button carries
|
||||||
|
`is-running` + the icon `is-spinning` (the class travels with
|
||||||
|
the spin), and the button's accessible name (aria-label) is
|
||||||
|
the FULL live label (the icon-only running button keeps a
|
||||||
|
name);
|
||||||
|
* the settle (the REAL settleSyncButton through the writer with
|
||||||
|
the default `running = false`): the box HIDES, the settle
|
||||||
|
label is written into the box text ("Sync sources" — hidden,
|
||||||
|
so inert), `is-running` + `is-spinning` are both gone, and
|
||||||
|
the aria-label is restored to the ship value "Sync sources".
|
||||||
|
"""
|
||||||
|
rows = _chunk_harness_rows()
|
||||||
|
by_tag = {row[0]: row for row in rows}
|
||||||
|
# row = [tag, title, announcer, main, count, countHidden, chunk,
|
||||||
|
# chunkHidden, cancelHidden, cancelDisabled, progressText,
|
||||||
|
# progressHidden, btnRunning, iconSpinning, ariaLabel]
|
||||||
|
|
||||||
|
# Every running state: box visible + the EXACT full label +
|
||||||
|
# is-running + spinning + the full-label accessible name.
|
||||||
|
for tag in (
|
||||||
|
"import-inflight",
|
||||||
|
"import-prebatch",
|
||||||
|
"prelude",
|
||||||
|
"click",
|
||||||
|
"upload",
|
||||||
|
"overview",
|
||||||
|
"summaries",
|
||||||
|
):
|
||||||
|
row = by_tag[tag]
|
||||||
|
assert row[11] is False, (
|
||||||
|
f"{tag}: the detail box must be VISIBLE while the run is in flight"
|
||||||
|
)
|
||||||
|
assert row[10] == row[1], (
|
||||||
|
f"{tag}: the box carries the EXACT full label (title == box)"
|
||||||
|
)
|
||||||
|
assert row[12] is True, (
|
||||||
|
f"{tag}: the button must carry is-running while in flight"
|
||||||
|
)
|
||||||
|
assert row[13] is True, (
|
||||||
|
f"{tag}: is-running travels with the spin (the icon spins)"
|
||||||
|
)
|
||||||
|
assert row[14] == row[1], (
|
||||||
|
f"{tag}: the running button's accessible name is the full live label"
|
||||||
|
)
|
||||||
|
|
||||||
|
# The settle: the box hides, the running state is fully cleared,
|
||||||
|
# and the accessible name is back to the ship value.
|
||||||
|
row = by_tag["idle"]
|
||||||
|
assert row[11] is True, "the settle must HIDE the detail box (run-scoped only)"
|
||||||
|
assert row[10] == "Sync sources", (
|
||||||
|
"the writer wrote the settle label into the box (hidden — inert)"
|
||||||
|
)
|
||||||
|
assert row[12] is False, "is-running is removed on the settle (with the spin)"
|
||||||
|
assert row[13] is False, "the spin is removed on the settle"
|
||||||
|
assert row[14] == "Sync sources", (
|
||||||
|
"the settle restores the ship aria-label (the idle truth)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_every_other_state_passes_no_chunk_text() -> None:
|
def test_every_other_state_passes_no_chunk_text() -> None:
|
||||||
"""Source-level pin of the render-only-while-chunks_total>0
|
"""Source-level pin of the render-only-while-chunks_total>0
|
||||||
contract: the import fall-through is the ONLY place in sources.js
|
contract: the import fall-through is the ONLY place in sources.js
|
||||||
@@ -701,7 +891,8 @@ def test_every_other_state_passes_no_chunk_text() -> None:
|
|||||||
branches and the bare builders never touch them), and every
|
branches and the bare builders never touch them), and every
|
||||||
NON-running label emission passes no chunk text (the part hides):
|
NON-running label emission passes no chunk text (the part hides):
|
||||||
the settle/idle labels go through setSyncLabel(label) — the only
|
the settle/idle labels go through setSyncLabel(label) — the only
|
||||||
three-arg call site is the running entry."""
|
running-flagged call site (phase 149: `running = true`) is the
|
||||||
|
running entry."""
|
||||||
js = _js()
|
js = _js()
|
||||||
body = _fn(js, "fmtSyncPhaseLabel")
|
body = _fn(js, "fmtSyncPhaseLabel")
|
||||||
i_fall = body.find("fmtSyncLabel(")
|
i_fall = body.find("fmtSyncLabel(")
|
||||||
@@ -711,7 +902,9 @@ def test_every_other_state_passes_no_chunk_text() -> None:
|
|||||||
)
|
)
|
||||||
fall = body[i_fall:]
|
fall = body[i_fall:]
|
||||||
assert "status.chunks_total" in fall and "status.current_chunk" in fall
|
assert "status.chunks_total" in fall and "status.current_chunk" in fall
|
||||||
assert js.count("setSyncLabel(main, count, chunk)") == 1
|
assert js.count("setSyncLabel(main, count, chunk, true)") == 1, (
|
||||||
|
"the ONLY running-flagged call site is the running entry (phase 149)"
|
||||||
|
)
|
||||||
assert "setSyncLabel(label)" in js, "the settle/idle labels stay two-arg (no chunk)"
|
assert "setSyncLabel(label)" in js, "the settle/idle labels stay two-arg (no chunk)"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -49,6 +49,17 @@ flight (the import phase after the first batch — hidden in every
|
|||||||
other state); the markup pin below pins the exact three-part ship
|
other state); the markup pin below pins the exact three-part ship
|
||||||
shape.
|
shape.
|
||||||
|
|
||||||
|
Phase 149 (task 01): the running button becomes the SPINNER — the
|
||||||
|
writer gains the `running` flag (the running entry passes `true`,
|
||||||
|
the settle the default `false`), which reveals the detail box
|
||||||
|
(#sync-progress — the full untruncated label, presentational
|
||||||
|
aria-hidden; #sync-result stays the sole announcer) and owns the
|
||||||
|
button's accessible name (the full live label while running, the
|
||||||
|
ship value "Sync sources" restored on every settle); the
|
||||||
|
`is-running` class travels with the spin (added in the running
|
||||||
|
entry, removed in the settle). The pins below move onto that shape
|
||||||
|
(the aria-label write leaves the running entry for the writer).
|
||||||
|
|
||||||
Phase 129 (task 03): the status contract gains the in-flight file's
|
Phase 129 (task 03): the status contract gains the in-flight file's
|
||||||
EMBED-chunk position — ``current_chunk`` (the 1-based first chunk of
|
EMBED-chunk position — ``current_chunk`` (the 1-based first chunk of
|
||||||
the in-flight token-budgeted batch) + ``chunks_total`` (the file's
|
the in-flight token-budgeted batch) + ``chunks_total`` (the file's
|
||||||
@@ -372,7 +383,12 @@ def test_sources_js_running_state_is_never_stale() -> None:
|
|||||||
(phase 129 — hidden when absent), and the FULL untruncated label
|
(phase 129 — hidden when absent), and the FULL untruncated label
|
||||||
on the button title + #sync-result (A4) — the direct span /
|
on the button title + #sync-result (A4) — the direct span /
|
||||||
title / announcer writes are GONE from the entry (they live in
|
title / announcer writes are GONE from the entry (they live in
|
||||||
setSyncLabel)."""
|
setSyncLabel). Phase 149 (task 01): the writer call passes
|
||||||
|
`running = true` (the detail box reveals with the full label),
|
||||||
|
and the button's accessible name rides in the writer too (the
|
||||||
|
full live label — the icon-only running button keeps a name);
|
||||||
|
the entry no longer sets aria-label at all. The `is-running`
|
||||||
|
class is added here, with the spin."""
|
||||||
js = _text(SOURCES_JS)
|
js = _text(SOURCES_JS)
|
||||||
body = _body(js, "enterSyncRunningState")
|
body = _body(js, "enterSyncRunningState")
|
||||||
assert "syncBtn.disabled = true" in body
|
assert "syncBtn.disabled = true" in body
|
||||||
@@ -381,15 +397,22 @@ def test_sources_js_running_state_is_never_stale() -> None:
|
|||||||
assert "fmtSyncPhaseLabel(status)" in body
|
assert "fmtSyncPhaseLabel(status)" in body
|
||||||
assert "fmtSyncLabel(kind, currentFile, done, total)" in body
|
assert "fmtSyncLabel(kind, currentFile, done, total)" in body
|
||||||
# …and the single writer lands the parts (A4 in it — the title is
|
# …and the single writer lands the parts (A4 in it — the title is
|
||||||
# set for EVERY running label: the file-only rule is adjusted away).
|
# set for EVERY running label: the file-only rule is adjusted
|
||||||
assert "setSyncLabel(main, count, chunk)" in body
|
# away) with the phase-149 `running` flag (the box reveals).
|
||||||
|
assert "setSyncLabel(main, count, chunk, true)" in body
|
||||||
assert body.count("setSyncLabel(") == 1, "the entry writes ONLY through the parts writer"
|
assert body.count("setSyncLabel(") == 1, "the entry writes ONLY through the parts writer"
|
||||||
assert "syncBtn.title =" not in body, ("the title write lives in setSyncLabel (A4)")
|
assert "syncBtn.title =" not in body, ("the title write lives in setSyncLabel (A4)")
|
||||||
assert "syncLabel.textContent" not in body, ("no direct span write — the parts")
|
assert "syncLabel.textContent" not in body, ("no direct span write — the parts")
|
||||||
assert "syncResult.textContent" not in body, ("the announcer write lives in setSyncLabel (A4)")
|
assert "syncResult.textContent" not in body, ("the announcer write lives in setSyncLabel (A4)")
|
||||||
assert 'syncBtn.setAttribute("aria-label", "Sync sources")' in body
|
assert 'syncBtn.setAttribute("aria-label"' not in body, (
|
||||||
|
"the aria-label write lives in setSyncLabel (phase 149: the full"
|
||||||
|
" live label while running)"
|
||||||
|
)
|
||||||
assert "syncBtn.classList.remove(\"is-error\")" in body
|
assert "syncBtn.classList.remove(\"is-error\")" in body
|
||||||
assert "syncIcon.classList.add(\"is-spinning\")" in body
|
assert "syncIcon.classList.add(\"is-spinning\")" in body
|
||||||
|
assert "syncBtn.classList.add(\"is-running\")" in body, (
|
||||||
|
"the icon-only running button class travels with the spin"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_sources_js_terminal_states() -> None:
|
def test_sources_js_terminal_states() -> None:
|
||||||
@@ -439,7 +462,11 @@ def test_sources_js_settles_the_button_on_terminal() -> None:
|
|||||||
(failed included: retry-ready). Phase 128 (task 01): the settle /
|
(failed included: retry-ready). Phase 128 (task 01): the settle /
|
||||||
idle label ("Sync sources", "Synced HH:MM") goes through the parts
|
idle label ("Sync sources", "Synced HH:MM") goes through the parts
|
||||||
writer — setSyncLabel(label), count hidden (absent), the full
|
writer — setSyncLabel(label), count hidden (absent), the full
|
||||||
string riding the title + #sync-result (A4, every label state)."""
|
string riding the title + #sync-result (A4, every label state).
|
||||||
|
Phase 149 (task 01): the `is-running` class is removed WITH the
|
||||||
|
spin (every terminal + both idles pass through the settle), and
|
||||||
|
the writer call's default `running = false` hides the detail box
|
||||||
|
+ restores the idle accessible name."""
|
||||||
js = _text(SOURCES_JS)
|
js = _text(SOURCES_JS)
|
||||||
body = _body(js, "settleSyncButton")
|
body = _body(js, "settleSyncButton")
|
||||||
assert "syncBtn.disabled = false" in body
|
assert "syncBtn.disabled = false" in body
|
||||||
@@ -447,7 +474,13 @@ def test_sources_js_settles_the_button_on_terminal() -> None:
|
|||||||
assert 'syncBtn.removeAttribute("title")' in body
|
assert 'syncBtn.removeAttribute("title")' in body
|
||||||
assert 'syncBtn.setAttribute("aria-label", "Sync sources")' in body
|
assert 'syncBtn.setAttribute("aria-label", "Sync sources")' in body
|
||||||
assert "syncIcon.classList.remove(\"is-spinning\")" in body
|
assert "syncIcon.classList.remove(\"is-spinning\")" in body
|
||||||
|
assert "syncBtn.classList.remove(\"is-running\")" in body, (
|
||||||
|
"the running class travels with the spin — the settle removes both"
|
||||||
|
)
|
||||||
assert "setSyncLabel(label)" in body, ("the settle label goes through the parts writer")
|
assert "setSyncLabel(label)" in body, ("the settle label goes through the parts writer")
|
||||||
|
assert "setSyncLabel(label, true)" not in body, (
|
||||||
|
"the settle is NEVER a running state — the box hides (phase 149)"
|
||||||
|
)
|
||||||
assert "syncLabel.textContent" not in body, ("no direct span write — the parts")
|
assert "syncLabel.textContent" not in body, ("no direct span write — the parts")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user