development-hub/tests/test_terminal_error_handlin...

399 lines
12 KiB
Python

"""Tests for terminal error handling edge cases."""
import os
import pytest
from pathlib import Path
from unittest.mock import patch, MagicMock
from development_hub.pty_manager import PtyReaderThread
from development_hub.terminal_display import TerminalDisplay
class TestPtyReaderThread:
"""Tests for PtyReaderThread error handling."""
def test_exit_reason_normal(self):
"""Exit reason is set for normal exit."""
# Create a mock that simulates normal PTY closure
r, w = os.pipe()
os.close(w) # Close write end to trigger EOF
thread = PtyReaderThread(r)
thread.start()
thread.wait(2000)
assert thread.exit_reason == "normal"
os.close(r)
def test_exit_reason_bad_fd(self):
"""Exit reason is set for bad file descriptor."""
# Use an invalid file descriptor
thread = PtyReaderThread(9999)
thread.start()
thread.wait(2000)
assert thread.exit_reason is not None
assert "invalid" in thread.exit_reason or "error" in thread.exit_reason
def test_stop_method(self):
"""Stop method terminates the thread."""
r, w = os.pipe()
thread = PtyReaderThread(r)
thread.start()
# Give it a moment to start
thread.msleep(50)
thread.stop()
finished = thread.wait(2000)
assert finished
os.close(r)
os.close(w)
def test_output_ready_signal(self, qtbot):
"""Output ready signal is emitted when data is available."""
r, w = os.pipe()
thread = PtyReaderThread(r)
received_data = []
def on_output(data):
received_data.append(data)
thread.output_ready.connect(on_output)
thread.start()
# Write some data
os.write(w, b"Hello, World!")
os.close(w)
# Wait for thread to finish and process Qt events
thread.wait(2000)
# Process pending Qt events to ensure signal is delivered
from PySide6.QtCore import QCoreApplication
QCoreApplication.processEvents()
# The signal may be queued, give it time
qtbot.wait(100)
assert len(received_data) > 0
assert b"Hello" in received_data[0]
os.close(r)
class TestTerminalDisplayErrorHandling:
"""Tests for TerminalDisplay error handling."""
def test_feed_malformed_utf8(self):
"""Feed handles malformed UTF-8 gracefully."""
display = TerminalDisplay(rows=24, cols=80)
# Invalid UTF-8 sequence
malformed = b'\xff\xfe Invalid UTF-8 \x80\x81'
# Should not raise
display.feed(malformed)
# Display should still be usable
display.feed(b'Normal text')
def test_feed_valid_utf8(self):
"""Feed handles valid UTF-8 correctly."""
display = TerminalDisplay(rows=24, cols=80)
# Valid UTF-8 with unicode
valid = "Hello 世界 🌍".encode('utf-8')
# Should not raise
display.feed(valid)
def test_feed_empty_data(self):
"""Feed handles empty data."""
display = TerminalDisplay(rows=24, cols=80)
# Empty bytes should not raise
display.feed(b'')
def test_feed_escape_sequences(self):
"""Feed handles various escape sequences."""
display = TerminalDisplay(rows=24, cols=80)
# Common escape sequences
sequences = [
b'\x1b[2J', # Clear screen
b'\x1b[H', # Cursor home
b'\x1b[31m', # Red color
b'\x1b[0m', # Reset
b'\x1b[?25h', # Show cursor
b'\x1b[?25l', # Hide cursor
]
for seq in sequences:
display.feed(seq)
def test_feed_cursor_position_query(self, qtbot):
"""Feed handles cursor position query (DSR)."""
display = TerminalDisplay(rows=24, cols=80)
signal_received = []
def on_cursor_request():
signal_received.append(True)
display.cursor_position_requested.connect(on_cursor_request)
# DSR sequence
display.feed(b'\x1b[6n')
assert len(signal_received) == 1
def test_feed_mixed_content(self):
"""Feed handles mixed text and escape sequences."""
display = TerminalDisplay(rows=24, cols=80)
mixed = b'\x1b[32mGreen text\x1b[0m and \x1b[31mred text\x1b[0m'
display.feed(mixed)
def test_resize_screen(self):
"""Resize screen updates dimensions."""
display = TerminalDisplay(rows=24, cols=80)
display.resize_screen(40, 120)
assert display.screen.lines == 40
assert display.screen.columns == 120
def test_resize_screen_small(self):
"""Resize screen to very small dimensions."""
display = TerminalDisplay(rows=24, cols=80)
display.resize_screen(1, 1)
assert display.screen.lines == 1
assert display.screen.columns == 1
class TestTerminalWidgetErrorHandling:
"""Tests for TerminalWidget error handling.
Note: These tests don't actually create terminals (which would require
a display), but test the error handling logic.
"""
def test_shell_running_property(self):
"""Shell running property reflects state."""
from development_hub.terminal_widget import TerminalWidget
# We can't easily test the full widget without a display,
# but we can test the property exists
assert hasattr(TerminalWidget, 'is_running')
def test_restart_shell_method_exists(self):
"""Restart shell method exists."""
from development_hub.terminal_widget import TerminalWidget
assert hasattr(TerminalWidget, 'restart_shell')
def test_shell_error_signal_exists(self):
"""Shell error signal exists."""
from development_hub.terminal_widget import TerminalWidget
assert hasattr(TerminalWidget, 'shell_error')
class TestTerminalDisplaySelection:
"""Tests for terminal selection functionality."""
def test_no_initial_selection(self):
"""No selection initially."""
display = TerminalDisplay(rows=24, cols=80)
assert display._selection_start is None
assert display._selection_end is None
assert display._has_selection() == False
def test_pos_to_cell_bounds(self):
"""Position to cell conversion respects bounds."""
display = TerminalDisplay(rows=24, cols=80)
# Create a mock position
class MockPos:
def __init__(self, x, y):
self._x = x
self._y = y
def x(self):
return self._x
def y(self):
return self._y
# Negative position should clamp to 0
row, col = display._pos_to_cell(MockPos(-100, -100))
assert row == 0
assert col == 0
# Large position should clamp to max
row, col = display._pos_to_cell(MockPos(10000, 10000))
assert row == display.screen.lines - 1
assert col == display.screen.columns - 1
class TestScrollback:
"""Tests for terminal scrollback functionality."""
def test_initial_scrollback_empty(self):
"""Scrollback buffer is empty initially."""
display = TerminalDisplay(rows=24, cols=80)
assert display.scrollback_lines == 0
assert display.is_scrolled == False
def test_scroll_offset_initial(self):
"""Scroll offset is 0 initially."""
display = TerminalDisplay(rows=24, cols=80)
assert display._scroll_offset == 0
def test_scroll_up_without_history(self):
"""Scrolling up without history does nothing harmful."""
display = TerminalDisplay(rows=24, cols=80)
display.scroll_up(10)
# Should clamp to available history (0)
assert display._scroll_offset == 0
def test_scroll_down_at_bottom(self):
"""Scrolling down at bottom does nothing harmful."""
display = TerminalDisplay(rows=24, cols=80)
display.scroll_down(10)
assert display._scroll_offset == 0
def test_scroll_to_bottom(self):
"""scroll_to_bottom resets offset."""
display = TerminalDisplay(rows=24, cols=80)
display._scroll_offset = 100 # Manually set for test
display.scroll_to_bottom()
assert display._scroll_offset == 0
def test_is_scrolled_property(self):
"""is_scrolled reflects scroll state."""
display = TerminalDisplay(rows=24, cols=80)
assert display.is_scrolled == False
display._scroll_offset = 1
assert display.is_scrolled == True
display._scroll_offset = 0
assert display.is_scrolled == False
def test_clear_scrollback(self):
"""clear_scrollback empties the buffer."""
display = TerminalDisplay(rows=24, cols=80)
# Manually add to scrollback for test
display._scrollback_buffer.append([])
display._scrollback_buffer.append([])
assert display.scrollback_lines == 2
display.clear_scrollback()
assert display.scrollback_lines == 0
assert display._scroll_offset == 0
def test_total_lines(self):
"""total_lines includes scrollback and screen."""
display = TerminalDisplay(rows=24, cols=80)
assert display.total_lines == 24 # Just screen lines
# Add to scrollback
display._scrollback_buffer.append([])
display._scrollback_buffer.append([])
assert display.total_lines == 26 # 2 history + 24 screen
def test_custom_scrollback_size(self):
"""Custom scrollback size is respected."""
display = TerminalDisplay(rows=24, cols=80, scrollback_lines=100)
assert display._scrollback_max == 100
def test_scroll_page_up_down(self):
"""Page up/down scroll by screen height."""
display = TerminalDisplay(rows=24, cols=80)
# Add enough history to scroll
for _ in range(100):
display._scrollback_buffer.append([])
display.scroll_page_up()
assert display._scroll_offset == 23 # rows - 1
display.scroll_page_down()
assert display._scroll_offset == 0
class TestColorHandling:
"""Tests for terminal color handling."""
def test_default_colors(self):
"""Default colors are returned correctly."""
display = TerminalDisplay(rows=24, cols=80)
fg = display._color_to_qcolor("default", default_fg=True)
bg = display._color_to_qcolor("default", default_fg=False)
assert fg.name() == "#d4d4d4"
assert bg.name() == "#1e1e1e"
def test_named_colors(self):
"""Named colors are converted correctly."""
display = TerminalDisplay(rows=24, cols=80)
red = display._color_to_qcolor("red", default_fg=True)
assert red.red() > 200 # Should be a red color
def test_256_color_standard(self):
"""256 color palette standard colors work."""
display = TerminalDisplay(rows=24, cols=80)
# Color 1 is red in standard palette
color = display._color_to_qcolor(1, default_fg=True)
assert color.red() > 200
def test_256_color_cube(self):
"""256 color palette color cube works."""
display = TerminalDisplay(rows=24, cols=80)
# Color 196 is bright red in the color cube
color = display._color_to_qcolor(196, default_fg=True)
assert color is not None
def test_256_color_grayscale(self):
"""256 color palette grayscale works."""
display = TerminalDisplay(rows=24, cols=80)
# Color 240 is in the grayscale range
color = display._color_to_qcolor(240, default_fg=True)
assert color is not None
# Grayscale should have equal RGB
assert abs(color.red() - color.green()) < 5
assert abs(color.green() - color.blue()) < 5
def test_hex_color(self):
"""Hex color strings are converted correctly."""
display = TerminalDisplay(rows=24, cols=80)
# 6-digit hex color
color = display._color_to_qcolor("ff5500", default_fg=True)
assert color.red() == 255
assert color.green() == 85
assert color.blue() == 0
def test_rgb_tuple(self):
"""RGB tuples are converted correctly."""
display = TerminalDisplay(rows=24, cols=80)
color = display._color_to_qcolor((128, 64, 32), default_fg=True)
assert color.red() == 128
assert color.green() == 64
assert color.blue() == 32
if __name__ == "__main__":
pytest.main([__file__, "-v"])