"""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"])