"""Tests for python_pkg.repo_explorer._execution.""" from __future__ import annotations import tkinter as tk from tkinter import ttk from typing import TYPE_CHECKING, Any from unittest.mock import MagicMock, patch from python_pkg.repo_explorer._execution import ExecutionMixin if TYPE_CHECKING: from pathlib import Path import subprocess # ── Protocol stub coverage ─────────────────────────────────────────── class TestProtocolStubs: def test_selected_path_stub(self) -> None: """Call the base stub to cover line 43.""" result = ExecutionMixin._selected_path(MagicMock()) assert result is None def test_after_stub(self) -> None: """Call the base stub to cover line 44.""" result = ExecutionMixin.after(MagicMock(), 0) assert result is None class StubExecution(ExecutionMixin): """Concrete stub for testing ExecutionMixin methods.""" _IDLE_FLUSH_TICKS = 2 def __init__(self) -> None: self._proc: subprocess.Popen[bytes] | None = None self._master_fd: int | None = None self._terminal_args: list[str] = ["kitty", "--"] self._args_var = MagicMock(spec=tk.StringVar) self._stdin_var = MagicMock(spec=tk.StringVar) self._status_var = MagicMock(spec=tk.StringVar) self._run_btn = MagicMock(spec=ttk.Button) self._stop_btn = MagicMock(spec=ttk.Button) self._output = MagicMock(spec=tk.Text) self._path: Any = None self._after_calls: list[tuple[Any, ...]] = [] def _selected_path(self) -> Path | None: return self._path def after(self, ms: int, *args: object) -> str: self._after_calls.append((ms, *args)) return "after_id" # ── _run_in_terminal ───────────────────────────────────────────────── class TestRunInTerminal: def test_path_none_returns(self) -> None: obj = StubExecution() obj._path = None obj._run_in_terminal() assert obj._after_calls == [] def test_no_terminal_args_returns(self) -> None: obj = StubExecution() obj._path = MagicMock() obj._terminal_args = [] obj._run_in_terminal() assert obj._after_calls == [] @patch("python_pkg.repo_explorer._execution.subprocess.Popen") def test_launches_with_args(self, mock_popen: MagicMock) -> None: obj = StubExecution() obj._path = MagicMock() obj._args_var.get.return_value = " --flag value " obj._run_in_terminal() mock_popen.assert_called_once() cmd = mock_popen.call_args[0][0] assert cmd[:2] == ["kitty", "--"] assert "bash" in cmd assert "--flag" in cmd assert "value" in cmd @patch("python_pkg.repo_explorer._execution.subprocess.Popen") def test_launches_no_extra_args(self, mock_popen: MagicMock) -> None: obj = StubExecution() obj._path = MagicMock() obj._args_var.get.return_value = " " obj._run_in_terminal() cmd = mock_popen.call_args[0][0] assert cmd == ["kitty", "--", "bash", "run.sh"] # ── _run_embedded ──────────────────────────────────────────────────── class TestRunEmbedded: def test_path_none_returns(self) -> None: obj = StubExecution() obj._path = None obj._run_embedded() assert obj._run_btn.configure.call_count == 0 @patch("python_pkg.repo_explorer._execution.threading.Thread") @patch("python_pkg.repo_explorer._execution.os.close") @patch("python_pkg.repo_explorer._execution.fcntl.fcntl") @patch("python_pkg.repo_explorer._execution.pty.openpty", return_value=(5, 6)) @patch("python_pkg.repo_explorer._execution.subprocess.Popen") def test_runs_new_process( self, mock_popen: MagicMock, mock_openpty: MagicMock, mock_fcntl: MagicMock, mock_os_close: MagicMock, mock_thread: MagicMock, ) -> None: obj = StubExecution() obj._path = MagicMock() obj._args_var.get.return_value = "" obj._run_embedded() assert obj._master_fd == 5 mock_os_close.assert_called_once_with(6) mock_popen.assert_called_once() assert mock_thread.call_count == 2 @patch("python_pkg.repo_explorer._execution.threading.Thread") @patch("python_pkg.repo_explorer._execution.os.close") @patch("python_pkg.repo_explorer._execution.fcntl.fcntl") @patch("python_pkg.repo_explorer._execution.pty.openpty", return_value=(5, 6)) @patch("python_pkg.repo_explorer._execution.subprocess.Popen") def test_stops_existing_then_runs( self, mock_popen: MagicMock, mock_openpty: MagicMock, mock_fcntl: MagicMock, mock_os_close: MagicMock, mock_thread: MagicMock, ) -> None: obj = StubExecution() obj._path = MagicMock() obj._args_var.get.return_value = "arg1 arg2" old_proc = MagicMock() old_proc.poll.return_value = None obj._proc = old_proc obj._run_embedded() old_proc.terminate.assert_called_once() @patch("python_pkg.repo_explorer._execution.threading.Thread") @patch("python_pkg.repo_explorer._execution.os.close") @patch("python_pkg.repo_explorer._execution.fcntl.fcntl") @patch("python_pkg.repo_explorer._execution.pty.openpty", return_value=(5, 6)) @patch("python_pkg.repo_explorer._execution.subprocess.Popen") def test_existing_proc_already_exited( self, mock_popen: MagicMock, mock_openpty: MagicMock, mock_fcntl: MagicMock, mock_os_close: MagicMock, mock_thread: MagicMock, ) -> None: obj = StubExecution() obj._path = MagicMock() obj._args_var.get.return_value = "" old_proc = MagicMock() old_proc.poll.return_value = 0 # already exited obj._proc = old_proc obj._run_embedded() old_proc.terminate.assert_not_called() # ── _decode_buf ────────────────────────────────────────────────────── class TestDecodeBuf: def test_plain_text(self) -> None: assert ExecutionMixin._decode_buf(b"hello world") == "hello world" def test_ansi_stripped(self) -> None: assert ExecutionMixin._decode_buf(b"\x1b[31mred\x1b[0m") == "red" def test_carriage_return_removed(self) -> None: assert ExecutionMixin._decode_buf(b"line\r\n") == "line\n" def test_invalid_utf8(self) -> None: result = ExecutionMixin._decode_buf(b"\xff\xfe") assert isinstance(result, str) # ── _flush_partial_buf ─────────────────────────────────────────────── class TestFlushPartialBuf: def test_non_empty_text(self) -> None: obj = StubExecution() obj._flush_partial_buf(b"hello") assert len(obj._after_calls) == 1 def test_empty_after_strip(self) -> None: obj = StubExecution() obj._flush_partial_buf(b"\x1b[0m") assert obj._after_calls == [] # ── _process_complete_lines ────────────────────────────────────────── class TestProcessCompleteLines: def test_complete_line(self) -> None: obj = StubExecution() remainder = obj._process_complete_lines(b"line1\nrest") assert remainder == b"rest" assert len(obj._after_calls) == 1 def test_multiple_lines(self) -> None: obj = StubExecution() remainder = obj._process_complete_lines(b"a\nb\nc") assert remainder == b"c" assert len(obj._after_calls) == 2 def test_no_newline(self) -> None: obj = StubExecution() remainder = obj._process_complete_lines(b"partial") assert remainder == b"partial" assert obj._after_calls == [] def test_empty_line_skipped(self) -> None: obj = StubExecution() remainder = obj._process_complete_lines(b"\x1b[0m\nrest") assert remainder == b"rest" # ANSI-only line decodes to empty → not written assert obj._after_calls == [] # ── _read_pty ──────────────────────────────────────────────────────── class TestReadPty: @patch("python_pkg.repo_explorer._execution.os.close") @patch("python_pkg.repo_explorer._execution.os.read") @patch("python_pkg.repo_explorer._execution.select.select") def test_reads_data_and_exits( self, mock_select: MagicMock, mock_read: MagicMock, mock_close: MagicMock, ) -> None: obj = StubExecution() proc = MagicMock() poll_values = iter([None, None, 0]) proc.poll.side_effect = lambda: next(poll_values) obj._proc = proc obj._master_fd = 10 mock_select.return_value = ([10], [], []) mock_read.return_value = b"hello\n" obj._read_pty() mock_close.assert_called_once_with(10) assert obj._master_fd is None @patch("python_pkg.repo_explorer._execution.os.close") @patch("python_pkg.repo_explorer._execution.os.read") @patch("python_pkg.repo_explorer._execution.select.select") def test_master_fd_none_breaks( self, mock_select: MagicMock, mock_read: MagicMock, mock_close: MagicMock, ) -> None: obj = StubExecution() proc = MagicMock() proc.poll.return_value = None obj._proc = proc obj._master_fd = None obj._read_pty() mock_close.assert_not_called() @patch("python_pkg.repo_explorer._execution.os.close") @patch("python_pkg.repo_explorer._execution.os.read") @patch("python_pkg.repo_explorer._execution.select.select") def test_oserror_on_read_breaks( self, mock_select: MagicMock, mock_read: MagicMock, mock_close: MagicMock, ) -> None: obj = StubExecution() proc = MagicMock() proc.poll.return_value = None obj._proc = proc obj._master_fd = 10 mock_select.return_value = ([10], [], []) mock_read.side_effect = OSError("read error") obj._read_pty() mock_close.assert_called_once_with(10) @patch("python_pkg.repo_explorer._execution.os.close") @patch("python_pkg.repo_explorer._execution.os.read") @patch("python_pkg.repo_explorer._execution.select.select") def test_empty_chunk_breaks( self, mock_select: MagicMock, mock_read: MagicMock, mock_close: MagicMock, ) -> None: obj = StubExecution() proc = MagicMock() proc.poll.return_value = None obj._proc = proc obj._master_fd = 10 mock_select.return_value = ([10], [], []) mock_read.return_value = b"" obj._read_pty() mock_close.assert_called_once_with(10) @patch("python_pkg.repo_explorer._execution.os.close") @patch("python_pkg.repo_explorer._execution.select.select") def test_idle_flushes_partial_buf( self, mock_select: MagicMock, mock_close: MagicMock, ) -> None: obj = StubExecution() obj._IDLE_FLUSH_TICKS = 2 proc = MagicMock() # poll returns None for idle iterations then exits poll_vals = iter([None, None, None, 0]) proc.poll.side_effect = lambda: next(poll_vals) obj._proc = proc obj._master_fd = 10 read_calls = [0] def fake_select( _rlist: list[int], *_a: object, **_kw: object, ) -> tuple[list[int], list[object], list[object]]: read_calls[0] += 1 if read_calls[0] == 1: # First call: return data (no newline → stays in buf) return ([10], [], []) return ([], [], []) # Subsequent: not ready (idle) mock_select.side_effect = fake_select with patch( "python_pkg.repo_explorer._execution.os.read", return_value=b"prompt> ", ): obj._read_pty() # buf should have been flushed assert any("prompt>" in str(c) for c in obj._after_calls) @patch("python_pkg.repo_explorer._execution.os.close") @patch("python_pkg.repo_explorer._execution.select.select") def test_idle_no_buf_continues( self, mock_select: MagicMock, mock_close: MagicMock, ) -> None: obj = StubExecution() proc = MagicMock() poll_vals = iter([None, 0]) proc.poll.side_effect = lambda: next(poll_vals) obj._proc = proc obj._master_fd = 10 mock_select.return_value = ([], [], []) obj._read_pty() # No writes since no data assert obj._after_calls == [] @patch("python_pkg.repo_explorer._execution.os.close") @patch("python_pkg.repo_explorer._execution.select.select") def test_idle_tick_under_threshold( self, mock_select: MagicMock, mock_close: MagicMock, ) -> None: """Idle tick < _IDLE_FLUSH_TICKS should NOT flush.""" obj = StubExecution() obj._IDLE_FLUSH_TICKS = 5 # high threshold proc = MagicMock() poll_vals = iter([None, None, None, 0]) proc.poll.side_effect = lambda: next(poll_vals) obj._proc = proc obj._master_fd = 10 call_count = [0] def fake_select( _rlist: list[int], *_a: object, **_kw: object, ) -> tuple[list[int], list[object], list[object]]: call_count[0] += 1 if call_count[0] == 1: return ([10], [], []) return ([], [], []) mock_select.side_effect = fake_select with patch( "python_pkg.repo_explorer._execution.os.read", return_value=b"data", ): obj._read_pty() # Final buf flush still happens at end assert any("data" in str(c) for c in obj._after_calls) @patch("python_pkg.repo_explorer._execution.os.close") def test_close_oserror_suppressed( self, mock_close: MagicMock, ) -> None: obj = StubExecution() proc = MagicMock() proc.poll.return_value = 1 obj._proc = proc obj._master_fd = 10 mock_close.side_effect = OSError("close error") obj._read_pty() assert obj._master_fd is None def test_proc_none_skips_loop(self) -> None: obj = StubExecution() obj._proc = None obj._master_fd = 10 obj._read_pty() # master_fd might be set to None if code tries to close # but since _proc is None, the while loop is never entered # ── _send_stdin ────────────────────────────────────────────────────── class TestSendStdin: @patch("python_pkg.repo_explorer._execution.os.write") def test_writes_to_master_fd(self, mock_write: MagicMock) -> None: obj = StubExecution() obj._master_fd = 10 obj._stdin_var.get.return_value = "hello" obj._send_stdin() mock_write.assert_called_once_with(10, b"hello\n") obj._stdin_var.set.assert_called_once_with("") def test_no_master_fd(self) -> None: obj = StubExecution() obj._master_fd = None obj._stdin_var.get.return_value = "hello" obj._send_stdin() obj._stdin_var.set.assert_called_once_with("") @patch("python_pkg.repo_explorer._execution.os.write") def test_oserror_suppressed(self, mock_write: MagicMock) -> None: obj = StubExecution() obj._master_fd = 10 obj._stdin_var.get.return_value = "hello" mock_write.side_effect = OSError("write failed") obj._send_stdin() # should not raise def test_with_event_arg(self) -> None: obj = StubExecution() obj._master_fd = None obj._stdin_var.get.return_value = "test" obj._send_stdin(MagicMock()) obj._stdin_var.set.assert_called_once_with("") # ── _wait_proc ─────────────────────────────────────────────────────── class TestWaitProc: def test_waits_and_calls_after(self) -> None: obj = StubExecution() proc = MagicMock() proc.wait.return_value = 0 obj._proc = proc obj._wait_proc() proc.wait.assert_called_once() assert len(obj._after_calls) == 1 def test_proc_none(self) -> None: obj = StubExecution() obj._proc = None obj._wait_proc() assert obj._after_calls == [] # ── _on_proc_done ────────────────────────────────────────────────────