"""Tests for ChatPanel render caching and the visible-history window cap.
The panel rebuilds every renderable on each WS event (including per-token
stream_delta). Caching makes only the changed item miss, and the window cap
bounds how many items are laid out regardless of total history length.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from clients.terminal.tui.tui_app import NaviCodeTui
@pytest.fixture(autouse=True)
def tmp_state_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
from clients.terminal import config
import clients.terminal.tui.settings as settings_module
original = config.settings.state_dir
config.settings.state_dir = tmp_path
settings_module._tui_settings = None
yield tmp_path
config.settings.state_dir = original
settings_module._tui_settings = None
@pytest.fixture(autouse=True)
def mock_tui_api(monkeypatch: pytest.MonkeyPatch) -> None:
import clients.terminal.api as api_module
monkeypatch.setattr(api_module, "create_session", lambda *a, **k: {"session_id": "s", "profile_id": "navi_code"})
monkeypatch.setattr(api_module, "get_session", lambda sid: {"session_id": sid, "profile_id": "navi_code"})
monkeypatch.setattr(api_module, "list_sessions", lambda: [])
monkeypatch.setattr(api_module, "get_profile_model", lambda pid: "configured-model")
def _capture_groups(chat) -> list:
"""Record each Group passed to the items container's update()."""
captured: list = []
original_update = chat._items_container.update
def _record(group, *args, **kwargs):
captured.append(group)
return original_update(group, *args, **kwargs)
chat._items_container.update = _record # type: ignore[method-assign]
return captured
def _wrap_render(chat) -> list:
"""Wrap the panel's renderer registry.render to count calls."""
calls: list[dict] = []
original_render = chat._registry.render
def _record(msg, *args, **kwargs):
calls.append(msg)
return original_render(msg, *args, **kwargs)
chat._registry.render = _record # type: ignore[method-assign]
return calls
@pytest.mark.anyio
async def test_per_token_refresh_renders_only_the_streaming_item() -> None:
"""A stream_delta changes only the assistant bubble's signature, so the
registry is called exactly once per token — every other visible item is
reused from the cache instead of being rebuilt/re-parsed."""
async with NaviCodeTui(new_session=True).run_test() as pilot:
await pilot.pause()
chat = pilot.app.query_one("ChatPanel")
calls = _wrap_render(chat)
chat.add_user_message("hi") # renders the user bubble once
chat.handle_ws_event({"type": "stream_start"})
chat.handle_ws_event({"type": "stream_delta", "delta": "a"}) # creates + renders assistant
baseline = len(calls)
# Two more tokens — only the streaming assistant item should be rebuilt.
chat.handle_ws_event({"type": "stream_delta", "delta": "b"})
after_one = len(calls)
chat.handle_ws_event({"type": "stream_delta", "delta": "c"})
after_two = len(calls)
assert after_one - baseline == 1
assert after_two - baseline == 2
@pytest.mark.anyio
async def test_window_cap_renders_only_last_n_with_hint(monkeypatch) -> None:
"""Beyond the cap, only the last N items are laid out and a hint is shown."""
async with NaviCodeTui(new_session=True).run_test() as pilot:
await pilot.pause()
chat = pilot.app.query_one("ChatPanel")
monkeypatch.setattr(chat, "_max_visible_items", lambda: 3)
captured = _capture_groups(chat)
chat.clear() # drop the startup cwd/connected status items
for i in range(5):
chat.add_user_message(f"msg {i}")
group = captured[-1]
# 3 visible items + 1 truncation hint.
assert len(group.renderables) == 4
hint = group.renderables[0]
assert "2 earlier messages not shown" in str(hint)
@pytest.mark.anyio
async def test_no_hint_when_within_limit(monkeypatch) -> None:
async with NaviCodeTui(new_session=True).run_test() as pilot:
await pilot.pause()
chat = pilot.app.query_one("ChatPanel")
monkeypatch.setattr(chat, "_max_visible_items", lambda: 10)
captured = _capture_groups(chat)
chat.clear() # drop the startup cwd/connected status items
for i in range(3):
chat.add_user_message(f"msg {i}")
group = captured[-1]
assert len(group.renderables) == 3
assert "not shown" not in str(group.renderables[0])
@pytest.mark.anyio
async def test_clear_empties_chat_render_cache() -> None:
async with NaviCodeTui(new_session=True).run_test() as pilot:
await pilot.pause()
chat = pilot.app.query_one("ChatPanel")
chat.add_user_message("hi")
chat.handle_ws_event({"type": "stream_start"})
chat.handle_ws_event({"type": "stream_delta", "delta": "a"})
assert chat._chat_render_cache # populated during refresh
chat.clear()
assert chat._chat_render_cache == {}
assert chat._model.items == []
@pytest.mark.anyio
async def test_removed_item_is_pruned_from_cache() -> None:
"""stream_end purges empty assistant bubbles; their cache entries must go
too so the cache stays bounded by live items, not by everything shown."""
async with NaviCodeTui(new_session=True).run_test() as pilot:
await pilot.pause()
chat = pilot.app.query_one("ChatPanel")
chat.handle_ws_event({"type": "stream_start"})
chat.handle_ws_event({"type": "stream_delta", "delta": ""}) # empty bubble
empty_ids = {id(it) for it in chat._model.items if it.kind == "assistant_message"}
assert empty_ids # the empty bubble exists before stream_end
chat.handle_ws_event({"type": "stream_end", "content": ""})
cached_ids = set(chat._chat_render_cache.keys())
assert not (cached_ids & empty_ids)
# And the empty bubble is no longer in the model.
assert not any(it.kind == "assistant_message" for it in chat._model.items)