Release 1.1.3 with game window and Gamescope lifecycle fixes
This commit is contained in:
214
tests/test_dashboard_operation_controller.py
Normal file
214
tests/test_dashboard_operation_controller.py
Normal file
@@ -0,0 +1,214 @@
|
||||
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()
|
||||
Reference in New Issue
Block a user