"""Unit: the phase-130 (task 02, TODO L6) chat actions cluster. The three answer actions (Tune / Retry / Save-as-doc) and the answer-status notes (Stopped / Failed) stop joining whatever the first ``.msg-meta`` row happens to be (usually the source chips row). They get-or-create a dedicated ``.msg-meta.msg-actions`` row — the bubble's LAST row, right-aligned by CSS (task 03) — so chips and images can never push the buttons around. Pins (the house source-pin pattern — no browser): * ``getActionsRow(wrap)`` — the shared get-or-create helper: scoped to the ``msg-actions`` class so it NEVER grabs the chips row (or any other meta row), ``role=list`` + ``aria-label="Answer actions"``, null without a ``.msg-body``; * all FIVE appenders/notes route through ``getActionsRow(wrap)`` — none of them reuses the first ``.msg-meta`` row anymore; * ``markLastRetryable`` re-appends the Retry button into the actions row and the phase-59 save-button re-append hack is GONE (the row's row-level right-align replaces the per-button auto margins); * the call-order audit (task 02 item 5): at every completed-bubble render site the order is answer figure → sources → related → the action appenders, so ``.msg-actions`` is the bubble's LAST row, and the done branch's insertion order inside the row is Tune → Retry → Save-as-doc. The browser-level assertions (the right-aligned cluster, the row order in the rendered DOM, the restore parity) are E2E-gated by tests/e2e/test_chat_answer_clusters.py (task 04). """ from __future__ import annotations from pathlib import Path FRONTEND = Path(__file__).resolve().parents[2] / "frontend" APP_JS = FRONTEND / "assets" / "app.js" def _js() -> str: return APP_JS.read_text(encoding="utf-8") def _fn_body(js: str, header: str) -> str: """The full source of the function whose header is *header* — brace-matched from the first ``{`` OUTSIDE the parameter list (paren depth 0, the house convention), so destructured parameters are skipped (the pinned functions carry no string literal with a stray brace).""" start = js.index(header) i = start paren = 0 while i < len(js): c = js[i] if c == "(": paren += 1 elif c == ")": paren -= 1 elif c == "{" and paren == 0: break i += 1 depth = 0 for j in range(i, len(js)): c = js[j] if c == "{": depth += 1 elif c == "}": depth -= 1 if depth == 0: return js[start : j + 1] raise AssertionError(f"unbalanced braces in {header!r}") def _done_branch(js: str) -> str: """The SSE ``done`` branch — from the branch test to the next ``else if`` (flat block, the slice is exact).""" start = js.index('ev.type === "done"') return js[start : js.index('ev.type === "tool_result"', start)] # --------------------------------------------------------------------------- # getActionsRow — the shared get-or-create helper # --------------------------------------------------------------------------- def test_get_actions_row_gets_or_creates_the_dedicated_row() -> None: """``getActionsRow(wrap)`` find-or-creates the bubble's ``.msg-meta.msg-actions`` row: the query is scoped to the ``msg-actions`` class (NEVER the bare first ``.msg-meta`` — that would grab the chips row), the created row is ``role=list`` + ``aria-label="Answer actions"`` (the ARIA convention of the other meta rows), and it lands in ``.msg-body``. A missing ``.msg-body`` (the defensive pre-phase convention) returns null — the appenders no-op.""" js = _js() body = _fn_body(js, "function getActionsRow(wrap) {") assert 'body.querySelector(".msg-meta.msg-actions")' in body, ( "the reuse query is scoped to the msg-actions class — the " "helper must NEVER grab the chips row (or any other meta row)" ) assert "document.querySelector" not in body and "body.querySelector(\".msg-meta\")" not in body assert 'row.className = "msg-meta msg-actions";' in body assert 'row.setAttribute("role", "list");' in body, ( "the row is an accessible list (the house meta-row convention)" ) assert 'row.setAttribute("aria-label", "Answer actions");' in body, ( "the row's accessible name (WCAG — the row is never unnamed)" ) assert "body.appendChild(row)" in body, "a new row lands in .msg-body" assert 'wrap?.querySelector?.(".msg-body")' in body, ( "defensive .msg-body lookup (the stopped-note convention)" ) assert "if (!body) return null" in body, "no .msg-body → null (appenders no-op)" assert "return row" in body def test_get_actions_row_is_the_shared_helper() -> None: """EXACTLY ONE getActionsRow (no per-appender copies of the get-or-create logic), and the created row's class string appears nowhere else in the file (the appenders only ever LOOK it up — they never build a second actions row).""" js = _js() assert js.count("function getActionsRow(wrap) {") == 1, ( "exactly ONE getActionsRow — the shared helper" ) assert js.count('row.className = "msg-meta msg-actions";') == 1, ( "the row is created in exactly ONE place — the helper; the " "appenders never build a row themselves" ) assert js.count('body.querySelector(".msg-meta.msg-actions")') == 1, ( "the reuse query lives in exactly ONE place — the helper" ) # --------------------------------------------------------------------------- # the five appenders/notes — all through getActionsRow # --------------------------------------------------------------------------- #: (function header, the one-per-bubble guard's class, the child class) APPENDERS = [ ("function appendTuneButton(wrap) {", ".tune-btn", "tune-btn"), ("function appendRetryButton(wrap) {", ".retry-btn", "retry-btn"), ( "function appendSaveAsDocButton(wrap, markdown) {", ".save-as-doc-btn", "save-as-doc-btn", ), ("function appendStoppedNote(wrap) {", ".stopped-note", "stopped-note"), ("function appendFailedNote(wrap, detail) {", ".failed-note", "failed-note"), ] def test_all_action_appenders_join_the_actions_row() -> None: """All FIVE appenders/notes (Tune, Retry, Save-as-doc, Stopped, Failed) route through ``getActionsRow(wrap)`` and no-op on its null (no ``.msg-body``) — none of them reuses the FIRST ``.msg-meta`` row anymore (the pre-phase-130 soup-div behavior is gone from every appender).""" js = _js() for header, guard, child in APPENDERS: body = _fn_body(js, header) assert "getActionsRow(wrap)" in body, f"{header} must use getActionsRow" assert 'querySelector(".msg-meta")' not in body, ( f"{header} must not query the bare first .msg-meta row" ) assert "if (!meta) return" in body, ( f"{header} must no-op when getActionsRow returns null" ) assert f'querySelector("{guard}")' in body, ( f"{header} keeps its one-per-bubble guard" ) assert f'"{child}"' in body, f"{header} keeps its child class" def test_action_appenders_keep_the_listitem_aria() -> None: """ARIA is kept (the existing convention): the role=list check stays on every appender and the child joins the row as a listitem (the row is ALWAYS role=list — getActionsRow sets it — so the check always applies).""" js = _js() for header, _, _ in APPENDERS: body = _fn_body(js, header) assert 'meta.getAttribute("role") === "list"' in body, ( f"{header} keeps the role=list check" ) assert 'setAttribute("role", "listitem")' in body, ( f"{header} keeps the listitem join" ) def test_tune_and_save_gates_are_unchanged() -> None: """The gates are untouched (no behavior change in completed work): Tune is admin-gated (phase 16) and Save-as-doc is admin-gated AND docs-repo-gated (phase 59) — both BEFORE any row work. Retry is NOT admin-gated (owner-locked: every visitor).""" js = _js() tune = _fn_body(js, "function appendTuneButton(wrap) {") assert 'if (!isAdmin) return' in tune save = _fn_body(js, "function appendSaveAsDocButton(wrap, markdown) {") assert "if (!isAdmin || !docsRepoConfigured) return" in save retry = _fn_body(js, "function appendRetryButton(wrap) {") assert "isAdmin" not in retry, "Retry stays NOT admin-gated" def test_failed_note_detail_is_textcontent_only() -> None: """The ``failed-detail`` contract survives (task 02 item 3): the error detail goes through ``textContent`` — no HTML from error strings, ever.""" js = _js() body = _fn_body(js, "function appendFailedNote(wrap, detail) {") assert 'd.className = "failed-detail"' in body assert "d.textContent = detail" in body, ( "the error string is NEVER innerHTML" ) # The icon constant is the ONLY innerHTML (the static aria-hidden # SVG) — the detail path never builds HTML (comments stripped — # the "NEVER innerHTML" remark is not code). code = "\n".join( line for line in body.splitlines() if "//" not in line ) assert code.count("innerHTML") == 1, ( "only the static FAILED_ICON innerHTML remains in the note" ) # --------------------------------------------------------------------------- # markLastRetryable — the rework (task 02 item 4) # --------------------------------------------------------------------------- def test_mark_last_retryable_moves_retry_into_the_actions_row() -> None: """``markLastRetryable`` keeps the phase-49 remove-then-append shape (remove every rendered .retry-btn FIRST, then re-append on the last brain bubble — only when its preceding user record exists), but the re-append now lands in the bubble's .msg-actions row (via appendRetryButton → getActionsRow). The phase-59 save-button re-append hack (the ``saveDocBtn`` block) is DELETED: the row's row-level right-align (task 03) right-aligns the whole cluster, and the DOM order inside the row is the insertion order. The 'auto margins split the free space' rationale is gone with it.""" js = _js() body = _fn_body(js, "function markLastRetryable() {") assert 'querySelectorAll(".retry-btn")' in body assert ".remove()" in body assert body.index(".remove()") < body.index("appendRetryButton(lastBrainWrap)"), ( "the existing buttons are removed before the new one is appended" ) assert "appendRetryButton(lastBrainWrap)" in body, ( "the re-append targets the last brain wrap (via getActionsRow)" ) assert 'who !== "user"' in body, "the preceding-user-record guard stays" assert "saveDocBtn" not in body, ( "the phase-59 save-button re-append hack is deleted" ) assert "parentElement" not in body, ( "no DOM re-parenting of the save button anymore" ) assert "auto margins" not in body, ( "the 'auto margins split the free space' rationale is gone" ) # The hack had no other home in the file. assert "saveDocBtn" not in js # --------------------------------------------------------------------------- # the call-order audit (task 02 item 5) — .msg-actions is the LAST row # --------------------------------------------------------------------------- def test_done_branch_appends_actions_last_in_insertion_order() -> None: """The live ``done`` branch (a completed brain bubble): the row order is the DOM order — the answer figure, then the source clusters (appendSources), then the related row, then the action appenders — so the .msg-actions row is the bubble's LAST row. Inside the row the insertion order is Tune → Retry (the markLastRetryable re-append) → Save-as-doc.""" js = _js() branch = _done_branch(js) for marker in ( "appendSources(wrap, ev.sources);", "appendRelated(wrap, ev.related);", "appendTuneButton(wrap);", "markLastRetryable();", 'appendSaveAsDocButton(wrap, finalText || acc || "…");', ): assert marker in branch, f"the done branch must keep {marker!r}" order = [ branch.index("appendSources(wrap, ev.sources);"), branch.index("appendRelated(wrap, ev.related);"), branch.index("appendTuneButton(wrap);"), branch.index("markLastRetryable();"), branch.index('appendSaveAsDocButton(wrap, finalText || acc || "…");'), ] assert order == sorted(order), ( "done branch: sources → related → Tune → Retry → Save-as-doc " "(the .msg-actions row is the bubble's LAST row — phase 130 " "task 02; the row's inner order is the insertion order)" ) # lastBrainWrap is set before the Retry re-append reads it. assert branch.index("lastBrainWrap = wrap") < branch.index( "markLastRetryable();" ) def test_zero_frame_fallback_appends_actions_last() -> None: """The zero-frame-but-completed fallback bubble (the stream settled with no events): a failed turn — Tune + the Failed note + Retry (markLastRetryable) + Save-as-doc, all in the bubble's .msg-actions row (the only row it has), the note BEFORE the Retry re-append (the 'note + Retry' reading), the save button LAST in the row (the phase-59 parity with the done path).""" js = _js() zero = js.index("if (!aborted && !wrap) {") block = js[zero : js.index("} catch (err) {", zero)] for marker in ( "appendTuneButton(fwrap);", "appendFailedNote(fwrap, nothing);", "markLastRetryable();", "appendSaveAsDocButton(fwrap, fallback);", ): assert marker in block, f"the fallback block must keep {marker!r}" order = [ block.index("appendTuneButton(fwrap);"), block.index("appendFailedNote(fwrap, nothing);"), block.index("markLastRetryable();"), block.index("appendSaveAsDocButton(fwrap, fallback);"), ] assert order == sorted(order), ( "fallback: Tune → Failed note → Retry → Save-as-doc — the " "actions row holds the whole cluster (phase 130 task 02)" ) def test_stop_finalize_appends_actions_last() -> None: """The stop finalize (a stopped partial — the prime retry candidate): Tune + the Stopped note + Retry (markLastRetryable), all in the actions row — the stop path renders no sources or related rows, so the actions row IS the bubble's only (and last) row. The order is Tune → note → Retry (the 'note + Retry' reading), unchanged from pre-phase (no save button — a stopped partial is a note, not an answer).""" js = _js() catch = js.index("} catch (err) {") stop = js.index('stoppedByUser || err?.name === "AbortError"', catch) branch = js[stop : js.index("} else {", stop)] for marker in ( "appendTuneButton(wrap);", "appendStoppedNote(wrap);", "markLastRetryable();", ): assert marker in branch, f"the stop branch must keep {marker!r}" order = [ branch.index("appendTuneButton(wrap);"), branch.index("appendStoppedNote(wrap);"), branch.index("markLastRetryable();"), ] assert order == sorted(order), ( "stop: Tune → Stopped note → Retry (phase 130 task 02 — the " "note + Retry in the actions row)" ) assert "appendSaveAsDocButton" not in branch, ( "the stopped partial (note, not answer) still gets no save button" ) def test_failed_funnel_note_and_retry_join_the_actions_row() -> None: """The phase-120 failure funnel (finalizeFailedTurn): the Failed note is appended and the funnel ENDS with markLastRetryable — the note + Retry in the failed bubble's actions row (the partial keeps its streamed text; the zero-frame shape gets a plain FAILED_TURN_TEXT bubble). Both shapes end retryable.""" js = _js() header = ( "function finalizeFailedTurn(\n" " detail,\n" " { acc, thinking, tools, segments, wrap, leavePartialIndex }\n" ") {" ) body = _fn_body(js, header) assert body.count("appendFailedNote(") == 2, ( "both funnel shapes add the in-bubble error note" ) lines = [line for line in body.splitlines() if line.strip()] assert lines[-1].strip() == "}" and lines[-2].strip().startswith( "markLastRetryable();" ), ( "the funnel still ends with the markLastRetryable() call — the " "Retry button lands on the failed bubble's actions row" ) def test_restore_path_appends_actions_last() -> None: """The phase-14 restore path (renderStoredMessage): the answer figure → sources → related → the action appenders (Tune / Save-as-doc / the Stopped + Failed notes) — so the .msg-actions row a restored bubble gets is its LAST row too, identical to the live done path. The restore loop's after-the-loop markLastRetryable() then lands the Retry button in that same row (on the LAST restored brain bubble).""" js = _js() body = _fn_body(js, "function renderStoredMessage(m) {") for marker in ( "appendSources(wrap, m.sources);", "appendRelated(wrap, m.related);", "appendTuneButton(wrap)", "appendSaveAsDocButton(wrap, m.text);", "appendStoppedNote(wrap);", "appendFailedNote(wrap, m.error);", "lastBrainWrap = wrap", ): assert marker in body, f"the restore path must keep {marker!r}" order = [ body.index("appendSources(wrap, m.sources);"), body.index("appendRelated(wrap, m.related);"), body.index("appendTuneButton(wrap)"), body.index("appendSaveAsDocButton(wrap, m.text);"), body.index("lastBrainWrap = wrap"), ] assert order == sorted(order), ( "restore: sources → related → the action appenders → " "lastBrainWrap (the after-the-loop markLastRetryable lands the " "Retry in the same actions row — phase 130 task 02)" )