""" Keyboard shortcut tests for DolphinTUIApp. Validates: Requirements 10.1, 10.2, 10.3, 10.4 Uses Textual's built-in async pilot (App.run_test / pilot.press) to simulate keypresses and assert expected behaviour. DolphinDataFetcher is mocked so no real Hazelcast or Prefect connections are needed. """ from __future__ import annotations import sys import os from unittest.mock import AsyncMock, MagicMock, patch import pytest sys.path.insert(0, os.path.dirname(__file__)) # --------------------------------------------------------------------------- # Compatibility shim: Textual 8.x moved VerticalScroll to textual.containers. # Patch textual.widgets so dolphin_tui.py (which imports from textual.widgets) # can load without error. # --------------------------------------------------------------------------- import textual.widgets as _tw import textual.containers as _tc if not hasattr(_tw, "VerticalScroll"): _tw.VerticalScroll = _tc.VerticalScroll # --------------------------------------------------------------------------- # Import the real app (all deps are available in this environment) # --------------------------------------------------------------------------- from dolphin_tui import ( # noqa: E402 DolphinTUIApp, DolphinDataFetcher, DataSnapshot, LogPanel, ) # --------------------------------------------------------------------------- # Shared fixture: a minimal DataSnapshot with no real data # --------------------------------------------------------------------------- _EMPTY_SNAP = DataSnapshot() def _make_mock_fetcher_instance() -> MagicMock: """Return a mock DolphinDataFetcher with all network methods stubbed.""" fetcher = MagicMock(spec=DolphinDataFetcher) fetcher.hz_connected = False fetcher.connect_hz = AsyncMock(return_value=False) fetcher.disconnect_hz = AsyncMock(return_value=None) fetcher.fetch = AsyncMock(return_value=_EMPTY_SNAP) fetcher.fetch_prefect = AsyncMock(return_value=(False, [])) fetcher.tail_log = MagicMock(return_value=[]) fetcher._running = True fetcher._reconnect_task = None return fetcher # --------------------------------------------------------------------------- # Test 10.1 — `q` key: action_quit called, app exits cleanly # Validates: Requirements 10.1 # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_q_key_quits_app_cleanly(): """Pressing `q` calls action_quit which disconnects HZ and exits (Req 10.1). Verifies that disconnect_hz is awaited before exit so the HZ client is shut down cleanly. """ mock_fetcher = _make_mock_fetcher_instance() with patch("dolphin_tui.DolphinDataFetcher", return_value=mock_fetcher): app = DolphinTUIApp(hz_host="localhost", hz_port=5701) async with app.run_test(size=(130, 35)) as pilot: # Stop the poll timer to avoid background noise try: app._poll_timer.stop() except Exception: pass await pilot.press("q") # run_test context exits cleanly after q — app.exit() was called # After the context exits, verify disconnect_hz was called (clean HZ shutdown) mock_fetcher.disconnect_hz.assert_awaited_once() # --------------------------------------------------------------------------- # Test 10.2 — `r` key: action_force_refresh triggers an immediate poll # Validates: Requirements 10.2 # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_r_key_triggers_immediate_poll(): """Pressing `r` calls action_force_refresh which runs _poll immediately (Req 10.2). Verifies that fetch() is called at least once more after pressing `r`, confirming a poll outside the normal 2s cycle. """ mock_fetcher = _make_mock_fetcher_instance() with patch("dolphin_tui.DolphinDataFetcher", return_value=mock_fetcher): app = DolphinTUIApp(hz_host="localhost", hz_port=5701) async with app.run_test(size=(130, 35)) as pilot: try: app._poll_timer.stop() except Exception: pass call_count_before = mock_fetcher.fetch.await_count await pilot.press("r") await pilot.pause(0.2) # fetch() should have been called at least once more after `r` assert mock_fetcher.fetch.await_count > call_count_before, ( f"Expected fetch() to be called after pressing 'r', " f"but call count stayed at {mock_fetcher.fetch.await_count}" ) # --------------------------------------------------------------------------- # Test 10.3 — `l` key: LogPanel visibility toggles on/off # Validates: Requirements 10.3 # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_l_key_shows_log_panel(): """Pressing `l` once makes the LogPanel visible (Req 10.3).""" mock_fetcher = _make_mock_fetcher_instance() with patch("dolphin_tui.DolphinDataFetcher", return_value=mock_fetcher): app = DolphinTUIApp(hz_host="localhost", hz_port=5701) async with app.run_test(size=(130, 35)) as pilot: try: app._poll_timer.stop() except Exception: pass log_panel = app.query_one("#panel_log", LogPanel) # Initially hidden assert log_panel.display is False, "LogPanel should be hidden on startup" # Press l → should become visible await pilot.press("l") await pilot.pause(0.05) assert log_panel.display is True, "LogPanel should be visible after first 'l' press" @pytest.mark.asyncio async def test_l_key_hides_log_panel_on_second_press(): """Pressing `l` twice returns LogPanel to hidden state (Req 10.3).""" mock_fetcher = _make_mock_fetcher_instance() with patch("dolphin_tui.DolphinDataFetcher", return_value=mock_fetcher): app = DolphinTUIApp(hz_host="localhost", hz_port=5701) async with app.run_test(size=(130, 35)) as pilot: try: app._poll_timer.stop() except Exception: pass log_panel = app.query_one("#panel_log", LogPanel) # Press l twice: hidden → visible → hidden await pilot.press("l") await pilot.pause(0.05) assert log_panel.display is True await pilot.press("l") await pilot.pause(0.05) assert log_panel.display is False, "LogPanel should be hidden after second 'l' press" @pytest.mark.asyncio async def test_l_key_updates_log_visible_flag(): """Pressing `l` updates the _log_visible internal flag (Req 10.3).""" mock_fetcher = _make_mock_fetcher_instance() with patch("dolphin_tui.DolphinDataFetcher", return_value=mock_fetcher): app = DolphinTUIApp(hz_host="localhost", hz_port=5701) async with app.run_test(size=(130, 35)) as pilot: try: app._poll_timer.stop() except Exception: pass assert app._log_visible is False await pilot.press("l") await pilot.pause(0.05) assert app._log_visible is True await pilot.press("l") await pilot.pause(0.05) assert app._log_visible is False # --------------------------------------------------------------------------- # Test 10.4 — Arrow keys: scroll actions dispatched on LogPanel # Validates: Requirements 10.4 # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_up_arrow_scrolls_log_panel(): """Pressing ↑ dispatches action_scroll_up on LogPanel when visible (Req 10.4).""" mock_fetcher = _make_mock_fetcher_instance() with patch("dolphin_tui.DolphinDataFetcher", return_value=mock_fetcher): app = DolphinTUIApp(hz_host="localhost", hz_port=5701) async with app.run_test(size=(130, 35)) as pilot: try: app._poll_timer.stop() except Exception: pass # Make log panel visible and focus it await pilot.press("l") await pilot.pause(0.05) log_panel = app.query_one("#panel_log", LogPanel) assert log_panel.display is True # Focus the log panel so it receives the scroll action log_panel.focus() await pilot.pause(0.05) # Track action_scroll_up calls on the log panel scroll_up_called = [] original = log_panel.action_scroll_up def _track(*args, **kwargs): scroll_up_called.append(True) return original(*args, **kwargs) log_panel.action_scroll_up = _track await pilot.press("up") await pilot.pause(0.1) assert len(scroll_up_called) > 0, ( "action_scroll_up should have been called on LogPanel after pressing 'up'" ) @pytest.mark.asyncio async def test_down_arrow_scrolls_log_panel(): """Pressing ↓ dispatches action_scroll_down on LogPanel when visible (Req 10.4).""" mock_fetcher = _make_mock_fetcher_instance() with patch("dolphin_tui.DolphinDataFetcher", return_value=mock_fetcher): app = DolphinTUIApp(hz_host="localhost", hz_port=5701) async with app.run_test(size=(130, 35)) as pilot: try: app._poll_timer.stop() except Exception: pass # Make log panel visible and focus it await pilot.press("l") await pilot.pause(0.05) log_panel = app.query_one("#panel_log", LogPanel) assert log_panel.display is True log_panel.focus() await pilot.pause(0.05) # Track action_scroll_down calls on the log panel scroll_down_called = [] original = log_panel.action_scroll_down def _track(*args, **kwargs): scroll_down_called.append(True) return original(*args, **kwargs) log_panel.action_scroll_down = _track await pilot.press("down") await pilot.pause(0.1) assert len(scroll_down_called) > 0, ( "action_scroll_down should have been called on LogPanel after pressing 'down'" )