"""Unit: the edit-and-resend frontend contract (phase 159, tasks 01–04). Pins the phase-159 task-01 + task-02 client contract as source assertions (the house ``test_frontend_*`` style — no browser): the Edit button joins the bubble's ``.msg-actions`` row (getActionsRow — the phase-130 house pattern) as a listitem of the ``role=list`` row, ``markLastEditable`` removes every rendered button FIRST and then targets exactly ONE bubble — the newest question (the phase-49 single-target discipline, one ``who`` over — bailing on the in-flight guard, a missing user record, and a missing tracked wrap), ``lastUserRecordIndex`` is a newest-first ``conversation`` scan, ``lastUserWrap``'s lifecycle sites, ``openEditor`` renders the ``openTuneForm`` family (a labeled ``textarea[maxlength]`` bound to ``MAX_QUESTION_CHARS`` — the 4000 cap stays single-sourced — prefilled through ``.value``, never an HTML string; Send/Cancel (phase 170 A2 — the submit re-sends the question, it never saves a note); a ``role=alert`` rejection line), the Cancel / ``Escape`` restore paths, the Ctrl/Cmd+Enter submit, the empty-guard rejection (the form stays open), the in-flight save no-op, and the task-02 SAVE = replace + truncate + re-ask flow: the guards (in-flight first, then the empty trim) run before the record edit; the trimmed text replaces the record in place; ``conversation.splice(editIdx + 1)`` drops the whole tail; the matching DOM bubbles are removed from ``lastUserWrap.nextSibling`` onward; ``lastBrainWrap = null`` + the phase-73 ``leavePartialIndex`` reset (when it pointed past ``editIdx``) precede the re-ask; ``saveConversation()`` + ``persistConversation()`` persist the truncated shape before the re-ask; ``markLastRetryable()`` re-targets (clears — no brain record remains); the editor closes; the double in-flight check sits on one synchronous stretch (no ``await``); and the re-ask is exactly ``runTurn(text, { reask: true })`` — no image (the phase-49/123 A7 text-only rule). The composer stays untouched. And — the phase's explicit "NOT touched" contract — ``retryLastTurn`` is byte-unchanged, ``runTurn`` is pinned at exactly the state the phase left it (its sanctioned additions — the task-01 ``lastUserWrap`` assignment + the idle-settle ``markLastEditable()`` mark pass, the task-03 aborted-settle mark pair, the phase-160 done-settle pair, and the phase-161 live-container additions — included; no task may restructure it), and — the task-04 regression pins — ``handleSend`` and ``rememberBrainTurn`` are byte-unchanged (the plain send's pre-work and the brain-record funnel the re-ask's settle rides are touched by no phase-159 task). Task 03 (the driver case — a failed or rejected question stays editable) is pinned here too: the non-``done`` settle pairings (the ``finalizeFailedTurn`` funnel that every HTTP-422 / SSE-``error``-frame turn settles through, the zero-frame fallback, the stop finalize, and the 300s guard's ``aborted`` catch branch — a timed-out turn must not strand the question), ``markLastEditable``'s guard naming ONLY ``UI_STATE.thinking`` / ``UI_STATE.streaming`` (never the error state — a failed turn IS idle-or-error at rest), the save guard mirroring it byte-identically, the failed ``{ who: "brain", failed: true, error }`` record being DROPPED (not kept) by the edit-save truncation with ``lastBrainWrap = null`` ahead of the re-ask, the restore paths (``renderStoredMessage``'s ``lastUserWrap`` tracking + the two restore mark pairs — the ``?chat=`` boot load confirmed as an editable working copy, no read-only gate), the truncation-ordering note (the new turn's ``rememberBrainTurn`` sees the post-splice array), and ``showErrorBanner`` byte-unchanged (the phase-120 shared pin — Edit is an in-bubble affordance beside Retry, never a banner control). """ from __future__ import annotations import re from pathlib import Path from tests.unit.test_frontend_failed_turn import ( PINNED_RETRY_LAST_TURN, PINNED_SHOW_ERROR_BANNER, ) FRONTEND = Path(__file__).resolve().parents[2] / "frontend" APP_JS = FRONTEND / "assets" / "app.js" STYLES_CSS = FRONTEND / "assets" / "styles.css" def _js() -> str: return APP_JS.read_text(encoding="utf-8") def _css() -> str: return STYLES_CSS.read_text(encoding="utf-8") def _find_body_brace(js: str, start: int) -> int: """The index of the REAL opening brace of a function at *start* — the first ``{`` OUTSIDE the parameter list (paren depth 0), so empty object defaults (``opts = {}``) and destructured parameters (``{ acc, thinking }``) are skipped (the house convention).""" i = start paren = 0 while i < len(js): c = js[i] if c == "(": paren += 1 elif c == ")": paren -= 1 elif c == "{" and paren == 0: return i i += 1 return -1 def _fn_source(js: str, name: str) -> str: """The full source of ``[async] function name(…){…}`` (signature + body).""" start = js.index(f"function {name}(") brace = _find_body_brace(js, start) depth = 0 i = brace while True: c = js[i] if c == "{": depth += 1 elif c == "}": depth -= 1 if depth == 0: break i += 1 return js[start : i + 1] def _fn_body(js: str, name: str) -> str: """Just the body of ``[async] function name(…){…}`` (between the braces).""" src = _fn_source(js, name) start = js.index(f"function {name}(") brace = _find_body_brace(js, start) rel = brace - start return src[rel + 1 : len(src) - 1] # --------------------------------------------------------------------------- # EDIT_ICON — the pencil glyph (the retry/tune icon family) # --------------------------------------------------------------------------- def test_edit_icon_is_an_aria_hidden_stroke_svg() -> None: """``EDIT_ICON`` is an inline ``aria-hidden`` decoration in the house stroke family (``fill="none"`` + ``stroke="currentColor"`` + ``stroke-width="1.7"`` + the 24×24 viewBox — the RETRY_ICON parameters): no icon font, no CDN (A11); the CSS themes the stroke (ink-soft → ink on hover).""" js = _js() m = re.search(r"const EDIT_ICON\s*=\s*\n?\s*'([^']*)'", js) assert m, "const EDIT_ICON must exist as a single-quoted SVG string" icon = m.group(1) assert 'aria-hidden="true"' in icon, "the glyph is decoration" assert 'viewBox="0 0 24 24"' in icon, "the 24×24 viewBox (the family)" assert 'fill="none"' in icon, "stroke family — never a filled glyph" assert 'stroke="currentColor"' in icon, "currentColor — the CSS themes it" assert 'stroke-width="1.7"' in icon, "stroke-width 1.7 (the RETRY_ICON family)" # --------------------------------------------------------------------------- # appendEditButton — the Edit button in the actions row # --------------------------------------------------------------------------- def test_append_edit_button_joins_the_actions_row() -> None: """``appendEditButton(wrap)`` is shaped exactly like ``appendRetryButton``: it routes through ``getActionsRow(wrap)`` (the phase-130 house pattern — the row's LAST row, right-aligned), bails without a ``.msg-body``, keeps ONE button per bubble (the duplicate guard), joins the ``role=list`` row as a ``listitem`` (ARIA valid), carries the visible "Edit" label (the accessible name — no ellipsis clipping at this width), and the click opens the editor with the toggle button (for the focus return).""" js = _js() body = _fn_body(js, "appendEditButton") assert "getActionsRow(wrap)" in body, ( "the button joins the dedicated .msg-actions row (getActionsRow — " "phase 130 task 02), never the first .msg-meta row" ) assert "if (!meta) return;" in body assert ( 'if (meta.querySelector(".edit-btn")) return;' in body ), "one .edit-btn per bubble (the duplicate guard, the retry-btn way)" assert 'btn.type = "button";' in body assert 'btn.className = "edit-btn";' in body assert ( 'if (meta.getAttribute("role") === "list") btn.setAttribute("role", "listitem");' in body ), "the role=list row joins the button as a listitem (ARIA valid)" assert 'btn.innerHTML = EDIT_ICON + "Edit";' in body, ( "the visible 'Edit' label carries the accessible name" ) assert 'btn.addEventListener("click", () => openEditor(wrap, btn));' in body, ( "the click opens the editor with the toggle (the focus return)" ) assert "meta.appendChild(btn);" in body def test_edit_button_is_not_admin_gated() -> None: """Unlike ``appendTuneButton``, ``appendEditButton`` has NO admin gate (the phase-49 Retry precedent — chat is public, so every visitor who can chat can edit the last question; B5's admin-only views are the header views, not the chat actions).""" js = _js() body = _fn_body(js, "appendEditButton") assert "isAdmin" not in body, ( "the Edit button is NOT admin-gated (the phase-49 Retry precedent)" ) # --------------------------------------------------------------------------- # markLastEditable — the single-target discipline (the phase-49 mirror) # --------------------------------------------------------------------------- def test_mark_last_editable_removes_first_bails_then_appends_the_single_target() -> None: """``markLastEditable`` mirrors ``markLastRetryable``: it removes EVERY rendered ``.edit-btn`` FIRST (an older bubble's button is stale the moment a newer question lands), then bails on the in-flight guard (thinking/streaming — the ``retryLastTurn`` guard), then bails on a missing user record (``lastUserRecordIndex() === -1``), then bails on a missing ``lastUserWrap`` (a stale wrap can never carry the button), and appends to ``lastUserWrap`` ONLY — exactly one bubble, the newest question (owner-locked 2026-10-05; earlier bubbles are never editable — no mid-conversation branching).""" js = _js() body = _fn_body(js, "markLastEditable") idx_remove = body.index( 'messagesEl.querySelectorAll(".edit-btn").forEach((b) => b.remove());' ) idx_inflight = body.index( "if (uiState === UI_STATE.thinking || uiState === UI_STATE.streaming) return;" ) idx_norecord = body.index("if (lastUserRecordIndex() === -1) return;") idx_nowrap = body.index("if (!lastUserWrap) return;") idx_append = body.index("appendEditButton(lastUserWrap);") assert idx_remove < idx_inflight < idx_norecord < idx_nowrap < idx_append, ( "remove FIRST, then the in-flight guard, the no-record bail, the " "no-wrap bail, and the single-target append" ) assert body.count("appendEditButton(") == 1, ( "exactly ONE append — the single-target discipline" ) def test_last_user_record_index_is_a_newest_first_scan() -> None: """``lastUserRecordIndex()`` scans ``conversation`` NEWEST-FIRST (``i`` from ``length - 1`` down to 0) for ``who === "user"`` and returns that index — or -1 when the conversation holds no user record (the markLastRetryable last-brain-index rule, one ``who`` over; the ASSUMPTION: the index comes from the record scan, NOT a DOM↔record index map — re-deriving keeps the editor correct after the task-02 truncation).""" js = _js() body = _fn_body(js, "lastUserRecordIndex") assert "for (let i = conversation.length - 1; i >= 0; i -= 1) {" in body, ( "the scan is newest-first (i from length-1 down)" ) assert 'if (conversation[i].who === "user") return i;' in body assert "return -1;" in body, "-1 when there is no user record" # --------------------------------------------------------------------------- # lastUserWrap — the lifecycle (the exact mirror of lastBrainWrap) # --------------------------------------------------------------------------- def test_last_user_wrap_lifecycle_sites() -> None: """``lastUserWrap`` is a module-scope variable (exactly one declaration) with the three sanctioned lifecycle sites: (a) assigned in ``runTurn``'s fresh-send block — the ``addMessage`` wrap is captured and the wrap assigned BEFORE the persistence save point (the question record push); (b) assigned in ``renderStoredMessage``'s user branch — the LAST restored user bubble wins (the mirror of the ``lastBrainWrap`` line below the brain branch); (c) cleared by ``startNewChat`` (the editable wrap resets with the conversation; the list reset removes the buttons with the list, so startNewChat needs no markLastEditable call — the phase-49 reasoning).""" js = _js() assert js.count("let lastUserWrap = null;") == 1, ( "exactly one module-scope declaration" ) # (a) the runTurn fresh-send assignment (before the save point) rt = _fn_source(js, "runTurn") assert "const userWrap = addMessage(" in rt, ( "the fresh send captures the wrap (the addMessage return)" ) assert "lastUserWrap = userWrap;" in rt assert rt.index("lastUserWrap = userWrap;") < rt.index("conversation.push("), ( "the wrap is assigned before the persistence save point" ) # (b) the restore user-branch assignment rst = _fn_body(js, "renderStoredMessage") assert "lastUserWrap = wrap;" in rst, ( "the restore user branch assigns the wrap (the mirror of the " "lastBrainWrap line below the brain branch)" ) # (c) the startNewChat clear snc = _fn_body(js, "startNewChat") assert "lastUserWrap = null;" in snc, ( "startNewChat clears the wrap (the buttons die with the list reset)" ) # --------------------------------------------------------------------------- # openEditor — the inline form (the openTuneForm family) # --------------------------------------------------------------------------- def test_open_editor_renders_a_labelled_capped_textarea_prefilled_from_the_record() -> None: """``openEditor`` renders the ``openTuneForm`` family: one editor at a time (every existing ``.edit-form`` removed first), a ``form.edit-form`` with ``noValidate``, a ``