fix(tui): address Volva code-vs-contract drift (issue #4)

Volva code-review surfaced 8 findings against the TDD-passing
TUI shell. All 8 addressed.

Drift fixes (code):
- Primary: INV-002 + INV-003 require visible Footer-area rendering
  of session-identity + Ctrl-C state hint. Implementation stored
  the strings in `self.sub_title` (which lands in the Header, not
  Footer) and `self.hint` (a plain attribute, never rendered). Fixed
  by adding two `Static` widgets (id="identity" and id="hint") in
  compose; the `_set_hint()` helper mirrors state into the widget on
  every state transition. Same-model TDD missed this because tests
  asserted internal state, not visible widget content.
- Reverted `_stream_turn_worker(content, log)` to single-param
  `(content)` per the contract FN signature. The widened signature
  was a TDD-time workaround for a NoMatches-during-worker
  execution; root cause was test timing (added `await pilot.pause()`
  before the polling loop in `_submit_and_wait`).
- Restored `exclusive=True` on `self.run_worker(...)` per the
  contract STEP 6 spec.
- Added missing `isinstance(args, ParsedArgs)` PRE assertion to
  `run_tui`. Required hoisting `from ratatoskr.cli import
  ParsedArgs` out of TYPE_CHECKING — runtime import is fine (no
  circular dependency: cli lazy-imports tui inside main; tui
  imports cli unconditionally at module load).
- Added missing union-type PRE assertion to `_render_event_to_log`.

Contract amendments (precision):
- COMPOSE shape: RichLog `markup=False, highlight=False` (was True,
  True). Explanatory comment in-line: bracketed labels like
  [cancel_failed] would otherwise be interpreted+stripped as Rich
  style spans; the post-Done Markdown rendering still works via
  Markdown() Renderable.
- INV-002 reworded: identity rendered via dedicated
  Static(id="identity") widget composed adjacent to Footer (Textual's
  built-in Footer renders BINDINGS descriptions; a sibling Static
  carries custom content in the same visual region).
- on_mount POST-003 amended to allow `agent_id is None` when
  --session is used without --agent (matches INV-002 carve-out;
  GET /sessions/{id} agent lookup is out of scope for this shell).
- run_tui happy_returns_zero_on_quit test description clarified:
  App.run() is sync and can't be driven by Pilot, so run_tui's
  wrapping behavior is tested via monkeypatch; the piloted Ctrl-D
  exit path is covered separately by TestActionQuit.

Test fixes:
- footer_identity_visible_first_frame, footer_hint_flips_to_cancel,
  streaming_first_ctrl_c_cancels: now query the Static(#identity) /
  Static(#hint) widgets via `widget.render()` instead of asserting
  on `app.sub_title` / `app.hint` internal state. The internal
  state still exists (mirror), but the load-bearing assertion is
  on visible widget content.

Meta-note from Volva: "TDD pass caught most stream/session/error
mechanics, but tested internal state where the contract required
visible Footer behavior, so same-model TDD would plausibly miss the
primary drift." Calibration shape continues across all four issues:
the post-TDD cross-model review consistently catches assert-boundary
+ observability-shape gaps the test-author's hypotheses don't cover
(#1: 4 findings, #2: 3, #3: 5, #4: 8).

164/164 tests GREEN; ruff clean; both contract drift checks clean.
This commit is contained in:
vh
2026-05-21 00:46:06 -07:00
parent dd89239c34
commit 942e33898c
4 changed files with 60 additions and 27 deletions
+18 -9
View File
@@ -306,16 +306,19 @@ class TestAppMount:
@respx.mock
async def test_footer_identity_visible_first_frame(self) -> None:
"""footer_identity_visible_first_frame [trace]: …"""
from textual.widgets import Static
respx.post("https://w.example/sessions").mock(
return_value=httpx.Response(201, json=_CREATE_OK_RESP)
)
app = RatatoskrApp(_args_new())
async with app.run_test() as pilot:
await pilot.pause()
# Both halves of the identity present
assert "mimir" in (app.sub_title or "")
assert "·" in (app.sub_title or "")
assert app.session_id[-8:] in (app.sub_title or "")
identity_widget = app.query_one("#identity", Static)
rendered = str(identity_widget.render())
assert "mimir" in rendered
assert "·" in rendered
assert app.session_id[-8:] in rendered
@respx.mock
async def test_client_open_after_mount(self) -> None:
@@ -349,7 +352,7 @@ import asyncio # noqa: E402
from textual.widgets import Input # noqa: E402
async def _noop_worker(self, content: str, log) -> None:
async def _noop_worker(self, content: str) -> None:
"""Fake _stream_turn_worker that never completes (lets state stay 'streaming')."""
await asyncio.Future() # await forever; cancelled when test exits
@@ -439,17 +442,20 @@ class TestOnInputSubmitted:
async def test_footer_hint_flips_to_cancel(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""footer_hint_flips_to_cancel [trace]: after submit, hint shows 'Ctrl-C to cancel'."""
"""footer_hint_flips_to_cancel [trace]: hint widget shows 'Ctrl-C to cancel'."""
from textual.widgets import Static
monkeypatch.setattr(RatatoskrApp, "_stream_turn_worker", _noop_worker)
app = RatatoskrApp(_args_existing())
async with app.run_test() as pilot:
await pilot.pause()
assert app.hint == RatatoskrApp.HINT_IDLE
hint_widget = app.query_one("#hint", Static)
assert str(hint_widget.render()) == RatatoskrApp.HINT_IDLE
inp = app.query_one("#prompt", Input)
inp.value = "hi"
await inp.action_submit()
await pilot.pause()
assert app.hint == RatatoskrApp.HINT_STREAMING
assert str(hint_widget.render()) == RatatoskrApp.HINT_STREAMING
import json # noqa: E402
@@ -798,7 +804,10 @@ class TestActionInterrupt:
await pilot.pause(0.02)
assert cancel_route.call_count == 1
assert app.state == "cancelling"
assert app.hint == RatatoskrApp.HINT_CANCELLING
from textual.widgets import Static
hint_widget = app.query_one("#hint", Static)
assert str(hint_widget.render()) == RatatoskrApp.HINT_CANCELLING
# Release the gate so the stream worker can finish cleanly during teardown
gate.set()