phase: 132_image_upload_feedback
All verification complete. Final report: **Phase 132 (image upload feedback) — final verification pass: 100% green** - **Verified** all 3 tasks' implementations in the working tree: `index.html` status part (hidden-by-default, arc + "Uploading image…", pinned order), `app.js` `enterUploadState`/`exitUploadState` (entered after `attachUpload` guard pre-await, exited on BOTH resolution paths, `setUiState` untouched, idle "Send" restore byte-identical), `styles.css` `.is-uploading` rules (house `spin` keyframes, reduced-motion block, existing `:disabled` Send language), unit pins, E2E suite with route-delayed upload window. - **Completion criteria** — (1) mid-flight spinner + "Uploading image…", remove + Send disabled with "Uploading…": ✅ E2E-asserted; (2) second Send press = no second upload (request count exactly 1): ✅; (3) success clears + turn proceeds (bubble carries image); failure clears + banner + text/attachment remain: ✅; (4) `uv run pytest tests/e2e/test_image_upload_feedback.py -v --no-cov`: ✅ 3 passed (18.5 s, in isolation); (5) no behavior change in completed phases: ✅ (full suite green, A5 path-never-bytes re-pinned in E2E). - **Full suite**: `uv run pytest --cov=app --cov-report=term-missing` → **3108 passed**, `app/` coverage **99%** (>90% gate); `uv run ruff check .` → clean; `uv run pyright` → 0 errors. - **Documentation**: README.md fully reviewed (setup, pages, all `.env.example` vars + every default vs `app/config.py`, tools incl. phase-131 `read(offset,limit)`, image/PDF/sync/deploy sections) and AGENTS.md fully reviewed (rules, quick-reference commands, gitignore/gpg claims) — **verified consistent; no changes needed**. Phase-132 README bullet already in the working tree; CSS `:root` contrast-note corrections (15.3:1 / 8.2:1) re-verified mathematically correct (comments only). - **Decisions**: none — all task work was already implemented and correct; no fixes required. - **Next pending phase**: `133_pdf_screenshot_fallback`.
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
All verification complete. Final report:
|
||||
|
||||
**Phase 132 (image upload feedback) — final verification pass: 100% green**
|
||||
|
||||
- **Verified** all 3 tasks' implementations in the working tree: `index.html` status part (hidden-by-default, arc + "Uploading image…", pinned order), `app.js` `enterUploadState`/`exitUploadState` (entered after `attachUpload` guard pre-await, exited on BOTH resolution paths, `setUiState` untouched, idle "Send" restore byte-identical), `styles.css` `.is-uploading` rules (house `spin` keyframes, reduced-motion block, existing `:disabled` Send language), unit pins, E2E suite with route-delayed upload window.
|
||||
- **Completion criteria** — (1) mid-flight spinner + "Uploading image…", remove + Send disabled with "Uploading…": ✅ E2E-asserted; (2) second Send press = no second upload (request count exactly 1): ✅; (3) success clears + turn proceeds (bubble carries image); failure clears + banner + text/attachment remain: ✅; (4) `uv run pytest tests/e2e/test_image_upload_feedback.py -v --no-cov`: ✅ 3 passed (18.5 s, in isolation); (5) no behavior change in completed phases: ✅ (full suite green, A5 path-never-bytes re-pinned in E2E).
|
||||
- **Full suite**: `uv run pytest --cov=app --cov-report=term-missing` → **3108 passed**, `app/` coverage **99%** (>90% gate); `uv run ruff check .` → clean; `uv run pyright` → 0 errors.
|
||||
- **Documentation**: README.md fully reviewed (setup, pages, all `.env.example` vars + every default vs `app/config.py`, tools incl. phase-131 `read(offset,limit)`, image/PDF/sync/deploy sections) and AGENTS.md fully reviewed (rules, quick-reference commands, gitignore/gpg claims) — **verified consistent; no changes needed**. Phase-132 README bullet already in the working tree; CSS `:root` contrast-note corrections (15.3:1 / 8.2:1) re-verified mathematically correct (comments only).
|
||||
- **Decisions**: none — all task work was already implemented and correct; no fixes required.
|
||||
- **Next pending phase**: `133_pdf_screenshot_fallback`.
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
........................................................................ [ 2%]
|
||||
........................................................................ [ 4%]
|
||||
........................................................................ [ 6%]
|
||||
........................................................................ [ 9%]
|
||||
........................................................................ [ 11%]
|
||||
........................................................................ [ 13%]
|
||||
........................................................................ [ 16%]
|
||||
........................................................................ [ 18%]
|
||||
........................................................................ [ 20%]
|
||||
........................................................................ [ 23%]
|
||||
........................................................................ [ 25%]
|
||||
........................................................................ [ 27%]
|
||||
........................................................................ [ 30%]
|
||||
........................................................................ [ 32%]
|
||||
........................................................................ [ 34%]
|
||||
........................................................................ [ 37%]
|
||||
........................................................................ [ 39%]
|
||||
........................................................................ [ 41%]
|
||||
........................................................................ [ 44%]
|
||||
........................................................................ [ 46%]
|
||||
........................................................................ [ 48%]
|
||||
........................................................................ [ 50%]
|
||||
........................................................................ [ 53%]
|
||||
........................................................................ [ 55%]
|
||||
........................................................................ [ 57%]
|
||||
........................................................................ [ 60%]
|
||||
........................................................................ [ 62%]
|
||||
........................................................................ [ 64%]
|
||||
........................................................................ [ 67%]
|
||||
........................................................................ [ 69%]
|
||||
........................................................................ [ 71%]
|
||||
........................................................................ [ 74%]
|
||||
........................................................................ [ 76%]
|
||||
........................................................................ [ 78%]
|
||||
........................................................................ [ 81%]
|
||||
........................................................................ [ 83%]
|
||||
........................................................................ [ 85%]
|
||||
........................................................................ [ 88%]
|
||||
........................................................................ [ 90%]
|
||||
........................................................................ [ 92%]
|
||||
........................................................................ [ 94%]
|
||||
........................................................................ [ 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 268 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 179 1 99%
|
||||
app/api/git_sources.py 241 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 156 0 100%
|
||||
app/api/tokens.py 40 0 100%
|
||||
app/api/ui_settings.py 55 0 100%
|
||||
app/config.py 256 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 132 0 100%
|
||||
app/rag/__init__.py 0 0 100%
|
||||
app/rag/agent.py 412 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 38 0 100%
|
||||
app/rag/importer.py 571 9 98%
|
||||
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 81 0 100%
|
||||
app/rag/prompts.py 144 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 49 0 100%
|
||||
app/schemas.py 410 0 100%
|
||||
--------------------------------------------------
|
||||
TOTAL 5206 22 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
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
All done. Final report:
|
||||
|
||||
**Implemented (phase 132, task 01 — uploading state):**
|
||||
- `frontend/index.html`: `#attach-preview` gains the hidden status part (house loading arc svg, `aria-hidden` + pinned "Uploading image…" text) between the filename and remove ✕ — ships hidden per the house rule
|
||||
- `frontend/assets/app.js`: module-scope `attachStatus` ref (null-guard pattern); `enterUploadState()`/`exitUploadState()` helpers beside `showAttachPreview`/`clearAttachedImage` (strip `.is-uploading` + status unhidden + remove disabled + Send disabled "Uploading…" / mirror exit restoring "Send" byte-identical to `setUiState`'s idle label); `handleSend` enters after the `attachUpload` guard before the await, exits at `attachUpload = false` (both success & failure — helper never throws). `setUiState`/`uiState`/turn loader untouched (pre-turn)
|
||||
- `tests/unit/test_chat_image_questions.py`: +4 tests — markup pin (order, exact arc, hidden), helper affordance pin (order, guards, pre-turn contract, byte-identical "Send"), `handleSend` exact-ordering pin (guard → arm → enter → await → release → exit → block, single enter/exit, no `setUiState(` call, byte-untouched phase-123 contract), node-harness behavioral test (enter / exit-success / exit-failure / re-enter rows)
|
||||
|
||||
**Gates (all green):**
|
||||
- `uv run pytest --cov=app --cov-report=term -q` → exit 0, **99%** on `app/` (>90% ✓)
|
||||
- `uv run ruff check .` → clean; `uv run pyright` → exit 0
|
||||
- Regression E2E in isolation: `test_chat_image_questions.py` (phase 123 contract on the modified surface) 5 passed; `test_smoke.py`, `test_answer_images.py`, `test_image_anchor_context.py`, `test_image_documents.py` all pass
|
||||
|
||||
**Docs:** no doc impact (no setup/commands/env/API changes)
|
||||
|
||||
**Decisions:** used the task's exact arc snippet (the "check `#turn-loader`" hint is stale — that loader is the brain-wave glyph, not an arc; the arc matches the house `.spinner` language for task 02's spin CSS); removed a stray `✕` glyph my comment introduced (caught by the emoji guard).
|
||||
|
||||
**Next pending task:** `02_css_upload_state.md`
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
........................................................................ [ 2%]
|
||||
........................................................................ [ 4%]
|
||||
........................................................................ [ 6%]
|
||||
........................................................................ [ 9%]
|
||||
........................................................................ [ 11%]
|
||||
........................................................................ [ 13%]
|
||||
........................................................................ [ 16%]
|
||||
........................................................................ [ 18%]
|
||||
........................................................................ [ 20%]
|
||||
........................................................................ [ 23%]
|
||||
........................................................................ [ 25%]
|
||||
........................................................................ [ 27%]
|
||||
........................................................................ [ 30%]
|
||||
........................................................................ [ 32%]
|
||||
........................................................................ [ 34%]
|
||||
........................................................................ [ 37%]
|
||||
........................................................................ [ 39%]
|
||||
........................................................................ [ 41%]
|
||||
........................................................................ [ 44%]
|
||||
........................................................................ [ 46%]
|
||||
........................................................................ [ 48%]
|
||||
........................................................................ [ 51%]
|
||||
........................................................................ [ 53%]
|
||||
........................................................................ [ 55%]
|
||||
........................................................................ [ 58%]
|
||||
........................................................................ [ 60%]
|
||||
........................................................................ [ 62%]
|
||||
........................................................................ [ 65%]
|
||||
........................................................................ [ 67%]
|
||||
........................................................................ [ 69%]
|
||||
........................................................................ [ 71%]
|
||||
........................................................................ [ 74%]
|
||||
........................................................................ [ 76%]
|
||||
........................................................................ [ 78%]
|
||||
........................................................................ [ 81%]
|
||||
........................................................................ [ 83%]
|
||||
........................................................................ [ 85%]
|
||||
........................................................................ [ 88%]
|
||||
........................................................................ [ 90%]
|
||||
........................................................................ [ 92%]
|
||||
........................................................................ [ 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 268 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 179 1 99%
|
||||
app/api/git_sources.py 241 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 156 0 100%
|
||||
app/api/tokens.py 40 0 100%
|
||||
app/api/ui_settings.py 55 0 100%
|
||||
app/config.py 256 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 132 0 100%
|
||||
app/rag/__init__.py 0 0 100%
|
||||
app/rag/agent.py 412 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 38 0 100%
|
||||
app/rag/importer.py 571 9 98%
|
||||
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 81 0 100%
|
||||
app/rag/prompts.py 144 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 49 0 100%
|
||||
app/schemas.py 410 0 100%
|
||||
--------------------------------------------------
|
||||
TOTAL 5206 22 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
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
Task 02 complete. Final report:
|
||||
|
||||
**Implemented**
|
||||
- `frontend/assets/styles.css`: Phase 132 uploading-state CSS in the `.attach-preview*` family — thumbnail dims to 0.5, name hides via `visibility` (slot kept, no reflow), `.attach-preview-status` row (`--ink-soft`, 0.85rem, `min-width: 0`), 14px arc on the house `spin` keyframes at the sync spinner's 1s pace, `prefers-reduced-motion` stills the arc, remove ✕ dims 0.5 with its disabled attribute
|
||||
- Verified in real Chromium (320/375/480/768/1440): "Uploading image…" never clips (one line at 320px), strip height stable, arc animates `spin 1s` / `none` under reduced motion
|
||||
- Disabled Send: existing `.send-btn:disabled` (`--brand-busy` + `not-allowed`) already reads disabled — verified + pinned, no new visual (B4)
|
||||
- Theme audit: block ships no raw color; `--ink-soft` on `--surface` computed 8.2:1 (≥4.5:1) from `:root` literals
|
||||
- 7 new unit pins appended to `tests/unit/test_chat_image_questions.py` (Phase 132 task 02 section)
|
||||
|
||||
**Tests / lint**
|
||||
- `uv run pytest --cov=app`: **3108 passed**, exit 0, `app/` coverage **99%** (>90% gate)
|
||||
- `uv run ruff check .`: All checks passed · `uv run pyright`: 0 errors, 0 warnings
|
||||
- Regression: `tests/e2e/test_chat_image_questions.py` (5 passed) + `test_pinned_composer.py` + `test_responsive_polish.py` (11 passed) in isolation
|
||||
|
||||
**Docs:** no README/AGENTS.md impact (visual-state CSS only)
|
||||
|
||||
**Decisions / deviations:** fixed stale `:root` contrast comments (5.1:1→8.2:1, 13.8:1→15.3:1 — palette changed at the phase-08 re-land, comments never updated; both pairs pass AA). ~40 other historical phase comments still cite the old low ratios (all underestimates, AA-safe) — left as out-of-scope for this task. Initial "✕" glyphs in my CSS comment tripped the phase-08 no-emoji guard — replaced with ASCII.
|
||||
|
||||
**Next pending task:** `.agents/phases/todo/132_image_upload_feedback/03_e2e_upload_feedback.md`
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
........................................................................ [ 2%]
|
||||
........................................................................ [ 4%]
|
||||
........................................................................ [ 6%]
|
||||
........................................................................ [ 9%]
|
||||
........................................................................ [ 11%]
|
||||
........................................................................ [ 13%]
|
||||
........................................................................ [ 16%]
|
||||
........................................................................ [ 18%]
|
||||
........................................................................ [ 20%]
|
||||
........................................................................ [ 23%]
|
||||
........................................................................ [ 25%]
|
||||
........................................................................ [ 27%]
|
||||
........................................................................ [ 30%]
|
||||
........................................................................ [ 32%]
|
||||
........................................................................ [ 34%]
|
||||
........................................................................ [ 37%]
|
||||
........................................................................ [ 39%]
|
||||
........................................................................ [ 41%]
|
||||
........................................................................ [ 44%]
|
||||
........................................................................ [ 46%]
|
||||
........................................................................ [ 48%]
|
||||
........................................................................ [ 50%]
|
||||
........................................................................ [ 53%]
|
||||
........................................................................ [ 55%]
|
||||
........................................................................ [ 57%]
|
||||
........................................................................ [ 60%]
|
||||
........................................................................ [ 62%]
|
||||
........................................................................ [ 64%]
|
||||
........................................................................ [ 67%]
|
||||
........................................................................ [ 69%]
|
||||
........................................................................ [ 71%]
|
||||
........................................................................ [ 74%]
|
||||
........................................................................ [ 76%]
|
||||
........................................................................ [ 78%]
|
||||
........................................................................ [ 81%]
|
||||
........................................................................ [ 83%]
|
||||
........................................................................ [ 85%]
|
||||
........................................................................ [ 88%]
|
||||
........................................................................ [ 90%]
|
||||
........................................................................ [ 92%]
|
||||
........................................................................ [ 94%]
|
||||
........................................................................ [ 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 268 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 179 1 99%
|
||||
app/api/git_sources.py 241 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 156 0 100%
|
||||
app/api/tokens.py 40 0 100%
|
||||
app/api/ui_settings.py 55 0 100%
|
||||
app/config.py 256 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 132 0 100%
|
||||
app/rag/__init__.py 0 0 100%
|
||||
app/rag/agent.py 412 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 38 0 100%
|
||||
app/rag/importer.py 571 9 98%
|
||||
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 81 0 100%
|
||||
app/rag/prompts.py 144 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 49 0 100%
|
||||
app/schemas.py 410 0 100%
|
||||
--------------------------------------------------
|
||||
TOTAL 5206 22 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
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
All gates green. Final report:
|
||||
|
||||
**Implemented**
|
||||
- `tests/e2e/test_image_upload_feedback.py` (new, mock-only, run in isolation): module app with `BOR_IMAGES=true` + suite-private image store, one-doc local source seeded through the real in-process sync, house 1×1 PNG constant.
|
||||
- Three tests per the mapping: (1) mid-flight uploading state + owner-spam (2 dead button presses + Enter) → exactly ONE upload request, ONE turn; (2) success clears state, strip hides, bubble carries the data-URL image, record carries the PATH (A5), grounded turn settles; (3) delayed-`route.abort` network drop → banner copy, draft + attachment intact, no turn.
|
||||
- In-flight window via `page.route` + async `asyncio.sleep(3.0)` handler, then `route.continue_()` (real server runs, response just late); module counter pins the double-send.
|
||||
|
||||
**Results**
|
||||
- `uv run pytest tests/e2e/test_image_upload_feedback.py -v --no-cov` → 3 passed (5 consecutive runs, 18 s each; instrumented run confirmed in-flight asserts settle ~10 ms after the click, inside the 3 s hold — not a race)
|
||||
- `uv run pytest --cov=app --cov-report=term-missing` → 3108 passed, `app/` coverage 99%
|
||||
- `uv run ruff check .` → all checks passed; `uv run pyright` → 0 errors, 0 warnings
|
||||
|
||||
**Docs:** README "Question images" gained the uploading-state bullet (phase 132 behavior); AGENTS.md — no doc impact (no new rules/commands).
|
||||
|
||||
**Notable decisions:** async Playwright API instead of the sync house pattern — a sync route handler's sleep blocks the test thread for the whole hold, making mid-flight DOM polling impossible (verified empirically; header docstring explains). Admin form login (async twin of `auth_helpers.login`) instead of anonymous: the phase-79 token gate locks the anonymous shell, and phase 123 (the referenced suite) uses the signed-in context. Same-question auto-saves are cleaned by the suite title (house pattern).
|
||||
|
||||
**Next pending task:** `.agents/phases/todo/133_pdf_screenshot_fallback/01_diagnosis_regression_test.md`
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
........................................................................ [ 2%]
|
||||
........................................................................ [ 4%]
|
||||
........................................................................ [ 6%]
|
||||
........................................................................ [ 9%]
|
||||
........................................................................ [ 11%]
|
||||
........................................................................ [ 13%]
|
||||
........................................................................ [ 16%]
|
||||
........................................................................ [ 18%]
|
||||
........................................................................ [ 20%]
|
||||
........................................................................ [ 23%]
|
||||
........................................................................ [ 25%]
|
||||
........................................................................ [ 27%]
|
||||
........................................................................ [ 30%]
|
||||
........................................................................ [ 32%]
|
||||
........................................................................ [ 34%]
|
||||
........................................................................ [ 37%]
|
||||
........................................................................ [ 39%]
|
||||
........................................................................ [ 41%]
|
||||
........................................................................ [ 44%]
|
||||
........................................................................ [ 46%]
|
||||
........................................................................ [ 48%]
|
||||
........................................................................ [ 50%]
|
||||
........................................................................ [ 53%]
|
||||
........................................................................ [ 55%]
|
||||
........................................................................ [ 57%]
|
||||
........................................................................ [ 60%]
|
||||
........................................................................ [ 62%]
|
||||
........................................................................ [ 64%]
|
||||
........................................................................ [ 67%]
|
||||
........................................................................ [ 69%]
|
||||
........................................................................ [ 71%]
|
||||
........................................................................ [ 74%]
|
||||
........................................................................ [ 76%]
|
||||
........................................................................ [ 78%]
|
||||
........................................................................ [ 81%]
|
||||
........................................................................ [ 83%]
|
||||
........................................................................ [ 85%]
|
||||
........................................................................ [ 88%]
|
||||
........................................................................ [ 90%]
|
||||
........................................................................ [ 92%]
|
||||
........................................................................ [ 94%]
|
||||
........................................................................ [ 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 268 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 179 1 99%
|
||||
app/api/git_sources.py 241 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 156 0 100%
|
||||
app/api/tokens.py 40 0 100%
|
||||
app/api/ui_settings.py 55 0 100%
|
||||
app/config.py 256 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 132 0 100%
|
||||
app/rag/__init__.py 0 0 100%
|
||||
app/rag/agent.py 412 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 38 0 100%
|
||||
app/rag/importer.py 571 9 98%
|
||||
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 81 0 100%
|
||||
app/rag/prompts.py 144 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 49 0 100%
|
||||
app/schemas.py 410 0 100%
|
||||
--------------------------------------------------
|
||||
TOTAL 5206 22 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
|
||||
@@ -482,6 +482,13 @@ image:
|
||||
to `POST /api/chat-images` (capped at `BOR_CHAT_IMAGE_MAX_MB`,
|
||||
default 10 MiB — over is a 413) and stored under `BOR_CHAT_IMAGE_DIR`.
|
||||
Saved and shared chats carry the returned *path* — never base64.
|
||||
- **Visible while uploading.** While the upload is in flight the
|
||||
preview strip shows a spinner + *"Uploading image…"* (its remove ✕
|
||||
disabled) and the Send button is disabled with the *"Uploading…"*
|
||||
label — a second press is dead, visually and functionally (no second
|
||||
upload, no second turn). On success the state clears and the turn
|
||||
proceeds; on failure it clears, the banner shows, and the typed text
|
||||
+ the attachment stay for the retry (phase 132).
|
||||
- **Current turn only.** The model receives a multimodal message for
|
||||
this question; prior turns' images are never replayed into history
|
||||
(the text of a prior turn stands alone).
|
||||
|
||||
@@ -314,6 +314,10 @@ const attachBtn = document.querySelector("#attach-btn");
|
||||
const attachFile = document.querySelector("#attach-file");
|
||||
const attachPreview = document.querySelector("#attach-preview");
|
||||
const attachRemove = document.querySelector("#attach-remove");
|
||||
// Phase 132 (task 01, TODO L8): the strip's uploading status part —
|
||||
// ships hidden; enterUploadState / exitUploadState (below) are its
|
||||
// sole `hidden` writers (shown iff the image upload is in flight).
|
||||
const attachStatus = document.querySelector(".attach-preview-status");
|
||||
const banner = document.querySelector("#kb-banner");
|
||||
const bannerText = document.querySelector("#kb-banner-text");
|
||||
const versionEl = document.querySelector("#app-version");
|
||||
@@ -2757,6 +2761,49 @@ function clearAttachedImage() {
|
||||
if (attachPreview) attachPreview.hidden = true;
|
||||
}
|
||||
|
||||
/* Phase 132 (task 01, TODO L8; A5, owner-confirmed 2026-09-27): the
|
||||
* PRE-turn uploading state — the visible proof that the attached
|
||||
* image is moving while POST /api/chat-images is in flight. The
|
||||
* affordance is BOTH halves at once: the preview strip shows the
|
||||
* spinner + "Uploading image…" (the status part unhidden, the
|
||||
* .is-uploading class for task 02's CSS) with its remove X disabled
|
||||
* (the attachment is mid-flight — it can't be removed out from under
|
||||
* the upload), AND the Send button is DISABLED with the "Uploading…"
|
||||
* label — the user-facing half of the attachUpload double-fire guard
|
||||
* (a second press does nothing: the top-of-handleSend
|
||||
* `sendBtn.disabled` guard AND the `attachUpload` guard both no-op).
|
||||
* exitUploadState is the mirror image: the strip returns to its
|
||||
* normal preview (thumbnail + filename stay visible), the remove X
|
||||
* re-enables, and the button restores EXACTLY the idle look —
|
||||
* enabled + "Send" (byte-identical to setUiState's idle label),
|
||||
* restored BEFORE the success path calls runTurn (whose
|
||||
* setUiState("thinking") then takes the button over as usual) and
|
||||
* before the failure path returns (banner shown, input text intact,
|
||||
* the attachment still attached — the user can retry or remove).
|
||||
* setUiState / uiState / the turn loader are deliberately UNTOUCHED:
|
||||
* the upload is PRE-turn (uiState stays idle; the turn's
|
||||
* thinking/streaming feedback starts only when runTurn does).
|
||||
* Both are idempotent and null-guarded on the attach family (the
|
||||
* house pattern — a New chat mid-upload hides the strip; the class
|
||||
* on a hidden element is harmless and still cleared at resolution);
|
||||
* the send refs are unconditional (the composer is required for the
|
||||
* chat view to exist at all). */
|
||||
function enterUploadState() {
|
||||
if (attachPreview) attachPreview.classList.add("is-uploading");
|
||||
if (attachStatus) attachStatus.hidden = false;
|
||||
if (attachRemove) attachRemove.disabled = true;
|
||||
sendBtn.disabled = true;
|
||||
sendLabel.textContent = "Uploading…";
|
||||
}
|
||||
|
||||
function exitUploadState() {
|
||||
if (attachPreview) attachPreview.classList.remove("is-uploading");
|
||||
if (attachStatus) attachStatus.hidden = true;
|
||||
if (attachRemove) attachRemove.disabled = false;
|
||||
sendBtn.disabled = false;
|
||||
sendLabel.textContent = "Send";
|
||||
}
|
||||
|
||||
async function handleSend(e) {
|
||||
e.preventDefault();
|
||||
// Phase 48: while a turn is in flight the Send button IS the Stop
|
||||
@@ -2787,8 +2834,20 @@ async function handleSend(e) {
|
||||
if (attachedImage) {
|
||||
if (attachUpload) return; // a second submit mid-upload: the first owns the send (never two uploads, never two turns)
|
||||
attachUpload = true;
|
||||
// Phase 132 (task 01, TODO L8): the uploading state is entered
|
||||
// exactly ONCE per upload — after the guard, before the await —
|
||||
// so the gap between the press and the resolution is never
|
||||
// frozen-looking (the strip's spinner + "Uploading image…", the
|
||||
// disabled "Uploading…" Send).
|
||||
enterUploadState();
|
||||
const path = await uploadAttachedImage(attachedImage.file);
|
||||
attachUpload = false; // the helper never throws (every failure path is a banner + null)
|
||||
// Phase 132: exited on BOTH resolution paths — the helper never
|
||||
// throws, so this line is reached on success AND failure. Before
|
||||
// the success path continues to runTurn (whose setUiState takes
|
||||
// the button over) and before the failure path returns below
|
||||
// (banner + editable composer + the attachment for the retry).
|
||||
exitUploadState();
|
||||
if (path === null) return; // A8: the send is blocked — the question stays
|
||||
image = {
|
||||
path, // the record + the /api/chat body (A5: the path, never base64)
|
||||
|
||||
@@ -7,9 +7,9 @@
|
||||
:root {
|
||||
/* Palette — all text/background pairs meet WCAG 2.1 AA (>= 4.5:1) */
|
||||
--bg: #0f0a0a; /* page: ink on bg 16.7:1 */
|
||||
--surface: #1a0f0f; /* ink on surface 13.8:1 */
|
||||
--surface: #1a0f0f; /* ink on surface 15.3:1 */
|
||||
--ink: #f0e6e6;
|
||||
--ink-soft: #b8a8a8; /* 5.1:1 on --surface */
|
||||
--ink-soft: #b8a8a8; /* 8.2:1 on --surface */
|
||||
--line: #2d1a1a; /* decorative 1px borders */
|
||||
--grid-line: #4a2626; /* background grid texture — decorative, no
|
||||
contrast duty (like --line) */
|
||||
@@ -1745,6 +1745,56 @@ a.answer-image:hover { text-decoration: underline; } /* flat — the caption und
|
||||
}
|
||||
.attach-preview-remove svg { width: 18px; height: 18px; }
|
||||
.attach-preview-remove:hover { background: var(--err-bg); color: var(--err-ink); }
|
||||
/* Phase 132 (task 02, TODO L8): the strip's UPLOADING look — the
|
||||
visual half of the owner-confirmed double-spam block (task 01's JS
|
||||
state proves the image is moving while POST /api/chat-images is in
|
||||
flight; before this the strip looked frozen and the user spammed
|
||||
Send). The thumbnail DIMS to 0.5 (it is decorative — no contrast
|
||||
duty — and 0.5 is the house dim value the disabled remove button
|
||||
below); the filename HIDES with visibility (the flex: 1 slot keeps
|
||||
its measured width — the status takes the row's right hand WITHOUT
|
||||
reflowing the pinned 44px remove button); the house loading arc
|
||||
(the status part's svg — task 01's markup, currentColor = the
|
||||
status's --ink-soft) SPINS at the sync spinner's pace (the house
|
||||
`spin` keyframes at 1s linear — .sync-icon.is-spinning's in-flight
|
||||
pace; the .spinner's 0.8s is the busy-turn arc's). The status text
|
||||
is --ink-soft on the strip's --surface — the :root-pinned pair,
|
||||
8.2:1 computed against WCAG 2.1 (AA ≥ 4.5:1), at the strip's
|
||||
small text size (the name's 0.85rem). The remove button (task 01
|
||||
disables it mid-flight) dims with its disabled attribute — the
|
||||
attribute is the state, the 0.5 is its visual half; the Send rides
|
||||
the same house :disabled language (.send-btn:disabled's
|
||||
--brand-busy + not-allowed below — which reads as disabled already,
|
||||
so NO new color or weight was added, B4). prefers-reduced-motion stills the arc (the house block): the
|
||||
"Uploading image…" TEXT + the dimmed thumbnail carry the state
|
||||
without motion (B5 — never motion alone). Every value is a theme
|
||||
variable or a neutral number — the block ships no raw color. */
|
||||
.attach-preview.is-uploading .attach-preview-img {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.attach-preview.is-uploading .attach-preview-name {
|
||||
visibility: hidden; /* keep the flex: 1 slot; the status takes its right hand */
|
||||
}
|
||||
.attach-preview-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
color: var(--ink-soft);
|
||||
font-size: 0.85rem; /* the strip's small text — the name's size */
|
||||
min-width: 0; /* the row's clamp: at 320 px the text WRAPS (no clip) */
|
||||
}
|
||||
.attach-preview-status-icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
flex: 0 0 auto;
|
||||
animation: spin 1s linear infinite; /* the house spin keyframes (the sync pace) */
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.attach-preview-status-icon { animation: none; }
|
||||
}
|
||||
.attach-preview.is-uploading .attach-preview-remove {
|
||||
opacity: 0.5; /* the disabled attribute's visual half (task 01 sets it) */
|
||||
}
|
||||
.composer {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
|
||||
@@ -280,6 +280,22 @@
|
||||
<div class="attach-preview" id="attach-preview" hidden>
|
||||
<img class="attach-preview-img" alt="">
|
||||
<span class="attach-preview-name"></span>
|
||||
<!-- Phase 132 (task 01, TODO L8): the strip's UPLOADING
|
||||
status part — the house loading arc (the svg is
|
||||
decorative, aria-hidden) + the pinned "Uploading image…"
|
||||
text (it carries the meaning). SHIPS HIDDEN (the house
|
||||
rule) — app.js's enterUploadState / exitUploadState
|
||||
(handleSend's attachedImage branch) are the SOLE writers
|
||||
of its `hidden`: shown exactly while the POST
|
||||
/api/chat-images upload is in flight, hidden by
|
||||
construction at BOTH resolution points (success → the
|
||||
turn, failure → the banner + the retry). The thumbnail
|
||||
+ filename stay visible throughout — the user watches
|
||||
THEIR image uploading. -->
|
||||
<span class="attach-preview-status" hidden>
|
||||
<svg class="attach-preview-status-icon" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M21 12a9 9 0 1 1-6.2-8.56"/></svg>
|
||||
<span class="attach-preview-status-text">Uploading image…</span>
|
||||
</span>
|
||||
<button type="button" class="attach-preview-remove" id="attach-remove" aria-label="Remove the attached image">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><path d="M6 6l12 12M18 6L6 18"/></svg>
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,602 @@
|
||||
"""Phase 132 task 03 E2E (Playwright, mock-only): the chat image upload
|
||||
shows visible "uploading" feedback (TODO L8).
|
||||
|
||||
Run in isolation (DB must be up: ``podman compose up -d db``):
|
||||
|
||||
uv run pytest tests/e2e/test_image_upload_feedback.py -v --no-cov
|
||||
|
||||
MOCK-ONLY suite: ``E2E_REAL_LLM=1`` is not supported — the settled-turn
|
||||
pins key on the mock's byte-stable grounded answer
|
||||
(``Deterministic mock answer for E2E``), which the live model will not
|
||||
produce. The in-flight mechanics themselves are LLM-independent.
|
||||
|
||||
THE UPLOAD WINDOW (the route-delay rationale): in production
|
||||
``POST /api/chat-images`` resolves in ~50 ms — the in-flight window is
|
||||
unsamplable. ``page.route`` intercepts the upload and the handler
|
||||
DELAYS (``asyncio.sleep``) before ``route.continue_()``: the server's
|
||||
REAL handler runs, the response is just late (the upload is genuine —
|
||||
the file lands, the path is returned, the turn proceeds). The window is
|
||||
therefore ≥ ``HOLD_SECONDS`` (3.0 s) and the in-flight state — set by
|
||||
``enterUploadState`` synchronously with the fetch, cleared by
|
||||
``exitUploadState`` on resolution — is directly observable.
|
||||
|
||||
WHY THE ASYNC API (not the house sync pattern): a sync-API route
|
||||
handler runs in a greenlet on the TEST thread — its ``time.sleep``
|
||||
blocks the thread for the whole hold, so no ``expect()`` can poll the
|
||||
DOM during the window (only the post-release residual state is visible
|
||||
— a race against the server's response time). The async API runs the
|
||||
handler as an independent event-loop task: the sleep suspends ONLY the
|
||||
handler, the test coroutine keeps polling through the driver while the
|
||||
upload is genuinely in flight. The in-flight assertions are therefore
|
||||
waits on a directly-set DOM state, not a race.
|
||||
|
||||
Context: SIGNED IN via the house form login (admin — the same context
|
||||
the phase-123 image suite drives; the async twin of
|
||||
``e2e.auth_helpers.login``, no cookie surgery). The paperclip's reveal
|
||||
gate is the ``/api/config`` ``images: true`` flag, not identity — but
|
||||
the shell's anonymous boot shows the phase-79 token gate (locked +
|
||||
inert), so a signed-in context is the house chat context. The
|
||||
suite's module app boots with ``BOR_IMAGES=true`` pinned EXPLICITLY
|
||||
(process env ranks above an operator's local gitignored ``.env`` — the
|
||||
leak-guard convention) and a suite-private ``BOR_CHAT_IMAGE_DIR``.
|
||||
|
||||
KB fixture: ONE local source (``tmp_path_factory`` dir, registered
|
||||
through the authenticated API + the real in-process ``POST /api/sync``
|
||||
— the ``test_chat_answer_clusters.py`` pattern) holding ONE text doc
|
||||
whose body shares the question's tokens (token-overlap cosine ~0.46–0.67
|
||||
vs the mock-calibrated 0.30 gate): the turn after a successful upload
|
||||
completes GROUNDED, exactly as pre-phase. The image fixture is the
|
||||
house 1×1 transparent PNG (inline constant — the pipeline is
|
||||
content-agnostic; the well-formed bytes keep the upload + render pins
|
||||
honest).
|
||||
|
||||
Test → observable mapping (Playwright Mapping Rule):
|
||||
1. ``test_upload_window_shows_uploading_and_blocks_resend`` — attach →
|
||||
type → send, the upload route-delayed: MID-FLIGHT (asserted by
|
||||
waiting on the class while the ≥3 s window is open) the strip has
|
||||
``is-uploading`` + the visible ``.attach-preview-status`` reading
|
||||
"Uploading image…" (the thumbnail stays visible — the user watches
|
||||
THEIR image moving), ``#attach-remove`` is disabled, ``#send-btn``
|
||||
is disabled with the "Uploading…" label; the owner's exact spam —
|
||||
two more presses on the disabled button (the browser suppresses the
|
||||
DOM event on a disabled control: genuinely dead presses) plus an
|
||||
Enter-to-submit (the form's ``sendBtn.disabled`` guard) — then, past
|
||||
the window: EXACTLY ONE upload request reached the interceptor
|
||||
(no double upload) and EXACTLY ONE user + ONE brain bubble (no
|
||||
second turn), the state cleared, the turn settled.
|
||||
2. ``test_upload_success_clears_and_turn_proceeds`` — the same flow,
|
||||
past the window: the state is cleared (no ``is-uploading``, the
|
||||
status hidden, the remove re-enabled, the label back to "Send"), the
|
||||
strip HIDEs with the send (the phase-123 contract), the user bubble
|
||||
carries the attached image (the live data URL, alt = the filename),
|
||||
the brain bubble is the mock's GROUNDED answer (not a deflection),
|
||||
and the localStorage record carries the stored PATH — never base64
|
||||
(A5).
|
||||
3. ``test_upload_failure_clears_state_and_keeps_the_draft`` — a route
|
||||
that DELAYS then ABORTS the upload (the network-drop path —
|
||||
``fetch`` rejects, ``uploadAttachedImage``'s catch): mid-flight the
|
||||
same uploading state; after resolution the state is CLEARED (Send
|
||||
re-enabled with "Send"), the OUT-OF-TURN banner shows the
|
||||
network-drop copy ("Couldn't attach the image — try again."), the
|
||||
input text is INTACT, the strip is back in its NORMAL preview
|
||||
(the attachment remains for the retry — visible, no
|
||||
``is-uploading``), and NO turn ran (no bubbles at all).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from playwright.async_api import Page, async_playwright, expect
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.config import Settings
|
||||
from app.db import SessionLocal
|
||||
from e2e.conftest import ADMIN_PASSWORD, SESSION_SECRET, USE_REAL_LLM, _wait_http
|
||||
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
|
||||
# Per-module app port (the conftest session app owns its port in a
|
||||
# combined run — the module app binds its own; env-overridable).
|
||||
APP_PORT = int(os.environ.get("E2E_APP_PORT_UPLOADFB", "8180"))
|
||||
APP_URL = f"http://127.0.0.1:{APP_PORT}"
|
||||
|
||||
#: The upload hold (the task's ≥2 s window; 3.0 s keeps the in-flight
|
||||
#: assertions + the spam comfortably inside the window even under slow
|
||||
#: driver polling — the hold is the ONLY thing between the press and
|
||||
#: the resolution, so the window is hold + intercept latency).
|
||||
HOLD_SECONDS = 3.0
|
||||
SETTLE_TIMEOUT_MS = 30_000
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# The fixtures' constants
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
#: The house 1×1 transparent PNG (the inline constant of the image E2E
|
||||
#: suites — no new binary committed).
|
||||
PNG_1X1 = base64.b64decode(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJ"
|
||||
"AAAAC0lEQVR4nGP4DwQACfsD/fteaysAAAAASUVORK5CYII="
|
||||
)
|
||||
PNG_NAME = "diagram.png"
|
||||
|
||||
SOURCE_NAME = "e2e-uploadfb" # the directory basename = the source name
|
||||
NOTE_PATH = "note.md"
|
||||
|
||||
#: The one text doc — its body carries the question's tokens, so the
|
||||
#: mock's token-overlap cosine against the (single) chunk clears the
|
||||
#: mock-calibrated 0.30 gate deterministically (~0.46–0.67 measured):
|
||||
#: the post-upload turn GROUNDS (HIGH), the mock's default grounded
|
||||
#: answer streams, and the turn completes exactly as pre-phase.
|
||||
NOTE_CONTENT = (
|
||||
"# Homelab Router Note\n"
|
||||
"\n"
|
||||
"The homelab router note for the e2e upload-feedback suite: the core\n"
|
||||
"router runs the firewall rules and the VLAN interfaces, and the\n"
|
||||
"uplink carries the whole homelab network.\n"
|
||||
)
|
||||
|
||||
QUESTION = "What does my homelab note say about the core router?"
|
||||
#: The phase-50 auto-title (first question, whitespace-collapsed, 120-
|
||||
#: char cap) of the two settled turns — the shared e2e Postgres keeps
|
||||
#: ``saved_chats`` across runs, so the module boundary owns this
|
||||
#: suite's rows by title (the test_chat_answer_clusters cleanup).
|
||||
SUITE_TITLE = " ".join(QUESTION.split())[:120]
|
||||
MOCK_MARKER = "Deterministic mock answer for E2E"
|
||||
DEFLECT_PHRASE = r"haven't done anything like that"
|
||||
IMAGE_PATH_RE = re.compile(r"^/api/chat-images/[0-9a-f]{32}\.png$")
|
||||
STORAGE_KEY = "bor.chat.v1"
|
||||
|
||||
#: The module counter the task's double-send assertion keys on — every
|
||||
#: intercepted upload request (method + path checked in the handler).
|
||||
UPLOAD_REQUESTS: list[str] = []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Module app: images ON, suite-private image store, the one-doc local
|
||||
# source as the sole registered source
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _app_env(mock_llm: int, scratch: Path) -> dict[str, str]:
|
||||
"""The conftest app env (leak guards included) with the phase-123
|
||||
image knob pinned EXPLICITLY (the ``/api/config`` ``images: true``
|
||||
gate reveals ``#attach-btn``) and the question-image store in the
|
||||
suite's scratch dir (the app under test never writes into the
|
||||
owner's real ``~/bor-sources``)."""
|
||||
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 gate (conftest pattern) — the one-doc KB grounds
|
||||
# the question on these values (see NOTE_CONTENT).
|
||||
env["BOR_RELEVANCE_THRESHOLD"] = "0.30"
|
||||
env["BOR_LEXICAL_SUPPORT_FLOOR"] = "0.15"
|
||||
env["BOR_SOURCE_USEFULNESS_FLOOR"] = "0.15"
|
||||
# Phase 67: instant retry waits + the code-default budget.
|
||||
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 registry
|
||||
# must hold EXACTLY this suite's local directory.
|
||||
env["BOR_GIT_SOURCES"] = ""
|
||||
# Leak guards (conftest pattern): docs-push inert; UI strings at the
|
||||
# code defaults.
|
||||
env["BOR_DOCS_REPO"] = ""
|
||||
env["BOR_SUGGESTIONS"] = json.dumps(Settings.model_fields["suggestions"].default)
|
||||
env["BOR_INPUT_PLACEHOLDER"] = Settings.model_fields["input_placeholder"].default
|
||||
env["BOR_FOOTER_TEXT"] = Settings.model_fields["footer_text"].default
|
||||
# The story's knob + the suite-private stores.
|
||||
env["BOR_IMAGES"] = "true"
|
||||
env["BOR_ANSWER_IMAGES"] = "false" # the mock never show_images anyway
|
||||
env["BOR_CHAT_IMAGE_DIR"] = str(scratch / "chat-images")
|
||||
env["BOR_UPLOAD_DIR"] = str(scratch / "uploads")
|
||||
env["BOR_SOURCES_DIR"] = str(scratch / "checkouts")
|
||||
env["BOR_IMAGE_DIR"] = str(scratch / "images")
|
||||
return env
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def kb_dir(tmp_path_factory: pytest.TempPathFactory) -> Path:
|
||||
"""The one-source fixture tree (see the module docstring): the app
|
||||
runs on the same host, so the path is visible to it."""
|
||||
scratch = tmp_path_factory.mktemp("bor_uploadfb")
|
||||
src = scratch / SOURCE_NAME
|
||||
src.mkdir()
|
||||
(src / NOTE_PATH).write_text(NOTE_CONTENT, encoding="utf-8")
|
||||
return src
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def app_server(mock_llm: int, kb_dir: Path) -> Iterator[str]:
|
||||
"""The real app under test — per-module app (the conftest pattern;
|
||||
the session app is never started in this isolated run, so no port
|
||||
clash)."""
|
||||
scratch = kb_dir.parent
|
||||
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=_app_env(mock_llm, scratch),
|
||||
)
|
||||
try:
|
||||
_wait_http(f"{APP_URL}/api/health")
|
||||
yield APP_URL
|
||||
finally:
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def app_url(app_server: str) -> str:
|
||||
return app_server
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DB + admin-HTTP helpers (the house patterns)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _truncate_all() -> None:
|
||||
"""Fresh registry + KB (the E2E isolation pattern): the suites share
|
||||
one Postgres, and a leftover row would corrupt the one-doc premise.
|
||||
``saved_chats`` is never truncated (the house pattern) — the two
|
||||
settled turns auto-save rows under this suite's title, and
|
||||
:func:`_cleanup_saved_rows` owns them."""
|
||||
with SessionLocal() as db:
|
||||
db.execute(text(
|
||||
"TRUNCATE chunks, documents, query_log, steering_notes, "
|
||||
"kb_overview, git_sources, sources_meta, folder_summaries"
|
||||
))
|
||||
db.commit()
|
||||
|
||||
|
||||
def _cleanup_saved_rows() -> None:
|
||||
"""Delete THIS suite's auto-saved rows by title (best-effort: a
|
||||
failed delete — DB unreachable, the skip case — never masks the
|
||||
skip; re-runnable on the shared DB)."""
|
||||
try:
|
||||
with SessionLocal() as db:
|
||||
db.execute(
|
||||
text("DELETE FROM saved_chats WHERE title = :title"),
|
||||
{"title": SUITE_TITLE},
|
||||
)
|
||||
db.commit()
|
||||
except Exception: # noqa: BLE001 — DB unreachable: the skip handles it
|
||||
pass
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _admin_client(app_url: str) -> Iterator[httpx.Client]:
|
||||
"""An httpx client holding a fresh admin session (the form login's
|
||||
API twin — the house httpx pattern of test_chat_answer_clusters)."""
|
||||
client = httpx.Client(base_url=app_url, timeout=30.0)
|
||||
try:
|
||||
r = client.post("/api/login", json={"password": ADMIN_PASSWORD})
|
||||
assert r.status_code == 204, r.text
|
||||
yield client
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
|
||||
def _wait_sync_done_http(client: httpx.Client, timeout_s: float = 180.0) -> dict[str, Any]:
|
||||
"""Poll the (cookie-authenticated) status endpoint until the run
|
||||
reaches a terminal state."""
|
||||
deadline = time.monotonic() + timeout_s
|
||||
body: dict[str, Any] = {}
|
||||
while time.monotonic() < deadline:
|
||||
r = client.get("/api/sync/status")
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
if body["state"] in ("success", "failed"):
|
||||
return body
|
||||
time.sleep(0.5)
|
||||
raise AssertionError(f"sync did not reach a terminal state: {body}")
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def seeded_kb(app_url: str, kb_dir: Path, db_ready: None) -> Iterator[None]:
|
||||
"""The story's precondition per test: a FRESH registry holding ONLY
|
||||
this suite's local source, synced by the REAL in-process
|
||||
``POST /api/sync`` pipeline (walk → chunk → embed → overview →
|
||||
version bump) under the deterministic mock — exactly ONE document.
|
||||
(Re-seeded per test — the shared e2e Postgres makes leftovers
|
||||
possible, and the one-doc premise is load-bearing for the gate.)"""
|
||||
_cleanup_saved_rows() # a previous run's rows would be stale
|
||||
_truncate_all()
|
||||
with _admin_client(app_url) as client:
|
||||
r = client.post("/api/git-sources", json={"kind": "local", "path": str(kb_dir)})
|
||||
assert r.status_code == 201, r.text
|
||||
r = client.post("/api/sync")
|
||||
assert r.status_code == 202, r.text
|
||||
body = _wait_sync_done_http(client)
|
||||
assert body["state"] == "success", body
|
||||
assert body["detail"]["added"] == 1, body
|
||||
yield
|
||||
_truncate_all()
|
||||
_cleanup_saved_rows()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Browser helpers (async API — see the header's WHY THE ASYNC API)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _png_file(tmp_path: Path) -> Path:
|
||||
png = tmp_path / PNG_NAME
|
||||
png.write_bytes(PNG_1X1)
|
||||
return png
|
||||
|
||||
|
||||
async def _admin_login(page: Page, app_url: str) -> None:
|
||||
"""The house form login (the async twin of ``e2e.auth_helpers.
|
||||
login``): the REAL form on /login.html — fill → submit → redirect.
|
||||
The phase-79 gate locks the anonymous shell (inert + the token
|
||||
gate is the only interactive surface), so the suite drives the same
|
||||
signed-in context the other chat suites do."""
|
||||
await page.goto(f"{app_url}/login.html?next=/")
|
||||
await expect(page.locator("#login-password")).to_be_visible()
|
||||
await page.fill("#login-password", ADMIN_PASSWORD)
|
||||
await page.click("#login-form button[type=submit]")
|
||||
await expect(page).to_have_url(app_url + "/", timeout=SETTLE_TIMEOUT_MS)
|
||||
|
||||
|
||||
def _upload_route_handler(fail: bool) -> Any:
|
||||
"""The route-delayed upload (or the delayed ABORT — the failure
|
||||
test's network drop). Only the POST upload is held; anything else
|
||||
(the bytes route's GETs) passes straight through. The request is
|
||||
counted the moment it is intercepted — the double-send pin."""
|
||||
|
||||
async def handle(route: Any) -> None:
|
||||
if (
|
||||
route.request.method == "POST"
|
||||
and urlsplit(route.request.url).path == "/api/chat-images"
|
||||
):
|
||||
UPLOAD_REQUESTS.append(route.request.url)
|
||||
await asyncio.sleep(HOLD_SECONDS)
|
||||
if fail:
|
||||
await route.abort("failed")
|
||||
return
|
||||
await route.continue_()
|
||||
|
||||
return handle
|
||||
|
||||
|
||||
async def _attach_via_button(page: Page, png: Path) -> None:
|
||||
"""The real user path: click the paperclip (revealed by the
|
||||
``images: true`` config gate) → the file chooser (the button's
|
||||
programmatic input click) → ``set_files``. The preview strip must
|
||||
reveal with the data-URL thumbnail + the filename (phase 123)."""
|
||||
btn = page.locator("#attach-btn")
|
||||
await expect(btn).to_be_visible()
|
||||
await expect(btn).to_have_attribute("aria-label", "Attach an image")
|
||||
async with page.expect_file_chooser() as fc_info:
|
||||
await btn.click()
|
||||
await (await fc_info.value).set_files(str(png))
|
||||
await expect(page.locator("#attach-preview")).to_be_visible()
|
||||
await expect(page.locator("#attach-preview .attach-preview-name")).to_have_text(PNG_NAME)
|
||||
src = await page.locator("#attach-preview img").get_attribute("src")
|
||||
assert (src or "").startswith("data:image/png;base64,"), "the thumbnail is the live data URL"
|
||||
|
||||
|
||||
async def _assert_uploading(page: Page) -> None:
|
||||
"""The in-flight state (phase 132 task 01) — every assertion is a
|
||||
WAIT on the directly-set DOM state (the UI is no-poll: the state is
|
||||
set synchronously with the fetch), so while the window is open each
|
||||
wait settles on the first tick inside it."""
|
||||
await expect(page.locator("#attach-preview")).to_have_class(
|
||||
re.compile(r"\bis-uploading\b")
|
||||
)
|
||||
# The thumbnail stays visible — the user watches THEIR image moving.
|
||||
await expect(page.locator("#attach-preview img")).to_be_visible()
|
||||
await expect(page.locator(".attach-preview-status")).to_be_visible()
|
||||
await expect(page.locator(".attach-preview-status-text")).to_have_text("Uploading image…")
|
||||
await expect(page.locator("#attach-remove")).to_be_disabled()
|
||||
await expect(page.locator("#send-btn")).to_be_disabled()
|
||||
await expect(page.locator("#send-label")).to_have_text("Uploading…")
|
||||
|
||||
|
||||
async def _send_with_held_upload(
|
||||
page: Page, app_url: str, png: Path, *, fail: bool = False, spam: bool = False
|
||||
) -> None:
|
||||
"""Chat view (house form login) → attach → type → (hold the upload
|
||||
route) → send → assert the in-flight state → optionally the owner's
|
||||
exact spam (mid-flight: two presses on the disabled button + an
|
||||
Enter-to-submit)."""
|
||||
UPLOAD_REQUESTS.clear()
|
||||
await _admin_login(page, app_url)
|
||||
await _attach_via_button(page, png)
|
||||
await page.fill("#message-input", QUESTION)
|
||||
await page.route("**/api/chat-images", _upload_route_handler(fail))
|
||||
await page.click("#send-btn")
|
||||
await _assert_uploading(page)
|
||||
if spam:
|
||||
# The owner's exact spam while the window is open: two more
|
||||
# presses on the disabled button (force=True dispatches the
|
||||
# mouse events; the BROWSER suppresses the DOM event on a
|
||||
# disabled control — genuinely dead presses, like a user's)
|
||||
# and an Enter-to-submit (the form's sendBtn.disabled guard).
|
||||
await page.click("#send-btn", force=True)
|
||||
await page.click("#send-btn", force=True)
|
||||
await page.locator("#message-input").press("Enter")
|
||||
|
||||
|
||||
async def _wait_grounded_settle(page: Page) -> None:
|
||||
"""Past the window: the upload resolved, the state CLEARED, and the
|
||||
turn settled GROUNDED on the one-doc KB (the mock's byte-stable
|
||||
answer — not a deflection), the button back to the idle "Send"."""
|
||||
brain = page.locator(".msg.brain .bubble").first
|
||||
await expect(brain).to_contain_text(MOCK_MARKER, timeout=SETTLE_TIMEOUT_MS)
|
||||
await expect(brain).not_to_contain_text(re.compile(DEFLECT_PHRASE, re.IGNORECASE))
|
||||
await expect(page.locator("#send-btn")).to_be_enabled()
|
||||
await expect(page.locator("#send-label")).to_have_text("Send")
|
||||
# The uploading state is gone at every writer's exit point…
|
||||
strip = page.locator("#attach-preview")
|
||||
assert "is-uploading" not in (await strip.get_attribute("class") or "")
|
||||
await expect(page.locator(".attach-preview-status")).to_be_hidden()
|
||||
await expect(page.locator("#attach-remove")).to_be_enabled()
|
||||
# …and the strip must not linger into the turn (the phase-123
|
||||
# contract — the preview clears when the question goes out).
|
||||
await expect(strip).to_be_hidden()
|
||||
# A successful turn shows no out-of-turn banner.
|
||||
await expect(page.locator("#kb-banner")).to_be_hidden()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. The window: uploading state visible, the spam is visibly AND
|
||||
# functionally dead (exactly ONE upload, ONE turn)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _scenario_window_blocks_resend(app_url: str, tmp_path: Path) -> None:
|
||||
png = _png_file(tmp_path)
|
||||
async with async_playwright() as p:
|
||||
browser = await p.chromium.launch(headless=True)
|
||||
try:
|
||||
page = await browser.new_page(viewport={"width": 1280, "height": 800})
|
||||
page.set_default_timeout(30_000)
|
||||
await _send_with_held_upload(page, app_url, png, spam=True)
|
||||
await _wait_grounded_settle(page)
|
||||
# The spam is FUNCTIONALLY dead too: exactly ONE upload
|
||||
# request reached the interceptor (the second press never
|
||||
# fired an upload; the Enter never fired a send).
|
||||
assert len(UPLOAD_REQUESTS) == 1, UPLOAD_REQUESTS
|
||||
# …and no second turn: exactly one question + one answer.
|
||||
await expect(page.locator(".msg.user .bubble")).to_have_count(1)
|
||||
await expect(page.locator(".msg.brain .bubble")).to_have_count(1)
|
||||
finally:
|
||||
await browser.close()
|
||||
|
||||
|
||||
def test_upload_window_shows_uploading_and_blocks_resend(
|
||||
app_url: str, mock_llm: int, db_ready: None, tmp_path: Path
|
||||
) -> None:
|
||||
asyncio.run(_scenario_window_blocks_resend(app_url, tmp_path))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Success: the indicator clears, the strip hides, the bubble carries
|
||||
# the image, the record carries the PATH, the turn completes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _scenario_success_clears_and_proceeds(app_url: str, tmp_path: Path) -> None:
|
||||
png = _png_file(tmp_path)
|
||||
async with async_playwright() as p:
|
||||
browser = await p.chromium.launch(headless=True)
|
||||
try:
|
||||
page = await browser.new_page(viewport={"width": 1280, "height": 800})
|
||||
page.set_default_timeout(30_000)
|
||||
await _send_with_held_upload(page, app_url, png)
|
||||
await _wait_grounded_settle(page)
|
||||
# The user bubble renders the attached image (the phase-123
|
||||
# attachBubbleImage — the live data URL, alt = the filename).
|
||||
img = page.locator(".msg.user .msg-image").first
|
||||
await expect(img).to_be_visible()
|
||||
src = await img.get_attribute("src") or ""
|
||||
assert src.startswith("data:image/png;base64,"), "the live bubble uses the data URL"
|
||||
assert base64.b64decode(src.split(",", 1)[1]) == PNG_1X1
|
||||
assert await img.get_attribute("alt") == PNG_NAME
|
||||
# A5 at the storage boundary: the record carries the stored
|
||||
# PATH, and NOTHING base64 crossed into the payload.
|
||||
raw = await page.evaluate(f"() => localStorage.getItem('{STORAGE_KEY}')")
|
||||
stored = json.loads(raw) if raw else None
|
||||
assert stored is not None, "the conversation must be persisted (save point 1)"
|
||||
path = stored["messages"][0]["image"]
|
||||
assert IMAGE_PATH_RE.fullmatch(path), f"the record must carry the stored PATH: {path!r}"
|
||||
assert "base64" not in json.dumps(stored)
|
||||
assert len(UPLOAD_REQUESTS) == 1, UPLOAD_REQUESTS
|
||||
finally:
|
||||
await browser.close()
|
||||
|
||||
|
||||
def test_upload_success_clears_and_turn_proceeds(
|
||||
app_url: str, mock_llm: int, db_ready: None, tmp_path: Path
|
||||
) -> None:
|
||||
asyncio.run(_scenario_success_clears_and_proceeds(app_url, tmp_path))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Failure (the network drop): the state clears, the banner shows,
|
||||
# the draft + the attachment remain for the retry, NO turn ran
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _scenario_failure_clears_and_keeps_draft(app_url: str, tmp_path: Path) -> None:
|
||||
png = _png_file(tmp_path)
|
||||
async with async_playwright() as p:
|
||||
browser = await p.chromium.launch(headless=True)
|
||||
try:
|
||||
page = await browser.new_page(viewport={"width": 1280, "height": 800})
|
||||
page.set_default_timeout(30_000)
|
||||
# The route DELAYS (the in-flight state is assertable, same
|
||||
# as the other tests) then ABORTS — the fetch rejects and
|
||||
# uploadAttachedImage's catch settles the network-drop
|
||||
# banner.
|
||||
await _send_with_held_upload(page, app_url, png, fail=True)
|
||||
# The OUT-OF-TURN banner (the network-drop copy).
|
||||
banner = page.locator("#kb-banner")
|
||||
await expect(banner).to_be_visible(timeout=SETTLE_TIMEOUT_MS)
|
||||
await expect(banner).to_have_class(re.compile(r"\bis-error\b"))
|
||||
await expect(page.locator("#kb-banner-text")).to_contain_text(
|
||||
"Couldn't attach the image — try again."
|
||||
)
|
||||
# The uploading state is CLEARED on the failure path too.
|
||||
strip = page.locator("#attach-preview")
|
||||
assert "is-uploading" not in (await strip.get_attribute("class") or "")
|
||||
await expect(page.locator(".attach-preview-status")).to_be_hidden()
|
||||
await expect(page.locator("#attach-remove")).to_be_enabled()
|
||||
await expect(page.locator("#send-btn")).to_be_enabled()
|
||||
await expect(page.locator("#send-label")).to_have_text("Send")
|
||||
# The strip is back in its NORMAL preview — the attachment
|
||||
# remains for the retry (the thumbnail + the filename).
|
||||
await expect(strip).to_be_visible()
|
||||
await expect(
|
||||
page.locator("#attach-preview .attach-preview-name")
|
||||
).to_have_text(PNG_NAME)
|
||||
src = await page.locator("#attach-preview img").get_attribute("src") or ""
|
||||
assert src.startswith("data:image/png;base64,"), "the preview keeps the data URL"
|
||||
# The typed question is INTACT (the send was blocked — A8).
|
||||
assert await page.locator("#message-input").input_value() == QUESTION
|
||||
# NO turn ran (the question never went out without the image).
|
||||
await expect(page.locator(".msg.user")).to_have_count(0)
|
||||
await expect(page.locator(".msg.brain")).to_have_count(0)
|
||||
# The request was intercepted exactly once (it never reached
|
||||
# the server — the abort dropped it).
|
||||
assert len(UPLOAD_REQUESTS) == 1, UPLOAD_REQUESTS
|
||||
finally:
|
||||
await browser.close()
|
||||
|
||||
|
||||
def test_upload_failure_clears_state_and_keeps_the_draft(
|
||||
app_url: str, mock_llm: int, db_ready: None, tmp_path: Path
|
||||
) -> None:
|
||||
asyncio.run(_scenario_failure_clears_and_keeps_draft(app_url, tmp_path))
|
||||
@@ -34,6 +34,19 @@ wiring (a recording fake LLM, a fake retriever, a fake DB session,
|
||||
patched settings) so the whole validate → build contract runs without
|
||||
a stack; the upload/serve tests point ``chat_image_dir`` at a tmp
|
||||
directory (the raw-string ``expanduser`` convention).
|
||||
|
||||
Phase 132 (task 01, TODO L8) pins the PRE-turn uploading state in the
|
||||
same chat-frontend surface: the preview strip ships a HIDDEN status
|
||||
part (the house loading arc — decorative, aria-hidden — + the pinned
|
||||
"Uploading image…" text), and ``handleSend``'s attachedImage branch
|
||||
enters it exactly once per upload (after the ``attachUpload`` guard,
|
||||
before the ``await uploadAttachedImage``) and exits it on BOTH
|
||||
resolution paths (the helper never throws) — strip class
|
||||
``is-uploading`` + status unhidden + remove ✕ disabled + the Send
|
||||
button disabled with "Uploading…" on entry; the mirror image on exit
|
||||
with the idle label restored byte-for-byte. ``setUiState`` / ``uiState``
|
||||
/ the turn loader stay untouched (the upload is pre-turn; ``uiState``
|
||||
stays ``idle``).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -41,6 +54,8 @@ import base64
|
||||
import io
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import uuid
|
||||
from collections.abc import Iterator
|
||||
from datetime import UTC, datetime
|
||||
@@ -1168,3 +1183,425 @@ def test_the_bad_pick_and_the_reader_failure_keep_the_banner_copy() -> None:
|
||||
m = re.search(r'reader\.onerror = \(\) => \{(.*?)\n \};', js, re.S)
|
||||
assert m, "the FileReader's onerror branch must exist"
|
||||
assert 'showErrorBanner("Couldn\'t attach the image — try again.");' in m.group(1)
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# Phase 132 (task 01, TODO L8) — the PRE-turn uploading state
|
||||
# =====================================================================
|
||||
|
||||
|
||||
def _fn(js: str, name: str) -> str:
|
||||
"""The source of the first `function <name>` in js (brace balanced
|
||||
— the house pin pattern from tests/unit/test_sync_button.py)."""
|
||||
fn = js.find(f"function {name}")
|
||||
assert fn != -1, f"{name} must be defined"
|
||||
open_idx = js.find("{", fn)
|
||||
depth = 0
|
||||
for i in range(open_idx, len(js)):
|
||||
c = js[i]
|
||||
if c == "{":
|
||||
depth += 1
|
||||
elif c == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return js[fn : i + 1]
|
||||
raise AssertionError(f"unbalanced braces in {name}")
|
||||
|
||||
|
||||
def test_the_strip_ships_the_hidden_uploading_status_part() -> None:
|
||||
"""The preview strip's markup gains ONE status part INSIDE the
|
||||
strip — after the filename span, before the remove button —
|
||||
shipping HIDDEN (the house "ships hidden" rule): the house
|
||||
loading arc (the svg is decorative, aria-hidden — the arc is not
|
||||
the meaning) + the pinned "Uploading image…" text (it carries the
|
||||
meaning). The strip's existing parts (thumbnail, name, remove ✕
|
||||
+ its aria-label) are untouched, and the thumbnail + filename stay
|
||||
visible throughout the upload (the user watches THEIR image
|
||||
uploading — only the status part's `hidden` moves)."""
|
||||
html = _index_html()
|
||||
start = html.find('<div class="attach-preview" id="attach-preview" hidden>')
|
||||
assert start != -1
|
||||
strip = html[start : html.find("</div>", start)]
|
||||
# the status part: the exact shipped markup (hidden by default).
|
||||
assert '<span class="attach-preview-status" hidden>' in strip
|
||||
assert (
|
||||
'<svg class="attach-preview-status-icon" aria-hidden="true"'
|
||||
' viewBox="0 0 24 24" fill="none" stroke="currentColor"'
|
||||
' stroke-width="2" stroke-linecap="round">'
|
||||
'<path d="M21 12a9 9 0 1 1-6.2-8.56"/></svg>' in strip
|
||||
), "the house loading arc (decorative, aria-hidden) must ship"
|
||||
assert '<span class="attach-preview-status-text">Uploading image…</span>' in strip
|
||||
# exactly ONE status part, in the pinned order: img → name →
|
||||
# status → remove (inside the strip, before its closing tag).
|
||||
assert strip.count('class="attach-preview-status"') == 1
|
||||
i_img = strip.find('class="attach-preview-img"')
|
||||
i_name = strip.find('class="attach-preview-name"')
|
||||
i_status = strip.find('class="attach-preview-status" hidden')
|
||||
i_remove = strip.find('id="attach-remove"')
|
||||
assert -1 < i_img < i_name < i_status < i_remove, (
|
||||
"the status part sits after the name, before the remove button"
|
||||
)
|
||||
# the pre-phase parts are byte-untouched (the phase-123 contract).
|
||||
assert '<img class="attach-preview-img" alt="">' in strip
|
||||
assert 'aria-label="Remove the attached image"' in strip
|
||||
|
||||
|
||||
def test_enter_exit_upload_state_are_the_exact_affordance_writers() -> None:
|
||||
"""The two tiny helpers next to showAttachPreview / clearAttachedImage
|
||||
(A5, owner-confirmed 2026-09-27 — the double-spam block is the
|
||||
BOTH-halves affordance). enterUploadState: the strip gains the
|
||||
.is-uploading class (task 02's CSS hooks it), the status part
|
||||
unhides, the remove ✕ disables (the attachment is mid-flight), and
|
||||
the Send button disables with the "Uploading…" label. exitUploadState
|
||||
is the mirror image — and its idle restore is BYTE-IDENTICAL to
|
||||
setUiState's idle label ("Send"), so the success path hands the
|
||||
button to runTurn's setUiState("thinking") as usual and the
|
||||
failure path leaves a fully editable composer. The attach family
|
||||
is null-guarded (the house pattern); the send refs are
|
||||
unconditional (the composer is required). setUiState / uiState /
|
||||
the turn loader are NEVER touched — the upload is PRE-turn.
|
||||
The module-scope ref is the strip's status part (the text is
|
||||
static — no attachStatusText)."""
|
||||
js = _app_js()
|
||||
enter = _fn(js, "enterUploadState")
|
||||
exit_ = _fn(js, "exitUploadState")
|
||||
# entry — the full affordance, in the pinned order.
|
||||
i_preview = enter.find('attachPreview.classList.add("is-uploading")')
|
||||
i_status = enter.find("attachStatus.hidden = false")
|
||||
i_remove = enter.find("attachRemove.disabled = true")
|
||||
i_send = enter.find("sendBtn.disabled = true")
|
||||
i_label = enter.find('sendLabel.textContent = "Uploading…"')
|
||||
assert -1 < i_preview < i_status < i_remove < i_send < i_label
|
||||
# exit — the mirror image, ending on the exact idle label.
|
||||
i_preview = exit_.find('attachPreview.classList.remove("is-uploading")')
|
||||
i_status = exit_.find("attachStatus.hidden = true")
|
||||
i_remove = exit_.find("attachRemove.disabled = false")
|
||||
i_send = exit_.find("sendBtn.disabled = false")
|
||||
i_label = exit_.find('sendLabel.textContent = "Send"')
|
||||
assert -1 < i_preview < i_status < i_remove < i_send < i_label
|
||||
# the idle restore is byte-identical to setUiState's idle label —
|
||||
# the same literal "Send", so no stale "Uploading…" can survive.
|
||||
assert 'sendLabel.textContent = inFlight ? "Stop" : "Send";' in js
|
||||
# the house null-guard pattern on the attach family only (a New
|
||||
# chat mid-upload hides the strip; the class on a hidden element
|
||||
# is harmless and still cleared at resolution); the send refs are
|
||||
# unconditional (the chat view cannot exist without the composer).
|
||||
for fn_src in (enter, exit_):
|
||||
for guarded in ("attachPreview", "attachStatus", "attachRemove"):
|
||||
assert f"if ({guarded})" in fn_src, f"{guarded} must be null-guarded"
|
||||
assert "if (sendBtn)" not in fn_src and "if (sendLabel)" not in fn_src
|
||||
# the PRE-turn contract: neither helper may touch the turn's
|
||||
# state owners (the turn loader spins only when the turn starts).
|
||||
for fn_src in (enter, exit_):
|
||||
assert "setUiState" not in fn_src
|
||||
assert "uiState" not in fn_src
|
||||
assert "turnLoader" not in fn_src
|
||||
assert "innerHTML" not in fn_src, "the house rule: textContent only"
|
||||
# the module-scope ref: the status part (no text ref — static).
|
||||
assert 'const attachStatus = document.querySelector(".attach-preview-status");' in js
|
||||
assert "attachStatusText" not in js
|
||||
|
||||
|
||||
def test_handle_send_wraps_the_await_with_the_upload_state() -> None:
|
||||
"""In ``handleSend``'s attachedImage branch: ``enterUploadState()``
|
||||
lands EXACTLY ONCE — after the ``attachUpload = true;`` guard line
|
||||
and BEFORE ``await uploadAttachedImage`` (the moment the upload
|
||||
starts — never before, never twice); ``exitUploadState()`` lands
|
||||
EXACTLY ONCE — after the ``attachUpload = false;`` line (the
|
||||
helper never throws, so this is reached on BOTH success and
|
||||
failure) and BEFORE the ``if (path === null) return;`` block
|
||||
(the failure path exits the state, then returns with the banner +
|
||||
the editable composer + the attachment for the retry). The
|
||||
``attachUpload`` guard is byte-untouched, the upload helper is
|
||||
still called from exactly one site (the def + the call), and
|
||||
``handleSend`` itself never calls ``setUiState`` (the pre-turn
|
||||
contract — the turn's own state takes over inside runTurn)."""
|
||||
js = _app_js()
|
||||
# the EXACT handleSend body (brace matched — the file-order
|
||||
# neighbors of handleSend carry their own setUiState references in
|
||||
# their docstrings; the pin is on this function alone).
|
||||
body = _fn(js, "handleSend")
|
||||
i_guard = body.index("if (attachUpload) return;")
|
||||
i_arm = body.index("attachUpload = true;")
|
||||
i_enter = body.index("enterUploadState();")
|
||||
i_await = body.index("await uploadAttachedImage(attachedImage.file)")
|
||||
i_release = body.index("attachUpload = false;")
|
||||
i_exit = body.index("exitUploadState();")
|
||||
i_block = body.index("if (path === null) return;")
|
||||
assert -1 < i_guard < i_arm < i_enter < i_await < i_release < i_exit < i_block, (
|
||||
"guard → arm → ENTER → await → release → EXIT → block, in order"
|
||||
)
|
||||
assert body.count("enterUploadState();") == 1, "entered exactly once per upload"
|
||||
assert body.count("exitUploadState();") == 1, (
|
||||
"exited exactly once — the helper never throws, one line covers both paths"
|
||||
)
|
||||
# the pre-phase contract is byte-untouched: the guard, the single
|
||||
# call site of the upload helper (def + call = 2 occurrences),
|
||||
# and the A8 block line.
|
||||
assert (
|
||||
"if (attachUpload) return; // a second submit mid-upload: "
|
||||
"the first owns the send (never two uploads, never two turns)" in js
|
||||
)
|
||||
assert js.count("uploadAttachedImage") == 2
|
||||
# handleSend itself never drives the turn's UI state — the upload
|
||||
# is PRE-turn (uiState stays idle across the whole flight); the
|
||||
# call is absent (the word survives only in the phase-48 comment).
|
||||
assert "setUiState(" not in body
|
||||
|
||||
|
||||
#: The node harness (the test-document-viewer pattern): the REAL
|
||||
#: extracted enterUploadState / exitUploadState run against stub
|
||||
#: element refs; every transition is snapshotted as a JSON row —
|
||||
#: [tag, isUploading, statusHidden, removeDisabled, sendDisabled,
|
||||
#: label]. Plain strings only (the script is concatenation-assembled).
|
||||
_UPLOAD_NODE_HEAD = """
|
||||
const _classes = new Set();
|
||||
const attachPreview = {
|
||||
classList: {
|
||||
add: (c) => _classes.add(c),
|
||||
remove: (c) => _classes.delete(c),
|
||||
contains: (c) => _classes.has(c),
|
||||
},
|
||||
};
|
||||
const attachStatus = { hidden: true };
|
||||
const attachRemove = { disabled: false };
|
||||
const sendBtn = { disabled: false };
|
||||
const sendLabel = { textContent: "Send" };
|
||||
const show = (tag) => {
|
||||
console.log(JSON.stringify([
|
||||
tag,
|
||||
_classes.has("is-uploading"),
|
||||
attachStatus.hidden,
|
||||
attachRemove.disabled,
|
||||
sendBtn.disabled,
|
||||
sendLabel.textContent,
|
||||
]));
|
||||
};
|
||||
"""
|
||||
|
||||
_UPLOAD_NODE_TAIL = """
|
||||
show("initial");
|
||||
enterUploadState();
|
||||
show("enter");
|
||||
exitUploadState();
|
||||
show("exit-success");
|
||||
enterUploadState();
|
||||
show("re-enter");
|
||||
exitUploadState();
|
||||
show("exit-failure");
|
||||
"""
|
||||
|
||||
#: node availability (the test-document-viewer pattern).
|
||||
HAVE_NODE = shutil.which("node") is not None
|
||||
|
||||
|
||||
def _run_node(script: str) -> str:
|
||||
proc = subprocess.run(["node", "-e", script], 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_state_transitions_under_node() -> None:
|
||||
"""Behavioral check (node) of the REAL enterUploadState /
|
||||
exitUploadState against stub element refs (the test-document-
|
||||
viewer pattern), across the pinned transition set:
|
||||
|
||||
* entry: the strip gains .is-uploading, the status part unhides,
|
||||
the remove ✕ disables, the Send disables with "Uploading…";
|
||||
* exit (success path): the mirror image — class removed, status
|
||||
re-hidden, remove re-enabled, Send re-enabled with "Send";
|
||||
* exit (failure path — the same call, banner the failure's
|
||||
signal, not the strip's): byte-identical to the success exit —
|
||||
the state is fully cleared either way (the user can retry or
|
||||
remove).
|
||||
"""
|
||||
js = _app_js()
|
||||
fns = "\n".join(_fn(js, name) for name in ("enterUploadState", "exitUploadState"))
|
||||
rows = [
|
||||
json.loads(line)
|
||||
for line in _run_node(_UPLOAD_NODE_HEAD + fns + _UPLOAD_NODE_TAIL).splitlines()
|
||||
if line
|
||||
]
|
||||
by_tag = {row[0]: row for row in rows}
|
||||
# row = [tag, isUploading, statusHidden, removeDisabled, sendDisabled, label]
|
||||
assert by_tag["initial"] == ["initial", False, True, False, False, "Send"]
|
||||
assert by_tag["enter"] == ["enter", True, False, True, True, "Uploading…"], (
|
||||
"entry is the full affordance — strip + both halves of the button"
|
||||
)
|
||||
# success and failure exits are byte-identical: fully cleared, the
|
||||
# idle label restored exactly.
|
||||
for tag in ("exit-success", "exit-failure"):
|
||||
assert by_tag[tag] == [tag, False, True, False, False, "Send"], tag
|
||||
# a re-entry (the retry after a failed upload) re-arms the full
|
||||
# affordance — the helpers are idempotent in both directions.
|
||||
assert by_tag["re-enter"] == ["re-enter", True, False, True, True, "Uploading…"]
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# Phase 132 (task 02, TODO L8) — the strip's uploading CSS (the visual
|
||||
# half of the affordance: the dimmed thumbnail, the hidden name slot,
|
||||
# the spinning --ink-soft status, the dimmed remove ✕, the reduced-
|
||||
# motion still, and the verified house :disabled language on the Send)
|
||||
# =====================================================================
|
||||
|
||||
|
||||
def _phase132_css() -> str:
|
||||
"""The Phase 132 CSS block in styles.css — from its comment marker
|
||||
to the next top-level rule (the ``.composer`` that follows the
|
||||
``.attach-preview*`` family) — with the comments stripped, so the
|
||||
raw-color audit sees only declarations."""
|
||||
css = _css()
|
||||
start = css.find("/* Phase 132 (task 02, TODO L8):")
|
||||
assert start != -1, "the Phase 132 CSS block must exist in styles.css"
|
||||
end = css.find("\n.composer {", start)
|
||||
assert end != -1, "the Phase 132 block must sit before the .composer rule"
|
||||
return re.sub(r"/\*.*?\*/", "", css[start:end], flags=re.S)
|
||||
|
||||
|
||||
def test_the_uploading_strip_dims_the_thumbnail_and_keeps_the_name_slot() -> None:
|
||||
"""The in-flight strip: the thumbnail DIMS (opacity 0.5 — it is
|
||||
decorative, no contrast duty), and the filename HIDES with
|
||||
visibility (NOT display/width — the flex: 1 slot keeps its measured
|
||||
space, so the status takes the row's right hand without reflowing
|
||||
the pinned 44px remove button; the strip height is stable)."""
|
||||
css = _css()
|
||||
m = re.search(r"\.attach-preview\.is-uploading \.attach-preview-img \{(.*?)\}", css, re.S)
|
||||
assert m, "the uploading thumbnail must dim"
|
||||
assert "opacity: 0.5;" in m.group(1)
|
||||
m = re.search(r"\.attach-preview\.is-uploading \.attach-preview-name \{(.*?)\}", css, re.S)
|
||||
assert m, "the uploading name must hide"
|
||||
body = m.group(1)
|
||||
assert "visibility: hidden;" in body
|
||||
assert "display: none" not in body and "width: 0" not in body, (
|
||||
"the name slot must keep its space (visibility, not removal)"
|
||||
)
|
||||
|
||||
|
||||
def test_the_uploading_status_row_is_the_ink_soft_small_text() -> None:
|
||||
"""The status part's row: the house inline-flex language (the icon
|
||||
+ the text as one unit), --ink-soft at the strip's small text size
|
||||
(the name's 0.85rem), and min-width 0 (the row's clamp — at 320px
|
||||
the text wraps rather than clipping; verified in the task's browser
|
||||
pass: one line at 320px, no clip)."""
|
||||
m = re.search(r"\.attach-preview-status \{(.*?)\}", _css(), re.S)
|
||||
assert m, ".attach-preview-status must be styled"
|
||||
body = m.group(1)
|
||||
for needle in (
|
||||
"display: inline-flex;",
|
||||
"align-items: center;",
|
||||
"gap: 0.35rem;",
|
||||
"color: var(--ink-soft);",
|
||||
"font-size: 0.85rem;",
|
||||
"min-width: 0;",
|
||||
):
|
||||
assert needle in body, f".attach-preview-status must keep: {needle}"
|
||||
|
||||
|
||||
def test_the_status_icon_is_the_14px_house_spin_at_the_sync_pace() -> None:
|
||||
"""The arc: 14px (the strip's small-text scale), flex 0 0 auto (the
|
||||
.sync-icon house pattern), animated with the HOUSE `spin`
|
||||
keyframes at 1s linear — .sync-icon.is-spinning's in-flight pace
|
||||
(the .spinner's 0.8s is the busy-turn arc's). The keyframes must
|
||||
EXIST — a dangling animation-name would spin nothing and the
|
||||
"in motion" state would be invisible."""
|
||||
m = re.search(r"\.attach-preview-status-icon \{(.*?)\}", _css(), re.S)
|
||||
assert m, ".attach-preview-status-icon must be styled"
|
||||
body = m.group(1)
|
||||
for needle in ("width: 14px;", "height: 14px;", "flex: 0 0 auto;"):
|
||||
assert needle in body, f".attach-preview-status-icon must keep: {needle}"
|
||||
assert "animation: spin 1s linear infinite;" in body
|
||||
assert re.search(r"@keyframes spin \{\s*to \{ transform: rotate\(360deg\); \}\s*\}", _css()), (
|
||||
"the house `spin` keyframes must exist (the arc's animation-name)"
|
||||
)
|
||||
|
||||
|
||||
def test_reduced_motion_stills_the_uploading_arc() -> None:
|
||||
"""The house reduced-motion block: with prefers-reduced-motion the
|
||||
arc STILLS (animation none) — the "Uploading image…" text + the
|
||||
dimmed thumbnail then carry the state without motion (B5: never
|
||||
motion alone; the state is text + dimming, not the spin)."""
|
||||
m = re.search(
|
||||
r"@media \(prefers-reduced-motion: reduce\) \{\s*"
|
||||
r"\.attach-preview-status-icon \{ animation: none; \}\s*\}",
|
||||
_css(),
|
||||
)
|
||||
assert m, "the house reduced-motion block must still the uploading arc"
|
||||
|
||||
|
||||
def test_the_disabled_remove_x_dims_with_the_disabled_affordance() -> None:
|
||||
"""The remove ✕ (task 01 disables it mid-flight via the attribute)
|
||||
dims to 0.5 while uploading — the attribute is the state, the
|
||||
opacity is its visual half (the same 0.5 as the dimmed thumbnail,
|
||||
the house dim language)."""
|
||||
m = re.search(r"\.attach-preview\.is-uploading \.attach-preview-remove \{(.*?)\}", _css(), re.S)
|
||||
assert m, "the uploading remove button must dim"
|
||||
assert "opacity: 0.5;" in m.group(1)
|
||||
|
||||
|
||||
def test_the_disabled_send_rule_is_the_existing_house_rule_not_a_new_visual() -> None:
|
||||
"""Task 02's verification step: the Send is disabled via the
|
||||
`disabled` attribute (task 01) and the EXISTING house rule
|
||||
``.send-btn:disabled`` already reads as disabled — the
|
||||
--brand-busy background (the brand brightened 40% toward white,
|
||||
visibly lighter than the enabled --brand fill, and a derived
|
||||
variable that follows every saved theme) + the not-allowed
|
||||
cursor. The pin says the rule is INTACT: the upload state must
|
||||
not invent a second disabled look (no new color, no new weight).
|
||||
(Verified in the task's browser pass: the rule applies to
|
||||
#send-btn, which rides .send-btn.)"""
|
||||
m = re.search(r"\.send-btn:disabled \{(.*?)\}", _css(), re.S)
|
||||
assert m, "the house .send-btn:disabled rule must exist"
|
||||
body = m.group(1)
|
||||
assert "background: var(--brand-busy);" in body
|
||||
assert "cursor: not-allowed;" in body
|
||||
|
||||
|
||||
def test_the_phase132_css_ships_no_raw_color_and_holds_the_aa_pair() -> None:
|
||||
"""The theme audit (the B4 byte-identical contract): every value
|
||||
in the Phase 132 block is a theme variable or a neutral number —
|
||||
NO raw hex/rgb/hsl (a custom theme repaints everything through the
|
||||
variables). And the status text's --ink-soft on the strip's
|
||||
--surface holds the AA floor: computed here against WCAG 2.1 from
|
||||
the built-in :root literals (the theme phases' method) — 8.2:1,
|
||||
≥ 4.5:1."""
|
||||
block = _phase132_css()
|
||||
assert not re.search(r"#[0-9a-fA-F]{3,8}\b", block), "no raw hex in the Phase 132 block"
|
||||
assert not re.search(r"\b(?:rgb|rgba|hsl|hsla)\(|\bsrgb\b", block), (
|
||||
"no raw rgb/hsl in the Phase 132 block"
|
||||
)
|
||||
ratio = _wcag_contrast(_root_color("--ink-soft"), _root_color("--surface"))
|
||||
assert ratio >= 4.5, f"--ink-soft on --surface = {ratio:.2f}:1 (< 4.5:1 AA floor)"
|
||||
|
||||
|
||||
def _root_color(name: str) -> str:
|
||||
"""A built-in :root literal color (the theme phases audit the
|
||||
built-in palette this way; a saved theme repaints through the
|
||||
variables and is checked by the theme E2E)."""
|
||||
m = re.search(rf"(?m)^\s*{name}:\s*(#[0-9a-fA-F]{{6}})", _css())
|
||||
assert m, f"{name} must ship a plain built-in color in :root"
|
||||
return m.group(1)
|
||||
|
||||
|
||||
def _hex_rgb(value: str) -> tuple[int, int, int]:
|
||||
h = value.lstrip("#")
|
||||
return int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
|
||||
|
||||
|
||||
def _rel_luminance(rgb: tuple[int, int, int]) -> float:
|
||||
"""WCAG 2.1 relative luminance (the house helper's math — the
|
||||
test_responsive_polish / test_dark_tech_theme contrast helper)."""
|
||||
|
||||
def chan(c: int) -> float:
|
||||
s = c / 255
|
||||
return s / 12.92 if s <= 0.04045 else ((s + 0.055) / 1.055) ** 2.4
|
||||
|
||||
r, g, b = (chan(c) for c in rgb)
|
||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b
|
||||
|
||||
|
||||
def _wcag_contrast(fg: str, bg: str) -> float:
|
||||
l1, l2 = _rel_luminance(_hex_rgb(fg)), _rel_luminance(_hex_rgb(bg))
|
||||
if l1 < l2:
|
||||
l1, l2 = l2, l1
|
||||
return (l1 + 0.05) / (l2 + 0.05)
|
||||
|
||||
Reference in New Issue
Block a user