Newer
Older
navi-1 / tests / clients / test_tui_themes.py
"""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