Skip to content

Commit da0c144

Browse files
committed
tests(textframe): Add display() tests
why: Verify interactive curses viewer behavior what: - Test RuntimeError when stdout is not a TTY - Test curses.wrapper integration - Test exit on q, Esc, Ctrl-C keys - Test scroll navigation with arrow keys - Test status line display
1 parent 90010ab commit da0c144

File tree

1 file changed

+139
-0
lines changed

1 file changed

+139
-0
lines changed

tests/textframe/test_display.py

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
"""Tests for TextFrame.display() interactive viewer."""
2+
3+
from __future__ import annotations
4+
5+
import io
6+
import typing as t
7+
from unittest.mock import MagicMock, patch
8+
9+
import pytest
10+
11+
from libtmux.textframe import TextFrame
12+
13+
14+
class TestDisplayTTYRequirement:
15+
"""Tests for display() TTY detection."""
16+
17+
def test_raises_when_not_tty(self) -> None:
18+
"""Verify display() raises RuntimeError when stdout is not a TTY.
19+
20+
This test simulates a non-interactive environment (e.g., piped output,
21+
CI environment) where curses cannot function.
22+
"""
23+
frame = TextFrame(content_width=10, content_height=2)
24+
frame.set_content(["hello", "world"])
25+
26+
with (
27+
patch("sys.stdout", new=io.StringIO()),
28+
pytest.raises(RuntimeError, match="interactive terminal"),
29+
):
30+
frame.display()
31+
32+
def test_calls_curses_wrapper_when_tty(self) -> None:
33+
"""Verify display() calls curses.wrapper when stdout is a TTY."""
34+
frame = TextFrame(content_width=10, content_height=2)
35+
frame.set_content(["hello", "world"])
36+
37+
with (
38+
patch("sys.stdout.isatty", return_value=True),
39+
patch("curses.wrapper") as mock_wrapper,
40+
):
41+
frame.display()
42+
mock_wrapper.assert_called_once()
43+
# Verify it was called with _curses_display method
44+
args = mock_wrapper.call_args[0]
45+
assert args[0].__name__ == "_curses_display"
46+
47+
48+
class TestCursesDisplay:
49+
"""Tests for _curses_display() curses viewer implementation.
50+
51+
These tests mock curses module-level functions (curs_set, A_REVERSE)
52+
since curses requires terminal initialization.
53+
"""
54+
55+
@pytest.fixture
56+
def mock_curses(self) -> t.Generator[None, None, None]:
57+
"""Mock curses module-level functions that require initscr()."""
58+
with (
59+
patch("curses.curs_set"),
60+
patch("curses.A_REVERSE", 0),
61+
):
62+
yield
63+
64+
def test_quit_on_q_key(self, mock_curses: None) -> None:
65+
"""Verify viewer exits when q key is pressed."""
66+
frame = TextFrame(content_width=10, content_height=2)
67+
frame.set_content(["hello", "world"])
68+
69+
mock_stdscr = MagicMock()
70+
mock_stdscr.getmaxyx.return_value = (24, 80)
71+
mock_stdscr.getch.return_value = ord("q")
72+
73+
# Should exit cleanly without error
74+
frame._curses_display(mock_stdscr)
75+
76+
# Verify basic curses setup was called
77+
mock_stdscr.clear.assert_called()
78+
mock_stdscr.refresh.assert_called()
79+
80+
def test_quit_on_escape_key(self, mock_curses: None) -> None:
81+
"""Verify viewer exits when Escape key is pressed."""
82+
frame = TextFrame(content_width=10, content_height=2)
83+
frame.set_content(["hello", "world"])
84+
85+
mock_stdscr = MagicMock()
86+
mock_stdscr.getmaxyx.return_value = (24, 80)
87+
mock_stdscr.getch.return_value = 27 # Escape key
88+
89+
frame._curses_display(mock_stdscr)
90+
mock_stdscr.clear.assert_called()
91+
92+
def test_quit_on_keyboard_interrupt(self, mock_curses: None) -> None:
93+
"""Verify viewer exits cleanly on Ctrl-C."""
94+
frame = TextFrame(content_width=10, content_height=2)
95+
frame.set_content(["hello", "world"])
96+
97+
mock_stdscr = MagicMock()
98+
mock_stdscr.getmaxyx.return_value = (24, 80)
99+
mock_stdscr.getch.side_effect = KeyboardInterrupt()
100+
101+
# Should not raise, should exit cleanly
102+
frame._curses_display(mock_stdscr)
103+
104+
def test_scroll_navigation_keys(self, mock_curses: None) -> None:
105+
"""Verify scroll navigation works with arrow keys."""
106+
import curses as curses_module
107+
108+
frame = TextFrame(content_width=10, content_height=10)
109+
frame.set_content([f"line {i}" for i in range(10)])
110+
111+
mock_stdscr = MagicMock()
112+
mock_stdscr.getmaxyx.return_value = (5, 20) # Small viewport
113+
114+
# Simulate: down arrow, then quit
115+
mock_stdscr.getch.side_effect = [curses_module.KEY_DOWN, ord("q")]
116+
117+
frame._curses_display(mock_stdscr)
118+
119+
# Verify multiple refresh cycles occurred (initial + after navigation)
120+
assert mock_stdscr.refresh.call_count >= 2
121+
122+
def test_status_line_displayed(self, mock_curses: None) -> None:
123+
"""Verify status line shows position and dimensions."""
124+
frame = TextFrame(content_width=10, content_height=2)
125+
frame.set_content(["hello", "world"])
126+
127+
mock_stdscr = MagicMock()
128+
mock_stdscr.getmaxyx.return_value = (24, 80)
129+
mock_stdscr.getch.return_value = ord("q")
130+
131+
frame._curses_display(mock_stdscr)
132+
133+
# Find the addstr call that contains status info
134+
status_calls = [
135+
call
136+
for call in mock_stdscr.addstr.call_args_list
137+
if len(call[0]) >= 3 and "q:quit" in str(call[0][2])
138+
]
139+
assert len(status_calls) > 0, "Status line should be displayed"

0 commit comments

Comments
 (0)