Files
brain-of-reese/tests/unit/test_frontend_tool_budget.py
ducoterra 34d60efbc1
Build and Push Containers / build-and-push-app (push) Successful in 2m11s
Build and Push Containers / build-and-push-db (push) Successful in 29s
phase: 164_chat_error_honesty
All gates green. Report:

- **Verified (phase 164, all 4 tasks already in `complete/`)**: `HistoryTurn`/`ChatMessage`/`ChatThinkingSegment` caps → 600 000, `history`/saved-messages lists → 500, new `SavedChatCreate/Update` total-8 000 000-char validator; exported `errorDetailText` at the two previously unguarded chat paths (pre-stream `!res.ok` + SSE `error` frame); `FAILED_REQUEST_TEXT` + `bubbleText` threading + phase-114 banner hint; dedicated E2E `tests/e2e/test_chat_error_honesty.py`.
- **Final-pass additions**: `tests/integration/test_chat_api.py::test_history_rejects_one_char_over_the_text_cap` (endpoint-level >600k → 422 with the pydantic list-of-objects `msg` — the criterion's upper half was only unit-pinned); README env rows for `BOR_APP_VERSION` + `BOR_SESSION_COOKIE`; restored `test_document_viewer.py`'s tracked screenshot artifact dirtied by the E2E run (via read-only `git show`, no index/history touched).
- **Tests/lint (exact commands)**: `uv run pytest --cov=app --cov-report=term-missing` → **3837 passed**, `app/` coverage **99%** (>90%); `uv run pytest tests/e2e/test_chat_error_honesty.py -v --no-cov` → **4 passed in isolation**; per-file loop over **all 148** E2E files (`uv run pytest tests/e2e/<f> -q --no-cov`) → **148 × exit 0**, incl. `test_failed_turn_retry`, `test_chat_save_payload_limits`, `test_edit_and_resend`, `test_llm_history`, `test_smoke`; `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors.
- **Completion criteria**: pre-stream 422 shows the distinct line + verbatim `msg`, `[object Object]` unrepresentable + no `input` in DOM — **met** (unit totality/source pins + E2E asserts 1–2); 525k history accepted (200 settles) / >600k → 422 / saved chat 201 + 8M bound — **met**; transport + SSE-frame settles keep `FAILED_TURN_TEXT`, stop/abort untouched, §7.4 re-enables — **met**; budget trim, `message` 4000, the 7 guarded detail-readers and all SSE shapes byte-identical (diff touches only caps + the two chat paths) — **met**; README/AGENTS full review + proxy deploy note — **met**; commit/`complete/` move = pipeline gate.
- **Docs**: README reviewed in full (env tables cross-checked programmatically: 74 `BOR_*` rows vs `Settings` defaults — no mismatches; every `.env.example` var documented; referenced scripts/pages/endpoints/`query_log` columns exist) — found & fixed the 2 undocumented real knobs above; the phase's payload-limits section, failed-turn copy and `client_max_body_size ≥ 16 MB` deploy note verified accurate. `AGENTS.md` verified consistent (rules, gitignore list, quick-reference commands, referenced paths all correct) — no changes needed.
- **Deviations**: `ChatThinkingSegment.text` raised 32 000 → 600 000 too (not itemized in A1, but its own docstring mirrors `HistoryTurn.thinking`; without it a long-thinking turn 422s its own auto-save — same mirror invariant). No live-infra changes made (the proxy limit lives in the separate deployment repo; README note only).
- **Next pending phase**: none — `164_chat_error_honesty` was the only entry in `todo/`.
2026-10-07 18:00:12 -04:00

547 lines
23 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Unit: the phase-160 (task 04, TODO L4) "Continue researching" button.
When a turn ends because its tool budget was spent (the ``done``
frame's additive ``budget_exhausted`` key, task 03 — ABSENT on every
non-cap turn), the answer bubble offers **Continue researching**: one
click sends the next turn (a plain send — the budget is per-turn, so
the fresh turn has a whole new round budget) and the marker persists
with the record so a page refresh restores the affordance. The shared
page never shows the button (read-only by construction).
Pins (the house source-pin pattern — no browser):
* ``CONTINUE_ICON`` / ``CONTINUE_QUESTION`` — the inline aria-hidden
SVG in the RETRY_ICON stroke family (no icon font, no CDN — A11)
and the literal continuation word (what the model is taught to ask
the user for);
* ``appendContinueButton`` — the house pattern through
``getActionsRow`` (the role=list row joins the button as a
listitem), the one-per-bubble guard, click → ``continueTurn()``;
* ``continueTurn`` — the ``retryLastTurn`` discipline: no-op while a
turn is in flight, every ``.continue-btn`` removed FIRST, then a
PLAIN ``runTurn(CONTINUE_QUESTION)`` (no ``reask``);
* the ``done`` settle — ``budget_exhausted`` rides
``rememberBrainTurn``'s meta (``undefined`` drops the key, so every
non-cap record stays byte-identical) and the button is appended
ONLY when the frame is flagged, in the row order Tune → Retry →
Continue → Save-as-doc (the insertion order phase 130 relies on);
* the restore branch — keys on ``m.budget_exhausted``;
* ``startNewChat`` needs nothing — its list reset drops the buttons;
* ``retryLastTurn`` / ``markLastRetryable`` / ``rememberBrainTurn``
are byte-unchanged (the phase-120 shared pin for the first);
* the shared page (work item 8) — ``shared.html`` never loads
``app.js`` and ``shared.js`` builds no buttons / actions rows, so a
flagged record renders without any Continue control there.
The browser-level assertions (the live click running a fresh turn, the
reload restoring the button, the shared page's read-only render) are
E2E-gated by tests/e2e/test_tool_budget_telemetry.py (task 05).
"""
from __future__ import annotations
from pathlib import Path
from tests.unit.test_frontend_failed_turn import PINNED_RETRY_LAST_TURN
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
APP_JS = FRONTEND / "assets" / "app.js"
STYLES_CSS = FRONTEND / "assets" / "styles.css"
SHARED_HTML = FRONTEND / "shared.html"
SHARED_JS = FRONTEND / "assets" / "shared.js"
def _js() -> str:
return APP_JS.read_text(encoding="utf-8")
def _css() -> str:
return STYLES_CSS.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)]
# ---------------------------------------------------------------------------
# the glyph + the continuation word
# ---------------------------------------------------------------------------
def test_continue_icon_is_an_inline_svg_in_the_stroke_family() -> None:
"""``CONTINUE_ICON`` is the inline ``aria-hidden`` SVG in the
RETRY_ICON stroke family (task 01: 24×24, ``fill="none"``,
``stroke="currentColor"``, ``stroke-width="1.7"`` — the CSS themes
the stroke) — no icon font, no CDN (A11)."""
js = _js()
start = js.index("const CONTINUE_ICON =")
seg = js[start :]
q1 = seg.index("'")
q2 = seg.index("'", q1 + 1)
literal = seg[q1 + 1 : q2] # the first string literal = the inline SVG
for attr in (
'aria-hidden="true"',
'viewBox="0 0 24 24"',
'fill="none"',
'stroke="currentColor"',
'stroke-width="1.7"',
):
assert attr in literal, f"the glyph keeps the shared family: {attr!r}"
assert literal.startswith("<svg") and literal.endswith("</svg>")
def test_continue_question_is_the_literal_word_continue() -> None:
"""``CONTINUE_QUESTION`` is the literal word ``continue`` — the
word the model is taught to ask the user for (task 02's
ROUND_CAP_WRAPUP_INSTRUCTION / the spent-budget trailer), so the
visible bubble, the typed equivalent, and the model's own
suggestion all agree."""
js = _js()
assert 'const CONTINUE_QUESTION = "continue";' in js
def test_continue_icon_is_defined() -> None:
"""EXACTLY ONE ``CONTINUE_ICON`` constant (the glyph is shared by
the button, never duplicated)."""
js = _js()
assert js.count("const CONTINUE_ICON =") == 1
assert js.count("const CONTINUE_QUESTION =") == 1
# ---------------------------------------------------------------------------
# appendContinueButton — the house pattern
# ---------------------------------------------------------------------------
def test_append_continue_button_joins_the_actions_row() -> None:
"""``appendContinueButton(wrap)`` keeps the house pattern (the
appendRetryButton shape, task 02): the getActionsRow lookup (the
dedicated actions row, NEVER the chips row), the null no-op, the
one-per-bubble guard (``.continue-btn``), the role=list →
listitem ARIA join, the icon + the visible "Continue researching"
label (the label carries the accessible name), and the click →
``continueTurn()`` binding."""
js = _js()
body = _fn_body(js, "function appendContinueButton(wrap) {")
assert "getActionsRow(wrap)" in body, "the shared get-or-create helper"
assert "if (!meta) return" in body, "no-op without a .msg-body"
assert 'querySelector(".continue-btn")' in body, (
"the one-per-bubble guard"
)
assert 'meta.getAttribute("role") === "list"' in body, (
"the role=list check (the row is always role=list)"
)
assert 'setAttribute("role", "listitem")' in body, "the listitem join"
assert 'btn.className = "continue-btn"' in body
assert 'btn.innerHTML = CONTINUE_ICON + "<span>Continue researching</span>"' in body, (
"icon + the visible label (the accessible name)"
)
assert "btn.addEventListener(\"click\", () => continueTurn())" in body, (
"the click handler — a plain continueTurn() (no wrap, no args)"
)
assert "meta.appendChild(btn)" in body
# NOT admin-gated, like Retry (chat is public).
assert "isAdmin" not in body
def test_continue_button_lands_after_the_retry_button() -> None:
"""``appendContinueButton`` is defined DIRECTLY AFTER
``appendRetryButton`` (task 02's placement — the sibling in the
actions-row family, before the Edit button's block)."""
js = _js()
retry = js.index("function appendRetryButton(wrap) {")
cont = js.index("function appendContinueButton(wrap) {")
edit = js.index("function appendEditButton(wrap) {")
assert retry < cont < edit
# ---------------------------------------------------------------------------
# continueTurn — the retryLastTurn discipline
# ---------------------------------------------------------------------------
def test_continue_turn_bails_and_clears_and_sends_plain() -> None:
"""``continueTurn()`` (task 03): (1) no-op while a turn is in
flight (the ``retryLastTurn`` guard — one turn at a time, no
double-send); (2) clears EVERY rendered ``.continue-btn`` FIRST
(the markLastRetryable remove-first pattern — a stale button on an
earlier bubble is gone the moment the new turn's answer lands);
(3) a PLAIN send — ``runTurn(CONTINUE_QUESTION)`` with NO
``reask`` argument (the default ``false``): the user bubble
appears, the record is pushed + saved at the ordinary save
points, and the request carries the full history."""
js = _js()
body = _fn_body(js, "function continueTurn() {")
assert (
"if (uiState === UI_STATE.thinking || uiState === UI_STATE.streaming) return;"
in body
), "the in-flight no-op (the retryLastTurn guard)"
assert (
'messagesEl.querySelectorAll(".continue-btn").forEach((b) => b.remove());'
in body
), "every continue button is cleared before the send"
assert "return runTurn(CONTINUE_QUESTION)" in body, (
"a PLAIN send — the returned promise (the retryLastTurn "
"convention)"
)
assert "reask" not in body, (
"NO reask argument — a plain send (runTurn's default false)"
)
order = [
body.index("UI_STATE.thinking"),
body.index(".remove()"),
body.index("runTurn(CONTINUE_QUESTION)"),
]
assert order == sorted(order), (
"guard → clear-every-button → send, in that order"
)
def test_continue_turn_sends_one_plain_turn_with_full_history() -> None:
"""The plain-send contract on the ``runTurn`` side (task 03's
save-point list): with ``reask`` defaulting to false, the turn
appends the user bubble, pushes ``{ who: "user", text }`` (no
image — a text-only record), runs the ordinary save points
(``saveConversation`` + ``persistConversation``), and the request's
``history`` is the FULL ``conversation.slice(0, -1)`` — the model
resumes with everything it already read."""
js = _js()
rt = _fn_body(js, "function runTurn(text, { reask = false, image = null } = {}) {")
assert "if (!reask) {" in rt, "the default is a plain send"
assert 'conversation.push(' in rt
assert 'saveConversation();' in rt
assert "persistConversation();" in rt
assert "const history = conversation.slice(0, -1).map(" in rt, (
"the request carries the full history (everything except the "
"just-pushed question)"
)
# ---------------------------------------------------------------------------
# the done settle — the flag rides the record + the button is gated
# ---------------------------------------------------------------------------
def test_done_handler_persists_the_flag_and_appends_the_button() -> None:
"""The ``done`` settle (task 04): ``budget_exhausted`` rides
``rememberBrainTurn``'s meta as
``ev.budget_exhausted || undefined`` (the ``answer_image``
precedent — ``undefined`` drops the key from the JSON, so every
non-cap record stays byte-identical), every earlier bubble's
continue button is cleared FIRST (the completion criterion —
"neither does any earlier answer once a newer turn lands" — the
markLastRetryable remove-first pattern), and the button is
appended ONLY when the frame is flagged."""
js = _js()
branch = _done_branch(js)
assert "budget_exhausted: ev.budget_exhausted || undefined" in branch, (
"the flag persists with the turn (undefined drops the key)"
)
assert (
'messagesEl.querySelectorAll(".continue-btn").forEach((b) => b.remove());'
in branch
), "every earlier bubble's continue button is cleared on the settle"
assert "if (ev.budget_exhausted) appendContinueButton(wrap);" in branch, (
"the button is appended ONLY on a flagged done"
)
order = [
branch.index('querySelectorAll(".continue-btn")'),
branch.index("appendContinueButton(wrap)"),
]
assert order == sorted(order), "clear-every-button → gated append"
def test_done_branch_row_order_is_tune_retry_continue_save() -> None:
"""The row order on a capped answer (task 04): Tune → Retry (the
markLastRetryable re-append) → Continue (flag-gated) → Save-as-doc
— the INSERTION order phase 130 relies on (the DOM order inside
the row is the visual order)."""
js = _js()
branch = _done_branch(js)
order = [
branch.index("appendTuneButton(wrap);"),
branch.index("markLastRetryable();"),
branch.index("appendContinueButton(wrap);"),
branch.index('appendSaveAsDocButton(wrap, finalText || acc || "…");'),
]
assert order == sorted(order), (
"done: Tune → Retry → Continue → Save-as-doc (the insertion "
"order phase 130 relies on)"
)
def test_continue_button_is_not_added_on_the_other_settle_paths() -> None:
"""The Continue button is a CAP-REACHED-DONE affordance only: the
zero-frame fallback, the stop finalize, and the failed-turn funnel
(none of them sees a ``budget_exhausted`` frame — a failed or
stopped turn never hit the cap) append NO continue button, and the
``done`` branch's flag-gate is the single gate."""
js = _js()
assert js.count("appendContinueButton(wrap);") == 2, (
"exactly TWO call sites (with the terminating semicolon — the "
"function header ends in a brace, not a semicolon): the done "
"settle + the restore path"
)
zero = js.index("if (!aborted && !wrap) {")
block = js[zero : js.index("} catch (err) {", zero)]
assert "appendContinueButton" not in block, (
"the zero-frame fallback (a failed turn) adds no continue button"
)
funnel = _fn_body(
js,
"function finalizeFailedTurn(\n"
" detail,\n"
" { acc, thinking, tools, segments, wrap, leavePartialIndex, bubbleText }\n"
") {",
)
assert "appendContinueButton" not in funnel, (
"the failed-turn funnel adds no continue button"
)
# ---------------------------------------------------------------------------
# the restore path + startNewChat
# ---------------------------------------------------------------------------
def test_restore_branch_keys_on_the_persisted_marker() -> None:
"""The phase-14 restore (``renderStoredMessage``'s brain branch,
task 05): the Continue affordance restores from the persisted
``m.budget_exhausted`` marker — a reload keeps the button on a
flagged answer (a restored ``history`` is exactly what the
continuation turn needs), and a pre-phase / non-cap record (no
key) restores byte-identically (no button). The append lands
BEFORE the ``lastBrainWrap`` assignment, after the per-message
appends (the actions row is the bubble's LAST row, as on the
live done path)."""
js = _js()
body = _fn_body(js, "function renderStoredMessage(m) {")
assert "if (m.budget_exhausted) appendContinueButton(wrap);" in body, (
"the restore keys on the persisted marker"
)
order = [
body.index("appendFailedNote(wrap, m.error);"),
body.index("if (m.budget_exhausted) appendContinueButton(wrap);"),
body.index("lastBrainWrap = wrap"),
]
assert order == sorted(order), (
"restore: the per-message appends → the continue button → "
"lastBrainWrap (the after-the-loop markLastRetryable lands the "
"Retry in the same actions row, as on the live path)"
)
def test_start_new_chat_unchanged_drops_the_buttons_with_the_list() -> None:
"""``startNewChat`` (task 05: needs nothing) — its list reset
removes every ``.msg`` bubble, so the continue buttons die with
the list (the phase-49 reasoning). The reset + the in-flight
guard stay byte-pinned."""
js = _js()
body = _fn_body(js, "function startNewChat() {")
assert (
"if (uiState === UI_STATE.thinking || uiState === UI_STATE.streaming) return;"
in body
), "the in-flight guard stays"
assert 'messagesEl.querySelectorAll(".msg").forEach((el) => el.remove());' in body, (
"the list reset drops the buttons along with the list"
)
assert "appendContinueButton" not in body, (
"no continue-button wiring was added to startNewChat"
)
# ---------------------------------------------------------------------------
# NOT touched — the phase's explicit contract (byte-pinned sources)
# ---------------------------------------------------------------------------
#: The FULL source of ``markLastRetryable`` as of pre-phase-160 task
#: 04 — the remove-then-append shape (remove every rendered
#: .retry-btn FIRST, then re-append on the last brain bubble) is
#: UNCHANGED: the continue button has its OWN clear pass in
#: continueTurn, and markLastRetryable's targeting is untouched.
PINNED_MARK_LAST_RETRYABLE = """function markLastRetryable() {
messagesEl.querySelectorAll(".retry-btn").forEach((b) => b.remove());
if (!lastBrainWrap) return;
let lastIdx = -1;
for (let i = conversation.length - 1; i >= 0; i -= 1) {
if (conversation[i].who === "brain") {
lastIdx = i;
break;
}
}
const prev = lastIdx > 0 ? conversation[lastIdx - 1] : null;
if (!prev || prev.who !== "user") return;
appendRetryButton(lastBrainWrap);
}"""
#: The FULL source of ``rememberBrainTurn`` as of pre-phase-160 task
#: 04 — the ``...meta`` spread means the new ``budget_exhausted``
#: optional key rides the record with ZERO change to this helper
#: (``undefined`` drops the key from the JSON, the house
#: optional-meta pattern).
PINNED_REMEMBER_BRAIN_TURN = """function rememberBrainTurn(rawText, meta, replaceIndex = -1) {
const rec = { who: "brain", text: rawText || "…", ...meta };
if (replaceIndex >= 0 && conversation[replaceIndex]?.who === "brain") {
conversation[replaceIndex] = rec;
} else {
conversation.push(rec);
}
saveConversation();
// Phase 55 (A2): the auto-save rides the brain save point — the row
// updates with the (possibly replaced) brain turn + metadata. The
// pagehide partial reuses this helper, so it rides the same path (no
// extra wiring).
persistConversation();
}"""
def test_mark_last_retryable_is_byte_unchanged() -> None:
"""``markLastRetryable`` is byte-unchanged by phase 160 (task 04
— the redo-in-place targeting is REUSED, not extended): the full
source must match the pin (a diff is a contract violation)."""
assert _fn_body(_js(), "function markLastRetryable() {") == PINNED_MARK_LAST_RETRYABLE
def test_remember_brain_turn_is_byte_unchanged() -> None:
"""``rememberBrainTurn`` is byte-unchanged by phase 160 (task 04 —
the ``budget_exhausted`` meta key rides the existing ``...meta``
spread, so the save point is consumed as-is): the full source must
match the pin."""
assert _fn_body(_js(), "function rememberBrainTurn(rawText, meta, replaceIndex = -1) {") == (
PINNED_REMEMBER_BRAIN_TURN
)
def test_retry_last_turn_is_byte_unchanged() -> None:
"""``retryLastTurn`` is byte-unchanged by phase 160 (task 04 — the
continue button REUSES its discipline, the function is consumed
as-is): the full source must match the phase-120 pin (the shared
constant — one pin, no drift)."""
assert _fn_body(_js(), "function retryLastTurn(wrap) {") == PINNED_RETRY_LAST_TURN
# ---------------------------------------------------------------------------
# the shared page (work item 8) — the Continue control never renders there
# ---------------------------------------------------------------------------
def test_shared_page_never_shows_the_continue_control() -> None:
"""Work item 8 (no code change expected): the shared page
(``/shared/<token>``, served server-side — ``shared.html``)
renders the conversation through ``shared.js``'s OWN read-only
renderer, never through ``app.js`` — so the ``budget_exhausted``
key is IGNORED there by construction: the page loads no
``app.js`` (no ``appendContinueButton`` in its script graph),
and ``shared.js`` builds NO buttons, no forms, and no
``.msg-actions`` row (owner-locked: zero interactive controls —
the "no buttons, no forms, no links, no click handlers"
contract), so a flagged record renders without any Continue
affordance."""
html = SHARED_HTML.read_text(encoding="utf-8")
assert "app.js" not in html, (
"the shared page never loads app.js — its action buttons "
"(incl. Continue) are not in its script graph"
)
shared = SHARED_JS.read_text(encoding="utf-8")
assert "continue-btn" not in shared
assert "msg-actions" not in shared, (
"shared.js never builds the actions row — zero controls"
)
assert 'createElement("button")' not in shared, (
"owner-locked: NO buttons are ever created on the shared page"
)
assert 'addEventListener("click"' not in shared, (
"owner-locked: NO click handlers on the shared page (the img "
"load-failure degradation handler is not a control — the "
"zero-interactive-controls contract)"
)
# ---------------------------------------------------------------------------
# styles.css — the .continue-btn family
# ---------------------------------------------------------------------------
def test_continue_btn_css_is_the_retry_family() -> None:
"""``.continue-btn`` is the exact visual family of ``.retry-btn``
(task 07): the same pill size/spacing (``min-height: 44px``,
``gap: 0.35rem``, ``padding: 0.35rem 0.8rem``,
``border-radius: 999px``, ``border: 1px solid var(--line)``),
the same neutral color (``--ink-soft`` → ``--ink`` on hover over
``--brand-soft``), the 14px currentColor glyph, and the shared
GLOBAL ``:focus-visible`` ring (styles.css L162 — the ring rule is
not per-class, so no selector-list extension is needed). No
animation of its own → the ``prefers-reduced-motion`` blocks need
nothing for it (the family's parity — .retry-btn / .edit-btn have
none either)."""
css = _css()
start = css.index(".continue-btn {")
end = css.index("}", start)
block = css[start : end + 1]
for decl in (
"display: inline-flex;",
"gap: 0.35rem;",
"min-height: 44px;",
"padding: 0.35rem 0.8rem;",
"border-radius: 999px;",
"border: 1px solid var(--line);",
"background: transparent;",
"color: var(--ink-soft);",
"font-weight: 600;",
"font-size: 0.82rem;",
"white-space: nowrap;",
"cursor: pointer;",
):
assert decl in block, f"the pill family keeps {decl!r}"
assert ".continue-btn svg { width: 14px; height: 14px; display: block; }" in css
assert ".continue-btn:hover { background: var(--brand-soft); color: var(--ink); }" in css
def test_continue_btn_mobile_block_min_height() -> None:
"""The mobile block (task 07): ``.continue-btn`` joins the
``.tune-btn`` / ``.retry-btn`` / ``.edit-btn`` 44px min-height
lockstep next to ``.retry-btn`` — EXACTLY ONE such single-line
rule (the base rule is a multi-line block, so the one-line form
occurs only in the mobile media block)."""
css = _css()
assert css.count(".continue-btn { min-height: 44px; }") == 1
idx = css.index(".continue-btn { min-height: 44px; }")
# It sits beside the family's other lockstep lines (near them in
# the same mobile block).
retry = css.index(".retry-btn { min-height: 44px; }")
assert abs(idx - retry) < 200, (
"the continue line is beside .retry-btn in the mobile block"
)