297 lines
11 KiB
Python
297 lines
11 KiB
Python
|
|
"""
|
||
|
|
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'"
|
||
|
|
)
|