All green. Final report: **Phase 160 completion pass — verified (final verification; all 5 tasks already in `complete/`)** - **Verified, no code defects found:** budget trailers (`TOOL_BUDGET_REMAINING/LAST/SPENT`), cap-path rejection + `ROUND_CAP_FALLBACK_ANSWER` + `ROUND_CAP_WRAPUP_INSTRUCTION`, `done.budget_exhausted` (ABSENT-when-false), `rounds=/cap_hit=` log fields, `agent_max_rounds=10` default, frontend Continue button + restore + shared-page exclusion, mock markers. - **Tests/lint:** `uv run pytest` 153/153 green (EXIT 0); `uv run pytest --cov=app --cov-report=term-missing` TOTAL **99%** (>90%); `uv run ruff check .` clean; `uv run pyright` 0 errors/0 warnings. - **Criteria:** ① decreasing trailer pinned by unit tests (9→…→spent) ✅ ② cap path ends readable, no tool lines after cap (E2E) ✅ ③ `budget_exhausted` true-only-on-cap, absent otherwise (integration) ✅ ④ Continue button → one fresh `continue` turn, fresh budget, record intact (E2E) ✅ ⑤ flag survives refresh; shared chat never renders it (`shared.js` has no `budget_exhausted` path) ✅ ⑥ `max_rounds=0` byte-identical single `tools=None` request; default still 10 ✅ ⑦ full suite/coverage/lint green ✅ ⑧ `test_tool_budget_telemetry.py` **7/7** in isolation; all 6 regression E2E suites green in isolation ✅ ⑨ commit + phase move = harness (I make no git history/index changes). - **Docs:** README.md — fixed: the "Continue researching" button and cap behavior were undocumented (added a Chat-section paragraph + an Agent-tools-section paragraph, incl. the phase-71 recorded-revision note); env-var table, commands, and architecture all verified consistent otherwise. AGENTS.md — verified consistent, no changes needed. - **Decisions:** no code changes required — phase was fully implemented; only README docs added. No live-infrastructure touched. - **Next pending phase:** `161_reasoning_bubble`.
547 lines
23 KiB
Python
547 lines
23 KiB
Python
"""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 }\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"
|
||
)
|