"""Unit: the phase-109 never-frozen-turn frontend contract (tasks 01-02), updated by phase 158 (task 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 phase-109 D15 re-open pin is REPLACED by the phase-158 per-run contract (owner direction 2026-10-04 — ``thinking`` frames open a NEW block per contiguous run, ``delta`` frames fold the active run, a later thinking frame opens a NEW run, not a re-open) — while the phase-14 restore path and the phase-17 follow-the-tail pin logic stay byte-for-byte untouched (the capture moves verbatim, per block). Task 02 (phase 109) 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_opens_a_new_block_per_run() -> None: """Phase 158 (D1 — the phase-109 D15 re-open pin, replaced by owner direction 2026-10-04): the `thinking` handler opens a NEW block per contiguous run — ``ensureThinkingRun(wrap)`` sits in the handler (a frame of a new kind closes the previous run and opens a new block), the RUN's text accumulates + re-renders into THAT block, and the D15 re-open assignment (``block.open = true``) is GONE from the branch — the open state is owned by the builder (a new run opens; the active run stays open).""" branch = _thinking_branch() ensure_idx = branch.find("ensureThinkingRun(wrap)") assert ensure_idx != -1, ( "the handler must open/extend the run's block (D1: per-run — " "a new kind opens a NEW block)" ) assert "currentRun.run.text += ev.text || \"\"" in branch, ( "the RUN's text accumulates (the block renders the run, not " "the whole-turn accumulator)" ) assert "block.open = true" not in branch, ( "the D15 re-open assignment is GONE — ensureThinkingRun owns " "the open state" ) def test_delta_handler_folds_the_active_run() -> None: """Phase 158 (D5 — the phase-109 D15 close-side pin, updated): the close side of the per-run contract — the `delta` handler calls ``closeCurrentRun()`` (folds the active run, idempotent); a later thinking frame opens a NEW run above the bubble (the D15 re-open of the shared block is gone).""" 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 "closeCurrentRun()" in branch, ( "the `delta` handler must fold the active run (D5: " "folded-while-answering)" ) def test_section_comment_says_per_run_not_reopen() -> None: """Phase 158 (D1 — the phase-109 D15 narrative, replaced): the section comment documenting the run builders no longer claims the D15 toggle contract (the "re-opens" narrative is gone — nowhere in app.js does "never reopens" survive either) — it names the per-run timeline instead: each CONTIGUOUS run its own block, arrival order, D2 placement, and the D15 contract as replaced.""" js = _js() assert "never reopens" not in js, ( "the one-way-door narrative must not survive anywhere in app.js" ) # The block comment that documents the builders: walk back from # the first builder to its section comment start. comment_start = js.rfind("/*", 0, js.find("function createThinkingBlock")) assert comment_start != -1 section = js[comment_start : js.find("function createThinkingBlock")] assert "re-opens" not in section, ( "the D15 re-open narrative is gone (phase 158, D1)" ) assert "PER-RUN" in section or "per-run" in section, ( "the contract is named: per-run" ) assert "CONTIGUOUS" in section, "a contiguous run per block is named" assert "arrival order" in section, "D2: arrival order is named" assert "REPLACED" in section or "superseded" in section.lower(), ( "the D15 contract is named as replaced" ) def test_restore_path_still_collapses_stored_blocks() -> None: """Phase-14 contract regression: ``renderStoredMessage`` restores stored thinking blocks COLLAPSED (``block.open = false`` — the shared ``createThinkingBlock`` builder, phase 158 task 02) — the phase-158 per-run live path never opens a restored block.""" 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 "createThinkingBlock()" 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 — now operating per block (phase 158: the re-render is the RUN's text, not the whole-turn accumulator). The capture already keys off ``block.open``, so a live-open block resumes pinned tail-following exactly like the phase-17 flow.""" 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 — the " "pin logic moves verbatim into the per-run branch (phase 17 " "regression)" ) capture_idx = branch.find(capture) render_idx = branch.find("textEl.innerHTML = renderMarkdown(currentRun.run.text)") 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'