phase: 115_doc_draft_discard
Build and Push Containers / build-and-push-app (push) Successful in 2m12s
Build and Push Containers / build-and-push-db (push) Successful in 14s

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:
2026-09-15 05:35:05 -04:00
parent 3846f26a58
commit 990c8adf13
29 changed files with 1384 additions and 30 deletions
+185 -4
View File
@@ -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"))
+58
View File
@@ -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)"
+48 -10
View File
@@ -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"