phase: 115_doc_draft_discard
All green — this was the final verification pass; everything from the four completed tasks was already in the working tree and verified. **Phase 115 — Doc drafts: Discard + DELETE route + title fix — verification report** - Verified all 4 task deliverables present: DELETE route (`app/api/doc_drafts.py`), Discard UI (`doc-edit.html` + `doc-edit.js` + `.discard-draft` CSS), title fix (`defaultDocTitle(wrap)` pairing + `saveAsDoc` call site), and all test pins (integration, frontend unit, E2E). No code changes needed. - **Completion criteria:** 1. ✅ Orphaned draft discardable from edit screen; row gone — `test_delete_removes_row_and_invalidates_token` (204 → GET 404), unknown-token 404, admin-gate 403 on all routes, E2E `test_discard_draft_from_edit_screen` all pass. 2. ✅ Title after retry redo = redone answer's own question — E2E `test_save_title_is_the_redo_question_after_retry` passes. 3. ✅ Push flow byte-identical — `git diff` shows only the new DELETE route + module docstring; all 7 existing push tests green. 4. ✅ `uv run pytest --cov=app` → **2457 passed**, app coverage **99%** (>90%); `uv run pytest tests/e2e/test_save_doc_session.py -v --no-cov` → **4 passed**; `uv run ruff check .` → clean; `uv run pyright` → 0 errors. 5. ⏳ Commit + phase-dir move left to the harness (per executor rules, no `git` run; all changes left in the working tree). - No defects found; no deviations. - Next pending phase: none in `todo/` other than this one (`115_doc_draft_discard` is the last).
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
All green — this was the final verification pass; everything from the four completed tasks was already in the working tree and verified.
|
||||
|
||||
**Phase 115 — Doc drafts: Discard + DELETE route + title fix — verification report**
|
||||
|
||||
- Verified all 4 task deliverables present: DELETE route (`app/api/doc_drafts.py`), Discard UI (`doc-edit.html` + `doc-edit.js` + `.discard-draft` CSS), title fix (`defaultDocTitle(wrap)` pairing + `saveAsDoc` call site), and all test pins (integration, frontend unit, E2E). No code changes needed.
|
||||
- **Completion criteria:**
|
||||
1. ✅ Orphaned draft discardable from edit screen; row gone — `test_delete_removes_row_and_invalidates_token` (204 → GET 404), unknown-token 404, admin-gate 403 on all routes, E2E `test_discard_draft_from_edit_screen` all pass.
|
||||
2. ✅ Title after retry redo = redone answer's own question — E2E `test_save_title_is_the_redo_question_after_retry` passes.
|
||||
3. ✅ Push flow byte-identical — `git diff` shows only the new DELETE route + module docstring; all 7 existing push tests green.
|
||||
4. ✅ `uv run pytest --cov=app` → **2457 passed**, app coverage **99%** (>90%); `uv run pytest tests/e2e/test_save_doc_session.py -v --no-cov` → **4 passed**; `uv run ruff check .` → clean; `uv run pyright` → 0 errors.
|
||||
5. ⏳ Commit + phase-dir move left to the harness (per executor rules, no `git` run; all changes left in the working tree).
|
||||
- No defects found; no deviations.
|
||||
- Next pending phase: none in `todo/` other than this one (`115_doc_draft_discard` is the last).
|
||||
@@ -0,0 +1,103 @@
|
||||
........................................................................ [ 2%]
|
||||
........................................................................ [ 5%]
|
||||
........................................................................ [ 8%]
|
||||
........................................................................ [ 11%]
|
||||
........................................................................ [ 14%]
|
||||
........................................................................ [ 17%]
|
||||
........................................................................ [ 20%]
|
||||
........................................................................ [ 23%]
|
||||
........................................................................ [ 26%]
|
||||
........................................................................ [ 29%]
|
||||
........................................................................ [ 32%]
|
||||
........................................................................ [ 35%]
|
||||
........................................................................ [ 38%]
|
||||
........................................................................ [ 41%]
|
||||
........................................................................ [ 43%]
|
||||
........................................................................ [ 46%]
|
||||
........................................................................ [ 49%]
|
||||
........................................................................ [ 52%]
|
||||
........................................................................ [ 55%]
|
||||
........................................................................ [ 58%]
|
||||
........................................................................ [ 61%]
|
||||
........................................................................ [ 64%]
|
||||
........................................................................ [ 67%]
|
||||
........................................................................ [ 70%]
|
||||
........................................................................ [ 73%]
|
||||
........................................................................ [ 76%]
|
||||
........................................................................ [ 79%]
|
||||
........................................................................ [ 82%]
|
||||
........................................................................ [ 84%]
|
||||
........................................................................ [ 87%]
|
||||
........................................................................ [ 90%]
|
||||
........................................................................ [ 93%]
|
||||
........................................................................ [ 96%]
|
||||
........................................................................ [ 99%]
|
||||
......... [100%]
|
||||
=============================== warnings summary ===============================
|
||||
.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1
|
||||
/var/home/ducoterra/Projects/Personal/brain-of-reese/.venv/lib64/python3.14/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.14.7-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 224 1 99%
|
||||
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 156 1 99%
|
||||
app/api/git_sources.py 232 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 139 0 100%
|
||||
app/api/tokens.py 40 0 100%
|
||||
app/api/ui_settings.py 55 0 100%
|
||||
app/config.py 210 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 66 0 100%
|
||||
app/models.py 128 0 100%
|
||||
app/rag/__init__.py 0 0 100%
|
||||
app/rag/agent.py 317 1 99%
|
||||
app/rag/archive_upload.py 134 0 100%
|
||||
app/rag/chunker.py 206 4 98%
|
||||
app/rag/doc_dates.py 18 0 100%
|
||||
app/rag/folder_summaries.py 123 0 100%
|
||||
app/rag/git_sources.py 14 0 100%
|
||||
app/rag/importer.py 215 3 99%
|
||||
app/rag/llm.py 244 1 99%
|
||||
app/rag/overview.py 71 0 100%
|
||||
app/rag/prompts.py 88 0 100%
|
||||
app/rag/retriever.py 192 3 98%
|
||||
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 24 0 100%
|
||||
app/schemas.py 329 0 100%
|
||||
--------------------------------------------------
|
||||
TOTAL 4147 16 99%
|
||||
coverage gate: app/ 99% (>90%) OK
|
||||
All checks passed!
|
||||
0 errors, 0 warnings, 0 informations
|
||||
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
|
||||
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
|
||||
|
||||
validation OK
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
Task 01 complete. Report:
|
||||
|
||||
**Implemented**
|
||||
- `DELETE /api/doc-drafts/{token}` in `app/api/doc_drafts.py` — 204 No Content, deletes the row via the existing `_get_draft_or_404` helper (unknown token → 404, malformed → 422); admin-gated by the router-level `require_admin` (locked A2). Placed after PUT, before push; file's int-literal status-code idiom used (no `status` import exists). Module docstring route list updated.
|
||||
- Integration tests in `tests/integration/test_doc_drafts_api.py`: delete → 204 + row gone from Postgres + GET/PUT/push all 404; unknown token → 404; malformed token → 422; works on a `pushed` draft (no push-side state guards it); anonymous DELETE → 403 added to the existing "403 on all routes" test.
|
||||
|
||||
**Results**
|
||||
- `uv run pytest tests/integration/test_doc_drafts_api.py -v --no-cov` → 32 passed
|
||||
- `uv run pytest --cov=app --cov-report=term-missing` → 2448 passed, TOTAL 99% (>90%), `app/api/doc_drafts.py` 100%
|
||||
- `uv run ruff check .` → clean; `uv run pyright` → 0 errors, 0 warnings
|
||||
|
||||
**Notes**
|
||||
- No migration, no other route touched; push flow byte-identical. Core route tests added now (completion criteria) — task 04 will extend with frontend/E2E pins.
|
||||
|
||||
**Next pending task:** `02_discard_ui.md` (Discard control on the doc-edit screen).
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
........................................................................ [ 2%]
|
||||
........................................................................ [ 5%]
|
||||
........................................................................ [ 8%]
|
||||
........................................................................ [ 11%]
|
||||
........................................................................ [ 14%]
|
||||
........................................................................ [ 17%]
|
||||
........................................................................ [ 20%]
|
||||
........................................................................ [ 23%]
|
||||
........................................................................ [ 26%]
|
||||
........................................................................ [ 29%]
|
||||
........................................................................ [ 32%]
|
||||
........................................................................ [ 35%]
|
||||
........................................................................ [ 38%]
|
||||
........................................................................ [ 41%]
|
||||
........................................................................ [ 44%]
|
||||
........................................................................ [ 47%]
|
||||
........................................................................ [ 50%]
|
||||
........................................................................ [ 52%]
|
||||
........................................................................ [ 55%]
|
||||
........................................................................ [ 58%]
|
||||
........................................................................ [ 61%]
|
||||
........................................................................ [ 64%]
|
||||
........................................................................ [ 67%]
|
||||
........................................................................ [ 70%]
|
||||
........................................................................ [ 73%]
|
||||
........................................................................ [ 76%]
|
||||
........................................................................ [ 79%]
|
||||
........................................................................ [ 82%]
|
||||
........................................................................ [ 85%]
|
||||
........................................................................ [ 88%]
|
||||
........................................................................ [ 91%]
|
||||
........................................................................ [ 94%]
|
||||
........................................................................ [ 97%]
|
||||
........................................................................ [100%]
|
||||
=============================== warnings summary ===============================
|
||||
.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1
|
||||
/var/home/ducoterra/Projects/Personal/brain-of-reese/.venv/lib64/python3.14/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.14.7-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 224 1 99%
|
||||
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 156 1 99%
|
||||
app/api/git_sources.py 232 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 139 0 100%
|
||||
app/api/tokens.py 40 0 100%
|
||||
app/api/ui_settings.py 55 0 100%
|
||||
app/config.py 210 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 66 0 100%
|
||||
app/models.py 128 0 100%
|
||||
app/rag/__init__.py 0 0 100%
|
||||
app/rag/agent.py 317 1 99%
|
||||
app/rag/archive_upload.py 134 0 100%
|
||||
app/rag/chunker.py 206 4 98%
|
||||
app/rag/doc_dates.py 18 0 100%
|
||||
app/rag/folder_summaries.py 123 0 100%
|
||||
app/rag/git_sources.py 14 0 100%
|
||||
app/rag/importer.py 215 3 99%
|
||||
app/rag/llm.py 244 1 99%
|
||||
app/rag/overview.py 71 0 100%
|
||||
app/rag/prompts.py 88 0 100%
|
||||
app/rag/retriever.py 192 3 98%
|
||||
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 24 0 100%
|
||||
app/schemas.py 329 0 100%
|
||||
--------------------------------------------------
|
||||
TOTAL 4147 16 99%
|
||||
coverage gate: app/ 99% (>90%) OK
|
||||
All checks passed!
|
||||
0 errors, 0 warnings, 0 informations
|
||||
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
|
||||
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
|
||||
|
||||
validation OK
|
||||
@@ -0,0 +1,10 @@
|
||||
All green. Final report:
|
||||
|
||||
**Task 02 — Discard control on the doc-edit screen — complete**
|
||||
|
||||
- `frontend/doc-edit.html`: "Discard draft" button (`#discard-draft`, exact task-spec markup) in `.doc-edit-actions` between the push button and the back link, with a phase-115 comment
|
||||
- `frontend/assets/doc-edit.js`: `wireDiscard()` — native `confirm()` first → `DELETE /api/doc-drafts/${draftToken}` (same token as GET/PUT) → 204 → `location.assign("/")`; non-204 → stale status cleared + `#push-error` inline banner (422-shape-aware `apiDetail`), no navigation; network failure → fixed one-line copy; §7.4 in-flight state (disable + "Discarding…", restored in `finally`); no-token guard mirrors push; file-header contract doc updated
|
||||
- `frontend/assets/styles.css`: `.discard-draft` — secondary ghost family (`.steering-delete`/`.tuning-delete` convention: ink-soft/`--line`, err-family hover 9.3:1, 44px touch floor, `:disabled` state); global 3px `:focus-visible` applies
|
||||
- Decision: task-spec native `confirm()` used — phase 69's alertdialog is page-local to the SPA Sources view, not a shared/theme asset, so the task's escape hatch doesn't apply
|
||||
- Tests: `uv run pytest` → 2448 passed; `--cov=app` → 99% (>90% gate); `ruff check .` clean; `pyright` 0 errors; `node --check` valid; TestClient smoke: served `/doc-edit.html` 200 with the button. (Task's own unit/E2E pins ship in task 04 per its Testing section — not started)
|
||||
- Next pending task: `.agents/phases/todo/115_doc_draft_discard/03_draft_title_fix.md`
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
........................................................................ [ 2%]
|
||||
........................................................................ [ 5%]
|
||||
........................................................................ [ 8%]
|
||||
........................................................................ [ 11%]
|
||||
........................................................................ [ 14%]
|
||||
........................................................................ [ 17%]
|
||||
........................................................................ [ 20%]
|
||||
........................................................................ [ 23%]
|
||||
........................................................................ [ 26%]
|
||||
........................................................................ [ 29%]
|
||||
........................................................................ [ 32%]
|
||||
........................................................................ [ 35%]
|
||||
........................................................................ [ 38%]
|
||||
........................................................................ [ 41%]
|
||||
........................................................................ [ 44%]
|
||||
........................................................................ [ 47%]
|
||||
........................................................................ [ 50%]
|
||||
........................................................................ [ 52%]
|
||||
........................................................................ [ 55%]
|
||||
........................................................................ [ 58%]
|
||||
........................................................................ [ 61%]
|
||||
........................................................................ [ 64%]
|
||||
........................................................................ [ 67%]
|
||||
........................................................................ [ 70%]
|
||||
........................................................................ [ 73%]
|
||||
........................................................................ [ 76%]
|
||||
........................................................................ [ 79%]
|
||||
........................................................................ [ 82%]
|
||||
........................................................................ [ 85%]
|
||||
........................................................................ [ 88%]
|
||||
........................................................................ [ 91%]
|
||||
........................................................................ [ 94%]
|
||||
........................................................................ [ 97%]
|
||||
........................................................................ [100%]
|
||||
=============================== warnings summary ===============================
|
||||
.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1
|
||||
/var/home/ducoterra/Projects/Personal/brain-of-reese/.venv/lib64/python3.14/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.14.7-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 224 1 99%
|
||||
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 156 1 99%
|
||||
app/api/git_sources.py 232 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 139 0 100%
|
||||
app/api/tokens.py 40 0 100%
|
||||
app/api/ui_settings.py 55 0 100%
|
||||
app/config.py 210 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 66 0 100%
|
||||
app/models.py 128 0 100%
|
||||
app/rag/__init__.py 0 0 100%
|
||||
app/rag/agent.py 317 1 99%
|
||||
app/rag/archive_upload.py 134 0 100%
|
||||
app/rag/chunker.py 206 4 98%
|
||||
app/rag/doc_dates.py 18 0 100%
|
||||
app/rag/folder_summaries.py 123 0 100%
|
||||
app/rag/git_sources.py 14 0 100%
|
||||
app/rag/importer.py 215 3 99%
|
||||
app/rag/llm.py 244 1 99%
|
||||
app/rag/overview.py 71 0 100%
|
||||
app/rag/prompts.py 88 0 100%
|
||||
app/rag/retriever.py 192 3 98%
|
||||
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 24 0 100%
|
||||
app/schemas.py 329 0 100%
|
||||
--------------------------------------------------
|
||||
TOTAL 4147 16 99%
|
||||
coverage gate: app/ 99% (>90%) OK
|
||||
All checks passed!
|
||||
0 errors, 0 warnings, 0 informations
|
||||
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.414).
|
||||
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
|
||||
|
||||
validation OK
|
||||
@@ -0,0 +1,17 @@
|
||||
All green. Task 03 complete.
|
||||
|
||||
**Implemented**
|
||||
- `frontend/assets/app.js`: `defaultDocTitle(wrap)` — when the saved brain bubble's wrap is given, the title is the nearest preceding `.msg.user` bubble's `.bubble` text (DOM-structural pairing, redo-in-place safe); falls back to the last-user-record-in-`conversation` rule when wrap is absent or no pair found. `DOC_TITLE_MAX` slice / whitespace collapse / "Note" unchanged.
|
||||
- `saveAsDoc(btn)` call site now passes `btn.closest(".msg.brain")`; house doc comments updated.
|
||||
- Unit pins (`tests/unit/test_save_as_doc_button.py`): replaced the stale last-question test with `test_app_js_default_title_is_the_paired_user_question` + `test_app_js_save_as_doc_passes_the_bubble_ancestor` (incl. "no caller stays on the no-wrap fallback" pin).
|
||||
- Updated the deliberate behavior change in `tests/e2e/test_save_doc_session.py::test_earlier_bubble_button_saves_whole_session` — saving from the FIRST bubble now titles Q1 (its own question) instead of Q3; last-bubble flow (Q3) is regression-checked unchanged.
|
||||
|
||||
**Results**
|
||||
- `uv run pytest` → exit 0, all pass
|
||||
- `uv run pytest --cov=app --cov-report=term-missing` → exit 0, `app/` TOTAL 99% (>90% gate)
|
||||
- `uv run pytest tests/e2e/test_save_doc_session.py -v --no-cov` → 2 passed in isolation
|
||||
- `uv run ruff check . && uv run pyright` → clean (0 errors)
|
||||
|
||||
**Decisions:** title text read from the user bubble's `.bubble` (not whole wrap — meta rows carry button labels). No git commit made (harness commits per phase).
|
||||
|
||||
**Next pending task:** `115_doc_draft_discard/04_draft_discard_tests.md` (API + frontend + E2E pins, incl. the title-after-retry pin).
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
........................................................................ [ 2%]
|
||||
........................................................................ [ 5%]
|
||||
........................................................................ [ 8%]
|
||||
........................................................................ [ 11%]
|
||||
........................................................................ [ 14%]
|
||||
........................................................................ [ 17%]
|
||||
........................................................................ [ 20%]
|
||||
........................................................................ [ 23%]
|
||||
........................................................................ [ 26%]
|
||||
........................................................................ [ 29%]
|
||||
........................................................................ [ 32%]
|
||||
........................................................................ [ 35%]
|
||||
........................................................................ [ 38%]
|
||||
........................................................................ [ 41%]
|
||||
........................................................................ [ 44%]
|
||||
........................................................................ [ 47%]
|
||||
........................................................................ [ 49%]
|
||||
........................................................................ [ 52%]
|
||||
........................................................................ [ 55%]
|
||||
........................................................................ [ 58%]
|
||||
........................................................................ [ 61%]
|
||||
........................................................................ [ 64%]
|
||||
........................................................................ [ 67%]
|
||||
........................................................................ [ 70%]
|
||||
........................................................................ [ 73%]
|
||||
........................................................................ [ 76%]
|
||||
........................................................................ [ 79%]
|
||||
........................................................................ [ 82%]
|
||||
........................................................................ [ 85%]
|
||||
........................................................................ [ 88%]
|
||||
........................................................................ [ 91%]
|
||||
........................................................................ [ 94%]
|
||||
........................................................................ [ 97%]
|
||||
........................................................................ [ 99%]
|
||||
. [100%]
|
||||
=============================== warnings summary ===============================
|
||||
.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1
|
||||
/var/home/ducoterra/Projects/Personal/brain-of-reese/.venv/lib64/python3.14/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.14.7-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 224 1 99%
|
||||
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 156 1 99%
|
||||
app/api/git_sources.py 232 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 139 0 100%
|
||||
app/api/tokens.py 40 0 100%
|
||||
app/api/ui_settings.py 55 0 100%
|
||||
app/config.py 210 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 66 0 100%
|
||||
app/models.py 128 0 100%
|
||||
app/rag/__init__.py 0 0 100%
|
||||
app/rag/agent.py 317 1 99%
|
||||
app/rag/archive_upload.py 134 0 100%
|
||||
app/rag/chunker.py 206 4 98%
|
||||
app/rag/doc_dates.py 18 0 100%
|
||||
app/rag/folder_summaries.py 123 0 100%
|
||||
app/rag/git_sources.py 14 0 100%
|
||||
app/rag/importer.py 215 3 99%
|
||||
app/rag/llm.py 244 1 99%
|
||||
app/rag/overview.py 71 0 100%
|
||||
app/rag/prompts.py 88 0 100%
|
||||
app/rag/retriever.py 192 3 98%
|
||||
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 24 0 100%
|
||||
app/schemas.py 329 0 100%
|
||||
--------------------------------------------------
|
||||
TOTAL 4147 16 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
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
All green. Final report:
|
||||
|
||||
**Phase 115 task 04 — API + frontend + E2E pins for discard + title fix**
|
||||
|
||||
- **Integration** (`tests/integration/test_doc_drafts_api.py`): DELETE section already landed in-tree by tasks 01–03 (204 + row gone + GET/PUT/push 404, unknown-token 404, malformed 422, anonymous 403, pushed-draft discard) — verified all 32 pass.
|
||||
- **Unit (new)** `tests/unit/test_frontend_doc_draft_discard.py`: 8 house source-assertion pins — `#discard-draft` placement/copy in the actions row; confirm-before-DELETE ordering; same `draftToken` credential; 204→`location.assign("/")` as the file's only navigation (code-only count), non-204→inline error no navigation; never-stale "Discarding…" lifecycle; `defaultDocTitle(wrap)` paired-bubble preference + last-record fallback; `saveAsDoc` passes `btn.closest(".msg.brain")`; `.discard-draft` ghost family (44px, err hover, disabled, focus-visible).
|
||||
- **E2E (extended)** `tests/e2e/test_save_doc_session.py`: (3) discard flow — save → edit screen → dismiss confirm (screen + draft intact, API 200) → accept → back on chat; draft gone (admin API GET 404 + Postgres row count 0); (4) title after Retry redo-in-place — redo (tagged old wrap leaves DOM, no question dup) → trailing Q3 → save redone answer → title/path == redone Q2, body byte-exact whole-session transcript. Dialog policy via one registered `page.on("dialog")` handler (listener-less dialogs are auto-dismissed by Playwright — fixed after 2 failed attempts).
|
||||
- **Results**: `uv run pytest --cov=app --cov-report=term-missing` → exit 0, `TOTAL 4147 16 99%` (>90% ✓); `uv run pytest tests/e2e/test_save_doc_session.py -v --no-cov` → 4 passed; `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors.
|
||||
- **Decisions**: no app-code changes needed (route/UI/title fixes shipped by tasks 01–03, all verified working); E2E row-gone check adds a direct Postgres count on top of the API 404 (the 404 alone would pass for any unknown token).
|
||||
- **Next pending task**: none in `115_doc_draft_discard` (04 was the last) — the pipeline gate moves the phase to `complete/`; next phase in `todo/` per roadmap ordering.
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
........................................................................ [ 2%]
|
||||
........................................................................ [ 5%]
|
||||
........................................................................ [ 8%]
|
||||
........................................................................ [ 11%]
|
||||
........................................................................ [ 14%]
|
||||
........................................................................ [ 17%]
|
||||
........................................................................ [ 20%]
|
||||
........................................................................ [ 23%]
|
||||
........................................................................ [ 26%]
|
||||
........................................................................ [ 29%]
|
||||
........................................................................ [ 32%]
|
||||
........................................................................ [ 35%]
|
||||
........................................................................ [ 38%]
|
||||
........................................................................ [ 41%]
|
||||
........................................................................ [ 43%]
|
||||
........................................................................ [ 46%]
|
||||
........................................................................ [ 49%]
|
||||
........................................................................ [ 52%]
|
||||
........................................................................ [ 55%]
|
||||
........................................................................ [ 58%]
|
||||
........................................................................ [ 61%]
|
||||
........................................................................ [ 64%]
|
||||
........................................................................ [ 67%]
|
||||
........................................................................ [ 70%]
|
||||
........................................................................ [ 73%]
|
||||
........................................................................ [ 76%]
|
||||
........................................................................ [ 79%]
|
||||
........................................................................ [ 82%]
|
||||
........................................................................ [ 84%]
|
||||
........................................................................ [ 87%]
|
||||
........................................................................ [ 90%]
|
||||
........................................................................ [ 93%]
|
||||
........................................................................ [ 96%]
|
||||
........................................................................ [ 99%]
|
||||
......... [100%]
|
||||
=============================== warnings summary ===============================
|
||||
.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1
|
||||
/var/home/ducoterra/Projects/Personal/brain-of-reese/.venv/lib64/python3.14/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.14.7-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 224 1 99%
|
||||
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 156 1 99%
|
||||
app/api/git_sources.py 232 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 139 0 100%
|
||||
app/api/tokens.py 40 0 100%
|
||||
app/api/ui_settings.py 55 0 100%
|
||||
app/config.py 210 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 66 0 100%
|
||||
app/models.py 128 0 100%
|
||||
app/rag/__init__.py 0 0 100%
|
||||
app/rag/agent.py 317 1 99%
|
||||
app/rag/archive_upload.py 134 0 100%
|
||||
app/rag/chunker.py 206 4 98%
|
||||
app/rag/doc_dates.py 18 0 100%
|
||||
app/rag/folder_summaries.py 123 0 100%
|
||||
app/rag/git_sources.py 14 0 100%
|
||||
app/rag/importer.py 215 3 99%
|
||||
app/rag/llm.py 244 1 99%
|
||||
app/rag/overview.py 71 0 100%
|
||||
app/rag/prompts.py 88 0 100%
|
||||
app/rag/retriever.py 192 3 98%
|
||||
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 24 0 100%
|
||||
app/schemas.py 329 0 100%
|
||||
--------------------------------------------------
|
||||
TOTAL 4147 16 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
|
||||
+22
-1
@@ -19,7 +19,10 @@ token — 404 ``draft not found`` when unknown), ``PUT
|
||||
/api/doc-drafts/{token}`` (partial update — absent fields unchanged,
|
||||
``updated_at`` bumped; editing a ``pushed`` draft resets ``status``
|
||||
back to ``draft`` — the stored sha no longer describes the current
|
||||
body, so the next push re-commits; phase 59 D3 ASSUMPTION), ``POST
|
||||
body, so the next push re-commits; phase 59 D3 ASSUMPTION), ``DELETE
|
||||
/api/doc-drafts/{token}`` (discard — 204 No Content; the row is
|
||||
removed, so every subsequent GET/PUT/push 404s; phase 115, the edit
|
||||
screen's Discard control), ``POST
|
||||
/api/doc-drafts/{token}/push`` (the single mutation the edit screen
|
||||
triggers — commit + ``git push`` the draft's file to the
|
||||
``BOR_DOCS_REPO`` ``BOR_DOCS_BRANCH`` via
|
||||
@@ -233,6 +236,24 @@ def update_draft(
|
||||
return _to_out(row)
|
||||
|
||||
|
||||
@router.delete("/{token}", status_code=204)
|
||||
def delete_draft(
|
||||
token: uuid.UUID,
|
||||
db: Session = Depends(get_db), # noqa: B008
|
||||
) -> None:
|
||||
"""Discard a draft (the edit screen's Discard control, phase 115).
|
||||
|
||||
Admin-gated like the whole router (phase 59); the uuid4 token is
|
||||
the screen's credential — after a successful discard, GET/PUT/push
|
||||
all 404. 204 No Content (no body): the token is a one-way
|
||||
credential and nothing else references the row (no FK targets, no
|
||||
push-side state — the git push happens only on push).
|
||||
"""
|
||||
row = _get_draft_or_404(db, token)
|
||||
db.delete(row)
|
||||
db.commit()
|
||||
|
||||
|
||||
@router.post("/{token}/push", response_model=DocDraftPushed)
|
||||
def push_doc_draft(
|
||||
token: uuid.UUID,
|
||||
|
||||
+44
-10
@@ -596,9 +596,11 @@ function markLastRetryable() {
|
||||
* keeps it rightmost when the last bubble also carries the Retry
|
||||
* button.
|
||||
*
|
||||
* Click: default title (UNCHANGED by phase 75 — the LAST user
|
||||
* question, whitespace-collapsed, ≤120 chars, the phase-50 auto-title
|
||||
* convention) + default in-repo path (docs/<slug>.md) + the
|
||||
* Click: default title (phase 115 — the QUESTION THE ANSWER
|
||||
* ANSWERED: the saved bubble's paired user bubble, whitespace-
|
||||
* collapsed, ≤120 chars, the phase-50 auto-title convention; the
|
||||
* last-conversation-record rule survives as the no-pair fallback) +
|
||||
* default in-repo path (docs/<slug>.md) + the
|
||||
* FULL-SESSION transcript as the body (phase 75 A6) → POST
|
||||
* /api/doc-drafts {title, path, body} → 201 →
|
||||
* /doc-edit.html?draft=<token> (the edit screen, task 06, owns the
|
||||
@@ -609,21 +611,49 @@ const SAVE_AS_DOC_ICON =
|
||||
|
||||
const DOC_TITLE_MAX = 120; // the phase-50 auto-title cap (owner-locked)
|
||||
|
||||
/* The default doc title: the LAST user question's text,
|
||||
* whitespace-collapsed, truncated to 120 chars — the phase-50
|
||||
* auto-title convention (server-side: " ".join(text.split())[:120])
|
||||
* applied to the last question. Defensive "Note" when the
|
||||
* conversation has no user record (the UI cannot produce one).
|
||||
/* The default doc title. Phase 115 (task 03 — the TODO L7 side
|
||||
* observation): when the saved brain bubble's *wrap* is given, the
|
||||
* title comes from the user bubble PAIRED with it — the nearest
|
||||
* preceding .msg.user in the DOM conversation flow (#messages' direct
|
||||
* children are the bubbles, in order; the walk is over the wrap's
|
||||
* previousElementSibling chain). The pairing is DOM-structural, not
|
||||
* index-based: redo-in-place (phase 49) reorders the DOM, and the
|
||||
* structural pair IS the answer's question by construction — the
|
||||
* last conversation record, after a retry, can be an unrelated
|
||||
* trailing question (the junk-title edge case). The text is read
|
||||
* from the bubble's .bubble (the rendered question — the meta rows
|
||||
* with their button labels live on brain bubbles, never user ones).
|
||||
* No wrap given, or no paired user bubble found (first-turn edge /
|
||||
* DOM mismatch) → the pre-phase-115 fallback: the LAST user record
|
||||
* in `conversation`. Whitespace-collapsed, truncated to 120 chars —
|
||||
* the phase-50 auto-title convention (server-side:
|
||||
* " ".join(text.split())[:120]); defensive "Note" when neither
|
||||
* source yields a question (the UI cannot produce one).
|
||||
* " ".join(split()) == replace(/\s+/g, " ").trim() for non-empty
|
||||
* input; the trim keeps the leading/trailing-whitespace edge identical. */
|
||||
function defaultDocTitle() {
|
||||
function defaultDocTitle(wrap) {
|
||||
let question = "";
|
||||
if (wrap) {
|
||||
for (
|
||||
let el = wrap.previousElementSibling;
|
||||
el !== null;
|
||||
el = el.previousElementSibling
|
||||
) {
|
||||
if (el.classList.contains("msg") && el.classList.contains("user")) {
|
||||
const bubble = el.querySelector(".bubble");
|
||||
question = bubble ? (bubble.textContent ?? "") : "";
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!question) {
|
||||
for (let i = conversation.length - 1; i >= 0; i -= 1) {
|
||||
if (conversation[i].who === "user") {
|
||||
question = conversation[i].text;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return question.replace(/\s+/g, " ").trim().slice(0, DOC_TITLE_MAX) || "Note";
|
||||
}
|
||||
|
||||
@@ -721,7 +751,11 @@ async function saveAsDoc(btn) {
|
||||
if (btn.disabled) return; // one save at a time (double-click guard)
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const title = defaultDocTitle();
|
||||
// Phase 115 (task 03): pass the button's OWN bubble (the .save-
|
||||
// as-doc-btn lives in the bubble's .msg-meta row, so closest
|
||||
// climbs button → meta → body → the .msg.brain wrap) — the title
|
||||
// pairs the answer with ITS question (the redo-in-place fix).
|
||||
const title = defaultDocTitle(btn.closest(".msg.brain"));
|
||||
const path = `docs/${docSlug(title)}.md`;
|
||||
const res = await fetch("/api/doc-drafts", {
|
||||
method: "POST",
|
||||
|
||||
@@ -45,6 +45,24 @@
|
||||
* an edit, not a re-type) and the button re-enables;
|
||||
* • network failure → the fixed one-line copy, same recovery.
|
||||
*
|
||||
* Discard (phase 115, task 02 — the page's one destructive action,
|
||||
* on #discard-draft): a native ``confirm()`` FIRST (destructive +
|
||||
* irreversible — no undo exists; the shell's alertdialog pattern is
|
||||
* page-local to the SPA's Sources view, not a shared asset), then
|
||||
* ``DELETE /api/doc-drafts/<token>`` (the same uuid4 token the GET/
|
||||
* PUT ran on — the screen's credential; the router is admin-gated
|
||||
* regardless):
|
||||
* • 204 → ``location.assign("/")`` — back to the chat (the draft
|
||||
* has no other home: no drafts list exists);
|
||||
* • non-204 (a 404 race — the row vanished under us) → the
|
||||
* #push-error inline banner with the server's detail (422
|
||||
* shape-aware), the stale success line cleared (one claim at a
|
||||
* time), NO navigation, no crash;
|
||||
* • network failure → the fixed one-line copy, same recovery.
|
||||
* The button runs the §7.4 in-flight lifecycle (disable +
|
||||
* "Discarding…" while the DELETE is out; restored in the finally —
|
||||
* never stale).
|
||||
*
|
||||
* The shared header module loads through this script's own relative
|
||||
* import ("./header.js") — a hoisted import evaluated before this body
|
||||
* runs (single-evaluation design: no direct <script> tag; esbuild
|
||||
@@ -252,6 +270,57 @@ function wirePush() {
|
||||
|
||||
wirePush();
|
||||
|
||||
/* ---------- discard (DELETE /api/doc-drafts/<token> — phase 115) ----------
|
||||
* The page's one destructive action: confirm → DELETE the row (the
|
||||
* same uuid4 token the GET/PUT ran on) → 204 → back to the chat (the
|
||||
* draft has no other home — no drafts list exists). Every failure
|
||||
* lands the inline error banner (the server's detail — 422
|
||||
* shape-aware) and does NOT navigate: never stale, no crash. */
|
||||
const DISCARD_LABEL = "Discard draft";
|
||||
|
||||
function wireDiscard() {
|
||||
const discardBtn = document.querySelector("#discard-draft");
|
||||
if (!discardBtn) return;
|
||||
discardBtn.addEventListener("click", async () => {
|
||||
if (!draftToken) {
|
||||
showError("No draft specified.");
|
||||
return;
|
||||
}
|
||||
// Destructive + irreversible — no undo exists. The native
|
||||
// confirm() is the approved dialog for this one action on the slim
|
||||
// flow page (phase 115 task 02 ASSUMPTION — the shell's
|
||||
// alertdialog is page-local to the Sources view, not a shared
|
||||
// asset).
|
||||
if (!confirm("Discard this draft? This cannot be undone.")) return;
|
||||
discardBtn.disabled = true; // §7.4: one discard per click
|
||||
discardBtn.textContent = "Discarding…";
|
||||
try {
|
||||
const r = await fetch(`/api/doc-drafts/${draftToken}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (r.status === 204) {
|
||||
// The row is gone — the chat page is the draft's only other
|
||||
// home.
|
||||
location.assign("/");
|
||||
return;
|
||||
}
|
||||
// Non-204 (a 404 race — the row is gone — or any other failure):
|
||||
// the server line in the inline banner, the stale success line
|
||||
// cleared (one claim at a time), NO navigation, no crash.
|
||||
setStatus("");
|
||||
showError(await apiDetail(r, `Could not discard the draft (${r.status}).`));
|
||||
} catch {
|
||||
setStatus("");
|
||||
showError("Could not reach the server — is the app running?");
|
||||
} finally {
|
||||
discardBtn.disabled = false; // never stale — success OR failure
|
||||
discardBtn.textContent = DISCARD_LABEL;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
wireDiscard();
|
||||
|
||||
/* ---------- boot ----------
|
||||
* The admin gate FIRST (the sources-gate pattern — one cached
|
||||
* whoami): anonymous visitors get the gate and NO /api/doc-drafts
|
||||
|
||||
@@ -4430,7 +4430,8 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
||||
}
|
||||
|
||||
/* Actions row: the primary Push button (brand, dark ink on brand
|
||||
5.2:1 — never white on brand) + the back link; wraps at narrow
|
||||
5.2:1 — never white on brand) + the Discard control (phase 115 —
|
||||
the secondary destructive action) + the back link; wraps at narrow
|
||||
widths. */
|
||||
.doc-edit-actions {
|
||||
display: flex;
|
||||
@@ -4455,6 +4456,37 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
||||
#push-doc-btn:hover:not(:disabled) { background: var(--brand-hover); }
|
||||
#push-doc-btn:disabled { opacity: 0.6; cursor: wait; }
|
||||
|
||||
/* Phase 115 (task 02): the Discard control — the SECONDARY destructive
|
||||
action, visually subordinate to the brand primary (the ghost family
|
||||
of .steering-delete / .tuning-delete): ink-soft on transparent
|
||||
(5.1:1 on --surface — more on the darker --bg canvas) with the
|
||||
--line border; hover joins the err family (err-ink on err-bg 9.3:1,
|
||||
the err-line border — the state stays text + color, never color
|
||||
alone); the 44px touch floor, the global 3px :focus-visible ring,
|
||||
and the :disabled state is the "Discarding…" in-flight affordance. */
|
||||
.discard-draft {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 44px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--ink-soft);
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
white-space: nowrap;
|
||||
padding-inline: 1rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.discard-draft:hover:not(:disabled) {
|
||||
background: var(--err-bg);
|
||||
color: var(--err-ink);
|
||||
border-color: var(--err-line);
|
||||
}
|
||||
.discard-draft:disabled { opacity: 0.6; cursor: wait; }
|
||||
|
||||
/* The success status line (role=status): the ok family (ok-ink on
|
||||
ok-bg 10.6:1) when a push outcome has landed — min-height holds the
|
||||
line's space so the layout never jumps when the text lands. Empty
|
||||
|
||||
@@ -90,6 +90,20 @@
|
||||
|
||||
<div class="doc-edit-actions">
|
||||
<button type="submit" id="push-doc-btn">Push to docs branch</button>
|
||||
|
||||
<!-- Phase 115 (task 02): the Discard control — this page's
|
||||
one destructive action. Without it an orphaned draft is
|
||||
un-deletable forever (the screen's only other action
|
||||
is the push). confirm() →
|
||||
DELETE /api/doc-drafts/<token> (admin-gated like the
|
||||
whole router; the uuid4 token is the screen's
|
||||
credential) → 204 → back to the chat (the draft has no
|
||||
other home — no drafts list exists). doc-edit.js owns
|
||||
the handler; a non-204 outcome lands the #push-error
|
||||
banner and does NOT navigate. -->
|
||||
<button type="button" id="discard-draft" class="discard-draft"
|
||||
title="Delete this draft permanently — this cannot be undone">Discard draft</button>
|
||||
|
||||
<a class="doc-edit-back" href="/">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 12H5"/><path d="m12 19-7-7 7-7"/></svg>
|
||||
<span>Back to chat</span>
|
||||
|
||||
@@ -43,9 +43,19 @@ App boots (the conftest pattern, module-scoped — as in
|
||||
``test_chat_rag.py`` fixture) — the three questions gate HIGH, so
|
||||
every turn is a grounded answer with the deterministic marker.
|
||||
|
||||
Phase 115 (TODO L7) extends this suite with the discard + title
|
||||
acceptance: an orphaned draft can be DISCARDED from the edit screen
|
||||
(confirm → DELETE → back on the chat, the row gone — the API check
|
||||
confirms the 404), and a save-as-doc after a Retry redo-in-place
|
||||
(phase 49) is titled with the redone answer's OWN question (the
|
||||
paired user bubble), not the unrelated trailing question that landed
|
||||
in the conversation after the redo (the pre-115 last-record rule).
|
||||
|
||||
Test → story mapping (Playwright Mapping Rule):
|
||||
1. ``test_full_session_save_and_edit_out``
|
||||
2. ``test_earlier_bubble_button_saves_whole_session``
|
||||
3. ``test_discard_draft_from_edit_screen`` (phase 115)
|
||||
4. ``test_save_title_is_the_redo_question_after_retry`` (phase 115)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -63,7 +73,7 @@ from urllib.parse import parse_qs, urlsplit
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from playwright.sync_api import Locator, Page, expect
|
||||
from playwright.sync_api import Dialog, Locator, Page, expect
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.config import Settings
|
||||
@@ -443,6 +453,21 @@ def _assert_no_thinking_leak(body: str) -> None:
|
||||
assert line not in body, f"thinking scratchpad text leaked into the doc: {line!r}"
|
||||
|
||||
|
||||
def _draft_status(app_url: str, token: str) -> httpx.Response:
|
||||
"""One GET of the draft by its uuid4 token, as the ADMIN (the
|
||||
drafts API is admin-only — anonymous gets 403, which would hide a
|
||||
real 404): 200 while the draft exists, 404 ``draft not found``
|
||||
once discarded (the phase-115 acceptance's row-gone check — the
|
||||
API is the authority, the same trust model as the edit screen)."""
|
||||
client = httpx.Client(timeout=30.0)
|
||||
try:
|
||||
r = client.post(f"{app_url}/api/login", json={"password": ADMIN_PASSWORD})
|
||||
assert r.status_code == 204, "the E2E admin login must succeed"
|
||||
return client.get(f"{app_url}/api/doc-drafts/{token}")
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. The whole loop: 3 turns → save → all turns in order → edit a
|
||||
# previous reply out → push → the bare repo agrees byte-for-byte
|
||||
@@ -543,9 +568,14 @@ def test_earlier_bubble_button_saves_whole_session(
|
||||
_open_edit_screen(
|
||||
page, page.locator(".msg.brain .save-as-doc-btn").first
|
||||
)
|
||||
# …with the UNCHANGED title (the last question, not the first).
|
||||
expect(page.locator("#draft-title")).to_have_value(q3)
|
||||
expect(page.locator("#draft-path")).to_have_value(f"docs/{doc_slug(q3)}.md")
|
||||
# …with the phase-115 title: the QUESTION THE ANSWER ANSWERED —
|
||||
# the first bubble's paired user bubble (Q1, whitespace-free and
|
||||
# ≤120 chars, so it arrives verbatim), no longer the
|
||||
# conversation's last record (the retry-redo mismatch fix —
|
||||
# saving from an earlier bubble titles that bubble's own
|
||||
# question, while the LAST bubble's button still yields Q3).
|
||||
expect(page.locator("#draft-title")).to_have_value(q1)
|
||||
expect(page.locator("#draft-path")).to_have_value(f"docs/{doc_slug(q1)}.md")
|
||||
body = session_transcript(turns)
|
||||
expect(page.locator("#draft-body")).to_have_value(body)
|
||||
_assert_no_thinking_leak(body)
|
||||
@@ -558,3 +588,154 @@ def test_earlier_bubble_button_saves_whole_session(
|
||||
assert tip_after == tip_before, (
|
||||
f"canceling moved the docs branch: {tip_before} -> {tip_after}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Phase 115 (TODO L7): an orphaned draft can be DISCARDED from the
|
||||
# edit screen — confirm → DELETE → back on the chat; the row is
|
||||
# gone afterward (the API 404s, the row is out of Postgres)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_discard_draft_from_edit_screen(
|
||||
page: Page,
|
||||
app_url: str,
|
||||
mock_llm: int,
|
||||
db_ready: None,
|
||||
) -> None:
|
||||
"""The discard acceptance: save as doc → the edit screen →
|
||||
Discard → the confirm dialog (destructive + irreversible) →
|
||||
back on the chat page, and the draft is GONE (the API check:
|
||||
GET by the now-dead token 404s with the unknown-token message, and
|
||||
the row is out of Postgres). The confirm is the gate: DISMISSING
|
||||
it leaves the screen AND the draft untouched (no request, no
|
||||
navigation, the button re-armable)."""
|
||||
page.set_default_timeout(30_000)
|
||||
_login_admin(page, app_url)
|
||||
_ask(page, Q3)
|
||||
|
||||
# One grounded turn → one save action → the edit screen (token).
|
||||
expect(page.locator(".msg.brain .save-as-doc-btn")).to_have_count(1)
|
||||
token = _open_edit_screen(page, page.locator(".msg.brain .save-as-doc-btn").last)
|
||||
alive = _draft_status(app_url, token)
|
||||
assert alive.status_code == 200, "the draft must exist before the discard"
|
||||
|
||||
# The confirm is the gate (a listener-less dialog is auto-dismissed
|
||||
# by Playwright, so the policy rides a single registered handler —
|
||||
# the house pattern, e.g. test_steering.py): the FIRST confirm is
|
||||
# dismissed, the second accepted. The handler runs while the page's
|
||||
# JS thread is blocked in confirm() — during the click call.
|
||||
dialogs: list[Dialog] = []
|
||||
|
||||
def _handle(d: Dialog) -> None:
|
||||
dialogs.append(d)
|
||||
if len(dialogs) == 1:
|
||||
d.dismiss() # first attempt: the user changes their mind
|
||||
else:
|
||||
d.accept() # second attempt: the discard is meant
|
||||
|
||||
page.on("dialog", _handle)
|
||||
|
||||
# DISMISS the confirm: no delete, no navigation — the screen stays,
|
||||
# the draft is intact, the control is armed for a real attempt.
|
||||
page.click("#discard-draft")
|
||||
assert len(dialogs) == 1, "the Discard control must confirm before deleting"
|
||||
dismissed = dialogs[0]
|
||||
assert dismissed.type == "confirm"
|
||||
assert "cannot be undone" in dismissed.message
|
||||
expect(page).to_have_url(f"{APP_URL}/doc-edit.html?draft={token}")
|
||||
expect(page.locator("#discard-draft")).to_be_enabled()
|
||||
still = _draft_status(app_url, token)
|
||||
assert still.status_code == 200, (
|
||||
"a dismissed confirm must not delete the draft"
|
||||
)
|
||||
|
||||
# ACCEPT the confirm: the DELETE lands, the 204 returns to the
|
||||
# chat (the draft's only other home — no drafts list exists).
|
||||
page.click("#discard-draft")
|
||||
assert len(dialogs) == 2, "the second attempt must confirm again"
|
||||
page.wait_for_url(APP_URL + "/", timeout=30_000)
|
||||
expect(page.locator("#sign-out-btn")).to_be_visible(timeout=30_000)
|
||||
|
||||
# The acceptance: the draft row is GONE — the token is dead (GET
|
||||
# 404s with the exact unknown-token message) …
|
||||
gone = _draft_status(app_url, token)
|
||||
assert gone.status_code == 404
|
||||
assert gone.json() == {"detail": "draft not found"}
|
||||
# …and the row is out of Postgres (the 404 alone would pass for
|
||||
# any unknown token — the row count is the acceptance).
|
||||
with SessionLocal() as db:
|
||||
n = db.execute(
|
||||
text("SELECT count(*) FROM doc_drafts WHERE token = :t"),
|
||||
{"t": token},
|
||||
).scalar_one()
|
||||
assert n == 0, "the discarded draft's row must be gone from Postgres"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. Phase 115 (TODO L7): the draft title after a Retry redo-in-place
|
||||
# — the redone answer's OWN question (its paired user bubble), not
|
||||
# the unrelated trailing question that followed the redo
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_save_title_is_the_redo_question_after_retry(
|
||||
page: Page,
|
||||
app_url: str,
|
||||
mock_llm: int,
|
||||
db_ready: None,
|
||||
) -> None:
|
||||
"""The title acceptance: ask → the answer → Retry (redo-in-place,
|
||||
phase 49) → an UNRELATED trailing question → save as doc on the
|
||||
REDONE answer. The pre-115 rule (the LAST user record in the
|
||||
conversation) titled the draft with the trailing question — the
|
||||
junk-title edge case. The fixed rule pairs the answer with ITS
|
||||
question: the edit screen's title field is the REDONE question
|
||||
(the body stays the whole session — the redo replaced turn 1 in
|
||||
place, so the transcript is the same shape as a fresh two-turn
|
||||
session)."""
|
||||
page.set_default_timeout(30_000)
|
||||
_login_admin(page, app_url)
|
||||
_ask(page, Q2)
|
||||
|
||||
# Redo in place: tag the current (only) wrap, click the Retry
|
||||
# button on it — the OLD wrap must leave the DOM and the fresh
|
||||
# answer stream into its place (the mock is byte-stable: the redo
|
||||
# of Q2 quotes Q2). The question is never duplicated.
|
||||
page.evaluate(
|
||||
"""() => {
|
||||
const wraps = document.querySelectorAll("#messages > .msg.brain");
|
||||
wraps[wraps.length - 1].setAttribute("data-retry-marker", "old-b1");
|
||||
}"""
|
||||
)
|
||||
expect(page.locator(".retry-btn")).to_have_count(1)
|
||||
page.locator(".retry-btn").click()
|
||||
expect(page.locator("#send-label")).to_have_text("Stop", timeout=5_000)
|
||||
expect(page.locator("#send-label")).to_have_text("Send", timeout=30_000)
|
||||
expect(page.locator("[data-retry-marker='old-b1']")).to_have_count(0)
|
||||
expect(page.locator(".msg.user .bubble")).to_have_count(1)
|
||||
|
||||
# The unrelated trailing question — the LAST user record once it
|
||||
# lands (the pre-115 title source, the junk-question mismatch).
|
||||
_ask(page, Q3)
|
||||
|
||||
# Save the REDONE answer (the FIRST brain bubble): the title is the
|
||||
# redone question — NOT the trailing one …
|
||||
expect(page.locator(".msg.brain .save-as-doc-btn")).to_have_count(2)
|
||||
_open_edit_screen(page, page.locator(".msg.brain .save-as-doc-btn").first)
|
||||
expect(page.locator("#draft-title")).to_have_value(Q2)
|
||||
expect(page.locator("#draft-path")).to_have_value(f"docs/{doc_slug(Q2)}.md")
|
||||
assert Q3 not in page.input_value("#draft-title"), (
|
||||
"the title must be the redone question, not the trailing one"
|
||||
)
|
||||
|
||||
# The body is still the WHOLE session (the redo replaced turn 1 in
|
||||
# place — the transcript is the fresh two-turn shape, byte-exact
|
||||
# against the mock: the composed answer is a pure function of the
|
||||
# last user message + the document context, history-independent).
|
||||
a2 = _stream_chat_answer(app_url, Q2)
|
||||
a3 = _stream_chat_answer(app_url, Q3)
|
||||
expect(page.locator("#draft-body")).to_have_value(
|
||||
session_transcript([(Q2, a2), (Q3, a3)])
|
||||
)
|
||||
_assert_no_thinking_leak(page.input_value("#draft-body"))
|
||||
|
||||
@@ -312,6 +312,63 @@ def test_put_rejects_blank_fields_and_leaves_row_unchanged(admin_client: TestCli
|
||||
assert admin_client.get(f"/api/doc-drafts/{created['token']}").json() == created
|
||||
|
||||
|
||||
# ---------- delete (by token — phase 115 Discard) ----------
|
||||
|
||||
|
||||
def test_delete_removes_row_and_invalidates_token(
|
||||
admin_client: TestClient, db
|
||||
) -> None:
|
||||
"""204 No Content; the row is gone from Postgres; after a discard
|
||||
the token is dead — GET/PUT/push all 404."""
|
||||
created = _create(admin_client)
|
||||
token = created["token"]
|
||||
|
||||
r = admin_client.delete(f"/api/doc-drafts/{token}")
|
||||
|
||||
assert r.status_code == 204
|
||||
assert r.content == b"" # 204: no body (the token is a one-way credential)
|
||||
row = db.execute(
|
||||
select(DocDraft).where(DocDraft.token == uuid.UUID(token))
|
||||
).scalars().first()
|
||||
assert row is None
|
||||
# Every sibling route now 404s with the same message as an unknown token.
|
||||
got = admin_client.get(f"/api/doc-drafts/{token}")
|
||||
assert got.status_code == 404
|
||||
assert got.json() == {"detail": "draft not found"}
|
||||
assert (
|
||||
admin_client.put(f"/api/doc-drafts/{token}", json={"body": "x"}).status_code
|
||||
== 404
|
||||
)
|
||||
assert admin_client.post(f"/api/doc-drafts/{token}/push").status_code == 404
|
||||
|
||||
|
||||
def test_delete_unknown_token_returns_404(admin_client: TestClient) -> None:
|
||||
r = admin_client.delete(f"/api/doc-drafts/{uuid.uuid4()}")
|
||||
assert r.status_code == 404
|
||||
assert r.json() == {"detail": "draft not found"}
|
||||
|
||||
|
||||
def test_delete_malformed_token_returns_422(admin_client: TestClient) -> None:
|
||||
assert admin_client.delete("/api/doc-drafts/not-a-uuid").status_code == 422
|
||||
|
||||
|
||||
def test_delete_works_on_pushed_draft_too(admin_client: TestClient, db) -> None:
|
||||
"""Nothing on the push side guards the row (no FK targets, no
|
||||
push-side state — the git push happens only on push): a draft that
|
||||
was already pushed is discarding-eligible; the row goes (the
|
||||
already-pushed file in the repo is out of scope — locked A1)."""
|
||||
created = _create(admin_client)
|
||||
token = uuid.UUID(created["token"])
|
||||
row = db.execute(select(DocDraft).where(DocDraft.token == token)).scalars().one()
|
||||
row.status = "pushed"
|
||||
row.branch = DOCS_BRANCH
|
||||
row.commit_sha = "a" * 40
|
||||
db.commit()
|
||||
|
||||
assert admin_client.delete(f"/api/doc-drafts/{token}").status_code == 204
|
||||
assert db.execute(select(DocDraft)).scalars().first() is None
|
||||
|
||||
|
||||
# ---------- auth: anonymous gets 403 on every route ----------
|
||||
|
||||
|
||||
@@ -324,6 +381,7 @@ def test_anonymous_gets_403_on_all_routes(admin_client: TestClient, db) -> None:
|
||||
assert r.json() == {"detail": "admin only"}
|
||||
assert anon.get(f"/api/doc-drafts/{created['token']}").status_code == 403
|
||||
assert anon.put(f"/api/doc-drafts/{created['token']}", json={"body": "nope"}).status_code == 403
|
||||
assert anon.delete(f"/api/doc-drafts/{created['token']}").status_code == 403
|
||||
|
||||
# The anonymous attempts changed nothing: exactly the admin's draft
|
||||
# exists, untouched.
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
"""Unit: phase 115 — the doc-draft discard (DELETE route + the
|
||||
edit-screen Discard control) and the draft title fix, at the frontend
|
||||
layer (house source-assertion style — ``test_doc_edit_screen.py`` /
|
||||
``test_save_as_doc_button.py``).
|
||||
|
||||
No Python logic exists for the frontend half of the phase — the
|
||||
behavior lives in ``frontend/doc-edit.html`` +
|
||||
``frontend/assets/doc-edit.js`` + ``frontend/assets/app.js`` +
|
||||
``styles.css``, and it is E2E-gated by
|
||||
``tests/e2e/test_save_doc_session.py`` (the discard flow + the
|
||||
title-after-retry pins). This module pins the HTML/JS/CSS markers the
|
||||
discard + title loop depends on, so a silent regression is caught
|
||||
without a browser:
|
||||
|
||||
* the Discard control — ``#discard-draft`` in the actions row,
|
||||
between the push button and the back link (the SECONDARY action —
|
||||
``type="button"``, never a submit), the exact "Discard draft" copy,
|
||||
the cannot-be-undone ``title``;
|
||||
* ``doc-edit.js`` — the handler: without a token the banner, no
|
||||
fetch; the native ``confirm()`` FIRST (destructive + irreversible —
|
||||
the shell's alertdialog is page-local to the Sources view, not a
|
||||
shared asset); then ``DELETE /api/doc-drafts/<token>`` with the SAME
|
||||
uuid4 ``draftToken`` the GET/PUT ran on (the screen's credential);
|
||||
204 → ``location.assign("/")`` (back to the chat — the draft's only
|
||||
other home, no drafts list exists) and the 204 arm is the file's
|
||||
ONLY navigation; a non-204 (a 404 race) or a network failure → the
|
||||
#push-error inline banner (the server's detail, 422 shape-aware; the
|
||||
stale success line cleared first) and NO navigation, no crash; the
|
||||
§7.4 in-flight lifecycle (disable + "Discarding…", restored in the
|
||||
finally — never stale);
|
||||
* ``app.js`` — the title fix: ``defaultDocTitle(wrap)`` prefers the
|
||||
user bubble PAIRED with the saved brain bubble — the NEAREST
|
||||
preceding ``.msg.user`` in the DOM conversation flow (the
|
||||
redo-in-place reorders the DOM, and the structural pair IS the
|
||||
answer's question by construction) — over the pre-115
|
||||
last-conversation-record rule, which survives only as the no-wrap /
|
||||
no-pair fallback; the ``DOC_TITLE_MAX`` slice, the whitespace
|
||||
collapse, and the defensive "Note" are unchanged; the ``saveAsDoc``
|
||||
call site passes the button's own bubble
|
||||
(``btn.closest(".msg.brain")``);
|
||||
* ``styles.css`` — the ``.discard-draft`` ghost family (44px floor,
|
||||
--line border, ink-soft on transparent — visually subordinate to
|
||||
the brand primary) with the err family on hover (the destructive
|
||||
state stays text + color, never color alone, B5) and the :disabled
|
||||
"Discarding…" affordance (the global 3px :focus-visible ring covers
|
||||
the control).
|
||||
|
||||
The API half (``DELETE /api/doc-drafts/{token}`` → 204 / 404,
|
||||
admin-gated like the whole router) is pinned by
|
||||
``tests/integration/test_doc_drafts_api.py`` (the delete section).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
|
||||
ASSETS = FRONTEND / "assets"
|
||||
DOC_EDIT_HTML = FRONTEND / "doc-edit.html"
|
||||
DOC_EDIT_JS = ASSETS / "doc-edit.js"
|
||||
APP_JS = ASSETS / "app.js"
|
||||
STYLES_CSS = ASSETS / "styles.css"
|
||||
|
||||
|
||||
def _html() -> str:
|
||||
assert DOC_EDIT_HTML.is_file(), "frontend/doc-edit.html is missing"
|
||||
return DOC_EDIT_HTML.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _doc_edit_js() -> str:
|
||||
assert DOC_EDIT_JS.is_file(), "frontend/assets/doc-edit.js is missing"
|
||||
return DOC_EDIT_JS.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _app_js() -> str:
|
||||
assert APP_JS.is_file(), "frontend/assets/app.js is missing"
|
||||
return APP_JS.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _css() -> str:
|
||||
return STYLES_CSS.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _discard_fn(js: str) -> str:
|
||||
"""The source of ``wireDiscard()`` (to its close)."""
|
||||
start = js.find("function wireDiscard() {")
|
||||
assert start != -1, "wireDiscard() must exist in doc-edit.js"
|
||||
return js[start : js.find("\n}\n", start) + 4]
|
||||
|
||||
|
||||
# ---------- doc-edit.html — the Discard control ----------
|
||||
|
||||
|
||||
def test_discard_button_sits_in_the_actions_row_next_to_push() -> None:
|
||||
"""#discard-draft: ``type="button"`` (NEVER a submit — the push
|
||||
button is the form's submit), the .discard-draft class (the CSS
|
||||
ghost-family hook), the exact house copy "Discard draft", and the
|
||||
cannot-be-undone ``title`` (the warning before the click). Layout:
|
||||
the actions row, BETWEEN the push button and the back link — the
|
||||
secondary destructive action, visually subordinate to the brand
|
||||
primary."""
|
||||
html = _html()
|
||||
btn = re.search(r"<button[^>]*id=\"discard-draft\"[^>]*>", html)
|
||||
assert btn, "#discard-draft is missing from the edit screen"
|
||||
tag = btn.group(0)
|
||||
assert 'type="button"' in tag, (
|
||||
"the Discard control must not submit the form (push is the submit)"
|
||||
)
|
||||
assert 'class="discard-draft"' in tag
|
||||
assert "cannot be undone" in tag, "the title attribute must warn it is final"
|
||||
assert ">Discard draft</button>" in html, (
|
||||
"the exact house copy: 'Discard draft'"
|
||||
)
|
||||
actions = html[
|
||||
html.find('class="doc-edit-actions"') : html.find("</form>")
|
||||
]
|
||||
push_i = actions.find('id="push-doc-btn"')
|
||||
discard_i = actions.find('id="discard-draft"')
|
||||
back_i = actions.find('class="doc-edit-back"')
|
||||
assert 0 <= push_i < discard_i < back_i, (
|
||||
"the Discard control sits in the actions row, between the push "
|
||||
"button and the back link"
|
||||
)
|
||||
|
||||
|
||||
# ---------- doc-edit.js — the discard handler ----------
|
||||
|
||||
|
||||
def test_discard_handler_guards_and_confirms_before_any_request() -> None:
|
||||
"""The handler runs on #discard-draft and gates the request:
|
||||
without a token the "No draft specified." banner (no fetch — the
|
||||
same guard as the push), then the native ``confirm()`` — the
|
||||
destructive action is confirmed FIRST, before the DELETE leaves the
|
||||
browser. A dismissed confirm must not delete anything."""
|
||||
js = _doc_edit_js()
|
||||
fn = _discard_fn(js)
|
||||
assert 'document.querySelector("#discard-draft")' in fn
|
||||
no_token_i = fn.find('showError("No draft specified.")')
|
||||
confirm_i = fn.find('confirm("Discard this draft? This cannot be undone.")')
|
||||
first_fetch = fn.find("await fetch(")
|
||||
assert -1 < no_token_i < confirm_i < first_fetch, (
|
||||
"token guard → confirm() → the DELETE: in that order, nothing "
|
||||
"fetches before the confirm"
|
||||
)
|
||||
# A dismissed confirm returns BEFORE the in-flight state starts
|
||||
# (the button is never left disabled).
|
||||
disabled_i = fn.find("discardBtn.disabled = true")
|
||||
assert confirm_i < disabled_i, (
|
||||
"a dismissed confirm must not disable the button"
|
||||
)
|
||||
|
||||
|
||||
def test_discard_uses_the_same_token_delete_route() -> None:
|
||||
"""The DELETE runs on ``/api/doc-drafts/${draftToken}`` — the SAME
|
||||
uuid4 ``draftToken`` the load (GET) and the save (PUT) ran on: the
|
||||
screen's credential, set once in boot. No second token source may
|
||||
exist in the file."""
|
||||
js = _doc_edit_js()
|
||||
fn = _discard_fn(js)
|
||||
assert 'fetch(`/api/doc-drafts/${draftToken}`, {' in fn, (
|
||||
"the DELETE must use the screen's draftToken credential"
|
||||
)
|
||||
fetch_i = fn.find('fetch(`/api/doc-drafts/${draftToken}`, {')
|
||||
assert 'method: "DELETE"' in fn[fetch_i : fetch_i + 120], (
|
||||
"the request method is DELETE"
|
||||
)
|
||||
# The token comes from the single boot assignment (no re-derivation
|
||||
# — a divergent token would delete a different row than the one
|
||||
# shown).
|
||||
assert js.count("draftToken = token") == 1
|
||||
assert "new URLSearchParams" not in _discard_fn(js)
|
||||
|
||||
|
||||
def test_discard_204_redirects_and_non_204_inlines_without_navigation() -> None:
|
||||
"""The outcomes: 204 → ``location.assign("/")`` (back to the chat —
|
||||
the draft's only other home; no drafts list exists) — and that is
|
||||
the file's ONLY navigation. A non-204 (a 404 race — the row
|
||||
vanished under us) clears the stale success line first, then lands
|
||||
the server's detail in the #push-error inline banner (422
|
||||
shape-aware via apiDetail) and does NOT navigate, no crash. A
|
||||
network failure → the fixed one-line copy, same recovery."""
|
||||
js = _doc_edit_js()
|
||||
fn = _discard_fn(js)
|
||||
ok_i = fn.find("r.status === 204")
|
||||
nav_i = fn.find('location.assign("/")')
|
||||
err_i = fn.find("showError(await apiDetail(r")
|
||||
assert -1 < ok_i < nav_i, "the 204 arm must navigate back to the chat"
|
||||
# The header comment quotes the 204 line too — count the CODE only
|
||||
# (from the first import on): the 204 redirect is the file's only
|
||||
# navigation, a failure never leaves the edit screen.
|
||||
code = js[js.find("\nimport ") :]
|
||||
assert code.count('location.assign("/")') == 1, (
|
||||
"the 204 redirect is the file's only navigation — a failure "
|
||||
"never leaves the edit screen"
|
||||
)
|
||||
# The non-204 arm comes after the 204 navigation …
|
||||
assert err_i > nav_i, "the non-204 error arm must follow the 204 arm"
|
||||
# …clearing the stale success line first (one claim at a time) …
|
||||
clear_i = fn.find('setStatus("")', nav_i)
|
||||
assert -1 < clear_i < err_i, (
|
||||
"a failed discard clears the stale status line before the banner"
|
||||
)
|
||||
# …with the 422-shape-aware server detail …
|
||||
assert "apiDetail(" in fn
|
||||
# …and a network failure → the fixed one-line copy.
|
||||
assert "is the app running?" in fn
|
||||
|
||||
|
||||
def test_discard_in_flight_lifecycle_is_never_stale() -> None:
|
||||
"""The §7.4 in-flight lifecycle: the button disables + relabels
|
||||
"Discarding…" while the DELETE is out (one discard per click); the
|
||||
finally restores BOTH on every outcome — success OR failure — with
|
||||
DISCARD_LABEL, the exact static button copy (a mismatch would
|
||||
relabel the button into an unknown state)."""
|
||||
js = _doc_edit_js()
|
||||
fn = _discard_fn(js)
|
||||
disable_i = fn.find("discardBtn.disabled = true")
|
||||
relabel_i = fn.find('discardBtn.textContent = "Discarding…"')
|
||||
fetch_i = fn.find("await fetch(")
|
||||
assert -1 < disable_i < relabel_i < fetch_i, (
|
||||
"disable + relabel before the request goes out"
|
||||
)
|
||||
finally_i = fn.find("} finally {")
|
||||
assert finally_i != -1, "the finally block is the never-stale guarantee"
|
||||
after = fn[finally_i:]
|
||||
assert "discardBtn.disabled = false" in after
|
||||
assert "discardBtn.textContent = DISCARD_LABEL" in after
|
||||
assert 'const DISCARD_LABEL = "Discard draft";' in js, (
|
||||
"the restored label is the static button copy"
|
||||
)
|
||||
|
||||
|
||||
# ---------- app.js — the title: the answer's own question ----------
|
||||
|
||||
|
||||
def test_default_doc_title_prefers_the_paired_user_bubble() -> None:
|
||||
"""Phase 115 (task 03): ``defaultDocTitle(wrap)`` — the title is
|
||||
the text of the user bubble PAIRED with the saved brain bubble:
|
||||
the NEAREST preceding ``.msg.user`` in the DOM conversation flow
|
||||
(the redo-in-place reorders the DOM, and the structural pair IS
|
||||
the answer's question by construction — the last conversation
|
||||
record, after a retry + a trailing question, can be unrelated).
|
||||
The text is read from that bubble's ``.bubble`` (the meta rows
|
||||
carry button labels — never the whole wrap). No wrap given, or no
|
||||
paired user bubble found (first-turn edge / DOM mismatch) → the
|
||||
pre-115 fallback: the LAST user record in ``conversation``
|
||||
(iterated backwards). The ``DOC_TITLE_MAX`` slice, the whitespace
|
||||
collapse, and the defensive "Note" are unchanged."""
|
||||
js = _app_js()
|
||||
assert "const DOC_TITLE_MAX = 120;" in js
|
||||
fn_idx = js.find("function defaultDocTitle(wrap) {")
|
||||
assert fn_idx != -1, "defaultDocTitle(wrap) is missing (or lost the wrap arg)"
|
||||
fn_body = js[fn_idx : js.find("function docSlug", fn_idx)]
|
||||
# The paired-bubble walk: from the saved bubble's wrap, down the
|
||||
# conversation flow (previousElementSibling chain) to the first
|
||||
# .msg.user …
|
||||
assert "if (wrap) {" in fn_body
|
||||
assert "wrap.previousElementSibling" in fn_body, (
|
||||
"the pairing must walk the DOM conversation flow backwards"
|
||||
)
|
||||
assert 'el.classList.contains("msg")' in fn_body
|
||||
assert 'el.classList.contains("user")' in fn_body
|
||||
assert "textContent" in fn_body
|
||||
# …reading the question from that bubble's .bubble …
|
||||
assert 'el.querySelector(".bubble")' in fn_body
|
||||
# …and the pre-115 rule survives ONLY as the no-pair fallback
|
||||
# (entered when the paired walk found nothing).
|
||||
assert "if (!question) {" in fn_body, (
|
||||
"the last-conversation-record rule must be the fallback, not the rule"
|
||||
)
|
||||
assert "conversation.length - 1" in fn_body
|
||||
assert 'conversation[i].who === "user"' in fn_body
|
||||
# The unchanged title rule: collapse, 120-cap, defensive "Note".
|
||||
assert 'question.replace(/\\s+/g, " ").trim().slice(0, DOC_TITLE_MAX)' in fn_body
|
||||
assert '|| "Note"' in fn_body
|
||||
|
||||
|
||||
def test_save_as_doc_call_site_passes_the_bubble_ancestor() -> None:
|
||||
"""The ``saveAsDoc`` call site passes the button's OWN bubble —
|
||||
the .save-as-doc-btn lives in the bubble's .msg-meta row, so
|
||||
``closest(".msg.brain")`` climbs button → meta → body → wrap — the
|
||||
title pairs the answer with ITS question (the redo-in-place fix).
|
||||
No call site may stay on the no-wrap fallback: the button always
|
||||
knows its own bubble."""
|
||||
js = _app_js()
|
||||
fn_idx = js.find("async function saveAsDoc(btn) {")
|
||||
assert fn_idx != -1, "saveAsDoc is missing"
|
||||
fn_body = js[fn_idx : fn_idx + 3000]
|
||||
assert 'defaultDocTitle(btn.closest(".msg.brain"))' in fn_body, (
|
||||
"the call site must pass the button's own .msg.brain ancestor"
|
||||
)
|
||||
assert "defaultDocTitle()" not in js, (
|
||||
"no caller may stay on the no-wrap fallback once the button "
|
||||
"knows its own bubble"
|
||||
)
|
||||
|
||||
|
||||
# ---------- styles.css — the .discard-draft ghost family ----------
|
||||
|
||||
|
||||
def test_discard_draft_css_is_secondary_with_err_hover_and_disabled() -> None:
|
||||
""".discard-draft: the SECONDARY destructive action — visually
|
||||
subordinate to the brand primary: ink-soft on transparent with the
|
||||
--line border, the 44px touch floor. Hover joins the err family
|
||||
(the destructive state stays text + color, never color alone — B5);
|
||||
:disabled is the "Discarding…" in-flight affordance; the global
|
||||
3px :focus-visible ring covers the control (AGENTS.md rule 5)."""
|
||||
css = _css()
|
||||
base = re.search(r"\.discard-draft \{([^}]*?)\}", css)
|
||||
assert base, "the .discard-draft rule is missing"
|
||||
bbody = base.group(1)
|
||||
assert "min-height: 44px" in bbody, "the WCAG touch floor"
|
||||
assert "border: 1px solid var(--line)" in bbody
|
||||
assert "background: transparent" in bbody, (
|
||||
"the ghost family — subordinate to the brand primary"
|
||||
)
|
||||
assert "var(--ink-soft)" in bbody
|
||||
hover = re.search(r"\.discard-draft:hover[^{]*\{([^}]*?)\}", css)
|
||||
assert hover, "the hover state must be styled"
|
||||
hbody = hover.group(1)
|
||||
assert "var(--err-bg)" in hbody and "var(--err-ink)" in hbody, (
|
||||
"hover joins the err family (text + color, never color alone)"
|
||||
)
|
||||
assert "var(--err-line)" in hbody
|
||||
disabled = re.search(r"\.discard-draft:disabled \{([^}]*?)\}", css)
|
||||
assert disabled and "opacity" in disabled.group(1), (
|
||||
"the :disabled state is the 'Discarding…' affordance"
|
||||
)
|
||||
assert ":focus-visible" in css, "the global focus ring (AGENTS.md rule 5)"
|
||||
@@ -202,28 +202,66 @@ def test_app_js_call_sites_pass_the_raw_markdown() -> None:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# app.js — the click: payload, slug rule, navigation, failure copy
|
||||
# app.js — the title: the paired user question (phase 115) + the click
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_app_js_default_title_is_the_last_user_question() -> None:
|
||||
"""The default title: the LAST user question's text,
|
||||
whitespace-collapsed, ≤120 chars (the phase-50 auto-title
|
||||
convention — the chat auto-title targets the FIRST question, the
|
||||
docs default the LAST). Defensive "Note" with no user record."""
|
||||
def test_app_js_default_title_is_the_paired_user_question() -> None:
|
||||
"""Phase 115 (task 03): defaultDocTitle(wrap) — the title is the
|
||||
text of the user bubble PAIRED with the saved brain bubble: the
|
||||
NEAREST preceding .msg.user in the DOM conversation flow (the
|
||||
redo-in-place reorders the DOM, and the structural pair IS the
|
||||
answer's question by construction — the last conversation record
|
||||
can be an unrelated trailing question after a retry). The text is
|
||||
read from the bubble's .bubble (the meta rows carry button labels
|
||||
— never the whole wrap). No wrap given, or no paired user bubble
|
||||
found (first-turn edge / DOM mismatch) → the pre-phase-115
|
||||
fallback: the LAST user record in `conversation` (iterate
|
||||
backwards). The DOC_TITLE_MAX slice, the whitespace collapse, and
|
||||
the defensive "Note" are unchanged."""
|
||||
js = _text(APP_JS)
|
||||
assert "const DOC_TITLE_MAX = 120;" in js
|
||||
fn_idx = js.find("function defaultDocTitle() {")
|
||||
assert fn_idx != -1, "defaultDocTitle missing"
|
||||
fn_idx = js.find("function defaultDocTitle(wrap) {")
|
||||
assert fn_idx != -1, "defaultDocTitle(wrap) missing"
|
||||
fn_body = js[fn_idx : js.find("function docSlug", fn_idx)]
|
||||
assert "conversation.length - 1" in fn_body, (
|
||||
"the LAST user record wins (iterate backwards)"
|
||||
# The paired-bubble walk: the wrap's previousElementSibling chain,
|
||||
# the first .msg.user wins (nearest preceding user bubble) …
|
||||
assert "wrap.previousElementSibling" in fn_body, (
|
||||
"the pairing must walk the DOM conversation flow backwards"
|
||||
)
|
||||
assert 'el.classList.contains("msg")' in fn_body
|
||||
assert 'el.classList.contains("user")' in fn_body
|
||||
# …with the text read from that bubble's .bubble (not the wrap —
|
||||
# the meta rows carry button labels).
|
||||
assert 'el.querySelector(".bubble")' in fn_body
|
||||
assert "textContent" in fn_body
|
||||
# The fallback: the LAST user record in `conversation` (kept for
|
||||
# the no-wrap / no-pair edges).
|
||||
assert "conversation.length - 1" in fn_body
|
||||
assert 'conversation[i].who === "user"' in fn_body
|
||||
# The unchanged title rule: collapse, 120-cap, "Note".
|
||||
assert 'question.replace(/\\s+/g, " ").trim().slice(0, DOC_TITLE_MAX)' in fn_body
|
||||
assert '|| "Note"' in fn_body
|
||||
|
||||
|
||||
def test_app_js_save_as_doc_passes_the_bubble_ancestor() -> None:
|
||||
"""Phase 115 (task 03): the saveAsDoc call site passes the
|
||||
bubble's ancestor — the .save-as-doc-btn's own .msg.brain wrap
|
||||
(the button lives in the bubble's .msg-meta row, so closest climbs
|
||||
button → meta → body → wrap) — the title pairs the answer with ITS
|
||||
question. No call site may stay on the no-wrap fallback: the
|
||||
button always knows its own bubble."""
|
||||
js = _text(APP_JS)
|
||||
fn_idx = js.find("async function saveAsDoc(btn) {")
|
||||
assert fn_idx != -1, "saveAsDoc missing"
|
||||
fn_body = js[fn_idx : fn_idx + 3000]
|
||||
assert 'defaultDocTitle(btn.closest(".msg.brain"))' in fn_body
|
||||
assert "defaultDocTitle()" not in js, (
|
||||
"no caller may stay on the no-wrap fallback once the button "
|
||||
"knows its own bubble"
|
||||
)
|
||||
|
||||
|
||||
def test_app_js_slug_rule() -> None:
|
||||
"""The default in-repo path slug: lowercase → runs of
|
||||
non-alphanumerics → "-" → trimmed → ≤60 chars → empty → "note"
|
||||
|
||||
Reference in New Issue
Block a user