"""Tests for theme picker and /themes command."""
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:
"""Override the state dir so tests never touch ~/.navi_code."""
from clients.terminal import config
original = config.settings.state_dir
config.settings.state_dir = tmp_path
import clients.terminal.tui.settings as settings_module
settings_module._tui_settings = None
yield tmp_path
config.settings.state_dir = original
settings_module._tui_settings = None
@pytest.mark.anyio
async def test_themes_command_opens_picker() -> None:
async with NaviCodeTui(new_session=True).run_test() as pilot:
await pilot.pause()
input_box = pilot.app.query_one("InputBox")
input_box._input.text = "/themes"
await pilot.press("enter")
await pilot.pause()
assert pilot.app.screen.__class__.__name__ == "ThemePickerScreen"
@pytest.mark.anyio
async def test_themes_command_switches_theme() -> None:
async with NaviCodeTui(new_session=True).run_test() as pilot:
await pilot.pause()
input_box = pilot.app.query_one("InputBox")
input_box._input.text = "/themes"
await pilot.press("enter")
await pilot.pause()
assert pilot.app.screen.__class__.__name__ == "ThemePickerScreen"
await pilot.press("down")
await pilot.press("enter")
await pilot.pause()
assert pilot.app._theme_name == "gnexus-light"
@pytest.mark.anyio
async def test_mouse_command_toggles_setting() -> None:
async with NaviCodeTui(new_session=True).run_test() as pilot:
await pilot.pause()
from clients.terminal.tui.settings import get_tui_settings
tui_settings = get_tui_settings()
original = tui_settings.mouse
input_box = pilot.app.query_one("InputBox")
input_box._input.text = "/mouse"
await pilot.press("enter")
await pilot.pause()
assert tui_settings.mouse is not original
@pytest.mark.anyio
async def test_apply_theme_re_renders_chat_bubbles() -> None:
"""A theme switch must re-render already-drawn chat bubbles in the new
palette — refresh_content drops the cached Content so the next paint
rebuilds against the active theme (regression for 5.B1)."""
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": "themed answer"})
await pilot.pause(0.2) # let the throttled rebuild flush
assistant = next(
w for w in chat._widgets.values() if w._item.kind == "assistant_message"
)
renderable_before = assistant._rich_renderable
# Sanity: the bubble rendered something.
assert renderable_before is not None
pilot.app._theme_name = "gnexus-light"
pilot.app.apply_theme()
await pilot.pause()
# The renderable was rebuilt (fresh Markdown against the new theme).
assert assistant._rich_renderable is not renderable_before
# The Content/height caches were dropped by refresh_content and then
# rebuilt against the NEW renderable on the next paint — so the cache
# keys reference the current renderable, not the old one.
if assistant._content_cache is not None:
assert assistant._content_cache[0][0] == id(assistant._rich_renderable)
if assistant._height_cache is not None:
assert assistant._height_cache[0][0] == id(assistant._rich_renderable)
@pytest.mark.anyio
async def test_theme_picker_debounces_live_preview() -> None:
"""Rapid highlight moves coalesce into one apply_theme (~100 ms), not one
per move — apply_theme now re-renders every chat bubble (refresh_content),
so debouncing keeps the picker smooth (regression for 4.P2)."""
async with NaviCodeTui(new_session=True).run_test() as pilot:
await pilot.pause()
pilot.app._run_command("/themes")
await pilot.pause()
screen = pilot.app.screen
assert screen.__class__.__name__ == "ThemePickerScreen"
original = pilot.app._theme_name
# Two rapid scheduled previews — the second coalesces into the first's
# pending timer instead of scheduling a second apply.
screen._schedule_preview("gnexus-light")
timer_first = screen._preview_timer
screen._schedule_preview("gnexus-dark")
assert screen._preview_timer is timer_first # no new timer
assert screen._pending_preview == "gnexus-dark" # latest wins
assert pilot.app._theme_name == original # not applied yet (debounced)
await pilot.pause(0.15) # past the debounce window
assert screen._preview_timer is None
assert pilot.app._theme_name == "gnexus-dark"
@pytest.mark.anyio
async def test_theme_picker_escape_cancels_pending_preview() -> None:
"""Escape restores the original theme and cancels a pending debounced
preview, so it cannot fire after the restore and re-apply the highlighted
theme (regression for 4.P2)."""
async with NaviCodeTui(new_session=True).run_test() as pilot:
await pilot.pause()
pilot.app._run_command("/themes")
await pilot.pause()
screen = pilot.app.screen
original = pilot.app._theme_name
screen._schedule_preview("gnexus-light")
assert screen._preview_timer is not None
screen._restore_original()
assert screen._preview_timer is None # cancelled
await pilot.pause(0.15) # past the window — pending would have fired
assert pilot.app._theme_name == original # restored, not light