Skip to content

Commit d335fc9

Browse files
committed
tests(textframe): Add shutil terminal size detection test
why: Verify display() uses shutil.get_terminal_size() for resize what: - Add test_terminal_resize_via_shutil test - Mock shutil.get_terminal_size to verify it's called
1 parent 79e4236 commit d335fc9

File tree

1 file changed

+152
-0
lines changed

1 file changed

+152
-0
lines changed

tests/textframe/test_display.py

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
"""Tests for TextFrame.display() interactive viewer."""
2+
3+
from __future__ import annotations
4+
5+
import curses
6+
import io
7+
import os
8+
import typing as t
9+
from unittest.mock import MagicMock, patch
10+
11+
import pytest
12+
13+
from libtmux.textframe import TextFrame
14+
15+
16+
class ExitKeyCase(t.NamedTuple):
17+
"""Test case for exit key handling."""
18+
19+
id: str
20+
key: int | None
21+
side_effect: type[BaseException] | None = None
22+
23+
24+
EXIT_KEY_CASES: tuple[ExitKeyCase, ...] = (
25+
ExitKeyCase(
26+
id="quit_on_q",
27+
key=ord("q"),
28+
),
29+
ExitKeyCase(
30+
id="quit_on_escape",
31+
key=27,
32+
),
33+
ExitKeyCase(
34+
id="quit_on_ctrl_c",
35+
key=None,
36+
side_effect=KeyboardInterrupt,
37+
),
38+
)
39+
40+
41+
@pytest.fixture
42+
def mock_curses_env() -> t.Generator[None, None, None]:
43+
"""Mock curses module-level functions that require initscr()."""
44+
with (
45+
patch("curses.curs_set"),
46+
patch("curses.A_REVERSE", 0),
47+
):
48+
yield
49+
50+
51+
def test_display_raises_when_not_tty() -> None:
52+
"""Verify display() raises RuntimeError when stdout is not a TTY."""
53+
frame = TextFrame(content_width=10, content_height=2)
54+
frame.set_content(["hello", "world"])
55+
56+
with (
57+
patch("sys.stdout", new=io.StringIO()),
58+
pytest.raises(RuntimeError, match="interactive terminal"),
59+
):
60+
frame.display()
61+
62+
63+
def test_display_calls_curses_wrapper_when_tty() -> None:
64+
"""Verify display() calls curses.wrapper when stdout is a TTY."""
65+
frame = TextFrame(content_width=10, content_height=2)
66+
frame.set_content(["hello", "world"])
67+
68+
with (
69+
patch("sys.stdout.isatty", return_value=True),
70+
patch("curses.wrapper") as mock_wrapper,
71+
):
72+
frame.display()
73+
mock_wrapper.assert_called_once()
74+
args = mock_wrapper.call_args[0]
75+
assert args[0].__name__ == "_curses_display"
76+
77+
78+
@pytest.mark.parametrize("case", EXIT_KEY_CASES, ids=lambda c: c.id)
79+
def test_curses_display_exit_keys(
80+
case: ExitKeyCase,
81+
mock_curses_env: None,
82+
) -> None:
83+
"""Verify viewer exits on various exit keys/events."""
84+
frame = TextFrame(content_width=10, content_height=2)
85+
frame.set_content(["hello", "world"])
86+
87+
mock_stdscr = MagicMock()
88+
89+
if case.side_effect:
90+
mock_stdscr.getch.side_effect = case.side_effect
91+
else:
92+
mock_stdscr.getch.return_value = case.key
93+
94+
# Should exit cleanly without error
95+
frame._curses_display(mock_stdscr)
96+
mock_stdscr.clear.assert_called()
97+
98+
99+
def test_curses_display_scroll_navigation(mock_curses_env: None) -> None:
100+
"""Verify scroll navigation works with arrow keys."""
101+
frame = TextFrame(content_width=10, content_height=10)
102+
frame.set_content([f"line {i}" for i in range(10)])
103+
104+
mock_stdscr = MagicMock()
105+
106+
# Simulate: down arrow, then quit
107+
mock_stdscr.getch.side_effect = [curses.KEY_DOWN, ord("q")]
108+
109+
frame._curses_display(mock_stdscr)
110+
111+
# Verify multiple refresh cycles occurred (initial + after navigation)
112+
assert mock_stdscr.refresh.call_count >= 2
113+
114+
115+
def test_curses_display_status_line(mock_curses_env: None) -> None:
116+
"""Verify status line shows position and dimensions."""
117+
frame = TextFrame(content_width=10, content_height=2)
118+
frame.set_content(["hello", "world"])
119+
120+
mock_stdscr = MagicMock()
121+
mock_stdscr.getch.return_value = ord("q")
122+
123+
frame._curses_display(mock_stdscr)
124+
125+
# Find the addstr call that contains status info
126+
status_calls = [
127+
call
128+
for call in mock_stdscr.addstr.call_args_list
129+
if len(call[0]) >= 3 and "q:quit" in str(call[0][2])
130+
]
131+
assert len(status_calls) > 0, "Status line should be displayed"
132+
133+
134+
def test_curses_display_uses_shutil_terminal_size(mock_curses_env: None) -> None:
135+
"""Verify terminal size is queried via shutil.get_terminal_size().
136+
137+
This approach works reliably in tmux/multiplexers because it directly
138+
queries the terminal via ioctl(TIOCGWINSZ) on each loop iteration,
139+
rather than relying on curses KEY_RESIZE events.
140+
"""
141+
frame = TextFrame(content_width=10, content_height=2)
142+
frame.set_content(["hello", "world"])
143+
144+
mock_stdscr = MagicMock()
145+
mock_stdscr.getch.return_value = ord("q")
146+
147+
with patch(
148+
"libtmux.textframe.core.shutil.get_terminal_size",
149+
return_value=os.terminal_size((120, 40)),
150+
) as mock_get_size:
151+
frame._curses_display(mock_stdscr)
152+
mock_get_size.assert_called()

0 commit comments

Comments
 (0)