399 lines
12 KiB
Python
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"])
|