Newer
Older
navi-1 / tests / clients / test_tui_themes.py
@Eugene Sukhodolskiy Eugene Sukhodolskiy 13 hours ago 7 KB tests: keep the theme tests off a live backend
"""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.fixture(autouse=True)
def mock_tui_api(monkeypatch: pytest.MonkeyPatch) -> None:
    """Mock the backend REST api so these tests never hit a live server.

    The mount worker resolves a session and replays its history through
    ``load_history``. Against a live backend that replay lands a moment after
    the test injected its stream events and wipes the chat, so a theme test
    that looks for the streaming bubble finds none — pass/fail depended on
    whether a Navi server happened to listen on 8099.
    """
    import clients.terminal.api as api_module

    async def fake_create_session(profile_id: str | None = None) -> dict:
        return {"session_id": "test-session", "profile_id": profile_id or "navi_code"}

    async def fake_get_session(session_id: str) -> dict:
        return {"session_id": session_id, "profile_id": "navi_code"}

    async def fake_list_sessions() -> list[dict]:
        return []

    async def fake_get_profile_model(profile_id: str) -> str | None:
        return "configured-model"

    async def fake_list_terminals(session_id: str) -> list[dict]:
        return []

    async def fake_get_todos(session_id: str) -> dict:
        return {"tasks": []}

    monkeypatch.setattr(api_module, "create_session", fake_create_session)
    monkeypatch.setattr(api_module, "get_session", fake_get_session)
    monkeypatch.setattr(api_module, "list_sessions", fake_list_sessions)
    monkeypatch.setattr(api_module, "get_profile_model", fake_get_profile_model)
    monkeypatch.setattr(api_module, "list_terminals", fake_list_terminals)
    monkeypatch.setattr(api_module, "get_todos", fake_get_todos)


@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