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,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)"
|
||||
Reference in New Issue
Block a user