phase: 154_sync_status_freshness
All 4 task files exist in `complete/`, and only `00_phase.md` remains in `todo/`. This is the final verification pass. I'll read through the task files and check the current state of the code.
This commit is contained in:
@@ -0,0 +1 @@
|
||||
All 4 task files exist in `complete/`, and only `00_phase.md` remains in `todo/`. This is the final verification pass. I'll read through the task files and check the current state of the code.
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
........................................................................ [ 2%]
|
||||
........................................................................ [ 4%]
|
||||
........................................................................ [ 6%]
|
||||
........................................................................ [ 8%]
|
||||
........................................................................ [ 10%]
|
||||
........................................................................ [ 12%]
|
||||
........................................................................ [ 14%]
|
||||
........................................................................ [ 16%]
|
||||
........................................................................ [ 18%]
|
||||
........................................................................ [ 20%]
|
||||
........................................................................ [ 22%]
|
||||
........................................................................ [ 24%]
|
||||
........................................................................ [ 26%]
|
||||
........................................................................ [ 28%]
|
||||
........................................................................ [ 30%]
|
||||
........................................................................ [ 32%]
|
||||
........................................................................ [ 34%]
|
||||
........................................................................ [ 36%]
|
||||
........................................................................ [ 38%]
|
||||
........................................................................ [ 40%]
|
||||
........................................................................ [ 42%]
|
||||
........................................................................ [ 44%]
|
||||
........................................................................ [ 46%]
|
||||
........................................................................ [ 48%]
|
||||
........................................................................ [ 50%]
|
||||
........................................................................ [ 52%]
|
||||
........................................................................ [ 54%]
|
||||
........................................................................ [ 56%]
|
||||
........................................................................ [ 58%]
|
||||
........................................................................ [ 60%]
|
||||
........................................................................ [ 62%]
|
||||
........................................................................ [ 64%]
|
||||
........................................................................ [ 66%]
|
||||
........................................................................ [ 68%]
|
||||
........................................................................ [ 70%]
|
||||
........................................................................ [ 72%]
|
||||
........................................................................ [ 75%]
|
||||
........................................................................ [ 77%]
|
||||
........................................................................ [ 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 204 0 100%
|
||||
app/api/tokens.py 40 0 100%
|
||||
app/api/ui_settings.py 55 0 100%
|
||||
app/config.py 266 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 764 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 192 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 5719 17 99%
|
||||
coverage gate: app/ 99% (>90%) OK
|
||||
All checks passed!
|
||||
0 errors, 0 warnings, 0 informations
|
||||
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
|
||||
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
|
||||
|
||||
validation OK
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
Everything is green. Final verification of the complete gate sequence was already done: full suite (unit + integration) 3546 passed, ruff clean, pyright 0 errors, app/ coverage 99%.
|
||||
|
||||
**Report — Phase 154, Task 01 (RAG sync badge date-qualified)**
|
||||
|
||||
- `frontend/assets/sources.js::fmtSyncTime` contract widened to the A1 date-qualifier (name kept, `applySyncSuccess` shape unchanged): same local day → `HH:MM` (byte-identical to pre-phase), same year → `M/D, h:MM AM/PM`, other year → `M/D/YYYY, h:MM AM/PM`; 12-hour without leading zero (`12:05 AM` edge), parse guard unchanged (`garbage`/missing → `""` → bare "Synced")
|
||||
- Parts-writer comment block (L821+) names the qualified stamp where the label parts are listed (cited, not restated); the five other stale `Synced HH:MM` comment cites in the file updated for accuracy
|
||||
- No CSS change (longer stamp rides the same ellipsized label part)
|
||||
- Unit pins in `tests/unit/test_sync_button.py`: source-level qualifier pins + the real `fmtSyncTime` run under node with a FAKE clock (patched `globalThis.Date`, fixed now Oct 3 2026 18:30 local; all ISOs designator-less — the wall clock never appears in an assertion): same-day/midnight/end-of-day, yesterday (3:14 PM, 12:05 AM, 12:07 PM, 11:58 PM), last-year (10/3/2025, 9:12 PM; 1/3/2025, 9:12 AM), garbage/empty/undefined → `""`; the `null`-epoch edge pinned TZ-independently (null ≡ epoch-ISO, dated+year form); settle-string test: `Synced 16:06` (byte-identical), `Synced 10/2, 3:14 PM`, bare `Synced` fallback
|
||||
- Results: `uv run pytest` → **3546 passed** (exit 0); `uv run pytest --cov=app` → TOTAL **99%** (>90%); `uv run ruff check . && uv run pyright` → clean (0 errors); `node --check sources.js` OK
|
||||
- Docs: README.md sync-settle paragraph now names the date-qualified stamp; AGENTS.md had no mention — no change
|
||||
- Notable: zero server change (frontend-only per phase design); pre-existing TODO.md working-tree change left untouched (owner's file swap)
|
||||
- Next pending task: `.agents/phases/todo/154_sync_status_freshness/02_upload_line_stamp.md`
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
........................................................................ [ 2%]
|
||||
........................................................................ [ 4%]
|
||||
........................................................................ [ 6%]
|
||||
........................................................................ [ 8%]
|
||||
........................................................................ [ 10%]
|
||||
........................................................................ [ 12%]
|
||||
........................................................................ [ 14%]
|
||||
........................................................................ [ 16%]
|
||||
........................................................................ [ 18%]
|
||||
........................................................................ [ 20%]
|
||||
........................................................................ [ 22%]
|
||||
........................................................................ [ 24%]
|
||||
........................................................................ [ 26%]
|
||||
........................................................................ [ 28%]
|
||||
........................................................................ [ 30%]
|
||||
........................................................................ [ 32%]
|
||||
........................................................................ [ 34%]
|
||||
........................................................................ [ 36%]
|
||||
........................................................................ [ 38%]
|
||||
........................................................................ [ 40%]
|
||||
........................................................................ [ 42%]
|
||||
........................................................................ [ 44%]
|
||||
........................................................................ [ 46%]
|
||||
........................................................................ [ 48%]
|
||||
........................................................................ [ 50%]
|
||||
........................................................................ [ 52%]
|
||||
........................................................................ [ 54%]
|
||||
........................................................................ [ 56%]
|
||||
........................................................................ [ 58%]
|
||||
........................................................................ [ 60%]
|
||||
........................................................................ [ 62%]
|
||||
........................................................................ [ 64%]
|
||||
........................................................................ [ 67%]
|
||||
........................................................................ [ 69%]
|
||||
........................................................................ [ 71%]
|
||||
........................................................................ [ 73%]
|
||||
........................................................................ [ 75%]
|
||||
........................................................................ [ 77%]
|
||||
........................................................................ [ 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 204 0 100%
|
||||
app/api/tokens.py 40 0 100%
|
||||
app/api/ui_settings.py 55 0 100%
|
||||
app/config.py 266 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 764 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 192 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 5719 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
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
Task 02 complete — all gates green. Report:
|
||||
|
||||
**Phase 154, task 02 — upload-line completion stamp (frontend-only, zero server change)**
|
||||
|
||||
- `git-sources.js`: `fmtUploadResult(detail, name, when)` — 4 shapes: `Uploaded <name> (<stamp>) — …`, `Uploaded <name> — …`, `Uploaded <stamp> — …`, `Uploaded — …`; `when === ""` renders byte-identical pre-phase strings; non-`uploaded` branch ("The upload finished.") unchanged. New `fmtUploadStamp(status)`: finished_at first, started_at fallback (corrupt finished degrades to started stamp), `""` when neither parses — A1 qualifier duplicated from sources.js's `fmtSyncTime` (cited, not restated). Both render sites (poll settle + boot re-attach) pass `fmtUploadStamp(status)`; header + section comments updated to the stamped shapes.
|
||||
- Unit pins (`test_frontend_sync_upload.py`): 3-arg signature + all 4 shape templates; ok-list/boot-reattach regex asserts for the 3-arg call; new `fmtUploadStamp` source pin (finished-before-started, skips, `|| {}` guard, full A1 structure) + real-node behavioral tests — 4 shapes (incl. legacy 2-arg call → pre-phase string) and fixed-clock stamp (finished wins / started fallback / `""` cases / corrupt-finish degrade / same-day, yesterday, last-year, `12:05 AM` + end-of-day edges).
|
||||
- 8 stale E2E files (exact-string `to_have_text` / `==` pins) updated to same-day `HH:MM` regex helpers (incl. nameless boot re-render, self-dating) — verified in isolation: sync_upload_progress 4✓, upload_no_scan 3✓, archive_upload_sources 5✓, answer_images 5✓.
|
||||
- Gates: full unit+integration **3549 passed**; `ruff` clean; `pyright` 0/0; `--cov=app` **99%**; `app/` untouched (no server change, per design).
|
||||
- Docs: README/AGENTS.md don't document the result-line text — **no doc change needed**. Next pending task: `03_unit_pins.md`.
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
........................................................................ [ 2%]
|
||||
........................................................................ [ 4%]
|
||||
........................................................................ [ 6%]
|
||||
........................................................................ [ 8%]
|
||||
........................................................................ [ 10%]
|
||||
........................................................................ [ 12%]
|
||||
........................................................................ [ 14%]
|
||||
........................................................................ [ 16%]
|
||||
........................................................................ [ 18%]
|
||||
........................................................................ [ 20%]
|
||||
........................................................................ [ 22%]
|
||||
........................................................................ [ 24%]
|
||||
........................................................................ [ 26%]
|
||||
........................................................................ [ 28%]
|
||||
........................................................................ [ 30%]
|
||||
........................................................................ [ 32%]
|
||||
........................................................................ [ 34%]
|
||||
........................................................................ [ 36%]
|
||||
........................................................................ [ 38%]
|
||||
........................................................................ [ 40%]
|
||||
........................................................................ [ 42%]
|
||||
........................................................................ [ 44%]
|
||||
........................................................................ [ 46%]
|
||||
........................................................................ [ 48%]
|
||||
........................................................................ [ 50%]
|
||||
........................................................................ [ 52%]
|
||||
........................................................................ [ 54%]
|
||||
........................................................................ [ 56%]
|
||||
........................................................................ [ 58%]
|
||||
........................................................................ [ 60%]
|
||||
........................................................................ [ 62%]
|
||||
........................................................................ [ 64%]
|
||||
........................................................................ [ 66%]
|
||||
........................................................................ [ 68%]
|
||||
........................................................................ [ 71%]
|
||||
........................................................................ [ 73%]
|
||||
........................................................................ [ 75%]
|
||||
........................................................................ [ 77%]
|
||||
........................................................................ [ 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 204 0 100%
|
||||
app/api/tokens.py 40 0 100%
|
||||
app/api/ui_settings.py 55 0 100%
|
||||
app/config.py 266 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 764 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 192 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 5719 17 99%
|
||||
coverage gate: app/ 99% (>90%) OK
|
||||
All checks passed!
|
||||
0 errors, 0 warnings, 0 informations
|
||||
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
|
||||
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
|
||||
|
||||
validation OK
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
Task 03 complete — all gates green.
|
||||
|
||||
**Report — Phase 154, Task 03 (unit pins: qualifier + the four stamp shapes)**
|
||||
|
||||
- `tests/unit/test_sync_button.py`: `PRE_PHASE_FMT_SYNC_TIME_JS` — the pre-phase `fmtSyncTime` pinned **verbatim** from git history (name renamed, body byte-for-byte) + `test_sources_js_fmt_sync_time_same_day_is_byte_identical_to_pre_phase`: real vs reference side-by-side on the shared fixed clock — same-day + fallbacks byte-identical (incl. the audit `16:06` settle), the divergent set (yesterday/last-year/epoch) byte-different, reference self-checked against the verbatim old strings (assertable, not assumed)
|
||||
- `tests/unit/test_frontend_sync_upload.py`: `PRE_PHASE_FMT_UPLOAD_RESULT_JS` (pre-phase 2-arg pinned verbatim) + verbatim old-line constants + `test_upload_result_no_stamp_is_byte_identical_to_pre_phase` — the two no-stamp fallbacks + the legacy 2-arg call byte-identical to the reference/verbatim strings; non-`uploaded` branch unchanged on both sides; module docstring gains the task-03 note
|
||||
- `fmtUploadStamp` (finished/started/missing/garbage) + the four shapes: already pinned by task 02's `test_upload_stamp_behavior_fixed_clock` / `test_upload_result_four_shapes_under_node` — verified green, additions only
|
||||
- Payload pin (work item 2): `test_idle_upload_status_pins_full_shape` (phase-64 full-dict pin) **unchanged and green** (42/42 in `test_git_sources_upload.py`); `git diff -- app/` empty — the no-server-change regression guard holds
|
||||
- Defect fixed: the dev DB was stale (alembic 0003 vs head 0026) breaking ~30 DB-backed tests — applied the documented `uv run alembic upgrade head`
|
||||
- Results: `uv run pytest tests/unit -q` → 2803 passed (exit 0); `uv run pytest -q` (unit+integration) → **3563 passed** (exit 0); `uv run pytest --cov=app` → **99%** (>90%); `uv run ruff check . && uv run pyright` → clean (0/0/0)
|
||||
- Docs: no doc impact (unit pins only; README updated by task 01, AGENTS.md doesn't describe the stamps)
|
||||
- Next pending task: `.agents/phases/todo/154_sync_status_freshness/04_e2e_status_freshness.md`
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
........................................................................ [ 2%]
|
||||
........................................................................ [ 4%]
|
||||
........................................................................ [ 6%]
|
||||
........................................................................ [ 8%]
|
||||
........................................................................ [ 10%]
|
||||
........................................................................ [ 12%]
|
||||
........................................................................ [ 14%]
|
||||
........................................................................ [ 16%]
|
||||
........................................................................ [ 18%]
|
||||
........................................................................ [ 20%]
|
||||
........................................................................ [ 22%]
|
||||
........................................................................ [ 24%]
|
||||
........................................................................ [ 26%]
|
||||
........................................................................ [ 28%]
|
||||
........................................................................ [ 30%]
|
||||
........................................................................ [ 32%]
|
||||
........................................................................ [ 34%]
|
||||
........................................................................ [ 36%]
|
||||
........................................................................ [ 38%]
|
||||
........................................................................ [ 40%]
|
||||
........................................................................ [ 42%]
|
||||
........................................................................ [ 44%]
|
||||
........................................................................ [ 46%]
|
||||
........................................................................ [ 48%]
|
||||
........................................................................ [ 50%]
|
||||
........................................................................ [ 52%]
|
||||
........................................................................ [ 54%]
|
||||
........................................................................ [ 56%]
|
||||
........................................................................ [ 58%]
|
||||
........................................................................ [ 60%]
|
||||
........................................................................ [ 62%]
|
||||
........................................................................ [ 64%]
|
||||
........................................................................ [ 66%]
|
||||
........................................................................ [ 68%]
|
||||
........................................................................ [ 70%]
|
||||
........................................................................ [ 72%]
|
||||
........................................................................ [ 75%]
|
||||
........................................................................ [ 77%]
|
||||
........................................................................ [ 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 204 0 100%
|
||||
app/api/tokens.py 40 0 100%
|
||||
app/api/ui_settings.py 55 0 100%
|
||||
app/config.py 266 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 764 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 192 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 5719 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
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
All gates green. Task 04 complete.
|
||||
|
||||
**Report**
|
||||
|
||||
- **Implemented:** `tests/e2e/test_sync_status_freshness.py` — the phase-154 dedicated Playwright suite (mock-only, no `slow_llm` needed):
|
||||
1. `test_fresh_sync_settles_same_day_shape` — real mock sync settles `#sync-btn` to `^Synced \d{2}:\d{2}$` (shape pin, never a wall-clock comparison) + real-run proof (counts line, terminal `finished_at` non-null)
|
||||
2. `test_upload_line_fresh_settle_carrys_name_and_stamp` — real archive upload → 202 toast → settle line `Uploaded <name> (<stamp>) — press Sync sources to import it.` (stamp capture asserted non-empty; terminal no-count payload + non-null `finished_at`; row + host folder; zero docs indexed)
|
||||
3. `test_upload_line_boot_rerender_stamped_nameless` — **the phase's fix**: settle, then reload → `initUploadStatus` success branch renders the NAMELESS line WITH the stamp (from the server payload); run identity pinned on `started_at` (no second upload); page boots honest (no toast/error, button restored)
|
||||
4. `test_running_upload_line_unchanged` — regression guard: deterministic in-flight observation (the 202 handler's ≥2 s `Processing…` window before the first poll tick) asserts the bare label + hidden result line; line appears ONLY on settle (distinct safe name proves the stale boot line was replaced)
|
||||
- **Gates:** `uv run pytest tests/e2e/test_sync_status_freshness.py -v --no-cov` → **4 passed** (run 4×, no flake); `uv run pytest` (unit+integration) → **exit 0, 100% green**; `uv run pytest --cov=app` → **app/ 99%** (>90%); `uv run ruff check .` → clean; `uv run pyright` → **0 errors**; cross-check `test_upload_no_scan.py` in isolation → 3 passed. `app/` untouched (phase is frontend-only).
|
||||
- **Docs:** README "Archive upload sources" gained the ready-for-sync line with the date-qualified completion stamp + both render paths (the sync badge was already named by tasks 01/02); AGENTS.md — no impact (no new commands/env/rules).
|
||||
- **Decisions:** sync label pinned exactly as the task specifies (`Synced \d{2}:\d{2}`); upload stamp uses the task's dual shape regex (same-day + date-qualified, midnight-safe); no delay proxy — the upload's 2 s poll window makes the in-flight observation deterministic; test-4 pre-upload hidden-assertion removed (shared module app's boot re-render of the prior test's line is the intended phase-154 behavior, not a regression).
|
||||
- **Next pending task:** none — 154 is fully complete (01–04); phase can move to `complete/`.
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
........................................................................ [ 2%]
|
||||
........................................................................ [ 4%]
|
||||
........................................................................ [ 6%]
|
||||
........................................................................ [ 8%]
|
||||
........................................................................ [ 10%]
|
||||
........................................................................ [ 12%]
|
||||
........................................................................ [ 14%]
|
||||
........................................................................ [ 16%]
|
||||
........................................................................ [ 18%]
|
||||
........................................................................ [ 20%]
|
||||
........................................................................ [ 22%]
|
||||
........................................................................ [ 24%]
|
||||
........................................................................ [ 26%]
|
||||
........................................................................ [ 28%]
|
||||
........................................................................ [ 30%]
|
||||
........................................................................ [ 32%]
|
||||
........................................................................ [ 34%]
|
||||
........................................................................ [ 36%]
|
||||
........................................................................ [ 38%]
|
||||
........................................................................ [ 40%]
|
||||
........................................................................ [ 42%]
|
||||
........................................................................ [ 44%]
|
||||
........................................................................ [ 46%]
|
||||
........................................................................ [ 48%]
|
||||
........................................................................ [ 50%]
|
||||
........................................................................ [ 52%]
|
||||
........................................................................ [ 54%]
|
||||
........................................................................ [ 56%]
|
||||
........................................................................ [ 58%]
|
||||
........................................................................ [ 60%]
|
||||
........................................................................ [ 62%]
|
||||
........................................................................ [ 64%]
|
||||
........................................................................ [ 66%]
|
||||
........................................................................ [ 68%]
|
||||
........................................................................ [ 70%]
|
||||
........................................................................ [ 72%]
|
||||
........................................................................ [ 75%]
|
||||
........................................................................ [ 77%]
|
||||
........................................................................ [ 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 204 0 100%
|
||||
app/api/tokens.py 40 0 100%
|
||||
app/api/ui_settings.py 55 0 100%
|
||||
app/config.py 266 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 764 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 192 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 5719 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
|
||||
@@ -306,6 +306,15 @@ first).
|
||||
|
||||
- Re-uploading the same filename replaces the source **in place** (no
|
||||
second folder, no duplicate row).
|
||||
- After the 202 the form settles into the ready-for-sync line
|
||||
"Uploaded <source> (<stamp>) — press Sync sources to import it." —
|
||||
the completion stamp (phase 154: the same date-qualified freshness
|
||||
rule as the sync badge — `HH:MM` on the same local day,
|
||||
`M/D, h:MM AM/PM` otherwise, `M/D/YYYY` across years) rides the
|
||||
line on EVERY render path, so a line that outlives its upload is
|
||||
self-dating: after a reload the source name is gone (it was
|
||||
page-local) but the stamp survives, coming from the server
|
||||
payload's `finished_at`.
|
||||
- Zip-bomb guard: absolute member paths, `..` traversal, and symlinks
|
||||
escaping the unpack folder are rejected.
|
||||
- Archives unpack under `BOR_UPLOAD_DIR` (default `~/bor-sources/uploads`).
|
||||
@@ -358,9 +367,13 @@ whole git-source refresh in one click:
|
||||
pre-phase result-line announcer into the box). At settle the box
|
||||
STAYS visible to carry the result line — the counts line
|
||||
("N added · …") after a success, "Sync cancelled" after a cancel —
|
||||
and the button leaves the running state (the "Synced HH:MM" pill
|
||||
after a success, the retry-ready "Sync sources" pill after a
|
||||
failure); the box hides only when its text is empty (a failed sync
|
||||
and the button leaves the running state (the "Synced <stamp>" pill
|
||||
after a success — the date-qualified freshness stamp (phase 154):
|
||||
`HH:MM` on the same local day, `M/D, h:MM AM/PM` otherwise
|
||||
(`M/D/YYYY` across years — a badge that tells the owner *when*),
|
||||
the bare "Synced" when the stamp is missing — the retry-ready
|
||||
"Sync sources" pill after a failure); the box hides only when its
|
||||
text is empty (a failed sync
|
||||
— the banner + modal carry the error — and the true idle). 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
|
||||
|
||||
@@ -75,8 +75,12 @@
|
||||
* "Processing…" label for the WHOLE run (phase 90, A2 — the run
|
||||
* is unpack + register only: no file, no "(n/m)" counts, no
|
||||
* title) and settles it: success → the ready-for-sync line
|
||||
* ("Uploaded <name> — press Sync sources to import it.",
|
||||
* fmtUploadResult off the status's {"message": "uploaded"}
|
||||
* ("Uploaded <name> (<stamp>) — press Sync sources to import
|
||||
* it." — the phase-154 completion stamp, fmtUploadStamp off the
|
||||
* status's finished_at, the A1 date-qualified form cited in
|
||||
* sources.js's fmtSyncTime, not restated; a running / partial
|
||||
* payload without timestamps renders the byte-identical pre-phase
|
||||
* line — fmtUploadResult off the status's {"message": "uploaded"}
|
||||
* detail — the role=status result line) + the "Archive uploaded
|
||||
* — press Sync sources to import it." announce + loadSources
|
||||
* (the new/updated row lands with the Local badge; a re-upload
|
||||
@@ -93,8 +97,10 @@
|
||||
* (a reload mid-run re-attaches — no second upload), a terminal
|
||||
* run re-renders its result line / error banner (the safe name
|
||||
* was page-local — lastUploadName is null after a reload — so the
|
||||
* re-rendered line is the nameless "Uploaded — press Sync sources
|
||||
* to import it.").
|
||||
* re-rendered line is the nameless "Uploaded <stamp> — press
|
||||
* Sync sources to import it." — the stamp comes from the SERVER
|
||||
* payload, so the line is self-dating even with the name
|
||||
* unknown, phase 154).
|
||||
* • remove — a row's Remove button opens the page-local
|
||||
* confirmation modal (#remove-confirm-dialog, a real
|
||||
* role="alertdialog" — the native confirm() retired, phase 69):
|
||||
@@ -1096,7 +1102,10 @@ export async function mount(root) {
|
||||
* "Processing…" label for the WHOLE run (phase 90, A2 — the unpack
|
||||
* has no file-level progress: no file, no "(n/m)" counts, no title)
|
||||
* and settles it: success → the ready-for-sync line ("Uploaded
|
||||
* <name> — press Sync sources to import it.", fmtUploadResult off
|
||||
* <name> (<stamp>) — press Sync sources to import it." — the
|
||||
* phase-154 completion stamp, fmtUploadStamp off the status's
|
||||
* finished_at; the A1 date-qualified form is cited in
|
||||
* fmtUploadResult's comment, not restated — fmtUploadResult off
|
||||
* the status's {"message": "uploaded"} detail) in the role=status
|
||||
* result line + the "Archive uploaded — press Sync sources to
|
||||
* import it." announce + loadSources (NO second toast — it already
|
||||
@@ -1117,23 +1126,75 @@ export async function mount(root) {
|
||||
* (The phase-49 synchronous 200 paragraph is superseded by phase 64;
|
||||
* the phase-64 scan counts are superseded by phase 90.) */
|
||||
|
||||
/* The result line's text (phase 90, A2 — the no-count contract): the
|
||||
/* The result line's text (phase 90, A2 — the no-count contract,
|
||||
extended by phase 154 task 02 with the completion stamp): the
|
||||
status success detail is exactly {"message": "uploaded"} — the
|
||||
sync-style counts the phase-64 line rendered are gone (the scan —
|
||||
and its counts — belong to the Sync button, which renders them on
|
||||
the RAG page). `name` is the accepted 202's safe source name
|
||||
(lastUploadName) when the run started on this page; it is null
|
||||
after a reload or on the 409 re-attach (the line still points at
|
||||
the next step, only without the name). */
|
||||
function fmtUploadResult(detail, name) {
|
||||
the next step, only without the name). `when` is the PRECOMPUTED
|
||||
completion stamp (fmtUploadStamp(status) — the phase-154 A1
|
||||
date-qualified form, the rule cited in sources.js's fmtSyncTime,
|
||||
not restated); "" (a running / legacy / partial payload without
|
||||
timestamps) renders the byte-identical pre-phase-154 strings. */
|
||||
function fmtUploadResult(detail, name, when) {
|
||||
if (detail && detail.message === "uploaded") {
|
||||
return name
|
||||
? `Uploaded ${name} — press Sync sources to import it.`
|
||||
if (name) {
|
||||
return when
|
||||
? `Uploaded ${name} (${when}) — press Sync sources to import it.`
|
||||
: `Uploaded ${name} — press Sync sources to import it.`;
|
||||
}
|
||||
return when
|
||||
? `Uploaded ${when} — press Sync sources to import it.`
|
||||
: "Uploaded — press Sync sources to import it.";
|
||||
}
|
||||
return "The upload finished.";
|
||||
}
|
||||
|
||||
/* Phase 154 (task 02): the upload line's completion stamp —
|
||||
* PRECOMPUTED from the /upload/status payload (the per-page
|
||||
* duplication idiom: the SAME date-qualifier as sources.js's
|
||||
* fmtSyncTime, duplicated, not shared — the A1 rule — same local
|
||||
* day `HH:MM`, same year `M/D, h:MM AM/PM`, across years
|
||||
* `M/D/YYYY, h:MM AM/PM` — is cited there, not restated). Reads
|
||||
* status.finished_at first (the success state always has it on the
|
||||
* server), falls back to status.started_at (the defensive guard
|
||||
* for a legacy / partial payload — a corrupt finished_at degrades
|
||||
* to the started_at stamp rather than to no stamp), and yields
|
||||
* "" when both are missing/absent or every ISO is unparseable
|
||||
* (the pre-phase-154 byte-identical fallback — the line renders
|
||||
* the pre-stamp copy). */
|
||||
function fmtUploadStamp(status) {
|
||||
const s = status || {};
|
||||
let d = null;
|
||||
for (const iso of [s.finished_at, s.started_at]) {
|
||||
if (!iso) continue;
|
||||
const t = new Date(iso);
|
||||
if (!Number.isNaN(t.getTime())) {
|
||||
d = t;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (d === null) return "";
|
||||
const now = new Date();
|
||||
const pad = (n) => String(n).padStart(2, "0");
|
||||
if (
|
||||
d.getFullYear() === now.getFullYear() &&
|
||||
d.getMonth() === now.getMonth() &&
|
||||
d.getDate() === now.getDate()
|
||||
)
|
||||
return `${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
const h12 = d.getHours() % 12 || 12;
|
||||
const ampm = d.getHours() < 12 ? "AM" : "PM";
|
||||
const date =
|
||||
d.getFullYear() === now.getFullYear()
|
||||
? `${d.getMonth() + 1}/${d.getDate()}`
|
||||
: `${d.getMonth() + 1}/${d.getDate()}/${d.getFullYear()}`;
|
||||
return `${date}, ${h12}:${pad(d.getMinutes())} ${ampm}`;
|
||||
}
|
||||
|
||||
/* Upload-success toast (phase 64 task 05, A2 — owner-locked): the
|
||||
* "successfully uploaded" confirmation, the phase-55 share-toast
|
||||
* pattern (frontend/assets/app.js) made page-local. A SINGLE node —
|
||||
@@ -1237,11 +1298,16 @@ export async function mount(root) {
|
||||
if (status.state === "success") {
|
||||
// The run finished (unpack + register only — phase 90): the
|
||||
// ready-for-sync line (fmtUploadResult reads the no-count
|
||||
// detail), the announcement, the row lands. NO toast here — it
|
||||
// detail + fmtUploadStamp's completion stamp — phase 154),
|
||||
// the announcement, the row lands. NO toast here — it
|
||||
// already fired at the 202 (A2).
|
||||
const detail = status.detail || {};
|
||||
if (uploadResult) {
|
||||
uploadResult.textContent = fmtUploadResult(detail, lastUploadName);
|
||||
uploadResult.textContent = fmtUploadResult(
|
||||
detail,
|
||||
lastUploadName,
|
||||
fmtUploadStamp(status)
|
||||
);
|
||||
uploadResult.hidden = false;
|
||||
}
|
||||
announce("Archive uploaded — press Sync sources to import it.");
|
||||
@@ -1276,10 +1342,11 @@ export async function mount(root) {
|
||||
* no second upload, no error); a finished run re-renders its result
|
||||
* line ONLY (no announce, no toast — the toast fired at the 202, A2;
|
||||
* the name is unknown after a reload — lastUploadName is null — so
|
||||
* the line is the nameless "Uploaded — press Sync sources to import
|
||||
* it.", phase 90); a failed run re-renders its error banner; idle
|
||||
* does nothing (and a blip is a no-op — the page boots honest
|
||||
* either way). */
|
||||
* the line is the nameless "Uploaded <stamp> — press Sync sources
|
||||
* to import it." — the stamp from the SERVER payload, so the line
|
||||
* is self-dating even with the name unknown, phase 154; phase 90);
|
||||
* a failed run re-renders its error banner; idle does nothing (and a
|
||||
* blip is a no-op — the page boots honest either way). */
|
||||
async function initUploadStatus() {
|
||||
if (!uploadBtn) return;
|
||||
let status;
|
||||
@@ -1299,9 +1366,15 @@ export async function mount(root) {
|
||||
if (status.state === "success") {
|
||||
// The last run's result line only — no announce, no toast (A2).
|
||||
// The safe name was page-local (lastUploadName is null after a
|
||||
// reload) — the line still points at the next step (phase 90).
|
||||
// reload) — the line still points at the next step (phase 90),
|
||||
// self-dating through fmtUploadStamp's completion stamp
|
||||
// (phase 154 — the stamp comes from the server payload).
|
||||
if (uploadResult) {
|
||||
uploadResult.textContent = fmtUploadResult(status.detail, lastUploadName);
|
||||
uploadResult.textContent = fmtUploadResult(
|
||||
status.detail,
|
||||
lastUploadName,
|
||||
fmtUploadStamp(status)
|
||||
);
|
||||
uploadResult.hidden = false;
|
||||
}
|
||||
return;
|
||||
|
||||
@@ -321,7 +321,9 @@
|
||||
* title + #sync-progress (A4 — the screen reader hears the whole
|
||||
* thing; the visible span carries main + count, the space between
|
||||
* them is the CSS gap). Every other #sync-label writer — the idle /
|
||||
* settle labels ("Sync sources", "Synced HH:MM"), the 202/409 click,
|
||||
* settle labels ("Sync sources", "Synced <stamp>" — the date-
|
||||
* qualified stamp from fmtSyncTime, phase 154: `HH:MM` on the same
|
||||
* local day, the dated form otherwise), the 202/409 click,
|
||||
* the initSyncButton re-attach — goes through setSyncLabel(main)
|
||||
* (count hidden). The full-string shapes are byte-identical to the
|
||||
* pre-task labels ("Syncing…", "Syncing… <file>",
|
||||
@@ -385,7 +387,8 @@
|
||||
* 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
|
||||
* The settle rendering ("Synced <stamp>" — the date-qualified stamp,
|
||||
* phase 154 — + the counts line), the Cancel
|
||||
* control, the 409 double-trigger, the error banner/modal, and the
|
||||
* sync status API are all UNCHANGED.
|
||||
*
|
||||
@@ -404,7 +407,8 @@
|
||||
* writes
|
||||
* the SAME untruncated full label through setSyncProgress, ONLY
|
||||
* while running (the idle / settle button labels — "Sync sources",
|
||||
* "Synced HH:MM" — must not fill the panel; they stay on the
|
||||
* "Synced <stamp>" (the date-qualified stamp, phase 154) — must
|
||||
* not fill the panel; they stay on the
|
||||
* button, A2);
|
||||
* • result mode — the settle paths write the result line through
|
||||
* setSyncProgress: the counts line (fmtSyncResult — "N added ·
|
||||
@@ -811,11 +815,40 @@ export async function mount(root) {
|
||||
}
|
||||
}
|
||||
|
||||
/* Phase 154 (task 01): the contract widens to the DATE-QUALIFIER
|
||||
* (the phase design's A1 rule — owner-locked; the stamp is
|
||||
* computed from the ISO's LOCAL calendar date, the page's clock —
|
||||
* the table `Updated` columns already render local dates):
|
||||
* • same local calendar day as now → `HH:MM` (byte-identical to
|
||||
* the pre-phase output — a same-day badge is unchanged);
|
||||
* • same local year, different day → `M/D, h:MM AM/PM`
|
||||
* (e.g. `10/2, 3:14 PM`);
|
||||
* • different local year → `M/D/YYYY, h:MM AM/PM`
|
||||
* (e.g. `1/3/2025, 9:12 PM`).
|
||||
* `h:MM` is the 12-hour form WITHOUT a leading zero on the hour
|
||||
* (the `12:05 AM` edge), month/day without leading zero (the table
|
||||
* dates' `M/D/YYYY` / `5:34:06 PM` family, the seconds dropped).
|
||||
* The parse guard is UNCHANGED — an unparseable/missing ISO still
|
||||
* yields "" (the bare "Synced" settle — e.g. the app-restart case,
|
||||
* A12). */
|
||||
function fmtSyncTime(iso) {
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return "";
|
||||
const now = new Date();
|
||||
const pad = (n) => String(n).padStart(2, "0");
|
||||
return `${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
if (
|
||||
d.getFullYear() === now.getFullYear() &&
|
||||
d.getMonth() === now.getMonth() &&
|
||||
d.getDate() === now.getDate()
|
||||
)
|
||||
return `${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
const h12 = d.getHours() % 12 || 12;
|
||||
const ampm = d.getHours() < 12 ? "AM" : "PM";
|
||||
const date =
|
||||
d.getFullYear() === now.getFullYear()
|
||||
? `${d.getMonth() + 1}/${d.getDate()}`
|
||||
: `${d.getMonth() + 1}/${d.getDate()}/${d.getFullYear()}`;
|
||||
return `${date}, ${h12}:${pad(d.getMinutes())} ${ampm}`;
|
||||
}
|
||||
|
||||
/* Phase 128 (task 01) + Phase 129 (task 04) + Phase 152 (task 03):
|
||||
@@ -861,7 +894,14 @@ export async function mount(root) {
|
||||
* 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). */
|
||||
* overwrites/restores it, never removes it).
|
||||
* Phase 154 (task 01): the settle badge among these label parts is
|
||||
* date-qualified — `Synced <stamp>`, where `<stamp>` is produced
|
||||
* by fmtSyncTime (its contract widened to the phase-154 A1 rule —
|
||||
* same-day stays `HH:MM`, non-same-day carries the date; the rule
|
||||
* is cited there, not restated). The parts themselves are
|
||||
* UNCHANGED — the longer stamp is just more text in the same part
|
||||
* (the label part ellipsizes as before). */
|
||||
function setSyncLabel(mainText, countText = "", chunkText = "", running = false, imageText = "") {
|
||||
if (syncLabelMain) syncLabelMain.textContent = mainText;
|
||||
if (syncLabelCount) {
|
||||
@@ -1106,7 +1146,8 @@ export async function mount(root) {
|
||||
// the running state.
|
||||
syncBtn.classList.remove("is-running");
|
||||
// Phase 128 (task 01): the idle / settle labels ("Sync sources",
|
||||
// "Synced HH:MM") go through the parts writer — count hidden
|
||||
// "Synced <stamp>" — the date-qualified stamp, phase 154) go
|
||||
// through the parts writer — count hidden
|
||||
// (absent), the label's full string rides the title (A4, as with
|
||||
// every label state). Phase 150 (task 02): the default
|
||||
// `running = false` leaves the detail box alone in every
|
||||
@@ -1196,7 +1237,8 @@ export async function mount(root) {
|
||||
settleSyncButton(time ? `Synced ${time}` : "Synced");
|
||||
// Phase 150 (task 02): the settle's counts line IS the box's
|
||||
// result line (the sole live region) — the string builder is
|
||||
// unchanged (A2: the "Synced HH:MM" label stays on the button).
|
||||
// unchanged (A2: the "Synced <stamp>" label stays on the button;
|
||||
// the stamp is date-qualified, phase 154).
|
||||
setSyncProgress(fmtSyncResult(status.detail));
|
||||
hideSyncError();
|
||||
emitSyncStatus(status);
|
||||
|
||||
@@ -335,10 +335,24 @@ def _offered_tool_names(body: dict[str, Any]) -> list[str]:
|
||||
return [t["function"]["name"] for t in (body.get("tools") or [])]
|
||||
|
||||
|
||||
def _ready_for_sync_line(source: str) -> re.Pattern[str]:
|
||||
"""Phase 154 (task 02): the ready-for-sync line carrying the
|
||||
completion stamp — "Uploaded <source> (<stamp>) — press Sync
|
||||
sources to import it." (the fresh 202 settle). In E2E the stamp
|
||||
is ALWAYS the same-day ``HH:MM`` form (the run settles within
|
||||
seconds; the date-qualified forms are unit-pinned on a fixed
|
||||
clock — E2E never depends on the wall clock)."""
|
||||
return re.compile(
|
||||
rf"^Uploaded {re.escape(source)} \(\d{{1,2}}:\d{{2}}\)"
|
||||
r" — press Sync sources to import it\.$"
|
||||
)
|
||||
|
||||
|
||||
def _upload_zip(page: Page, app_url: str, archive: Path) -> None:
|
||||
"""The real admin upload flow: form login → the git-sources page →
|
||||
pick the archive → submit → the terminal result line (phase 90:
|
||||
"Uploaded <source> — press Sync sources to import it.")."""
|
||||
pick the archive → submit → the terminal result line (phase 90 A3,
|
||||
phase 154 task 02: "Uploaded <source> (<stamp>) — press Sync
|
||||
sources to import it." — the completion stamp)."""
|
||||
login(page, app_url, next=GIT_SOURCES_URL)
|
||||
expect(page).to_have_url(app_url + GIT_SOURCES_URL, timeout=30_000)
|
||||
expect(page.locator("#sign-out-btn")).to_be_visible(timeout=15_000)
|
||||
@@ -347,7 +361,7 @@ def _upload_zip(page: Page, app_url: str, archive: Path) -> None:
|
||||
result = page.locator("#archive-upload-result")
|
||||
expect(result).to_be_visible(timeout=UPLOAD_TIMEOUT_MS)
|
||||
expect(result).to_have_text(
|
||||
f"Uploaded {SOURCE_NAME} — press Sync sources to import it.",
|
||||
_ready_for_sync_line(SOURCE_NAME),
|
||||
timeout=UPLOAD_TIMEOUT_MS,
|
||||
)
|
||||
|
||||
|
||||
@@ -20,7 +20,8 @@ server-side behind ``GET /api/git-sources/upload/status`` (the
|
||||
phase-32 ``SyncStatus`` pattern; the phase-64 key set with
|
||||
``current_file``/``files_done``/``files_total`` null/0/0 for the whole
|
||||
run — phase 90 A2). The settled result line points at the next step:
|
||||
"Uploaded <name> — press Sync sources to import it." (phase 90 A3) —
|
||||
"Uploaded <name> (<stamp>) — press Sync sources to import it."
|
||||
(phase 90 A3, the phase-154 completion stamp) —
|
||||
the scan is the RAG page's **Sync sources** button's job, so this
|
||||
suite asserts **zero indexed documents** after every upload.
|
||||
|
||||
@@ -66,8 +67,9 @@ Contract under test:
|
||||
button then carries the BARE "Processing…" label for the whole
|
||||
background run (no file, no "(n/m)" — the first status GET is held
|
||||
so the in-run window outlives the 2 s poll) until the status
|
||||
``success`` lands: the result line reads "Uploaded e2e-upload —
|
||||
press Sync sources to import it.", the button restores ("Upload"),
|
||||
``success`` lands: the result line reads "Uploaded e2e-upload
|
||||
(<stamp>) — press Sync sources to import it." (the phase-154
|
||||
completion stamp), the button restores ("Upload"),
|
||||
the file input clears, the list gains exactly one row for
|
||||
``e2e-upload`` with the **Local** badge and its "Ignore paths"
|
||||
control (the phase-89 editor the deferral exists for), the terminal
|
||||
@@ -139,6 +141,19 @@ SOURCES_URL = "/sources.html"
|
||||
#: filename minus the archive suffix (the phase's locked naming rule).
|
||||
SOURCE_NAME = "e2e-upload"
|
||||
|
||||
|
||||
def _ready_for_sync_line(name: str) -> re.Pattern[str]:
|
||||
"""Phase 154 (task 02): the ready-for-sync line carrying the
|
||||
completion stamp — "Uploaded <name> (<stamp>) — press Sync sources
|
||||
to import it." (the fresh 202 settle, the page-local name). In
|
||||
E2E the stamp is ALWAYS the same-day ``HH:MM`` form — the run
|
||||
settles within seconds (the date-qualified forms are unit-pinned
|
||||
on a fixed clock — E2E never depends on the wall clock)."""
|
||||
return re.compile(
|
||||
rf"^Uploaded {re.escape(name)} \(\d{{1,2}}:\d{{2}}\)"
|
||||
r" — press Sync sources to import it\.$"
|
||||
)
|
||||
|
||||
#: v1: two sentinel docs. v2 (same filename): alpha CHANGED, beta DROPPED,
|
||||
#: gamma ADDED — the in-place-replace subject (the on-disk folder swap).
|
||||
ALPHA_SENTINEL_V1 = "ALPHA-TOKEN-v1-7f31"
|
||||
@@ -436,8 +451,9 @@ def test_upload_registers_without_indexing(
|
||||
then carries the BARE "Processing…" label for the whole background
|
||||
run (no file, no "(n/m)" — phase 90 A2, proven across a held first
|
||||
status GET); when the status ``success`` lands the result line
|
||||
reads "Uploaded <source> — press Sync sources to import it."
|
||||
(A3), the button restores ("Upload"), the file input clears, the
|
||||
reads "Uploaded <source> (<stamp>) — press Sync sources to
|
||||
import it." (A3 — the phase-154 completion stamp), the button
|
||||
restores ("Upload"), the file input clears, the
|
||||
list gains exactly ONE row for ``e2e-upload`` (Local badge, the
|
||||
"Ignore paths" control), the terminal status carries the no-count
|
||||
``{"message": "uploaded"}`` detail with null/0/0 progress — and
|
||||
@@ -490,9 +506,7 @@ def test_upload_registers_without_indexing(
|
||||
# …release: the status success lands → the ready-for-sync line +
|
||||
# the never-stale restore (input cleared).
|
||||
expect(result).to_be_visible(timeout=UPLOAD_TIMEOUT_MS)
|
||||
expect(result).to_have_text(
|
||||
f"Uploaded {SOURCE_NAME} — press Sync sources to import it."
|
||||
)
|
||||
expect(result).to_have_text(_ready_for_sync_line(SOURCE_NAME))
|
||||
expect(btn).to_be_enabled()
|
||||
expect(btn).to_have_text("Upload")
|
||||
expect(page.locator("#archive-upload-file")).to_have_value("")
|
||||
@@ -560,8 +574,8 @@ def test_reupload_replaces_in_place(
|
||||
|
||||
# Baseline: v1 through the page (202 → the ready-for-sync line,
|
||||
# one row, no index).
|
||||
assert _upload_via_page(page, tarball_v1) == (
|
||||
f"Uploaded {SOURCE_NAME} — press Sync sources to import it."
|
||||
assert _ready_for_sync_line(SOURCE_NAME).fullmatch(
|
||||
_upload_via_page(page, tarball_v1)
|
||||
)
|
||||
expect(page.locator("#git-sources-tbody tr", has_text=SOURCE_NAME)).to_have_count(1)
|
||||
folder = upload_dir / SOURCE_NAME
|
||||
@@ -570,8 +584,8 @@ def test_reupload_replaces_in_place(
|
||||
|
||||
# Re-upload v2 — SAME basename, different parent dir (the file
|
||||
# input's selection is replaced wholesale).
|
||||
assert _upload_via_page(page, tarball_v2) == (
|
||||
f"Uploaded {SOURCE_NAME} — press Sync sources to import it."
|
||||
assert _ready_for_sync_line(SOURCE_NAME).fullmatch(
|
||||
_upload_via_page(page, tarball_v2)
|
||||
)
|
||||
|
||||
# No duplicate: exactly ONE row for that source (and one row total).
|
||||
@@ -635,8 +649,8 @@ def test_bad_file_inline_error(
|
||||
|
||||
# The form is not wedged: a good upload right after still works —
|
||||
# and indexes nothing (phase 90 A1).
|
||||
assert _upload_via_page(page, tarball_v1) == (
|
||||
f"Uploaded {SOURCE_NAME} — press Sync sources to import it."
|
||||
assert _ready_for_sync_line(SOURCE_NAME).fullmatch(
|
||||
_upload_via_page(page, tarball_v1)
|
||||
)
|
||||
expect(error).to_be_hidden()
|
||||
expect(page.locator("#git-sources-tbody tr", has_text=SOURCE_NAME)).to_have_count(1)
|
||||
|
||||
@@ -296,10 +296,24 @@ def _health_db(app_url: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _ready_for_sync_line(source: str) -> re.Pattern[str]:
|
||||
"""Phase 154 (task 02): the ready-for-sync line carrying the
|
||||
completion stamp — "Uploaded <source> (<stamp>) — press Sync
|
||||
sources to import it." (the fresh 202 settle). In E2E the stamp
|
||||
is ALWAYS the same-day ``HH:MM`` form (the run settles within
|
||||
seconds; the date-qualified forms are unit-pinned on a fixed
|
||||
clock — E2E never depends on the wall clock)."""
|
||||
return re.compile(
|
||||
rf"^Uploaded {re.escape(source)} \(\d{{1,2}}:\d{{2}}\)"
|
||||
r" — press Sync sources to import it\.$"
|
||||
)
|
||||
|
||||
|
||||
def _upload_zip(page: Page, app_url: str, archive: Path, source: str) -> None:
|
||||
"""The real admin upload flow: form login → the git-sources page →
|
||||
pick the archive → submit → the terminal result line (phase 90:
|
||||
"Uploaded <source> — press Sync sources to import it.")."""
|
||||
pick the archive → submit → the terminal result line (phase 90 A3,
|
||||
phase 154 task 02: "Uploaded <source> (<stamp>) — press Sync
|
||||
sources to import it." — the completion stamp)."""
|
||||
login(page, app_url, next=GIT_SOURCES_URL)
|
||||
expect(page).to_have_url(app_url + GIT_SOURCES_URL, timeout=30_000)
|
||||
expect(page.locator("#sign-out-btn")).to_be_visible(timeout=15_000)
|
||||
@@ -308,7 +322,7 @@ def _upload_zip(page: Page, app_url: str, archive: Path, source: str) -> None:
|
||||
result = page.locator("#archive-upload-result")
|
||||
expect(result).to_be_visible(timeout=UPLOAD_TIMEOUT_MS)
|
||||
expect(result).to_have_text(
|
||||
f"Uploaded {source} — press Sync sources to import it.",
|
||||
_ready_for_sync_line(source),
|
||||
timeout=UPLOAD_TIMEOUT_MS,
|
||||
)
|
||||
|
||||
|
||||
@@ -233,10 +233,24 @@ def _health_db(app_url: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _ready_for_sync_line(source: str) -> re.Pattern[str]:
|
||||
"""Phase 154 (task 02): the ready-for-sync line carrying the
|
||||
completion stamp — "Uploaded <source> (<stamp>) — press Sync
|
||||
sources to import it." (the fresh 202 settle). In E2E the stamp
|
||||
is ALWAYS the same-day ``HH:MM`` form (the run settles within
|
||||
seconds; the date-qualified forms are unit-pinned on a fixed
|
||||
clock — E2E never depends on the wall clock)."""
|
||||
return re.compile(
|
||||
rf"^Uploaded {re.escape(source)} \(\d{{1,2}}:\d{{2}}\)"
|
||||
r" — press Sync sources to import it\.$"
|
||||
)
|
||||
|
||||
|
||||
def _upload_zip(page: Page, app_url: str, archive: Path) -> None:
|
||||
"""The real admin upload flow: form login → the git-sources page →
|
||||
pick the archive → submit → the terminal result line (phase 90:
|
||||
"Uploaded <source> — press Sync sources to import it.")."""
|
||||
pick the archive → submit → the terminal result line (phase 90 A3,
|
||||
phase 154 task 02: "Uploaded <source> (<stamp>) — press Sync
|
||||
sources to import it." — the completion stamp)."""
|
||||
login(page, app_url, next=GIT_SOURCES_URL)
|
||||
expect(page).to_have_url(app_url + GIT_SOURCES_URL, timeout=30_000)
|
||||
expect(page.locator("#sign-out-btn")).to_be_visible(timeout=15_000)
|
||||
@@ -245,7 +259,7 @@ def _upload_zip(page: Page, app_url: str, archive: Path) -> None:
|
||||
result = page.locator("#archive-upload-result")
|
||||
expect(result).to_be_visible(timeout=UPLOAD_TIMEOUT_MS)
|
||||
expect(result).to_have_text(
|
||||
f"Uploaded {SOURCE_NAME} — press Sync sources to import it.",
|
||||
_ready_for_sync_line(SOURCE_NAME),
|
||||
timeout=UPLOAD_TIMEOUT_MS,
|
||||
)
|
||||
|
||||
|
||||
@@ -320,10 +320,24 @@ def _health_db(app_url: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _ready_for_sync_line(source: str) -> re.Pattern[str]:
|
||||
"""Phase 154 (task 02): the ready-for-sync line carrying the
|
||||
completion stamp — "Uploaded <source> (<stamp>) — press Sync
|
||||
sources to import it." (the fresh 202 settle). In E2E the stamp
|
||||
is ALWAYS the same-day ``HH:MM`` form (the run settles within
|
||||
seconds; the date-qualified forms are unit-pinned on a fixed
|
||||
clock — E2E never depends on the wall clock)."""
|
||||
return re.compile(
|
||||
rf"^Uploaded {re.escape(source)} \(\d{{1,2}}:\d{{2}}\)"
|
||||
r" — press Sync sources to import it\.$"
|
||||
)
|
||||
|
||||
|
||||
def _upload_zip(page: Page, app_url: str, archive: Path, source: str) -> None:
|
||||
"""The real admin upload flow: form login → the git-sources page →
|
||||
pick the archive → submit → the terminal result line (phase 90:
|
||||
"Uploaded <source> — press Sync sources to import it.")."""
|
||||
pick the archive → submit → the terminal result line (phase 90 A3,
|
||||
phase 154 task 02: "Uploaded <source> (<stamp>) — press Sync
|
||||
sources to import it." — the completion stamp)."""
|
||||
login(page, app_url, next="/git-sources.html")
|
||||
expect(page).to_have_url(app_url + "/git-sources.html", timeout=30_000)
|
||||
expect(page.locator("#sign-out-btn")).to_be_visible(timeout=15_000)
|
||||
@@ -332,7 +346,7 @@ def _upload_zip(page: Page, app_url: str, archive: Path, source: str) -> None:
|
||||
result = page.locator("#archive-upload-result")
|
||||
expect(result).to_be_visible(timeout=UPLOAD_TIMEOUT_MS)
|
||||
expect(result).to_have_text(
|
||||
f"Uploaded {source} — press Sync sources to import it.",
|
||||
_ready_for_sync_line(source),
|
||||
timeout=UPLOAD_TIMEOUT_MS,
|
||||
)
|
||||
|
||||
|
||||
@@ -379,10 +379,24 @@ def _health_db(app_url: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _ready_for_sync_line(source: str) -> re.Pattern[str]:
|
||||
"""Phase 154 (task 02): the ready-for-sync line carrying the
|
||||
completion stamp — "Uploaded <source> (<stamp>) — press Sync
|
||||
sources to import it." (the fresh 202 settle). In E2E the stamp
|
||||
is ALWAYS the same-day ``HH:MM`` form (the run settles within
|
||||
seconds; the date-qualified forms are unit-pinned on a fixed
|
||||
clock — E2E never depends on the wall clock)."""
|
||||
return re.compile(
|
||||
rf"^Uploaded {re.escape(source)} \(\d{{1,2}}:\d{{2}}\)"
|
||||
r" — press Sync sources to import it\.$"
|
||||
)
|
||||
|
||||
|
||||
def _upload_zip(page: Page, app_url: str, archive: Path, source_name: str) -> None:
|
||||
"""The real admin upload flow: form login → the git-sources page →
|
||||
pick the archive → submit → the terminal result line (phase 90:
|
||||
"Uploaded <source> — press Sync sources to import it.")."""
|
||||
pick the archive → submit → the terminal result line (phase 90 A3,
|
||||
phase 154 task 02: "Uploaded <source> (<stamp>) — press Sync
|
||||
sources to import it." — the completion stamp)."""
|
||||
login(page, app_url, next="/git-sources.html")
|
||||
expect(page).to_have_url(app_url + "/git-sources.html", timeout=30_000)
|
||||
expect(page.locator("#sign-out-btn")).to_be_visible(timeout=15_000)
|
||||
@@ -391,7 +405,7 @@ def _upload_zip(page: Page, app_url: str, archive: Path, source_name: str) -> No
|
||||
result = page.locator("#archive-upload-result")
|
||||
expect(result).to_be_visible(timeout=UPLOAD_TIMEOUT_MS)
|
||||
expect(result).to_have_text(
|
||||
f"Uploaded {source_name} — press Sync sources to import it.",
|
||||
_ready_for_sync_line(source_name),
|
||||
timeout=UPLOAD_TIMEOUT_MS,
|
||||
)
|
||||
|
||||
|
||||
@@ -278,10 +278,24 @@ def _health_db(app_url: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _ready_for_sync_line(source: str) -> re.Pattern[str]:
|
||||
"""Phase 154 (task 02): the ready-for-sync line carrying the
|
||||
completion stamp — "Uploaded <source> (<stamp>) — press Sync
|
||||
sources to import it." (the fresh 202 settle). In E2E the stamp
|
||||
is ALWAYS the same-day ``HH:MM`` form (the run settles within
|
||||
seconds; the date-qualified forms are unit-pinned on a fixed
|
||||
clock — E2E never depends on the wall clock)."""
|
||||
return re.compile(
|
||||
rf"^Uploaded {re.escape(source)} \(\d{{1,2}}:\d{{2}}\)"
|
||||
r" — press Sync sources to import it\.$"
|
||||
)
|
||||
|
||||
|
||||
def _upload_zip(page: Page, app_url: str, archive: Path) -> None:
|
||||
"""The real admin upload flow: form login → the git-sources page →
|
||||
pick the archive → submit → the terminal result line (phase 90:
|
||||
"Uploaded <source> — press Sync sources to import it.")."""
|
||||
pick the archive → submit → the terminal result line (phase 90 A3,
|
||||
phase 154 task 02: "Uploaded <source> (<stamp>) — press Sync
|
||||
sources to import it." — the completion stamp)."""
|
||||
login(page, app_url, next="/git-sources.html")
|
||||
expect(page).to_have_url(app_url + "/git-sources.html", timeout=30_000)
|
||||
expect(page.locator("#sign-out-btn")).to_be_visible(timeout=15_000)
|
||||
@@ -290,7 +304,7 @@ def _upload_zip(page: Page, app_url: str, archive: Path) -> None:
|
||||
result = page.locator("#archive-upload-result")
|
||||
expect(result).to_be_visible(timeout=UPLOAD_TIMEOUT_MS)
|
||||
expect(result).to_have_text(
|
||||
f"Uploaded {SOURCE_NAME} — press Sync sources to import it.",
|
||||
_ready_for_sync_line(SOURCE_NAME),
|
||||
timeout=UPLOAD_TIMEOUT_MS,
|
||||
)
|
||||
|
||||
|
||||
@@ -427,10 +427,24 @@ def _clean_kb(app_url: str) -> Iterator[None]:
|
||||
_truncate_all()
|
||||
|
||||
|
||||
def _ready_for_sync_line(source: str) -> re.Pattern[str]:
|
||||
"""Phase 154 (task 02): the ready-for-sync line carrying the
|
||||
completion stamp — "Uploaded <source> (<stamp>) — press Sync
|
||||
sources to import it." (the fresh 202 settle). In E2E the stamp
|
||||
is ALWAYS the same-day ``HH:MM`` form (the run settles within
|
||||
seconds; the date-qualified forms are unit-pinned on a fixed
|
||||
clock — E2E never depends on the wall clock)."""
|
||||
return re.compile(
|
||||
rf"^Uploaded {re.escape(source)} \(\d{{1,2}}:\d{{2}}\)"
|
||||
r" — press Sync sources to import it\.$"
|
||||
)
|
||||
|
||||
|
||||
def _upload_tar(page: Page, app_url: str, archive: Path) -> None:
|
||||
"""The real admin upload flow: form login → the git-sources page →
|
||||
pick the archive → submit → the terminal result line (phase 90:
|
||||
"Uploaded <source> — press Sync sources to import it.")."""
|
||||
pick the archive → submit → the terminal result line (phase 90 A3,
|
||||
phase 154 task 02: "Uploaded <source> (<stamp>) — press Sync
|
||||
sources to import it." — the completion stamp)."""
|
||||
login(page, app_url, next="/git-sources.html")
|
||||
expect(page).to_have_url(app_url + "/git-sources.html", timeout=30_000)
|
||||
expect(page.locator("#sign-out-btn")).to_be_visible(timeout=15_000)
|
||||
@@ -439,7 +453,7 @@ def _upload_tar(page: Page, app_url: str, archive: Path) -> None:
|
||||
result = page.locator("#archive-upload-result")
|
||||
expect(result).to_be_visible(timeout=UPLOAD_TIMEOUT_MS)
|
||||
expect(result).to_have_text(
|
||||
f"Uploaded {SOURCE_NAME} — press Sync sources to import it.",
|
||||
_ready_for_sync_line(SOURCE_NAME),
|
||||
timeout=UPLOAD_TIMEOUT_MS,
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,769 @@
|
||||
"""Phase 154 task 04 E2E (Playwright, mock-only): the two visible
|
||||
FRESHNESS-STAMP contracts on the live UI (the phase-154 date-qualified
|
||||
stamps — the same-day shape is pinned here; the non-same-day shapes
|
||||
are unit-pinned on a fixed clock, so this suite never depends on the
|
||||
wall clock beyond "the run finishes today").
|
||||
|
||||
1. **The RAG sync badge settles same-day-shaped**: a fresh mock sync
|
||||
(a small registered local source, the mock LLM keeps it short)
|
||||
settles the ``#sync-btn`` label to ``Synced HH:MM`` — the
|
||||
DATE-QUALIFIER (phase 154, task 01, ``fmtSyncTime``'s widened
|
||||
contract) must yield the same-day ``HH:MM`` shape for a run that
|
||||
finished "now". The assertion is a SHAPE pin (the regex
|
||||
``^Synced \\d{2}:\\d{2}$`` — the zero-padded 24-hour form
|
||||
``fmtSyncTime`` produces for the same local calendar day), never a
|
||||
comparison against the wall clock's actual value (the
|
||||
date-qualified forms are unit-pinned on a fixed clock, phase 154,
|
||||
task 03 — the E2E never depends on the wall clock beyond "the
|
||||
run finishes today").
|
||||
2. **The upload line's fresh settle carries the name AND the stamp**:
|
||||
a real small fixture archive through the page's upload form → the
|
||||
202 "Successfully uploaded — <source>" toast → the 2 s poll's
|
||||
settle renders the ready-for-sync line
|
||||
``Uploaded <name> (<stamp>) — press Sync sources to import it.``
|
||||
(the phase-154 task 02 shape, the page-local safe name +
|
||||
``fmtUploadStamp``'s completion stamp off the server payload's
|
||||
``finished_at`` — the terminal status pin carries the non-null
|
||||
``finished_at`` the stamp is computed from). The stamp matches the
|
||||
shape pin (same-day ``HH:MM`` in practice; the shape regex also
|
||||
covers the date-qualified forms, so the pin never breaks across a
|
||||
midnight boundary).
|
||||
3. **The boot re-render is NAMELESS but still STAMPED** (the phase's
|
||||
actual fix — the owner's "the line can sit there indefinitely and
|
||||
mislead"): after the settled fresh line, a page reload boots
|
||||
``initUploadStatus``'s success branch — the safe name was
|
||||
page-local (``lastUploadName`` is null after a reload — the
|
||||
phase-90 contract), so the line is the NAMELESS
|
||||
``Uploaded <stamp> — press Sync sources to import it.`` — but the
|
||||
stamp SURVIVES because it comes from the SERVER payload
|
||||
(``finished_at``), not from page state. The run's identity pin
|
||||
(``started_at`` unchanged across the reload) proves the re-render
|
||||
is the same run's line, not a second upload's.
|
||||
4. **A running upload never renders the ready-for-sync line** (the
|
||||
phase-90 regression guard, extended): at the 202 moment the button
|
||||
is the BARE ``Processing…`` (phase 90, A2 — no file, no counts,
|
||||
title cleared) and ``#archive-upload-result`` is HIDDEN — the line
|
||||
appears ONLY on settle (the poll's success tick). The
|
||||
deterministic in-flight window: the 202 handler synchronously
|
||||
enters the processing state and schedules the first poll tick at
|
||||
+2 s, so the ``Processing…`` state holds for ≥ 2 s regardless of
|
||||
how fast the sub-second background run finishes — the
|
||||
in-flight observation needs no delay proxy (the upload makes zero
|
||||
LLM calls; the sync leg of test 1 is 2 files and settles within
|
||||
the generous budget).
|
||||
|
||||
Run in isolation (DB must be up: ``podman compose up -d db``):
|
||||
|
||||
uv run pytest tests/e2e/test_sync_status_freshness.py -v --no-cov
|
||||
|
||||
MOCK-ONLY suite: ``E2E_REAL_LLM=1`` is not supported — the whole
|
||||
point is the deterministic mock (no real-LLM dependency), and the
|
||||
time assertions are shape pins, never wall-clock value comparisons.
|
||||
|
||||
Per-module app env (the conftest pattern — mirroring
|
||||
``test_upload_no_scan.py``'s local helpers, NOT importing that
|
||||
module): ``BOR_UPLOAD_DIR`` points at a scratch dir (the app runs on
|
||||
the same host), ``BOR_GIT_SOURCES`` is forced empty (this suite's
|
||||
own registered rows / upload rows are the only sync sources),
|
||||
``BOR_LLM_BASE_URL`` is the mock (the sync leg's embeds + overview),
|
||||
the retry knobs pinned (the sync leg never burns a 5 s retry wait),
|
||||
and the autouse ``_clean`` truncates the shared Postgres (plus waits
|
||||
for no running background job — a leaked run must not survive into
|
||||
the next test's truncated KB).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
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, 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,
|
||||
SESSION_SECRET,
|
||||
USE_REAL_LLM,
|
||||
_wait_http,
|
||||
)
|
||||
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
|
||||
# Phase 79 (task 04, full inventory): in a combined session run the
|
||||
# conftest session app already owns its port — a second uvicorn on it
|
||||
# dies on bind and this suite would silently drive the wrong server.
|
||||
# The module app binds its own port instead (env-overridable).
|
||||
APP_PORT = int(os.environ.get("E2E_APP_PORT_SYNCFRESH", "8172"))
|
||||
APP_URL = f"http://127.0.0.1:{APP_PORT}"
|
||||
|
||||
SOURCES_URL = "/sources.html"
|
||||
GIT_SOURCES_URL = "/git-sources.html"
|
||||
|
||||
#: The sync-leg fixture source (a host temp dir, ``kind=local``) —
|
||||
#: exactly two small markdown files, so the counts line is "2 added"
|
||||
#: and the run is short against the fast mock LLM.
|
||||
SYNC_SOURCE = "freshness-sync"
|
||||
SYNC_FILES: dict[str, str] = {
|
||||
"alpha.md": (
|
||||
"# Freshness alpha\n"
|
||||
"\n"
|
||||
"A small note that exists only to give the phase-154 sync\n"
|
||||
"fixture a real import.\n"
|
||||
"\n"
|
||||
"Marker: FRESHNESS-SYNC-ALPHA-7f31\n"
|
||||
),
|
||||
"beta.md": (
|
||||
"# Freshness beta\n"
|
||||
"\n"
|
||||
"The second small note — the counts line's denominator.\n"
|
||||
"\n"
|
||||
"Marker: FRESHNESS-SYNC-BETA-2c90\n"
|
||||
),
|
||||
}
|
||||
|
||||
#: The upload-leg archive basenames (one per upload test — distinct
|
||||
#: names keep the in-place-replace identity out of the assertions).
|
||||
UPLOAD_A = "e2e-freshness-a"
|
||||
UPLOAD_B = "e2e-freshness-b"
|
||||
UPLOAD_C = "e2e-freshness-c"
|
||||
|
||||
|
||||
def _targz_files(name: str) -> dict[str, str]:
|
||||
"""One small deterministic markdown file per archive (a valid
|
||||
replacement archive — what the KB indexes with it is the sync's
|
||||
call, phase 90)."""
|
||||
return {
|
||||
"note.md": (
|
||||
f"# Freshness {name}\n"
|
||||
"\n"
|
||||
"A small note carried by this suite's archive.\n"
|
||||
f"\nMarker: FRESHNESS-{name.upper()}-9d16\n"
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shape pins (the task's contract: shapes, never wall-clock values)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
#: The date-qualified stamp's shape (phase 154, A1 rule): the
|
||||
#: same-day ``HH:MM`` (zero-padded — ``fmtSyncTime`` /
|
||||
#: ``fmtUploadStamp`` pad both fields) OR the date-qualified forms
|
||||
#: (``M/D(, YYYY), h:MM AM/PM`` — no leading zero on month/day/hour).
|
||||
#: In practice a run that settles "now" always lands on the same-day
|
||||
#: branch; the shape regex covers both so the pin never breaks across
|
||||
#: a midnight boundary. The date-qualified VALUES are unit-pinned on a
|
||||
#: fixed clock (phase 154, task 03) — this suite never compares a
|
||||
#: stamp against the actual time.
|
||||
STAMP = (
|
||||
r"\d{2}:\d{2}"
|
||||
r"|\d{1,2}/\d{1,2}(?:/\d{4})?, \d{1,2}:\d{2} [AP]M"
|
||||
)
|
||||
|
||||
#: ``Synced HH:MM`` — the same-day settled sync label (task 01: the
|
||||
#: qualifier yields the zero-padded ``HH:MM`` for a run that finished
|
||||
#: on the same local calendar day — byte-identical to the pre-phase
|
||||
#: shape). The date-qualified ``M/D(, YYYY), h:MM AM/PM`` forms are
|
||||
#: unit-pinned on a fixed clock (phase 154, task 03) — this pin is
|
||||
#: the same-day shape the task pins; a run that settles "now" always
|
||||
#: lands on the same-day branch.
|
||||
SYNCED_SAME_DAY = re.compile(r"^Synced \d{2}:\d{2}$")
|
||||
|
||||
#: The fresh-settle ready-for-sync line WITH the safe name and the
|
||||
#: stamp (phase 154, task 02 — the page-local ``lastUploadName`` +
|
||||
#: ``fmtUploadStamp(status)`` off the server payload's
|
||||
#: ``finished_at``).
|
||||
def _ready_line(name: str) -> re.Pattern[str]:
|
||||
return re.compile(
|
||||
rf"^Uploaded {re.escape(name)} \(({STAMP})\)"
|
||||
r" — press Sync sources to import it\.$"
|
||||
)
|
||||
|
||||
|
||||
#: The boot re-render ready-for-sync line — NAMELESS (the safe name
|
||||
#: was page-local, ``lastUploadName`` is null after a reload — the
|
||||
#: phase-90 contract) but STAMPED (the stamp comes from the SERVER
|
||||
#: payload — the phase-154 fix: a line that outlives its upload is
|
||||
#: self-dating).
|
||||
NAMELESS_READY_LINE = re.compile(
|
||||
rf"^Uploaded ({STAMP}) — press Sync sources to import it\.$"
|
||||
)
|
||||
|
||||
#: Generous settle budgets: the upload run settles in milliseconds
|
||||
#: (phase 90 — unpack + register only, zero LLM calls) and the UI's
|
||||
#: 2 s poll lands one tick after; the sync leg is 2 files against the
|
||||
#: fast mock LLM and may settle between the 2 s poll ticks — the
|
||||
#: settled-state assertions retry until then.
|
||||
UPLOAD_TIMEOUT_MS = 30_000
|
||||
SYNC_TIMEOUT_MS = 90_000
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _build_targz(path: Path, files: dict[str, str]) -> Path:
|
||||
"""A deterministic ``.tar.gz`` (mtime 0) over the given files."""
|
||||
with tarfile.open(path, "w:gz") as tf:
|
||||
for rel, content in files.items():
|
||||
data = content.encode("utf-8")
|
||||
info = tarfile.TarInfo(rel)
|
||||
info.size = len(data)
|
||||
info.mtime = 0
|
||||
tf.addfile(info, io.BytesIO(data))
|
||||
return path
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def sync_dir(tmp_path_factory: pytest.TempPathFactory) -> Path:
|
||||
"""The sync-leg's local source (``kind=local`` — the directory's
|
||||
basename is the source name, phase 38): EXACTLY the two fixture
|
||||
files (the counts line reads ``2 added``)."""
|
||||
root = tmp_path_factory.mktemp("bor_freshness_sync")
|
||||
src = root / SYNC_SOURCE
|
||||
src.mkdir()
|
||||
for rel, content in SYNC_FILES.items():
|
||||
(src / rel).write_text(content, encoding="utf-8")
|
||||
assert len(list(src.rglob("*"))) == len(SYNC_FILES)
|
||||
return src
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def upload_dir(tmp_path_factory: pytest.TempPathFactory) -> Path:
|
||||
"""The app's ``BOR_UPLOAD_DIR`` for this suite — a scratch dir
|
||||
the host-side assertions inspect (the app server runs on the same
|
||||
machine). The app creates it on the first upload."""
|
||||
return tmp_path_factory.mktemp("bor_freshness_uploads") / "uploads"
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def tarball_a(tmp_path_factory: pytest.TempPathFactory) -> Path:
|
||||
root = tmp_path_factory.mktemp("bor_freshness_a")
|
||||
return _build_targz(root / f"{UPLOAD_A}.tar.gz", _targz_files(UPLOAD_A))
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def tarball_b(tmp_path_factory: pytest.TempPathFactory) -> Path:
|
||||
root = tmp_path_factory.mktemp("bor_freshness_b")
|
||||
return _build_targz(root / f"{UPLOAD_B}.tar.gz", _targz_files(UPLOAD_B))
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def tarball_c(tmp_path_factory: pytest.TempPathFactory) -> Path:
|
||||
root = tmp_path_factory.mktemp("bor_freshness_c")
|
||||
return _build_targz(root / f"{UPLOAD_C}.tar.gz", _targz_files(UPLOAD_C))
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def app_server(
|
||||
mock_llm: int,
|
||||
upload_dir: Path,
|
||||
tmp_path_factory: pytest.TempPathFactory,
|
||||
) -> Iterator[str]:
|
||||
"""The real app under test — per-module env: uploads unpack into
|
||||
a scratch dir, the env git list is forced empty (this suite's own
|
||||
rows are the only sync sources), and the LLM base URL is the mock
|
||||
(the upload makes zero LLM calls — phase 90; the mock serves the
|
||||
sync leg's embeds + overview). No ``slow_llm`` proxy: the upload
|
||||
run is sub-second and the 2 s poll window is what the in-flight
|
||||
assertion leans on (see the module docstring's test 4 paragraph).
|
||||
Skips the module (clear instructions) when Postgres is down —
|
||||
the sync needs the KB tables."""
|
||||
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 f"http://127.0.0.1:{mock_llm}/v1"
|
||||
)
|
||||
# Mock-calibrated threshold (conftest pattern): this suite never
|
||||
# asks the chat model anything — the gate is never on a path.
|
||||
env["BOR_RELEVANCE_THRESHOLD"] = "0.30"
|
||||
# Phase 67: instant retry waits + the code-default budget (the
|
||||
# conftest leak-guard pattern — the sync leg never burns a 5 s
|
||||
# retry wait).
|
||||
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 repo's .env file carries the owner's BOR_GIT_SOURCES (the app
|
||||
# reads it from cwd) — override it with an EMPTY value (the env var
|
||||
# beats the .env file): the registry must hold EXACTLY this
|
||||
# suite's own rows.
|
||||
env["BOR_GIT_SOURCES"] = ""
|
||||
# Phase 49: unpack uploads into the suite's scratch dir (host-
|
||||
# inspectable) and keep the (unused) git checkouts out of the dev
|
||||
# location.
|
||||
env["BOR_UPLOAD_DIR"] = str(upload_dir)
|
||||
env["BOR_SOURCES_DIR"] = str(tmp_path_factory.mktemp("bor_checkouts"))
|
||||
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")
|
||||
body = httpx.get(f"{APP_URL}/api/health", timeout=5).json()
|
||||
if body["db"] != "up":
|
||||
pytest.skip("Postgres not reachable — run `podman compose up -d db` first")
|
||||
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 per test (the E2E isolation pattern): the
|
||||
row-count and doc-list assertions must be this test's own doing.
|
||||
The E2E suites share one Postgres, and a leftover git_sources row
|
||||
or document would corrupt them (a leftover row would be a SECOND
|
||||
sync source, skewing the sync's counts). ``sources_meta`` (the KB
|
||||
generation counter) resets with the KB — the sync re-creates the
|
||||
row; ``folder_summaries`` / ``steering_notes`` ride along (the
|
||||
phase-96 pattern)."""
|
||||
with SessionLocal() as db:
|
||||
db.execute(
|
||||
text(
|
||||
"TRUNCATE chunks, documents, query_log, steering_notes, "
|
||||
"kb_overview, git_sources, sources_meta, folder_summaries"
|
||||
)
|
||||
)
|
||||
db.commit() # without the commit the TRUNCATE rolls back (the house pattern)
|
||||
|
||||
|
||||
def _wait_no_running_jobs(app_url: str) -> None:
|
||||
"""No background job may leak across tests (the run states live in
|
||||
the app's memory, and a still-running sync would keep importing
|
||||
into the NEXT test's truncated KB, and a still-running upload
|
||||
would 409 the next test's upload): wait for BOTH status endpoints
|
||||
to be non-running BEFORE the truncate. Own admin session (the
|
||||
endpoints are admin-only) — usually a no-op: the tests settle
|
||||
only after their terminal states."""
|
||||
with httpx.Client(timeout=5.0) as client:
|
||||
client.post(f"{app_url}/api/login", json={"password": ADMIN_PASSWORD})
|
||||
for path in ("/api/sync/status", "/api/git-sources/upload/status"):
|
||||
body: dict[str, Any] = {}
|
||||
deadline = time.monotonic() + 90
|
||||
while time.monotonic() < deadline:
|
||||
r = client.get(f"{app_url}{path}")
|
||||
if r.status_code == 200:
|
||||
body = r.json()
|
||||
if body["state"] != "running":
|
||||
break
|
||||
time.sleep(0.2)
|
||||
assert body["state"] != "running", (
|
||||
f"a background job was still running at test boundary: {path} {body}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean(app_url: str, db_ready: None) -> Iterator[None]:
|
||||
_wait_no_running_jobs(app_url)
|
||||
_truncate_all()
|
||||
yield
|
||||
_truncate_all()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _register_local_source(base_url: str, path: str) -> None:
|
||||
"""Register the temp directory through the authenticated API (the
|
||||
``test_local_directory_sources.py`` / phase-94 pattern — a plain
|
||||
directory is an API/DB-only operation)."""
|
||||
with httpx.Client(base_url=base_url, timeout=30.0) as client:
|
||||
r = client.post("/api/login", json={"password": ADMIN_PASSWORD})
|
||||
assert r.status_code == 204, r.text
|
||||
r = client.post(
|
||||
"/api/git-sources", json={"kind": "local", "path": path}
|
||||
)
|
||||
assert r.status_code == 201, r.text
|
||||
|
||||
|
||||
def _status(page: Page, app_url: str, path: str) -> dict[str, Any]:
|
||||
"""The status endpoint as the signed-in page (admin cookie)."""
|
||||
r = page.request.get(f"{app_url}{path}")
|
||||
assert r.status == 200, r.text
|
||||
return r.json()
|
||||
|
||||
|
||||
def _admin_git_sources_page(page: Page, app_url: str) -> None:
|
||||
"""Real form login landing on the git sources page (admin settled:
|
||||
Sign out visible, the manager revealed by the page module)."""
|
||||
login(page, app_url, next=GIT_SOURCES_URL)
|
||||
expect(page).to_have_url(app_url + GIT_SOURCES_URL, timeout=30_000)
|
||||
expect(page.locator("#sign-out-btn")).to_be_visible(timeout=15_000)
|
||||
expect(page.locator("#git-sources-gate")).to_be_hidden()
|
||||
expect(page.locator("#git-sources-content")).to_be_visible()
|
||||
|
||||
|
||||
def _upload_via_page(page: Page, archive: Path) -> None:
|
||||
"""Pick the archive and submit the form (the 202 path: the toast
|
||||
fires at the 202, then the button's status polling settles the
|
||||
line — the caller asserts the line's shape). Every upload in this
|
||||
suite succeeds (the failure path is another suite's subject), so
|
||||
any non-202 outcome here is a test error."""
|
||||
page.set_input_files("#archive-upload-file", str(archive))
|
||||
page.click("#archive-upload-btn")
|
||||
toast = page.locator(".toast")
|
||||
expect(toast).to_have_count(1, timeout=UPLOAD_TIMEOUT_MS)
|
||||
expect(toast).to_have_class(re.compile(r"\bis-visible\b"))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. The RAG sync badge settles in the same-day shape (the qualifier's
|
||||
# E2E half — the date-qualified values are unit-pinned, the shape
|
||||
# is pinned here on a run that finished "now")
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_fresh_sync_settles_same_day_shape(
|
||||
page: Page, app_url: str, db_ready: None, sync_dir: Path
|
||||
) -> None:
|
||||
"""A fresh mock sync (the small registered local source — the mock
|
||||
LLM keeps the run short) settles the RAG ``#sync-btn`` label to
|
||||
the same-day ``Synced HH:MM`` shape (phase 154, task 01 — the
|
||||
qualifier must yield ``HH:MM`` for a run that finished on the
|
||||
same local calendar day — the SHAPE pin, never a wall-clock value
|
||||
comparison). The run is real (the counts line ``2 added`` + the
|
||||
terminal status's non-null ``finished_at`` — the ISO the
|
||||
badge's stamp is computed from — + 2 of 2 files), the settled
|
||||
button is re-enabled with the ship aria value restored, and no
|
||||
error UI is present."""
|
||||
page.set_default_timeout(30_000)
|
||||
_register_local_source(app_url, str(sync_dir))
|
||||
login(page, app_url, next=SOURCES_URL)
|
||||
expect(page).to_have_url(app_url + SOURCES_URL, timeout=30_000)
|
||||
|
||||
btn = page.locator("#sync-btn")
|
||||
expect(btn).to_be_visible(timeout=30_000)
|
||||
# The settled boot label — the fresh app's idle state (this is the
|
||||
# module's first test; the app's in-memory sync state is idle).
|
||||
expect(page.locator("#sync-label")).to_have_text("Sync sources")
|
||||
expect(btn).to_be_enabled()
|
||||
expect(page.locator("#sync-error-banner")).to_be_hidden()
|
||||
|
||||
btn.click()
|
||||
|
||||
# The click's immediate state (the 202 moment — the bare prefix in
|
||||
# the box — phase 149: the running label rides the box, the
|
||||
# button is the spinner).
|
||||
expect(btn).to_be_disabled()
|
||||
expect(btn).to_have_attribute("aria-busy", "true")
|
||||
expect(btn.locator(".sync-icon")).to_have_class(re.compile(r"\bis-spinning\b"))
|
||||
expect(page.locator("#sync-progress")).to_have_text("Syncing…")
|
||||
|
||||
# The settle (phase 154, task 01 — the SAME-DAY SHAPE pin):
|
||||
# "Synced HH:MM" — the date-qualified ``M/D(, YYYY), h:MM AM/PM``
|
||||
# forms are unit-pinned on a fixed clock (phase 154, task 03);
|
||||
# this run finished "now", so the qualifier must yield the
|
||||
# same-day shape (the shape is asserted; the actual time value is
|
||||
# never compared against the wall clock).
|
||||
expect(page.locator("#sync-label")).to_have_text(
|
||||
SYNCED_SAME_DAY, timeout=SYNC_TIMEOUT_MS
|
||||
)
|
||||
# The run was real (the counts line + the terminal status agree —
|
||||
# the status is the source of truth for the settle; the UI's 2 s
|
||||
# poll renders it at most one tick later).
|
||||
expect(page.locator("#sync-progress-text")).to_have_text(
|
||||
f"{len(SYNC_FILES)} added", timeout=SYNC_TIMEOUT_MS
|
||||
)
|
||||
btn = page.locator("#sync-btn")
|
||||
expect(btn).to_be_enabled()
|
||||
expect(btn).not_to_have_attribute("aria-busy")
|
||||
# Phase 150: the settle restores the ship aria value (the running
|
||||
# full label is gone — the badge is the pill's text).
|
||||
assert btn.get_attribute("aria-label") == "Sync sources", (
|
||||
btn.get_attribute("aria-label")
|
||||
)
|
||||
expect(page.locator("#sync-error-banner")).to_be_hidden()
|
||||
|
||||
terminal = _status(page, app_url, "/api/sync/status")
|
||||
assert terminal["state"] == "success", terminal
|
||||
# The run was a fresh import of exactly the two fixture files —
|
||||
# and its ``finished_at`` (non-null) is the ISO the badge's stamp
|
||||
# is computed from (the stamp's server-side half).
|
||||
assert terminal["detail"]["added"] == len(SYNC_FILES), terminal
|
||||
assert terminal["detail"]["pruned"] == 0, terminal
|
||||
assert terminal["files_done"] == len(SYNC_FILES), terminal
|
||||
assert terminal["files_total"] == len(SYNC_FILES), terminal
|
||||
assert terminal["finished_at"], terminal
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. The upload line's fresh settle carries the name AND the stamp
|
||||
# (phase 154, task 02 — the phase-90 no-count contract extended)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_upload_line_fresh_settle_carrys_name_and_stamp(
|
||||
page: Page,
|
||||
app_url: str,
|
||||
db_ready: None,
|
||||
tarball_a: Path,
|
||||
upload_dir: Path,
|
||||
) -> None:
|
||||
"""One real upload through the page: the 202 fires the
|
||||
"Successfully uploaded — <source>" toast (phase 64, A2); the
|
||||
sub-second background run (unpack + register only — phase 90)
|
||||
settles at the next 2 s poll tick into the ready-for-sync line
|
||||
``Uploaded <name> (<stamp>) — press Sync sources to import it.``
|
||||
— the SAFE name (the 202's ``UploadAccepted.name`` — the
|
||||
phase-90 contract) + the phase-154 COMPLETION STAMP
|
||||
(``fmtUploadStamp`` off the server payload's ``finished_at`` —
|
||||
the shape pin: same-day ``HH:MM`` in practice, the date-qualified
|
||||
forms covered by the shape regex). The never-stale restore
|
||||
(button + input) lands with the line, the terminal status is the
|
||||
no-count payload (``{"message": "uploaded"}``, null/0/0 — the
|
||||
phase-90 A2 key set) with a NON-NULL ``finished_at`` (the stamp's
|
||||
source), the source row is present (Local badge), and the
|
||||
unpacked folder exists on the host (the run did its unpack +
|
||||
register work — zero documents indexed: the scan is the Sync
|
||||
button's job, phase 90, A1)."""
|
||||
page.set_default_timeout(30_000)
|
||||
_admin_git_sources_page(page, app_url)
|
||||
expect(page.locator("#git-sources-tbody tr")).to_have_count(0)
|
||||
|
||||
btn = page.locator("#archive-upload-btn")
|
||||
expect(btn).to_be_enabled()
|
||||
expect(btn).to_have_text("Upload")
|
||||
|
||||
_upload_via_page(page, tarball_a)
|
||||
|
||||
# The 202's toast names the safe source name (phase 64, A2)…
|
||||
toast = page.locator(".toast")
|
||||
expect(toast).to_have_attribute("role", "status")
|
||||
expect(toast).to_have_text(f"Successfully uploaded — {UPLOAD_A}")
|
||||
# …and the settle: the ready-for-sync line WITH the name and the
|
||||
# completion stamp (phase 154, task 02 — the shape pin).
|
||||
result = page.locator("#archive-upload-result")
|
||||
expect(result).to_be_visible(timeout=UPLOAD_TIMEOUT_MS)
|
||||
expect(result).to_have_text(_ready_line(UPLOAD_A))
|
||||
# The stamp is present and non-empty (the regex's capture is the
|
||||
# stamp itself — the pre-phase string has no "(…)" at all, so a
|
||||
# match IS the stamp's proof).
|
||||
m = _ready_line(UPLOAD_A).fullmatch(result.text_content() or "")
|
||||
assert m is not None, "the settled line did not match the named + stamped shape"
|
||||
assert m.group(1), "the stamp is empty in the settled line"
|
||||
# The never-stale restore (the poll's success tick owns it)…
|
||||
expect(btn).to_be_enabled()
|
||||
expect(btn).to_have_text("Upload")
|
||||
expect(page.locator("#archive-upload-file")).to_have_value("")
|
||||
# …and the terminal status: the no-count payload (phase 90, A2)
|
||||
# with the NON-NULL finished_at the stamp is computed from.
|
||||
status = _status(page, app_url, "/api/git-sources/upload/status")
|
||||
assert status["state"] == "success", status
|
||||
assert status["detail"] == {"message": "uploaded"}, status
|
||||
assert status["current_file"] is None, status
|
||||
assert status["files_done"] == 0 and status["files_total"] == 0, status
|
||||
assert status["finished_at"], status
|
||||
assert status["started_at"], status
|
||||
# The row landed (Local badge) and the folder is on the host —
|
||||
# but the upload indexed NOTHING (phase 90, A1 — the scan is the
|
||||
# Sync button's job).
|
||||
row = page.locator("#git-sources-tbody tr", has_text=UPLOAD_A)
|
||||
expect(row).to_have_count(1, timeout=30_000)
|
||||
expect(row.locator("span.git-source-kind")).to_have_text("Local")
|
||||
assert (upload_dir / UPLOAD_A).is_dir(), (
|
||||
f"the unpacked folder is missing: {upload_dir / UPLOAD_A}"
|
||||
)
|
||||
r = page.request.get(f"{app_url}/api/docs")
|
||||
assert r.status == 200, r.text
|
||||
assert r.json()["documents"] == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. The boot re-render is nameless but STAMPED — the phase's actual
|
||||
# fix (the stale "Uploaded" line is self-dating after a reload)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_upload_line_boot_rerender_stamped_nameless(
|
||||
page: Page,
|
||||
app_url: str,
|
||||
db_ready: None,
|
||||
tarball_b: Path,
|
||||
) -> None:
|
||||
"""Seed a settled upload (the fresh named line — the run's
|
||||
identity pinned on the status's ``started_at``), then RELOAD the
|
||||
page: the boot re-attach (``initUploadStatus``'s success branch —
|
||||
phase 64, task 05) re-renders the ready-for-sync line — the SAFE
|
||||
NAME IS GONE (``lastUploadName`` was page-local, null after a
|
||||
reload — the phase-90 contract) but the STAMP SURVIVES
|
||||
(``fmtUploadStamp`` reads the SERVER payload's ``finished_at`` —
|
||||
phase 154, task 02 — the line is self-dating even with the name
|
||||
unknown; THIS is the regression the phase fixes: a line that
|
||||
outlives its upload can no longer masquerade as fresh). The page
|
||||
boots honest (no error banner, no toast — it fired at the 202 on
|
||||
the previous document life — the button restored), the list shows
|
||||
exactly one row for the archive, and the terminal status is the
|
||||
SAME run (the ``started_at`` is unchanged — no second upload)."""
|
||||
page.set_default_timeout(30_000)
|
||||
_admin_git_sources_page(page, app_url)
|
||||
|
||||
# The seed: a settled upload (the fresh named line with the
|
||||
# stamp — test 2's shape, re-proven here as the pre-reload state).
|
||||
_upload_via_page(page, tarball_b)
|
||||
expect(page.locator(".toast")).to_have_text(
|
||||
f"Successfully uploaded — {UPLOAD_B}"
|
||||
)
|
||||
result = page.locator("#archive-upload-result")
|
||||
expect(result).to_be_visible(timeout=UPLOAD_TIMEOUT_MS)
|
||||
expect(result).to_have_text(_ready_line(UPLOAD_B))
|
||||
# The run's identity (a second upload would reset it — the
|
||||
# single-run claim is pinned on it).
|
||||
before = _status(page, app_url, "/api/git-sources/upload/status")
|
||||
assert before["state"] == "success", before
|
||||
started_at = before["started_at"]
|
||||
assert started_at, before
|
||||
|
||||
# The reload (the session cookie survives — the boot re-attach is
|
||||
# the admin's).
|
||||
page.reload()
|
||||
expect(page).to_have_url(app_url + GIT_SOURCES_URL, timeout=30_000)
|
||||
expect(page.locator("#sign-out-btn")).to_be_visible(timeout=15_000)
|
||||
expect(page.locator("#git-sources-content")).to_be_visible()
|
||||
|
||||
# The boot re-render: the NAMELESS line WITH the stamp (phase
|
||||
# 154, task 02 — the stamp from the server payload). The
|
||||
# nameless shape has NO "(…)" and NO <name> — the regex's match
|
||||
# IS the nameless+stamped proof (the pre-phase nameless string
|
||||
# "Uploaded — press …" has no stamp at all).
|
||||
expect(result).to_have_text(NAMELESS_READY_LINE, timeout=UPLOAD_TIMEOUT_MS)
|
||||
m = NAMELESS_READY_LINE.fullmatch(result.text_content() or "")
|
||||
assert m is not None, "the boot re-render line did not match the nameless + stamped shape"
|
||||
assert m.group(1), (
|
||||
"the stamp is empty in the boot re-render line"
|
||||
)
|
||||
# The page boots honest: no error banner, no toast at boot (A2 —
|
||||
# the toast fired at the 202 on the previous document life), the
|
||||
# never-stale restore (button re-enabled / "Upload").
|
||||
expect(page.locator("#archive-upload-error")).to_be_hidden()
|
||||
expect(page.locator(".toast")).to_have_count(0)
|
||||
expect(page.locator("#archive-upload-btn")).to_be_enabled()
|
||||
expect(page.locator("#archive-upload-btn")).to_have_text("Upload")
|
||||
# The list shows exactly one row for the archive (the in-place
|
||||
# identity survived the reload).
|
||||
expect(page.locator("#git-sources-tbody tr")).to_have_count(1)
|
||||
row = page.locator("#git-sources-tbody tr", has_text=UPLOAD_B)
|
||||
expect(row).to_have_count(1)
|
||||
expect(row.locator("span.git-source-kind")).to_have_text("Local")
|
||||
# No second upload: the terminal status is the SAME run — its
|
||||
# started_at is the one from before the reload.
|
||||
terminal = _status(page, app_url, "/api/git-sources/upload/status")
|
||||
assert terminal["state"] == "success", terminal
|
||||
assert terminal["started_at"] == started_at, (
|
||||
f"the run's identity changed (a second upload ran): {terminal['started_at']}"
|
||||
)
|
||||
assert terminal["detail"] == {"message": "uploaded"}, terminal
|
||||
assert terminal["finished_at"], terminal
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. A running upload never renders the ready-for-sync line (the
|
||||
# phase-90 regression guard, extended — the line appears ONLY on
|
||||
# settle)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_running_upload_line_unchanged(
|
||||
page: Page,
|
||||
app_url: str,
|
||||
db_ready: None,
|
||||
tarball_c: Path,
|
||||
) -> None:
|
||||
"""At the 202 moment the upload run is in flight: the button is
|
||||
the BARE "Processing…" (phase 90, A2 — no file, no "(n/m)"
|
||||
counts, the title cleared) and ``#archive-upload-result`` is
|
||||
HIDDEN — the ready-for-sync line NEVER renders during the run
|
||||
(the phase-90 contract: the line appears ONLY on settle). The
|
||||
deterministic in-flight window: the 202 handler synchronously
|
||||
enters the processing state and schedules the first poll tick at
|
||||
+2 s, so the "Processing…" state holds for ≥ 2 s regardless of
|
||||
how fast the sub-second background run finishes — no delay proxy
|
||||
is needed (the upload makes zero LLM calls). Then the poll's
|
||||
success tick settles the line (the NAMED shape with the
|
||||
completion stamp — the line appears only on settle), with the
|
||||
never-stale restore and no error UI throughout."""
|
||||
page.set_default_timeout(30_000)
|
||||
_admin_git_sources_page(page, app_url)
|
||||
|
||||
btn = page.locator("#archive-upload-btn")
|
||||
expect(btn).to_be_enabled()
|
||||
expect(btn).to_have_text("Upload")
|
||||
expect(page.locator("#archive-upload-error")).to_be_hidden()
|
||||
|
||||
# The 202 moment (the toast fires at the 202 — the run is in
|
||||
# flight from here until the poll's success tick). The submit
|
||||
# handler hid the result line before the fetch (a new attempt
|
||||
# starts clean), so at the in-flight moment the line is hidden
|
||||
# even though the shared module app's boot re-render may have
|
||||
# re-shown the PREVIOUS test's settled line at page load (the
|
||||
# phase-154 boot re-attach — the line's self-dating contract,
|
||||
# test 3's subject — not a partial-state regression).
|
||||
_upload_via_page(page, tarball_c)
|
||||
expect(page.locator(".toast")).to_have_text(
|
||||
f"Successfully uploaded — {UPLOAD_C}"
|
||||
)
|
||||
|
||||
# The IN-FLIGHT state (the ≥ 2 s window — deterministic): the
|
||||
# bare "Processing…" label (phase 90, A2), the button disabled
|
||||
# (the poll owns it — PLAN §7.4), the title cleared, and the
|
||||
# ready-for-sync line HIDDEN (the line appears ONLY on settle).
|
||||
expect(btn).to_be_disabled()
|
||||
expect(btn).to_have_text("Processing…")
|
||||
expect(btn).to_have_attribute("title", "")
|
||||
expect(page.locator("#archive-upload-result")).to_be_hidden()
|
||||
expect(page.locator("#archive-upload-error")).to_be_hidden()
|
||||
# The in-flight status: the bare payload (phase 90, A2 — the
|
||||
# run's unpack + register is in flight or just finished; either
|
||||
# way the line is the poll tick's job, not the in-flight state's).
|
||||
running = _status(page, app_url, "/api/git-sources/upload/status")
|
||||
assert running["state"] in ("running", "success"), running
|
||||
|
||||
# The SETTLE (the poll's success tick — the line appears ONLY
|
||||
# here, and the line is THIS run's — the named shape with the
|
||||
# completion stamp; the distinct safe name also proves the
|
||||
# previous test's boot re-render line was replaced, not carried
|
||||
# through) + the never-stale restore, no error UI.
|
||||
expect(page.locator("#archive-upload-result")).to_have_text(
|
||||
_ready_line(UPLOAD_C), timeout=UPLOAD_TIMEOUT_MS
|
||||
)
|
||||
expect(btn).to_be_enabled()
|
||||
expect(btn).to_have_text("Upload")
|
||||
expect(page.locator("#archive-upload-error")).to_be_hidden()
|
||||
status = _status(page, app_url, "/api/git-sources/upload/status")
|
||||
assert status["state"] == "success", status
|
||||
assert status["detail"] == {"message": "uploaded"}, status
|
||||
assert status["finished_at"], status
|
||||
@@ -16,8 +16,9 @@ unpack → swap → row upsert continues server-side behind
|
||||
pattern; the phase-64 key set with ``current_file``/``files_done``/
|
||||
``files_total`` null/0/0 for the whole run — phase 90 A2), and the
|
||||
upload's UI processing state is the BARE "Processing…" (no file, no
|
||||
"(n/m)") until the no-count "Uploaded <name> — press Sync sources to
|
||||
import it." result line lands (phase 90 A3). The scan — with its live
|
||||
"(n/m)") until the no-count "Uploaded <name> (<stamp>) — press Sync
|
||||
sources to import it." result line lands (phase 90 A3 — the phase-154
|
||||
completion stamp). The scan — with its live
|
||||
file label — is the RAG page's **Sync sources** button's job, and the
|
||||
suite proves the new loop: upload → nothing indexed → **the sync that
|
||||
follows the upload shows its live file label and lands the counts**.
|
||||
@@ -159,6 +160,31 @@ SLOW_DELAY_S = "0.15"
|
||||
|
||||
#: The uploaded archive: 25 small docs under ``docs/``.
|
||||
UPLOAD_NAME = "e2e-prog"
|
||||
|
||||
|
||||
def _ready_for_sync_line(name: str) -> re.Pattern[str]:
|
||||
"""Phase 154 (task 02): the ready-for-sync line carrying the
|
||||
completion stamp — "Uploaded <name> (<stamp>) — press Sync sources
|
||||
to import it." (the fresh 202 settle, the page-local name). In
|
||||
E2E the stamp is ALWAYS the same-day ``HH:MM`` form — the run
|
||||
settles within seconds (the date-qualified forms are unit-pinned
|
||||
on a fixed clock — E2E never depends on the wall clock)."""
|
||||
return re.compile(
|
||||
rf"^Uploaded {re.escape(name)} \(\d{{1,2}}:\d{{2}}\)"
|
||||
r" — press Sync sources to import it\.$"
|
||||
)
|
||||
|
||||
|
||||
def _nameless_ready_for_sync_line() -> re.Pattern[str]:
|
||||
"""Phase 154 (task 02): the boot re-render line — nameless (the
|
||||
safe name was page-local — ``lastUploadName`` is null after a
|
||||
reload, phase 90) but SELF-DATING through the completion stamp
|
||||
(``fmtUploadStamp`` from the server payload): "Uploaded <stamp> —
|
||||
press Sync sources to import it." (the same-day ``HH:MM`` form in
|
||||
E2E)."""
|
||||
return re.compile(
|
||||
r"^Uploaded \d{1,2}:\d{2} — press Sync sources to import it\.$"
|
||||
)
|
||||
UPLOAD_ARCHIVE = f"{UPLOAD_NAME}.tar.gz"
|
||||
N_FILES = 25
|
||||
UPLOAD_FILES: dict[str, str] = {
|
||||
@@ -747,7 +773,7 @@ def test_upload_progress_is_bare(
|
||||
# the archive — and the KB empty (no scan, phase 90 A1).
|
||||
result = page.locator("#archive-upload-result")
|
||||
expect(result).to_have_text(
|
||||
f"Uploaded {UPLOAD_NAME} — press Sync sources to import it.",
|
||||
_ready_for_sync_line(UPLOAD_NAME),
|
||||
timeout=30_000,
|
||||
)
|
||||
expect(btn).to_be_enabled()
|
||||
@@ -915,9 +941,12 @@ def test_upload_reattach_after_reload(
|
||||
expect(page.locator("#archive-upload-error")).to_be_hidden()
|
||||
expect(page.locator(".toast")).to_have_count(0)
|
||||
# …and the settled result line: the nameless variant (the safe
|
||||
# name was page-local — lastUploadName is null after a reload).
|
||||
# name was page-local — lastUploadName is null after a reload),
|
||||
# self-dating through the completion stamp (phase 154 — the stamp
|
||||
# comes from the server payload, so the line is still self-dating
|
||||
# with the name unknown).
|
||||
expect(page.locator("#archive-upload-result")).to_have_text(
|
||||
"Uploaded — press Sync sources to import it.",
|
||||
_nameless_ready_for_sync_line(),
|
||||
timeout=SETTLE_TIMEOUT_MS,
|
||||
)
|
||||
expect(page.locator("#archive-upload-btn")).to_be_enabled()
|
||||
|
||||
@@ -54,8 +54,9 @@ Contract under test:
|
||||
|
||||
* **test 1 — upload does not scan**: the "Upload" button (phase 90 A3)
|
||||
→ the archive → the 202 "Successfully uploaded — <source>" toast →
|
||||
the settled result line "Uploaded <source> — press Sync sources to
|
||||
import it." with the terminal no-count status payload
|
||||
the settled result line "Uploaded <source> (<stamp>) — press Sync
|
||||
sources to import it." (the phase-154 completion stamp) with the
|
||||
terminal no-count status payload
|
||||
(``{"message": "uploaded"}``, null/0/0 progress — phase 90 A2); the
|
||||
source row is present (Local badge + the phase-89 "Ignore paths"
|
||||
control) and its folder exists on the host under ``BOR_UPLOAD_DIR``
|
||||
@@ -127,6 +128,19 @@ SOURCES_URL = "/sources.html"
|
||||
#: filename minus the archive suffix (the phase-49 naming rule).
|
||||
SOURCE_NAME = "e2e-upload-no-scan"
|
||||
|
||||
|
||||
def _ready_for_sync_line(name: str) -> re.Pattern[str]:
|
||||
"""Phase 154 (task 02): the ready-for-sync line carrying the
|
||||
completion stamp — "Uploaded <name> (<stamp>) — press Sync sources
|
||||
to import it." (the fresh 202 settle, the page-local name). In
|
||||
E2E the stamp is ALWAYS the same-day ``HH:MM`` form — the run
|
||||
settles within seconds (the date-qualified forms are unit-pinned
|
||||
on a fixed clock — E2E never depends on the wall clock)."""
|
||||
return re.compile(
|
||||
rf"^Uploaded {re.escape(name)} \(\d{{1,2}}:\d{{2}}\)"
|
||||
r" — press Sync sources to import it\.$"
|
||||
)
|
||||
|
||||
#: v1: two sentinel docs + the notes/ file the phase-89 ignore entry
|
||||
#: (``notes``) excludes from the sync. v2 (same basename): beta CHANGED,
|
||||
#: alpha DROPPED, gamma ADDED — the in-place-replace subject (the
|
||||
@@ -394,7 +408,8 @@ def test_upload_does_not_scan(
|
||||
"""One real upload through the page: the button reads **Upload**
|
||||
(phase 90 A3); the 202 fires the "Successfully uploaded — <source>"
|
||||
toast; the settled result line points at the next step — "Uploaded
|
||||
<source> — press Sync sources to import it." (A3) — with the
|
||||
<source> (<stamp>) — press Sync sources to import it." (A3 — the
|
||||
phase-154 completion stamp) — with the
|
||||
terminal no-count status payload (``{"message": "uploaded"}``,
|
||||
null/0/0 progress — A2). The source row is present (Local badge +
|
||||
the phase-89 "Ignore paths" control) and its folder exists on the
|
||||
@@ -428,9 +443,7 @@ def test_upload_does_not_scan(
|
||||
# never-stale restore (button + input)…
|
||||
result = page.locator("#archive-upload-result")
|
||||
expect(result).to_be_visible(timeout=UPLOAD_TIMEOUT_MS)
|
||||
expect(result).to_have_text(
|
||||
f"Uploaded {SOURCE_NAME} — press Sync sources to import it."
|
||||
)
|
||||
expect(result).to_have_text(_ready_for_sync_line(SOURCE_NAME))
|
||||
expect(btn).to_be_enabled()
|
||||
expect(btn).to_have_text("Upload")
|
||||
expect(page.locator("#archive-upload-file")).to_have_value("")
|
||||
@@ -507,9 +520,10 @@ def test_ignore_list_then_sync_scans(
|
||||
page.set_default_timeout(30_000)
|
||||
_admin_git_sources_page(page, app_url)
|
||||
|
||||
# Upload: the ready-for-sync line, and still nothing indexed.
|
||||
assert _upload_via_page(page, tarball_v1) == (
|
||||
f"Uploaded {SOURCE_NAME} — press Sync sources to import it."
|
||||
# Upload: the ready-for-sync line (the phase-154 completion
|
||||
# stamp), and still nothing indexed.
|
||||
assert _ready_for_sync_line(SOURCE_NAME).fullmatch(
|
||||
_upload_via_page(page, tarball_v1)
|
||||
)
|
||||
assert _docs(page, app_url) == []
|
||||
expect(page.locator("#git-sources-tbody tr", has_text=SOURCE_NAME)).to_have_count(1)
|
||||
@@ -622,8 +636,8 @@ def test_reupload_replaces_without_scan(
|
||||
|
||||
# Baseline: v1 through the page (202 → the ready-for-sync line,
|
||||
# one row, no index).
|
||||
assert _upload_via_page(page, tarball_v1) == (
|
||||
f"Uploaded {SOURCE_NAME} — press Sync sources to import it."
|
||||
assert _ready_for_sync_line(SOURCE_NAME).fullmatch(
|
||||
_upload_via_page(page, tarball_v1)
|
||||
)
|
||||
expect(page.locator("#git-sources-tbody tr", has_text=SOURCE_NAME)).to_have_count(1)
|
||||
folder = upload_dir / SOURCE_NAME
|
||||
@@ -632,8 +646,8 @@ def test_reupload_replaces_without_scan(
|
||||
|
||||
# Re-upload v2 — SAME basename, different parent dir (the file
|
||||
# input's selection is replaced wholesale).
|
||||
assert _upload_via_page(page, tarball_v2) == (
|
||||
f"Uploaded {SOURCE_NAME} — press Sync sources to import it."
|
||||
assert _ready_for_sync_line(SOURCE_NAME).fullmatch(
|
||||
_upload_via_page(page, tarball_v2)
|
||||
)
|
||||
|
||||
# No duplicate: exactly ONE row for that source (and one row
|
||||
|
||||
@@ -149,6 +149,46 @@ positions are distinct and each disappears independently. The node
|
||||
harness gains the two in-flight image rows (both parts at once —
|
||||
the owner's stuck label — and the image-only pictorial PDF) and
|
||||
pins the composed full string byte-for-byte.
|
||||
|
||||
Phase 154 (task 02) extends the Sources-page upload line with the
|
||||
COMPLETION STAMP (the phase's date-qualified freshness): the settled
|
||||
line reads "Uploaded <name> (<stamp>) — press Sync sources to import
|
||||
it." (the fresh 202 settle, the page-local name) or the nameless
|
||||
"Uploaded <stamp> — press Sync sources to import it." (the boot
|
||||
re-render — the stamp comes from the SERVER payload, so the line is
|
||||
self-dating even with the name unknown); a running / legacy / partial
|
||||
payload without timestamps renders the byte-identical pre-phase-154
|
||||
strings (the nameful / nameless "Uploaded … — press Sync sources to
|
||||
import it." lines). The stamp is PRECOMPUTED by the page-local
|
||||
fmtUploadStamp(status) (the per-page duplication idiom — the same A1
|
||||
date-qualifier as sources.js's fmtSyncTime, duplicated, not shared;
|
||||
the rule's boundary set is pinned in test_sync_button.py on a fixed
|
||||
clock): it reads status.finished_at first (the success state always
|
||||
has it on the server), falls back to status.started_at (a defensive
|
||||
guard — a corrupt finished_at degrades to the started_at stamp rather
|
||||
than to no stamp), and yields "" when both are absent or every ISO is
|
||||
unparseable. Both render sites — the poll's success settle and the
|
||||
boot re-attach — pass fmtUploadStamp(status) as the third argument;
|
||||
the NO server change (the /upload/status payload is untouched —
|
||||
started_at / finished_at already shipped, phase 64).
|
||||
|
||||
Phase 154 (task 03) pins the BYTE-IDENTITY of the no-stamp fallbacks
|
||||
against the VERBATIM pre-phase-154 strings: the old 2-arg
|
||||
fmtUploadResult (git history) is pinned in this file as a reference
|
||||
implementation (the name renamed, the body byte-for-byte) and run
|
||||
side by side with the real 3-arg function under node — the no-stamp
|
||||
(nameful / nameless) outputs + the legacy 2-arg call are asserted
|
||||
byte-identical to the reference's output, and the reference's
|
||||
output to the verbatim old line strings (assertable, not assumed).
|
||||
Clock-free: `when` is a precomputed argument, so the no-stamp
|
||||
shapes need no clock at all. The /upload/status full-dict pin
|
||||
(tests/integration/test_git_sources_upload.py, the phase-64
|
||||
precedent) stays UNCHANGED — this phase adds no server keys (the
|
||||
app/ no-touch regression guard). The qualifier's byte-identity
|
||||
(same-day + fallbacks vs the verbatim pre-phase fmtSyncTime) is
|
||||
pinned in test_sync_button.py on the shared fixed clock; the four
|
||||
shapes and the fmtUploadStamp behavior (finished / started /
|
||||
missing / garbage) ride the task-02 pins below.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -1639,23 +1679,42 @@ def test_processing_state_is_bare_for_the_whole_run() -> None:
|
||||
|
||||
|
||||
def test_fmt_upload_result_is_the_ready_for_sync_line() -> None:
|
||||
"""Phase 90 (A2/A3): the result line reads the no-count
|
||||
{"message": "uploaded"} detail and renders "Uploaded <name> —
|
||||
press Sync sources to import it." (the name from the accepted
|
||||
202); without a name (a reload re-render, the 409 re-attach) it
|
||||
renders the nameless variant; the old sync-style count keys
|
||||
"""Phase 90 (A2/A3) extended by Phase 154 (task 02): the result
|
||||
line reads the no-count {"message": "uploaded"} detail and renders
|
||||
the ready-for-sync line in the FOUR phase-154 shapes —
|
||||
"Uploaded <name> (<stamp>) — press Sync sources to import it." (the
|
||||
name + the completion stamp), "Uploaded <name> — press Sync sources
|
||||
to import it." (name, no stamp — BYTE-IDENTICAL to the pre-phase
|
||||
string), "Uploaded <stamp> — press Sync sources to import it." (no
|
||||
name + stamp — the boot re-render, self-dating with the name
|
||||
unknown), "Uploaded — press Sync sources to import it." (no name,
|
||||
no stamp — BYTE-IDENTICAL to the pre-phase string; the running /
|
||||
legacy / partial payload). The non-uploaded detail keeps the
|
||||
unchanged "The upload finished."; the old sync-style count keys
|
||||
(added / updated / unchanged / pruned) are no longer read at all.
|
||||
The 202 branch records the safe name (lastUploadName); the poll's
|
||||
settle and the boot re-attach both render the line from
|
||||
(detail, lastUploadName); the button's idle label is "Upload"."""
|
||||
settle and the boot re-attach both render the line from (detail,
|
||||
lastUploadName, fmtUploadStamp(status)); the button's idle label
|
||||
is "Upload"."""
|
||||
js = _gjs()
|
||||
body = _gfn(js, "fmtUploadResult")
|
||||
assert "function fmtUploadResult(detail, name)" in body
|
||||
assert 'detail.message === "uploaded"' in body
|
||||
assert "Uploaded ${name} — press Sync sources to import it." in body
|
||||
assert "Uploaded — press Sync sources to import it." in body, (
|
||||
"the nameless variant (reload / 409 re-attach)"
|
||||
assert "function fmtUploadResult(detail, name, when)" in body, (
|
||||
"phase 154: the precomputed completion stamp is the third parameter"
|
||||
)
|
||||
assert 'detail.message === "uploaded"' in body
|
||||
# The four shapes (name×stamp, name×no-stamp, no-name×stamp,
|
||||
# no-name×no-stamp).
|
||||
assert "Uploaded ${name} (${when}) — press Sync sources to import it." in body
|
||||
assert "Uploaded ${name} — press Sync sources to import it." in body, (
|
||||
"the no-stamp nameful variant — byte-identical to pre-phase"
|
||||
)
|
||||
assert "Uploaded ${when} — press Sync sources to import it." in body, (
|
||||
"the nameless + stamp variant (the boot re-render — self-dating)"
|
||||
)
|
||||
assert "Uploaded — press Sync sources to import it." in body, (
|
||||
"the nameless no-stamp variant — byte-identical to pre-phase"
|
||||
)
|
||||
assert '"The upload finished."' in body, ("the non-uploaded branch is unchanged")
|
||||
for key in ("added", "updated", "unchanged", "pruned"):
|
||||
assert key not in body, f"the sync-style count {key!r} is gone"
|
||||
# The 202 branch records the safe name for the settled line.
|
||||
@@ -1664,9 +1723,19 @@ def test_fmt_upload_result_is_the_ready_for_sync_line() -> None:
|
||||
i409 = sub.find("r.status === 409")
|
||||
assert "lastUploadName = name" in sub[i202:i409]
|
||||
assert "let lastUploadName = null" in js, "page-local (null after a reload)"
|
||||
# Both render sites pass (detail, lastUploadName).
|
||||
assert "fmtUploadResult(detail, lastUploadName)" in _utick(js)
|
||||
assert "fmtUploadResult(status.detail, lastUploadName)" in _gfn(js, "initUploadStatus")
|
||||
# Both render sites pass (…, lastUploadName, fmtUploadStamp(status)).
|
||||
tick = _utick(js)
|
||||
assert re.search(
|
||||
r"uploadResult\.textContent = fmtUploadResult\(\s*detail,\s*"
|
||||
r"lastUploadName,\s*fmtUploadStamp\(status\)\s*\)",
|
||||
tick,
|
||||
), "the poll's success settle passes the completion stamp (phase 154)"
|
||||
init = _gfn(js, "initUploadStatus")
|
||||
assert re.search(
|
||||
r"uploadResult\.textContent = fmtUploadResult\(\s*status\.detail,\s*"
|
||||
r"lastUploadName,\s*fmtUploadStamp\(status\)\s*\)",
|
||||
init,
|
||||
), "the boot re-attach passes the completion stamp (phase 154)"
|
||||
# The button's idle label (static + the poll's restores).
|
||||
restore = _gfn(js, "restoreUploadButton")
|
||||
assert 'uploadBtn.textContent = "Upload"' in restore
|
||||
@@ -1751,7 +1820,7 @@ def test_upload_polling_decision_tree() -> None:
|
||||
# next-step announce + reload, NO toast.
|
||||
ok = tick[i_ok:i_fail]
|
||||
for line in (
|
||||
"fmtUploadResult(detail, lastUploadName)",
|
||||
"fmtUploadResult(",
|
||||
"uploadResult.hidden = false",
|
||||
'announce("Archive uploaded — press Sync sources to import it.")',
|
||||
'uploadFileInput.value = ""',
|
||||
@@ -1759,6 +1828,11 @@ def test_upload_polling_decision_tree() -> None:
|
||||
"loadSources()",
|
||||
):
|
||||
assert line in ok, f"the success settle must carry {line!r}"
|
||||
assert re.search(
|
||||
r"uploadResult\.textContent = fmtUploadResult\(\s*detail,\s*"
|
||||
r"lastUploadName,\s*fmtUploadStamp\(status\)\s*\)",
|
||||
ok,
|
||||
), "the success settle passes the completion stamp (phase 154)"
|
||||
assert "showUploadToast" not in tick, "no toast in the poll — it fired at the 202 (A2)"
|
||||
# failed: the sanitized error banner + reload.
|
||||
fail = tick[i_fail:]
|
||||
@@ -1815,8 +1889,14 @@ def test_boot_reattach_branches() -> None:
|
||||
assert "uploadError" not in run and "showUploadToast" not in run
|
||||
ok = body[i_ok:i_fail]
|
||||
# The last result line — the nameless variant after a reload
|
||||
# (lastUploadName is null: the safe name was page-local, phase 90).
|
||||
assert "fmtUploadResult(status.detail, lastUploadName)" in ok
|
||||
# (lastUploadName is null: the safe name was page-local, phase 90),
|
||||
# self-dating through the completion stamp (phase 154 — the stamp
|
||||
# comes from the server payload).
|
||||
assert re.search(
|
||||
r"uploadResult\.textContent = fmtUploadResult\(\s*status\.detail,\s*"
|
||||
r"lastUploadName,\s*fmtUploadStamp\(status\)\s*\)",
|
||||
ok,
|
||||
), "the boot re-attach passes the completion stamp (phase 154)"
|
||||
assert "uploadResult.hidden = false" in ok
|
||||
assert "announce(" not in ok, "no announce at boot (A2)"
|
||||
assert "showUploadToast" not in ok, "no toast at boot (A2)"
|
||||
@@ -1860,3 +1940,330 @@ def test_git_sources_html_comment_documents_the_202_contract() -> None:
|
||||
assert "Phase 90" in comment, "the unpack-only rework is documented"
|
||||
assert "UNPACK + REGISTER ONLY" in comment
|
||||
assert "Sync sources" in comment, "the result line points at the RAG page's button"
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# Phase 154 (task 02) — the Sources upload line's completion stamp
|
||||
# =====================================================================
|
||||
|
||||
|
||||
def test_fmt_upload_stamp_is_the_duplicated_qualifier() -> None:
|
||||
"""Phase 154 (task 02): ``fmtUploadStamp(status)`` — the page-local
|
||||
DUPLICATE of sources.js's ``fmtSyncTime`` date-qualifier (the
|
||||
per-page duplication idiom — not shared). It reads
|
||||
``status.finished_at`` first (the success state always has it on
|
||||
the server), falls back to ``status.started_at`` (the defensive
|
||||
guard — a corrupt ``finished_at`` degrades to the ``started_at``
|
||||
stamp rather than to no stamp), and yields ``""`` when both are
|
||||
missing/absent or every ISO is unparseable (the byte-identical
|
||||
pre-phase-154 fallback). The qualifier is the SAME A1 structure as
|
||||
the phase-154 task-01 pin in test_sync_button.py — local calendar
|
||||
dates, ``HH:MM`` same day, ``M/D, h:MM AM/PM`` same year,
|
||||
``M/D/YYYY, h:MM AM/PM`` across years, the 12-hour form WITHOUT a
|
||||
leading zero on the hour, month/day without leading zeros — cited
|
||||
in sources.js, not restated. The ``status || {}`` guard defends a
|
||||
missing status object."""
|
||||
js = _gjs()
|
||||
body = _gfn(js, "fmtUploadStamp")
|
||||
assert "function fmtUploadStamp(status)" in body
|
||||
assert "const s = status || {};" in body, (
|
||||
"defensive: a missing / null status object yields ''"
|
||||
)
|
||||
assert "s.finished_at" in body and "s.started_at" in body
|
||||
i_fin = body.find("s.finished_at")
|
||||
i_st = body.find("s.started_at")
|
||||
assert -1 < i_fin < i_st, "finished_at is read first — the started_at fallback"
|
||||
assert "if (!iso) continue;" in body, (
|
||||
"absent / null timestamps are skipped (the fallback reads)"
|
||||
)
|
||||
assert "if (!Number.isNaN(t.getTime()))" in body, (
|
||||
"an unparseable ISO is skipped — a corrupt finished_at degrades"
|
||||
" to started_at, '' when neither parses"
|
||||
)
|
||||
assert 'if (d === null) return "";' in body, (
|
||||
"both absent (or unparseable) → '' — the byte-identical pre-"
|
||||
"phase-154 fallback"
|
||||
)
|
||||
# The duplicated A1 qualifier (cited in sources.js's fmtSyncTime,
|
||||
# not restated) — the same structure as the task-01 pin.
|
||||
assert "const now = new Date();" in body, "'now' is the page's clock"
|
||||
assert (
|
||||
"d.getFullYear() === now.getFullYear() &&" in body
|
||||
and "d.getMonth() === now.getMonth()" in body
|
||||
and "d.getDate() === now.getDate()" in body
|
||||
), "same local calendar day as now → HH:MM (byte-identical to pre-phase)"
|
||||
assert "d.getHours() % 12 || 12" in body, (
|
||||
"12-hour WITHOUT leading zero on the hour (the 12 o'clock edge)"
|
||||
)
|
||||
assert '"AM"' in body and '"PM"' in body, "the AM/PM family"
|
||||
assert "pad(d.getMinutes())" in body, "MM zero-padded in every form"
|
||||
assert "`${d.getMonth() + 1}/${d.getDate()}`" in body, (
|
||||
"M/D, no leading zeros (same year, different day)"
|
||||
)
|
||||
assert "`${d.getMonth() + 1}/${d.getDate()}/${d.getFullYear()}`" in body, (
|
||||
"M/D/YYYY across years"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
|
||||
def test_upload_result_four_shapes_under_node() -> None:
|
||||
"""Phase 154 (task 02): the REAL git-sources.js ``fmtUploadResult``
|
||||
under node — the four shapes, ``when`` PRECOMPUTED by the caller
|
||||
(so the shape itself is clock-free):
|
||||
|
||||
* name + stamp: ``Uploaded docs.zip (21:14) — press Sync sources
|
||||
to import it.`` (the fresh 202 settle, the page-local name);
|
||||
* name, no stamp (``""``): ``Uploaded docs.zip — press Sync
|
||||
sources to import it.`` — BYTE-IDENTICAL to the pre-phase-154
|
||||
string (the running / legacy / partial payload);
|
||||
* no name + stamp: ``Uploaded 10/2, 3:14 PM — press Sync sources
|
||||
to import it.`` (the boot re-render — self-dating with the name
|
||||
unknown);
|
||||
* no name, no stamp (``""``): ``Uploaded — press Sync sources to
|
||||
import it.`` — BYTE-IDENTICAL to the pre-phase-154 string;
|
||||
* a legacy 2-arg call (``when`` undefined) renders the pre-phase
|
||||
nameful string (the fallback is falsy-driven);
|
||||
* the non-``uploaded`` detail and a missing detail keep the
|
||||
unchanged ``The upload finished.``."""
|
||||
js = _gjs()
|
||||
fn = _gfn(js, "fmtUploadResult")
|
||||
script = (
|
||||
fn
|
||||
+ "\n"
|
||||
'const S = { message: "uploaded" };\n'
|
||||
'const O = { message: "other" };\n'
|
||||
"console.log(JSON.stringify([\n"
|
||||
' fmtUploadResult(S, "docs.zip", "21:14"),\n'
|
||||
' fmtUploadResult(S, "docs.zip", ""),\n'
|
||||
' fmtUploadResult(S, null, "10/2, 3:14 PM"),\n'
|
||||
' fmtUploadResult(S, null, ""),\n'
|
||||
' fmtUploadResult(S, "docs.zip"),\n'
|
||||
' fmtUploadResult(O, "docs.zip", "21:14"),\n'
|
||||
" fmtUploadResult(null, \"docs.zip\", \"21:14\"),\n"
|
||||
"]));\n"
|
||||
)
|
||||
rows = json.loads(_run_node(script))
|
||||
assert rows == [
|
||||
"Uploaded docs.zip (21:14) — press Sync sources to import it.",
|
||||
# byte-identical to pre-phase (the no-stamp nameful variant):
|
||||
"Uploaded docs.zip — press Sync sources to import it.",
|
||||
"Uploaded 10/2, 3:14 PM — press Sync sources to import it.",
|
||||
# byte-identical to pre-phase (the no-stamp nameless variant):
|
||||
"Uploaded — press Sync sources to import it.",
|
||||
# the legacy 2-arg call (when undefined → falsy → pre-phase):
|
||||
"Uploaded docs.zip — press Sync sources to import it.",
|
||||
"The upload finished.",
|
||||
"The upload finished.",
|
||||
], rows
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# Phase 154 (task 03) — the byte-identity pins (the pre-phase
|
||||
# strings, pinned verbatim — assertable, not assumed)
|
||||
# =====================================================================
|
||||
|
||||
#: The pre-phase-154 fmtUploadResult, pinned VERBATIM from git
|
||||
#: history (the 2-arg no-count formatter — the phase-90 contract
|
||||
#: pre-stamp). Only the function NAME is renamed (so the real + the
|
||||
#: reference run side by side under node); the body is the old code
|
||||
#: byte-for-byte — the two old line strings + "The upload finished."
|
||||
#: live in this constant, so the byte-identity below is assertable,
|
||||
#: not assumed.
|
||||
PRE_PHASE_FMT_UPLOAD_RESULT_JS = """
|
||||
function prePhaseUploadResult(detail, name) {
|
||||
if (detail && detail.message === "uploaded") {
|
||||
return name
|
||||
? `Uploaded ${name} — press Sync sources to import it.`
|
||||
: "Uploaded — press Sync sources to import it.";
|
||||
}
|
||||
return "The upload finished.";
|
||||
}
|
||||
"""
|
||||
|
||||
#: The two pre-phase-154 result-line strings, pinned verbatim as
|
||||
#: constants (the byte-identity targets — the task-03 completion
|
||||
#: criterion). The nameful one is the old template instantiated with
|
||||
#: the test's input name.
|
||||
PRE_PHASE_UPLOAD_LINE_NAMEFUL = "Uploaded docs.zip — press Sync sources to import it."
|
||||
PRE_PHASE_UPLOAD_LINE_NAMELESS = "Uploaded — press Sync sources to import it."
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
|
||||
def test_upload_result_no_stamp_is_byte_identical_to_pre_phase() -> None:
|
||||
"""Phase 154 (task 03): the two BYTE-IDENTITY fallbacks — the
|
||||
REAL 3-arg ``fmtUploadResult`` and the VERBATIM pre-phase 2-arg
|
||||
formatter (pinned above) run side by side under node (clock-free
|
||||
— ``when`` is precomputed):
|
||||
|
||||
* name + ``when === ""`` → the pre-phase nameful string,
|
||||
byte-identical (the running / legacy / partial payload);
|
||||
* no name + ``when === ""`` → the pre-phase nameless string,
|
||||
byte-identical (the same payload, the name unknown);
|
||||
* the legacy 2-arg call (``when`` undefined → falsy) renders the
|
||||
SAME two byte-identical strings (the old call shape is
|
||||
unchanged in output);
|
||||
* the non-``uploaded`` detail (and a missing detail) keep
|
||||
``The upload finished.`` on BOTH sides (the branch the phase
|
||||
never touched).
|
||||
|
||||
The reference implementation's own output is asserted against the
|
||||
verbatim old strings FIRST (a self-check on the pinned constant —
|
||||
a typo in the pinned function fails here, not silently), so the
|
||||
real-vs-reference identity is an assertion against the verbatim
|
||||
pre-phase strings (the completion criterion), not an
|
||||
assumption."""
|
||||
# Source-level self-check: the pinned constant carries the old
|
||||
# strings verbatim (both line templates + the non-uploaded
|
||||
# branch).
|
||||
assert (
|
||||
"return name\n"
|
||||
" ? `Uploaded ${name} — press Sync sources to import it.`\n"
|
||||
' : "Uploaded — press Sync sources to import it.";'
|
||||
in PRE_PHASE_FMT_UPLOAD_RESULT_JS
|
||||
), "the old nameful + nameless line templates are pinned verbatim"
|
||||
assert '"The upload finished."' in PRE_PHASE_FMT_UPLOAD_RESULT_JS, (
|
||||
"the non-uploaded branch is pinned verbatim"
|
||||
)
|
||||
|
||||
fn = _gfn(_gjs(), "fmtUploadResult")
|
||||
script = (
|
||||
fn
|
||||
+ "\n"
|
||||
+ PRE_PHASE_FMT_UPLOAD_RESULT_JS
|
||||
+ "\n"
|
||||
'const S = { message: "uploaded" };\n'
|
||||
'const O = { message: "other" };\n'
|
||||
"console.log(JSON.stringify({\n"
|
||||
" REAL: [\n"
|
||||
' fmtUploadResult(S, "docs.zip", ""),\n'
|
||||
" fmtUploadResult(S, null, \"\"),\n"
|
||||
' fmtUploadResult(S, "docs.zip"),\n' # legacy 2-arg call
|
||||
" fmtUploadResult(S, null),\n" # legacy 2-arg call
|
||||
' fmtUploadResult(O, "docs.zip", "21:14"),\n'
|
||||
' fmtUploadResult(null, "docs.zip", "21:14"),\n'
|
||||
" ],\n"
|
||||
" PRE: [\n"
|
||||
' prePhaseUploadResult(S, "docs.zip"),\n'
|
||||
" prePhaseUploadResult(S, null),\n"
|
||||
' prePhaseUploadResult(O, "docs.zip"),\n'
|
||||
' prePhaseUploadResult(null, "docs.zip"),\n'
|
||||
" ]\n"
|
||||
"}));\n"
|
||||
)
|
||||
rows = json.loads(_run_node(script))
|
||||
# 1. the pinned reference reproduces the verbatim old strings
|
||||
# (self-check on the constant — assertable, not assumed).
|
||||
assert rows["PRE"] == [
|
||||
PRE_PHASE_UPLOAD_LINE_NAMEFUL,
|
||||
PRE_PHASE_UPLOAD_LINE_NAMELESS,
|
||||
"The upload finished.",
|
||||
"The upload finished.",
|
||||
], rows
|
||||
# 2. the no-stamp + legacy shapes are byte-identical to the
|
||||
# reference (i.e. to the verbatim old strings).
|
||||
assert rows["REAL"][:4] == [
|
||||
PRE_PHASE_UPLOAD_LINE_NAMEFUL, # name + when === ""
|
||||
PRE_PHASE_UPLOAD_LINE_NAMELESS, # no name + when === ""
|
||||
PRE_PHASE_UPLOAD_LINE_NAMEFUL, # legacy 2-arg call
|
||||
PRE_PHASE_UPLOAD_LINE_NAMELESS, # legacy 2-arg call
|
||||
], rows
|
||||
assert rows["REAL"][0] == rows["PRE"][0], (
|
||||
"the nameful no-stamp shape is byte-identical to pre-phase"
|
||||
)
|
||||
assert rows["REAL"][1] == rows["PRE"][1], (
|
||||
"the nameless no-stamp shape is byte-identical to pre-phase"
|
||||
)
|
||||
# 3. the non-uploaded branch is unchanged on both sides.
|
||||
assert rows["REAL"][4] == rows["PRE"][2] == "The upload finished."
|
||||
assert rows["REAL"][5] == rows["PRE"][3] == "The upload finished."
|
||||
|
||||
|
||||
#: Phase 154 (task 02): the fixed clock for the stamp's boundary set —
|
||||
#: the SAME fixed now as test_sync_button.py's task-01 pin (Oct 3,
|
||||
#: 2026, 18:30 LOCAL). The test body never calls `new Date()` for its
|
||||
#: own timing: the fake clock is installed in the driver (global Date
|
||||
#: patched BEFORE the call — `new Date()` → the fixed now,
|
||||
#: `new Date(iso)` → the real parse), and every ISO is
|
||||
#: designator-less (local) so the comparison is
|
||||
#: timezone-independent.
|
||||
|
||||
_UPLOAD_STAMP_NOW_JS = "new RealDate(2026, 9, 3, 18, 30, 0)"
|
||||
|
||||
|
||||
def _upload_stamp_driver(fn_src: str, expr: str) -> str:
|
||||
"""Run `expr` under node with the REAL git-sources.js function +
|
||||
the fake clock (the idiom: the Date patch precedes the call)."""
|
||||
driver = (
|
||||
f"{fn_src}\n"
|
||||
"const RealDate = Date;\n"
|
||||
f"const NOW = {_UPLOAD_STAMP_NOW_JS};\n"
|
||||
"globalThis.Date = function (...args) {\n"
|
||||
" return args.length === 0 ? NOW : new RealDate(...args);\n"
|
||||
"};\n"
|
||||
f"{expr}\n"
|
||||
)
|
||||
proc = subprocess.run(
|
||||
["node", "-e", driver], capture_output=True, text=True, timeout=60
|
||||
)
|
||||
assert proc.returncode == 0, f"node failed: {proc.stderr}"
|
||||
return proc.stdout
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
|
||||
def test_upload_stamp_behavior_fixed_clock() -> None:
|
||||
"""Phase 154 (task 02): the REAL git-sources.js
|
||||
``fmtUploadStamp`` under node (fake clock, the fixed now Oct 3
|
||||
2026 18:30 local — the same clock as test_sync_button.py's
|
||||
task-01 pin): finished wins over started; started when finished is
|
||||
missing/null; ``""`` when both are absent (or the status object is
|
||||
missing — the defensive guard); a garbage ISO → ``""`` with no
|
||||
fallback (and a corrupt finished_at degrades to the started_at
|
||||
stamp when a started_at is present — the defensive guard); the A1
|
||||
date-qualifier's boundary set (same day ``HH:MM`` — the audit's
|
||||
own ``21:14`` case + the end-of-day edge; yesterday
|
||||
``M/D, h:MM AM/PM`` with the ``12:05 AM`` edge; last year
|
||||
``M/D/YYYY, h:MM AM/PM``). The wall clock never appears in an
|
||||
assertion."""
|
||||
fn = _gfn(_gjs(), "fmtUploadStamp")
|
||||
|
||||
def _lit(status: object) -> str:
|
||||
if status is None:
|
||||
return "null"
|
||||
assert isinstance(status, dict)
|
||||
parts = []
|
||||
for k, v in status.items():
|
||||
if v is None:
|
||||
parts.append(f"{k}: null")
|
||||
else:
|
||||
parts.append(f'{k}: "{v}"')
|
||||
return "{" + ", ".join(parts) + "}"
|
||||
|
||||
# (status, expected) — the expected strings are the pins.
|
||||
cases = [
|
||||
(
|
||||
{"finished_at": "2026-10-03T16:06:00", "started_at": "2026-10-03T16:05:00"},
|
||||
"16:06", # finished wins over started
|
||||
),
|
||||
({"started_at": "2026-10-02T15:14:00"}, "10/2, 3:14 PM"), # no finished → started
|
||||
(
|
||||
{"finished_at": None, "started_at": "2025-10-03T21:12:00"},
|
||||
"10/3/2025, 9:12 PM", # null finished → started, last year
|
||||
),
|
||||
({"finished_at": "garbage"}, ""), # unparseable, no fallback
|
||||
(
|
||||
{"finished_at": "garbage", "started_at": "2026-10-03T16:06:00"},
|
||||
"16:06", # corrupt finished degrades to the started_at stamp
|
||||
),
|
||||
({"finished_at": None, "started_at": None}, ""), # both absent
|
||||
({}, ""), # both missing
|
||||
(None, ""), # the status object itself missing (the guard)
|
||||
({"finished_at": "2026-10-02T00:05:00"}, "10/2, 12:05 AM"), # 12:05 AM edge
|
||||
({"finished_at": "2026-10-03T23:59:00"}, "23:59"), # same day, end-of-day
|
||||
]
|
||||
driver_expr = (
|
||||
"const CASES = [" + ", ".join(_lit(s) for s, _ in cases) + "];\n"
|
||||
"const STAMPS = CASES.map((c) => fmtUploadStamp(c));\n"
|
||||
"console.log(JSON.stringify(STAMPS));"
|
||||
)
|
||||
out = json.loads(_upload_stamp_driver(fn, driver_expr))
|
||||
assert out == [expected for _, expected in cases], out
|
||||
|
||||
@@ -164,8 +164,11 @@ tests/unit/test_frontend_sync_upload.py's node check).
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Callable, Iterator, Sequence
|
||||
@@ -198,6 +201,8 @@ RETIRED_ID = "-".join(("sync", "result")) # built at runtime: 3.12+ folds
|
||||
# adjacent/"+" constant strings at compile time, which would leave the
|
||||
# retired name in the .pyc — the phase gate greps even the bytecode.
|
||||
|
||||
HAVE_NODE = shutil.which("node") is not None
|
||||
|
||||
|
||||
def _text(path: Path) -> str:
|
||||
assert path.is_file(), f"missing frontend file: {path}"
|
||||
@@ -563,6 +568,10 @@ def test_sources_js_terminal_states() -> None:
|
||||
|
||||
success = _body(js, "applySyncSuccess")
|
||||
assert '"Synced"' in success and "fmtSyncTime(status.finished_at)" in success
|
||||
assert 'settleSyncButton(time ? `Synced ${time}` : "Synced")' in success, (
|
||||
"the settle string — phase 154: the qualified stamp rides the SAME"
|
||||
" shape (the qualifier lives inside the formatter)"
|
||||
)
|
||||
|
||||
failure = _body(js, "applySyncFailure")
|
||||
assert 'settleSyncButton("Sync sources")' in failure # retry-ready
|
||||
@@ -578,6 +587,313 @@ def test_sources_js_terminal_states() -> None:
|
||||
|
||||
time = _body(js, "fmtSyncTime")
|
||||
assert "getHours()" in time and "getMinutes()" in time, "local HH:MM of finished_at"
|
||||
assert 'if (Number.isNaN(d.getTime())) return "";' in time, (
|
||||
"the parse guard is UNCHANGED — unparseable ISO → "" (the bare"
|
||||
" 'Synced' settle)"
|
||||
)
|
||||
# Phase 154 (task 01): the DATE-QUALIFIER (A1 rule — the LOCAL
|
||||
# calendar dates, the page's clock; the table dates' family).
|
||||
assert "const now = new Date();" in time, "'now' is the page's clock"
|
||||
assert (
|
||||
"d.getFullYear() === now.getFullYear() &&" in time
|
||||
and "d.getMonth() === now.getMonth()" in time
|
||||
and "d.getDate() === now.getDate()" in time
|
||||
), "same local calendar day as now → HH:MM (byte-identical to pre-phase)"
|
||||
assert "d.getHours() % 12 || 12" in time, (
|
||||
"12-hour WITHOUT leading zero on the hour (the 12 o'clock edge)"
|
||||
)
|
||||
assert '"AM"' in time and '"PM"' in time, "the AM/PM family"
|
||||
assert "pad(d.getMinutes())" in time, "MM zero-padded in every form"
|
||||
assert "`${d.getMonth() + 1}/${d.getDate()}`" in time, (
|
||||
"M/D, no leading zeros (same year, different day)"
|
||||
)
|
||||
assert "`${d.getMonth() + 1}/${d.getDate()}/${d.getFullYear()}`" in time, (
|
||||
"M/D/YYYY across years"
|
||||
)
|
||||
|
||||
|
||||
# Phase 154 (task 01) — the date-qualifier's boundary set, behavioral
|
||||
# under node with a FIXED clock. The test body never calls `new
|
||||
# Date()` for its own timing: the fake clock is installed in the
|
||||
# driver (global Date patched BEFORE the call — `new Date()` → the
|
||||
# fixed now, `new Date(iso)` → the real parse), and every ISO is
|
||||
# designator-less (local) so the comparison is timezone-independent.
|
||||
# The fixed now is Oct 3, 2026, 18:30 LOCAL.
|
||||
|
||||
_STAMP_NOW_JS = "new RealDate(2026, 9, 3, 18, 30, 0)"
|
||||
|
||||
|
||||
def _stamp_driver(fn_src: str, expr: str) -> str:
|
||||
"""Run `expr` under node with the REAL sources.js function + the
|
||||
fake clock (the idiom: the Date patch precedes the call)."""
|
||||
driver = (
|
||||
f"{fn_src}\n"
|
||||
"const RealDate = Date;\n"
|
||||
f"const NOW = {_STAMP_NOW_JS};\n"
|
||||
"globalThis.Date = function (...args) {\n"
|
||||
" return args.length === 0 ? NOW : new RealDate(...args);\n"
|
||||
"};\n"
|
||||
f"{expr}\n"
|
||||
)
|
||||
proc = subprocess.run(
|
||||
["node", "-e", driver], capture_output=True, text=True, timeout=60
|
||||
)
|
||||
assert proc.returncode == 0, f"node failed: {proc.stderr}"
|
||||
return proc.stdout
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
|
||||
def test_sources_js_fmt_sync_time_qualifier_boundaries_fixed_clock() -> None:
|
||||
"""The REAL sources.js ``fmtSyncTime`` under node (fake clock, the
|
||||
fixed now Oct 3 2026 18:30 local) — the A1 boundary set:
|
||||
same-day → ``HH:MM`` (byte-identical to the pre-phase output —
|
||||
the audit's own ``16:06`` case, plus the midnight / end-of-day
|
||||
edges); yesterday (same year) → ``M/D, h:MM AM/PM`` (no leading
|
||||
zero on the hour — ``3:14 PM``; the ``12:05 AM`` / ``12:07 PM``
|
||||
o'clock edges); last-year same M/D → ``M/D/YYYY, h:MM AM/PM``;
|
||||
garbage / missing ISO → ``""`` (the guard unchanged).
|
||||
The ``null`` edge (``new Date(null)`` IS the epoch — parseable,
|
||||
the guard unchanged) is pinned TZ-independently: ``null`` and the
|
||||
epoch ISO render identically (same instant), in the dated + year
|
||||
form (1969 ≠ the fixed 2026). The wall clock never appears in an
|
||||
assertion."""
|
||||
fn = _body(_text(SOURCES_JS), "fmtSyncTime")
|
||||
# (iso, expected) — the expected strings are the pins.
|
||||
cases = [
|
||||
("2026-10-03T16:06:00", "16:06"), # same day — the audit case
|
||||
("2026-10-03T00:05:00", "00:05"), # same day, midnight edge
|
||||
("2026-10-03T23:59:00", "23:59"), # same day, end-of-day edge
|
||||
("2026-10-02T15:14:00", "10/2, 3:14 PM"), # yesterday, no zero hour
|
||||
("2026-10-02T00:05:00", "10/2, 12:05 AM"), # yesterday, 12:05 AM edge
|
||||
("2026-10-02T12:07:00", "10/2, 12:07 PM"), # yesterday, noon edge
|
||||
("2026-10-02T23:58:00", "10/2, 11:58 PM"), # yesterday, PM minutes
|
||||
("2025-10-03T21:12:00", "10/3/2025, 9:12 PM"), # last year, same M/D
|
||||
("2025-01-03T09:12:00", "1/3/2025, 9:12 AM"), # last year, month 1
|
||||
("garbage", ""), # unparseable → the pre-phase fallback
|
||||
("", ""), # empty → the pre-phase fallback
|
||||
("undefined", ""), # missing ISO (the app-restart case) → ""
|
||||
]
|
||||
iso_literals = []
|
||||
for iso, _ in cases:
|
||||
if iso == "undefined":
|
||||
iso_literals.append("undefined")
|
||||
else:
|
||||
iso_literals.append(f'"{iso}"')
|
||||
driver_expr = (
|
||||
"const CASES = [" + ", ".join(iso_literals) + "];\n"
|
||||
"const STAMPS = CASES.map((c) => fmtSyncTime(c));\n"
|
||||
"const EPOCH = [fmtSyncTime(null), fmtSyncTime(new RealDate(0).toISOString())];\n"
|
||||
"console.log(JSON.stringify({ STAMPS, EPOCH }));"
|
||||
)
|
||||
out = json.loads(_stamp_driver(fn, driver_expr))
|
||||
assert out["STAMPS"] == [expected for _, expected in cases], out
|
||||
assert out["EPOCH"][0] == out["EPOCH"][1], (
|
||||
"null and the epoch ISO render identically (the same instant —"
|
||||
" the guard is unchanged: new Date(null) IS the epoch)"
|
||||
)
|
||||
assert re.fullmatch(
|
||||
r"\d{1,2}/\d{1,2}/\d{4}, \d{1,2}:\d{2} (AM|PM)", out["EPOCH"][0]
|
||||
), "the epoch takes the M/D/YYYY, h:MM AM/PM form (1969 ≠ the fixed year)"
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
|
||||
def test_sources_js_synced_settle_string_qualifies_the_stamp() -> None:
|
||||
"""The ``applySyncSuccess`` settle string (the pinned shape
|
||||
``time ? `Synced ${time}` : "Synced"`` — the qualifier lives
|
||||
INSIDE the formatter, the shape is unchanged) under node with the
|
||||
fixed clock: a same-day ``finished_at`` settles as
|
||||
``Synced 16:06`` (byte-identical to pre-phase), a non-same-day
|
||||
one settles as ``Synced 10/2, 3:14 PM`` (the date-qualified
|
||||
badge — the phase's ask), and an unparseable/missing ISO settles
|
||||
as the bare ``Synced`` (the existing fallback). The ``null`` edge
|
||||
pins identically to the boundary test (the epoch — the pre-phase
|
||||
rendered ``Synced 00:00`` there)."""
|
||||
fn = _body(_text(SOURCES_JS), "fmtSyncTime")
|
||||
settle = (
|
||||
"const settle = (iso) => {\n"
|
||||
" const t = fmtSyncTime(iso);\n"
|
||||
" return t ? `Synced ${t}` : \"Synced\";\n"
|
||||
"};\n"
|
||||
)
|
||||
iso_literals = [
|
||||
'"2026-10-03T16:06:00"', # same day
|
||||
'"2026-10-02T15:14:00"', # yesterday
|
||||
'"2025-10-03T21:12:00"', # last year
|
||||
'"garbage"', # unparseable
|
||||
"undefined", # missing ISO
|
||||
]
|
||||
driver_expr = (
|
||||
settle
|
||||
+ "const CASES = [" + ", ".join(iso_literals) + "];\n"
|
||||
+ "const SETTLES = CASES.map(settle);\n"
|
||||
+ "const EPOCH = [settle(null), settle(new RealDate(0).toISOString())];\n"
|
||||
+ "console.log(JSON.stringify({ SETTLES, EPOCH }));"
|
||||
)
|
||||
out = json.loads(_stamp_driver(fn, driver_expr))
|
||||
assert out["SETTLES"] == [
|
||||
"Synced 16:06", # same day — byte-identical to pre-phase
|
||||
"Synced 10/2, 3:14 PM", # yesterday — date-qualified
|
||||
"Synced 10/3/2025, 9:12 PM", # last year — date + year
|
||||
"Synced", # garbage — the bare fallback
|
||||
"Synced", # missing ISO — the bare fallback
|
||||
], out
|
||||
assert out["EPOCH"][0] == out["EPOCH"][1], (
|
||||
"settle(null) and the epoch ISO settle identically (the guard"
|
||||
" unchanged)"
|
||||
)
|
||||
assert out["EPOCH"][0].startswith("Synced "), (
|
||||
"the epoch settles with a stamp (new Date(null) is parseable"
|
||||
" — the pre-phase rendered 'Synced 00:00' there)"
|
||||
)
|
||||
|
||||
|
||||
# Phase 154 (task 03) — the qualifier's BYTE-IDENTITY: the pre-phase
|
||||
# fmtSyncTime, pinned VERBATIM from git history (the phase-154
|
||||
# pre-state — the time-only formatter). Only the function NAME is
|
||||
# renamed (so the real + the reference run side by side under node);
|
||||
# the body is the old code byte-for-byte — the old strings (the
|
||||
# `HH:MM` return + the `""` guard) live in this constant, so the
|
||||
# byte-identity below is assertable, not assumed. The reference is
|
||||
# clock-free (it reads no `now` — only the ISO's local time), but
|
||||
# the same fixed-clock driver (the task-01 boundary set's clock) is
|
||||
# used, so the real function's same-day comparison runs on the fixed
|
||||
# now exactly as in the boundary test.
|
||||
PRE_PHASE_FMT_SYNC_TIME_JS = """
|
||||
function prePhaseSyncTime(iso) {
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return "";
|
||||
const pad = (n) => String(n).padStart(2, "0");
|
||||
return `${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
|
||||
def test_sources_js_fmt_sync_time_same_day_is_byte_identical_to_pre_phase() -> None:
|
||||
"""Phase 154 (task 03): the REAL sources.js ``fmtSyncTime`` and
|
||||
the VERBATIM pre-phase formatter (pinned above) run side by side
|
||||
under node with the fixed clock (Oct 3 2026 18:30 local — the
|
||||
task-01 boundary set's clock):
|
||||
|
||||
* the pinned reference's own output is asserted against the
|
||||
verbatim old strings FIRST (a self-check on the constant — a
|
||||
typo in the pinned function fails here, not silently);
|
||||
* EVERY same-day ISO renders BYTE-IDENTICALLY to the pre-phase
|
||||
output (the old ``HH:MM`` — the audit's ``16:06`` case, the
|
||||
midnight / end-of-day edges), and its settle string is
|
||||
byte-identical to the pre-phase settle (``"Synced " + HH:MM``
|
||||
— a same-day badge never moved);
|
||||
* EVERY unparseable/missing ISO (``garbage`` / ``""`` /
|
||||
``undefined``) renders byte-identically to the pre-phase
|
||||
``""`` (the guard unchanged);
|
||||
* the NON-same-day ISOs (yesterday + last year + the
|
||||
``null``/epoch edge — the epoch's local time is a different
|
||||
day AND a different year, so the qualified form applies; its
|
||||
value is TZ-dependent, so it is pinned by SHAPE: the
|
||||
``M/D/YYYY, h:MM AM/PM`` family vs the old ``HH:MM``) are the
|
||||
pinned DIVERGENT set — the qualified form is byte-different
|
||||
from the pre-phase time-only string (the phase's change lives
|
||||
exactly there).
|
||||
|
||||
The wall clock never appears in an assertion."""
|
||||
# Source-level self-check: the pinned constant carries the old
|
||||
# strings verbatim (the `HH:MM` return + the parse guard).
|
||||
assert (
|
||||
"return `${pad(d.getHours())}:${pad(d.getMinutes())}`;"
|
||||
in PRE_PHASE_FMT_SYNC_TIME_JS
|
||||
), "the old HH:MM return is pinned verbatim"
|
||||
assert (
|
||||
'if (Number.isNaN(d.getTime())) return "";' in PRE_PHASE_FMT_SYNC_TIME_JS
|
||||
), "the old parse guard is pinned verbatim"
|
||||
|
||||
fn = _body(_text(SOURCES_JS), "fmtSyncTime")
|
||||
# (js literal, category) — category: same / fallback / diverge.
|
||||
cases = [
|
||||
('"2026-10-03T16:06:00"', "same"), # the audit case
|
||||
('"2026-10-03T00:05:00"', "same"), # midnight edge
|
||||
('"2026-10-03T23:59:00"', "same"), # end-of-day edge
|
||||
('"garbage"', "fallback"),
|
||||
('""', "fallback"), # empty ISO
|
||||
("undefined", "fallback"), # missing ISO (the app-restart case)
|
||||
("null", "diverge"), # the epoch edge — shape-pinned (TZ-dependent)
|
||||
('"2026-10-02T15:14:00"', "diverge"), # yesterday
|
||||
('"2026-10-02T00:05:00"', "diverge"),
|
||||
('"2026-10-02T12:07:00"', "diverge"),
|
||||
('"2026-10-02T23:58:00"', "diverge"),
|
||||
('"2025-10-03T21:12:00"', "diverge"), # last year
|
||||
('"2025-01-03T09:12:00"', "diverge"),
|
||||
]
|
||||
# The verbatim old strings (the pre-phase output — pinned here so
|
||||
# the identity is assertable); the null/epoch value is
|
||||
# TZ-dependent, so its old string is pinned by shape, not value.
|
||||
old = {
|
||||
'"2026-10-03T16:06:00"': "16:06",
|
||||
'"2026-10-03T00:05:00"': "00:05",
|
||||
'"2026-10-03T23:59:00"': "23:59",
|
||||
'"garbage"': "",
|
||||
'""': "",
|
||||
"undefined": "",
|
||||
}
|
||||
driver_expr = (
|
||||
"const CASES = [" + ", ".join(lit for lit, _ in cases) + "];\n"
|
||||
"const PRE = CASES.map((c) => prePhaseSyncTime(c));\n"
|
||||
"const REAL = CASES.map((c) => fmtSyncTime(c));\n"
|
||||
"const settle = (f) => (iso) => {\n"
|
||||
" const t = f(iso);\n"
|
||||
" return t ? `Synced ${t}` : \"Synced\";\n"
|
||||
"};\n"
|
||||
"const PRE_SETTLE = CASES.map((c) => settle(prePhaseSyncTime)(c));\n"
|
||||
"const REAL_SETTLE = CASES.map((c) => settle(fmtSyncTime)(c));\n"
|
||||
"console.log(JSON.stringify({ PRE, REAL, PRE_SETTLE, REAL_SETTLE }));"
|
||||
)
|
||||
out = json.loads(
|
||||
_stamp_driver(fn + "\n" + PRE_PHASE_FMT_SYNC_TIME_JS, driver_expr)
|
||||
)
|
||||
# 1. the pinned reference reproduces the verbatim old strings
|
||||
# (self-check on the constant — assertable, not assumed).
|
||||
for i, (lit, _cat) in enumerate(cases):
|
||||
if lit in old:
|
||||
assert out["PRE"][i] == old[lit], (
|
||||
f"the pre-phase reference for {lit} must be the verbatim old"
|
||||
f" string {old[lit]!r} — got {out['PRE'][i]!r} (a typo in the"
|
||||
" pinned constant?"
|
||||
)
|
||||
null_i = [lit for lit, _ in cases].index("null")
|
||||
assert re.fullmatch(r"\d{2}:\d{2}", out["PRE"][null_i]), (
|
||||
"the null/epoch edge takes the old HH:MM shape on the reference"
|
||||
" side (the pre-phase is time-only — the value is TZ-dependent,"
|
||||
" the shape is not)"
|
||||
)
|
||||
assert out["PRE_SETTLE"][null_i] == "Synced " + out["PRE"][null_i] # old settle
|
||||
assert re.fullmatch(
|
||||
r"\d{1,2}/\d{1,2}/\d{4}, \d{1,2}:\d{2} (AM|PM)", out["REAL"][null_i]
|
||||
), (
|
||||
"the null/epoch edge takes the M/D/YYYY, h:MM AM/PM shape on the"
|
||||
" real side (the epoch is a different day + year — the qualified"
|
||||
" form; the value is TZ-dependent, the shape is not)"
|
||||
)
|
||||
assert out["REAL_SETTLE"][null_i].startswith("Synced ")
|
||||
# 2. the audit case's settle is byte-identical to pre-phase.
|
||||
audit_i = [lit for lit, _ in cases].index('"2026-10-03T16:06:00"')
|
||||
assert out["REAL_SETTLE"][audit_i] == "Synced 16:06", (
|
||||
"the same-day settle is byte-identical to the pre-phase string"
|
||||
)
|
||||
# 3. same-day + fallbacks: byte-identical to the reference, settle
|
||||
# included; the divergent set: byte-different (the qualified form).
|
||||
for i, (lit, cat) in enumerate(cases):
|
||||
if cat == "diverge":
|
||||
assert out["REAL"][i] != out["PRE"][i], (
|
||||
f"{lit} must DIFFER from the pre-phase form (the qualified"
|
||||
f" branch) — got {out['REAL'][i]!r} == {out['PRE'][i]!r}"
|
||||
)
|
||||
assert out["REAL_SETTLE"][i] != out["PRE_SETTLE"][i]
|
||||
else:
|
||||
assert out["REAL"][i] == out["PRE"][i], (
|
||||
f"{lit} must be byte-identical to the pre-phase output"
|
||||
f" (category {cat}) — got {out['REAL'][i]!r} vs"
|
||||
f" {out['PRE'][i]!r}"
|
||||
)
|
||||
assert out["REAL_SETTLE"][i] == out["PRE_SETTLE"][i]
|
||||
|
||||
|
||||
def test_sources_js_failed_error_is_sanitized() -> None:
|
||||
|
||||
Reference in New Issue
Block a user