testsAndMisc/python_pkg/repo_explorer/tests/test_execution.py

501 lines
17 KiB
Python
Raw Normal View History

"""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 ────────────────────────────────────────────────────