"""Unit: the phase-109 never-frozen-turn frontend contract (tasks 01-02). No new Python app logic exists for this task — the behavior lives in ``frontend/assets/app.js`` and is E2E-gated by the story suite (task 03). Like the other frontend-adjacent unit files (the house read-the-assets-as-text pattern, cf. ``test_frontend_tool_states.py``), this module pins the JS markers the phase depends on: the thinking block becomes a TOGGLE (D15) — ``thinking`` frames open it (idempotent), ``delta`` frames close it — while the phase-14 restore path and the phase-17 follow-the-tail pin logic stay byte-for-byte untouched. Task 02 pins the persistent in-turn loader (D16): the static ``#turn-loader`` markup, ``setUiState`` as its SOLE visibility owner (the structural never-stale guarantee), and the CSS contract (reused typing-dot animation + reduced-motion + provenance). """ from __future__ import annotations import re from pathlib import Path FRONTEND = Path(__file__).resolve().parents[2] / "frontend" APP_JS = FRONTEND / "assets" / "app.js" INDEX_HTML = FRONTEND / "index.html" STYLES_CSS = FRONTEND / "assets" / "styles.css" def _js() -> str: return APP_JS.read_text(encoding="utf-8") def _html() -> str: return INDEX_HTML.read_text(encoding="utf-8") def _css() -> str: return STYLES_CSS.read_text(encoding="utf-8") def _thinking_branch() -> str: """The `thinking` handler branch (between the thinking and tool branches of the turn dispatch).""" js = _js() thinking_idx = js.find('ev.type === "thinking"') tool_idx = js.find('ev.type === "tool"') assert -1 < thinking_idx < tool_idx, ( "the turn handler must branch on thinking frames" ) return js[thinking_idx:tool_idx] def test_thinking_handler_reopens_the_collapsed_block() -> None: """Phase 109 (D15): the `thinking` handler re-opens the block — ``block.open = true`` sits in the handler, positioned AFTER the ``ensureThinkingBlock(wrap)`` line (the block must exist before it can be opened). Idempotent: a no-op while already open (the pre-delta live flow), a re-open after a `delta` closed it (the next agent round — the reported freeze, TODO.md L3).""" branch = _thinking_branch() ensure_idx = branch.find("ensureThinkingBlock(wrap)") assert ensure_idx != -1, "the handler must ensure the block first" reopen_idx = branch.find("block.open = true") assert reopen_idx != -1, ( "the `thinking` handler must open the block (D15: " "open-while-thinking)" ) assert ensure_idx < reopen_idx, ( "`block.open = true` must sit AFTER `ensureThinkingBlock(wrap)` " "— the block must exist before it is opened" ) def test_delta_handler_still_closes_the_block() -> None: """Phase 109 (D15): the close side of the toggle survives — the `delta` handler still calls ``closeThinkingBlock(wrap)`` (closed while answering); nothing else about the block's lifecycle changes.""" js = _js() delta_idx = js.find('ev.type === "delta"') done_idx = js.find('ev.type === "done"') assert -1 < delta_idx < done_idx, "the turn handler must branch on delta" branch = js[delta_idx:done_idx] assert "closeThinkingBlock(wrap)" in branch, ( "the `delta` handler must keep closing the block (D15: " "closed-while-answering)" ) def test_close_thinking_block_docstring_says_toggle_not_one_way() -> None: """Phase 109 (D15): the narrative flips. The section comment documenting ``closeThinkingBlock`` no longer claims "never reopens" — nowhere in app.js does — and documents the toggle contract instead: open-while-thinking / closed-while-answering, naming the `thinking` handler's re-open and the `delta` handler's close. The function body itself is unchanged (idempotent close, no-op without a block).""" js = _js() fn = js.find("function closeThinkingBlock") assert fn != -1, "closeThinkingBlock must exist" body = js[fn : js.find("\n}\n", fn)] assert "block.open = false" in body, "the close side is unchanged" # The block comment that documents the helpers: walk back from the # function to its section comment start. comment_start = js.rfind("/*", 0, fn) assert comment_start != -1 section = js[comment_start:fn] assert "never reopens" not in section, ( "the one-way-door claim is gone (phase 109, D15)" ) assert "never reopens" not in js, ( "the 'never reopens' narrative must not survive anywhere in app.js" ) # The toggle contract is documented — and it names both sides: # the `thinking` handler's re-open and the `delta` handler's close. assert "TOGGLE" in section, "the contract is named: a toggle" assert "open-while-thinking" in section assert "closed-while-answering" in section assert "`thinking`" in section, "the contract names the thinking handler" assert "re-opens" in section, "the contract names the thinking handler's re-open" assert "`delta`" in section, "the contract names the delta handler's close" def test_restore_path_still_collapses_stored_blocks() -> None: """Phase-14 contract regression: ``renderStoredMessage`` restores stored thinking blocks COLLAPSED (``block.open = false``) — the phase-109 toggle only changes the LIVE `thinking` handler, the restore path is untouched.""" js = _js() fn = js.find("function renderStoredMessage") assert fn != -1, "renderStoredMessage must exist" body = js[fn : js.find("\n}\n", fn)] assert "if (m.thinking)" in body assert "ensureThinkingBlock(wrap)" in body assert "block.open = false" in body, "stored blocks restore collapsed" # The live re-open must NOT have leaked into the restore path: # only the collapse assignment may appear in the function body. assert "block.open = true" not in body, ( "the restore path must never open a stored block (phase-14)" ) def test_follow_the_tail_pin_logic_is_untouched() -> None: """Cross-file regression (phase 17): the follow-the-tail pin is unchanged — ``THINKING_NEAR_BOTTOM_PX`` (32px band) + ``isThinkingNearBottom`` and the pre-render capture ``const pinned = block.open && isThinkingNearBottom(textEl);`` (measured BEFORE the re-render) still sit in the `thinking` handler. The capture already keys off ``block.open``, so a re-opened block resumes pinned tail-following exactly like the live pre-delta block — no pin logic change needed for D15.""" js = _js() assert "export const THINKING_NEAR_BOTTOM_PX = 32;" in js, ( "the 32px window band is unchanged" ) fn = re.search( r"function isThinkingNearBottom\(textEl\) \{([\s\S]*?)\n\}", js ) assert fn, "isThinkingNearBottom must still exist" assert "THINKING_NEAR_BOTTOM_PX" in fn.group(1) branch = _thinking_branch() capture = "const pinned = block.open && isThinkingNearBottom(textEl);" assert capture in branch, ( "the pre-render `block.open &&` guard line is unchanged — D15 " "keeps the pin logic untouched (it already reads block.open " "before the re-render)" ) capture_idx = branch.find(capture) render_idx = branch.find("textEl.innerHTML = renderMarkdown(thinkingAcc)") assert -1 < capture_idx < render_idx, ( "pin state is still measured BEFORE the re-render (phase-17 " "regression — the 2-newline-gap fix)" ) assert "textEl.scrollTop = textEl.scrollHeight" in branch, ( "pinned tail-following is intact" ) # ---------- task 02: the persistent in-turn loader (D16) ---------- def test_index_html_carries_exactly_one_turn_loader() -> None: """Phase 109 (D16) + phase 117 redesign: ``index.html`` carries exactly ONE static ``#turn-loader`` — the equalizer shell holding the FIVE static ``.eq`` square spans (never constructed in JS: the createElement/textContent house rule), ``aria-hidden="true"`` (decorative — ``#send-status`` carries the meaning), ``hidden`` by default (idle on load), sitting INSIDE the composer form and LEFT of the send button (phase 117, owner requirement: the loader's appearance/disappearance must never shift the button — it eats the flex:1 textarea's space instead).""" html = _html() assert html.count('id="turn-loader"') == 1, ( "exactly one #turn-loader — static markup, never JS-built" ) match = re.search(r']*>', html) assert match, "the loader must be a static
" tag = match.group(0) assert 'class="turn-loader"' in tag assert 'aria-hidden="true"' in tag, "decorative — aria-hidden" # the hidden attribute (a standalone word in the tag, not the # substring of some other attribute value): ships hidden (idle). assert re.search(r"\shidden\s*/?>$", tag), ( "the loader ships hidden (idle on load)" ) # Phase 117: the static brain-wave SVG — EXACTLY the ghost trace + # the pulse sweep (pathLength-normalized for the CSS dash math), # and nothing else. No JS-built HTML anywhere near it. close_idx = html.index("
", match.end()) block = html[match.end():close_idx] assert '", composer_idx) assert composer_idx < match.start() < form_end, ( "the loader sits in the composer" ) input_idx = html.find('id="message-input"', composer_idx) btn_idx = html.find('id="send-btn"', composer_idx) assert input_idx < match.start() < btn_idx, ( "the loader sits LEFT of the send button (the button never " "shifts when the loader appears/disappears)" ) def test_set_ui_state_is_the_sole_owner_of_the_loader() -> None: """Phase 109 (D16) — the never-stale guarantee, structural: in ``app.js`` the string ``turnLoader.hidden`` appears EXACTLY ONCE, inside ``setUiState`` (the cross-file single-owner check — any second write site fails this test). The toggle is ``turnLoader.hidden = !inFlight`` next to the existing ``is-stop`` toggle: shown iff ``uiState`` is thinking|streaming. Every terminal path funnels through ``setUiState`` (done → idle, error → error, stop/timeout → their landings), so the loader hides in every terminal state BY CONSTRUCTION — no per-handler cleanup.""" js = _js() # The element lookup joins the other module-top lookups. assert 'const turnLoader = document.querySelector("#turn-loader");' in js # THE invariant: exactly one write site in the whole file. assert js.count("turnLoader.hidden") == 1, ( "setUiState is the SOLE writer of the loader's hidden attribute — " "a second write site breaks the §7.4 never-stale guarantee" ) # ...and it sits INSIDE setUiState, next to the is-stop toggle. fn = js.find("export function setUiState") assert fn != -1 body_end = js.find("\n}\n", fn) owner_idx = js.find("turnLoader.hidden") assert fn < owner_idx < body_end, ( "the toggle must live inside setUiState (the single entry point)" ) stop_idx = js.find('sendBtn.classList.toggle("is-stop", inFlight);', fn) assert -1 < stop_idx < owner_idx, ( "the loader toggle joins the existing is-stop toggle" ) assert "turnLoader.hidden = !inFlight;" in js, ( "shown iff inFlight (thinking|streaming)" ) def test_loader_css_brain_wave_contract() -> None: """Phase 117 (the phase-109 D16 CSS pin, superseded by the owner's brain-wave design — picked over the first equalizer mock): the ``.turn-loader`` rule sits NEXT TO the typing-dots rules (after the ``@keyframes typing`` block); the sweep is a NEW ``bwdraw`` keyframe (a ``stroke-dashoffset`` travel — the 42/140 dash segment loops the pathLength-normalized path seamlessly); the equalizer machinery (``.eq`` squares + ``eqpulse``) is GONE; the ghost trace stays decorative-dim; a ``prefers-reduced-motion`` block stills the sweep (the full trace shows static — §7.2 house law); the provenance comment names phase 117.""" css = _css() keyframes_idx = css.find("@keyframes typing") assert keyframes_idx != -1, ( "the typing-dot keyframes still exist (the chat-log typing bubble)" ) # The MAIN rule (".turn-loader {" — the reduced-motion block's # selector never starts that exact string) must sit next to # (after) the typing-dots rules. rule_idx = css.find(".turn-loader {") assert rule_idx != -1, "styles.css must carry a .turn-loader rule" assert keyframes_idx < rule_idx, ( "the .turn-loader rule sits next to (after) the typing-dots rules" ) # The provenance comment: the /* ... */ block immediately preceding # the rule names phase 117 (the brain-wave redesign). comment_start = css.rfind("/*", 0, rule_idx) assert comment_start != -1 comment = css[comment_start:rule_idx] assert "Phase 117" in comment, "the provenance comment names phase 117" # The sweep: the .bw-pulse path runs the NEW bwdraw keyframe (the # phase-109 "reuse the typing animation" pin is superseded by the # owner's redesign — an explicit decision, not a silent deviation). pulse_idx = css.find(".turn-loader .bw-pulse {") assert pulse_idx != -1, "styles.css must carry a .turn-loader .bw-pulse rule" pulse_rule = css[pulse_idx : css.find("}", pulse_idx)] assert re.search(r"animation:\s*bwdraw\b", pulse_rule), ( "the sweep runs the bwdraw keyframe" ) assert "stroke-dasharray: 42 98" in pulse_rule, ( "the 42/140 bright segment is pinned (the owner-tuned sweep — " "the dash cycle equals the pathLength-normalized path: seamless)" ) draw_idx = css.find("@keyframes bwdraw") assert draw_idx != -1, "the bwdraw keyframes must exist" draw_block = css[draw_idx : css.find("\n}", draw_idx) + 2] assert "stroke-dashoffset: 140" in draw_block, "the travel starts at 140" assert "stroke-dashoffset: 0" in draw_block, ( "the travel ends at 0 (one full normalized loop — seamless)" ) # The ghost trace stays decorative-dim (ink-soft at 30% — the shape # must be readable between sweep peaks). ghost_idx = css.find(".turn-loader .bw-ghost {") assert ghost_idx != -1, "styles.css must carry a .turn-loader .bw-ghost rule" ghost_rule = css[ghost_idx : css.find("}", ghost_idx)] assert "stroke-opacity: 0.3" in ghost_rule, ( "the ghost trace stays dim (the sweep carries the attention)" ) # The old machinery is GONE: the phase-109 pseudo-dots, the # typing-animation reuse inside the loader's rules, and the first- # mock equalizer (.eq squares + eqpulse). assert ".turn-loader::before" not in css, ( "the phase-109 pseudo-dot rules are gone" ) loader_region = css[rule_idx:draw_idx] assert "animation: typing" not in loader_region, ( "the loader no longer reuses the typing animation (phase 117)" ) assert ".turn-loader .eq" not in css, ( "the first-mock equalizer rules are gone (the owner picked the " "brain wave)" ) assert "@keyframes eqpulse" not in css, "the eqpulse keyframes are gone" # The prefers-reduced-motion block stills the SWEEP: the full trace # shows static — §7.2 (stilling never hides: visibility is the JS # hidden attribute in setUiState, D16). reduced_ok = False for m in re.finditer(r"@media \(prefers-reduced-motion: reduce\) \{([\s\S]*?)\n\}", css): if ".turn-loader .bw-pulse" in m.group(1) and "animation: none" in m.group(1): reduced_ok = True break assert reduced_ok, ( "a prefers-reduced-motion block must still the sweep (static " "trace, no motion — §7.2)" ) def test_loader_is_aria_hidden_and_status_untouched() -> None: """Phase 109 (D16) — the a11y split is untouched: the loader is ``aria-hidden`` (decoration), and ``#send-status`` keeps its exact attributes in ``index.html`` — still the sole ``aria-live`` announcer (the house a11y split: visual cues are aria-hidden, the live region carries the state text).""" html = _html() match = re.search(r']*>', html) assert match and 'aria-hidden="true"' in match.group(0) # #send-status is unchanged: one element, the exact same tag — # the visually-hidden polite live region inside the send button, # still the chat view's state announcer. assert html.count('id="send-status"') == 1 status = re.search(r']*id="send-status"[^>]*>', html) assert status, "the #send-status live region is intact" tag = status.group(0) assert tag == ( '' ), "the #send-status attributes are byte-unchanged" # the loader added no a11y surface: it is aria-hidden and carries # no live region of its own. assert 'aria-live' not in match.group(0)