215 lines
6.2 KiB
Python
215 lines
6.2 KiB
Python
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()
|