"""Headless smoke tests for the TUI (uses Textual's test driver, no terminal).""" from __future__ import annotations import asyncio import pytest from rich.table import Table from textual.widgets import ListView, Static from sjaur.tui.app import InstallerConfirmScreen, SJaurApp def pytest_fail(message: str): pytest.fail(message) def run(coro): return asyncio.run(coro) def test_tui_shows_popular_results_and_live_search(engine): async def scenario(): app = SJaurApp(engine=engine, cache=None, cache_note="test") async with app.run_test(size=(120, 36)) as pilot: await pilot.pause() list_view = app.query_one("#results", ListView) initial_count = len(list_view.children) assert initial_count > 0 # empty input shows the most-voted packages # Type a query; results update live. await pilot.click("#search") for char in "firefox": await pilot.press(char) await pilot.pause(1.0) # debounce + background search worker current = [r.package.name for r in app._current_results] assert "firefox" in current assert len(current) < initial_count run(scenario()) def test_tui_details_pane_follows_selection(engine): async def scenario(): app = SJaurApp(engine=engine, cache=None, cache_note="test") async with app.run_test(size=(120, 36)) as pilot: await pilot.pause() details = app.query_one("#details", Static) renderable = details.content # The first popular package's details are rendered on startup. assert isinstance(renderable, Table) # Highlight another row; the detail pane follows. list_view = app.query_one("#results", ListView) list_view.index = 2 await pilot.pause() assert app.query_one("#details", Static).content is not renderable run(scenario()) def test_tui_updates_results_on_every_keystroke(engine): async def scenario(): app = SJaurApp(engine=engine, cache=None, cache_note="test") async with app.run_test(size=(120, 36)) as pilot: await pilot.pause() popular = [r.package.name for r in app._current_results] await pilot.click("#search") await pilot.press("f", "z", "f") await pilot.pause(0.05) # less than the debounce: still instant feedback live = [r.package.name for r in app._current_results] assert set(live) < set(popular) # narrowed immediately, not after enter await pilot.pause(1.0) # background search refines further assert "fzf" in [r.package.name for r in app._current_results] assert "fzf" in app._results_cache # finished search is cached run(scenario()) def test_tui_cached_results_are_restored_instantly(engine): async def scenario(): app = SJaurApp(engine=engine, cache=None, cache_note="test") async with app.run_test(size=(120, 36)) as pilot: await pilot.pause() await pilot.click("#search") for _ in range(3): await pilot.press("f", "z", "f") await pilot.pause(1.0) fzf_results = [r.package.name for r in app._current_results] # Clear and retype: results come back from the cache. await pilot.press("backspace", "backspace", "backspace") await pilot.pause(1.0) assert app._current_results # popular list restored await pilot.press("f", "z", "f") await pilot.pause(0.05) assert [r.package.name for r in app._current_results] == fzf_results run(scenario()) def test_tui_install_flow_is_requested_for_selected_package(engine): async def scenario(): app = SJaurApp(engine=engine, cache=None, cache_note="test") async with app.run_test(size=(120, 36)) as pilot: await pilot.pause() calls = [] app._install_flow = lambda package, refresh: calls.append( (package.name, refresh) ) # Highlight a package and open the install dialog. app.action_focus_results() await pilot.pause() await pilot.press("ctrl+i") await pilot.pause() assert isinstance(app.screen, InstallerConfirmScreen) # 'n' cancels: nothing is installed. await pilot.press("n") await pilot.pause() assert calls == [] # Re-open and confirm with 'y'. await pilot.press("ctrl+i") await pilot.pause() await pilot.press("y") await pilot.pause() assert len(calls) == 1 assert calls[0][1] is False # 'u' confirms with a refresh (update the clone first). await pilot.press("ctrl+i") await pilot.pause() await pilot.press("u") await pilot.pause() assert calls[-1][1] is True # The plain 'i' key (results focused) opens the dialog as well. await pilot.press("escape") await pilot.pause() app.action_focus_results() await pilot.pause() await pilot.press("i") await pilot.pause() assert isinstance(app.screen, InstallerConfirmScreen) run(scenario()) def test_tui_install_reports_environment_problems(engine, monkeypatch): async def scenario(): import sjaur.tui.app as app_module monkeypatch.setattr( app_module, "check_environment", lambda **kwargs: ["'git' not found in PATH — required"], ) app = SJaurApp(engine=engine, cache=None, cache_note="test") async with app.run_test(size=(120, 36)) as pilot: await pilot.pause() app._install_flow = lambda package, refresh: pytest_fail("flow must not run") app.action_focus_results() await pilot.pause() await pilot.press("ctrl+i") await pilot.pause() await pilot.press("y") await pilot.pause() assert "git" in str(app.query_one("#status", Static).content) run(scenario()) def test_tui_install_flow_error_is_reported(engine, monkeypatch): async def scenario(): app = SJaurApp(engine=engine, cache=None, cache_note="test") async with app.run_test(size=(120, 36)) as pilot: await pilot.pause() def failing_flow(package, refresh): from sjaur.errors import SjaurError raise SjaurError("makepkg failed with exit code 4") app._install_flow = failing_flow app._install_package(engine.packages[0], refresh=False) await pilot.pause() # The report goes to the suspended terminal, so no status error: # the app must simply survive and stay usable. assert app._search_input.has_focus run(scenario()) def test_tui_escape_clears_search(engine): async def scenario(): app = SJaurApp(engine=engine, cache=None, cache_note="test") async with app.run_test(size=(120, 36)) as pilot: await pilot.pause() await pilot.click("#search") for char in "fzf": await pilot.press(char) await pilot.pause(1.0) assert app._current_results await pilot.press("escape") await pilot.pause(1.0) assert app._search_input.value == "" run(scenario()) def test_tui_enter_expands_details_and_escape_returns(engine): async def scenario(): app = SJaurApp(engine=engine, cache=None, cache_note="test") async with app.run_test(size=(120, 36)) as pilot: await pilot.pause() content = app.query_one("#content") # Focus the results and open the selected package. app.action_focus_results() await pilot.pause() await pilot.press("enter") await pilot.pause() assert content.has_class("expanded") await pilot.press("escape") await pilot.pause() assert not content.has_class("expanded") run(scenario())