"""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): ``index.html`` carries exactly ONE static ``#turn-loader`` — an empty ``