from __future__ import annotations import queue import subprocess import unittest from unittest.mock import MagicMock from ptlinux.ui.controllers.operation import start_operation_process from ptlinux.ui.viewmodels.downloader import ProgressCoalescer class _InlineThread: def __init__(self, *, target, name: str, daemon: bool) -> None: self._target = target self.name = name self.daemon = daemon def start(self) -> None: self._target() class _Process: def __init__(self, lines: list[str], returncode: int = 0, pid: int = 4321) -> None: self.stdout = iter(lines) self.returncode = returncode self.pid = pid def wait(self) -> int: return self.returncode def _drain(events: queue.Queue[tuple[str, object]]) -> list[tuple[str, object]]: values: list[tuple[str, object]] = [] while not events.empty(): values.append(events.get_nowait()) return values class DashboardOperationControllerTests(unittest.TestCase): def test_routes_status_progress_output_and_completion(self) -> None: events: queue.Queue[tuple[str, object]] = queue.Queue() progress = ProgressCoalescer() registry = MagicMock() process = _Process( [ 'STATUS {"step": 1, "total": 3, "message": "Starting"}\n', 'PROGRESS {"phase": "downloading", "bytes_completed": 8}\n', "plain output\n", "STATUS invalid\n", ], returncode=7, ) seen_process_args: list[tuple[list[str], dict[str, object]]] = [] def process_factory(command: list[str], **kwargs: object) -> _Process: seen_process_args.append((command, kwargs)) return process start_operation_process( ["launcher", "update"], events=events, progress_updates=progress, game_launch_supervisors=registry, child_environment=lambda: {"ROLE": "operation"}, interrupt_supervisor=MagicMock(), process_factory=process_factory, thread_factory=_InlineThread, ) self.assertEqual( _drain(events), [ ( "operation_status", {"step": 1, "total": 3, "message": "Starting"}, ), ("output", "plain output\n"), ("output", "STATUS invalid\n"), ("done", 7), ], ) self.assertEqual( progress.take(), {"phase": "downloading", "bytes_completed": 8}, ) self.assertEqual(seen_process_args[0][0], ["launcher", "update"]) self.assertEqual(seen_process_args[0][1]["env"], {"ROLE": "operation"}) self.assertEqual(seen_process_args[0][1]["stdout"], subprocess.PIPE) self.assertEqual(seen_process_args[0][1]["stderr"], subprocess.STDOUT) def test_routes_game_readiness_and_cancellation_race(self) -> None: events: queue.Queue[tuple[str, object]] = queue.Queue() registry = MagicMock() registry.is_cancel_requested.return_value = False registry.attach.return_value = True process = _Process( [ "Game window created; preparing it\n", "Game window ready: PristonTale EU\n", ], ) interrupt = MagicMock(return_value=True) start_operation_process( ["launcher", "play"], events=events, progress_updates=ProgressCoalescer(), game_launch_supervisors=registry, child_environment=dict, interrupt_supervisor=interrupt, game_client_id=3, process_factory=lambda *_args, **_kwargs: process, thread_factory=_InlineThread, ) kinds = [kind for kind, _value in _drain(events)] self.assertEqual( kinds, [ "game_supervisor_started", "output", "output", "game_operation_status", "game_started", "output", "game_window_ready", "game_done", ], ) registry.attach.assert_called_once_with(3, process) registry.detach.assert_called_once_with(3, process) interrupt.assert_called_once_with(process) def test_unmonitored_process_releases_controls_without_a_window_ready_event(self) -> None: for line in ( "Game window monitoring unavailable; process started: PristonTale EU\n", "Game window ready: unmanaged PristonTale EU\n", ): with self.subTest(line=line): events: queue.Queue[tuple[str, object]] = queue.Queue() registry = MagicMock() registry.is_cancel_requested.return_value = False registry.attach.return_value = False process = _Process([line], returncode=1) start_operation_process( ["launcher", "play"], events=events, progress_updates=ProgressCoalescer(), game_launch_supervisors=registry, child_environment=dict, interrupt_supervisor=MagicMock(), game_client_id=3, process_factory=lambda *_args, **_kwargs: process, thread_factory=_InlineThread, ) result = _drain(events) self.assertIn(("game_window_unmonitored", 3), result) self.assertNotIn(("game_window_ready", 3), result) self.assertEqual(result[-1], ("game_done", (3, 1))) def test_cancellation_before_process_start_skips_the_child(self) -> None: events: queue.Queue[tuple[str, object]] = queue.Queue() registry = MagicMock() registry.is_cancel_requested.return_value = True process_factory = MagicMock() start_operation_process( ["launcher", "play"], events=events, progress_updates=ProgressCoalescer(), game_launch_supervisors=registry, child_environment=dict, interrupt_supervisor=MagicMock(), game_client_id=4, process_factory=process_factory, thread_factory=_InlineThread, ) self.assertEqual( _drain(events), [ ( "output", "[Client 4] Launch cancelled before the game supervisor started.\n", ), ("game_done", (4, 130)), ], ) process_factory.assert_not_called() def test_process_start_failure_uses_the_matching_completion_event(self) -> None: for client_id, done_event in ((None, ("done", 1)), (5, ("game_done", (5, 1)))): with self.subTest(client_id=client_id): events: queue.Queue[tuple[str, object]] = queue.Queue() registry = MagicMock() registry.is_cancel_requested.return_value = False def fail_to_start(*_args: object, **_kwargs: object) -> _Process: raise OSError("missing executable") start_operation_process( ["missing"], events=events, progress_updates=ProgressCoalescer(), game_launch_supervisors=registry, child_environment=dict, interrupt_supervisor=MagicMock(), game_client_id=client_id, process_factory=fail_to_start, thread_factory=_InlineThread, ) emitted = _drain(events) self.assertIn(done_event, emitted) self.assertIn("Could not start operation", str(emitted[0][1])) if __name__ == "__main__": unittest.main()