Files
pristontale-eu-linux/tests/test_game_launch_compatibility.py

595 lines
27 KiB
Python

from dataclasses import asdict, replace
import ctypes
import json
from pathlib import Path
import subprocess
import tempfile
import unittest
from unittest.mock import MagicMock, patch
from ptlinux.application.commands.play import (
ensure_portable_umu_for_play,
play_locked,
runtime_issue_summary,
)
from ptlinux.cli import _load_or_default_settings
from ptlinux.depot import DepotError, DepotLaunchCatalog, DepotStartOption
from ptlinux.game.arguments import split_windows_arguments
from ptlinux.game.depot.catalogs import select_depot_start_option
from ptlinux.game.depot.manifest import parse_start_options
from ptlinux.game.runtime import (
RuntimeErrorPT,
RuntimeSelection,
game_runtime_ready,
prepare_game_runtime,
run_game,
run_runtime_prefix_command,
runtime_settings_for_realm,
select_runtime,
)
from ptlinux.game.runtime_view import (
KNOWN_SOURCE_SHA256,
OverlayError,
_scene_action,
prepare_runtime_view,
select_scene_action,
)
from ptlinux.paths import AppPaths
from ptlinux.presentation import game_window
from ptlinux.settings import (
Settings,
ensure_settings,
load_settings,
reset_settings,
save_settings,
update_settings,
)
from ptlinux.telemetry.runtime import _selected_prefix, _wine_command
class WindowsArgumentTests(unittest.TestCase):
def test_preserves_windows_paths_quotes_empty_arguments_and_shell_literals(self) -> None:
arguments = (
"PTEU_REBORN", r"C:\game\data", "Babel's Realm", "", "a b",
'a"b', "trailing\\", r"$(touch /tmp/should-not-exist)", "a;b", "%TEMP%",
)
self.assertEqual(split_windows_arguments(subprocess.list2cmdline(arguments)), arguments)
def test_windows_quoting_examples(self) -> None:
for command, expected in (
('"a b c" d e', ("a b c", "d", "e")),
(r'a\\\b d"e f"g h', (r"a\\\b", "de fg", "h")),
(r'a\\\"b c d', ('a\\"b', "c", "d")),
(r'a\\\\"b c" d e', (r"a\\b c", "d", "e")),
('a"b"" c d', ('ab" c d',)),
("Babel's Realm", ("Babel's", "Realm")),
(" \t ", ()),
):
with self.subTest(command=command):
self.assertEqual(split_windows_arguments(command), expected)
def test_manifest_allows_no_arguments_and_keeps_publisher_default(self) -> None:
options, default = parse_start_options(
[
{"Name": "Babel", "Path": "Game.exe", "Arguments": ""},
{"Name": "New Realm", "Path": "New.exe"},
],
"New Realm",
)
self.assertEqual(options[0].arguments, "")
self.assertEqual(options[1].arguments, "")
catalog = DepotLaunchCatalog(10, options, default)
self.assertEqual(select_depot_start_option(catalog, "Removed Realm"), options[1])
class RuntimeDefaultMigrationTests(unittest.TestCase):
def setUp(self) -> None:
temporary = tempfile.TemporaryDirectory()
self.addCleanup(temporary.cleanup)
self.root = Path(temporary.name)
self.paths = AppPaths(*(self.root / name for name in ("data", "config", "state", "cache")))
self.paths.config.mkdir(parents=True)
def write_settings(self, value: dict[str, object]) -> None:
self.paths.settings_file.write_text(json.dumps(value), encoding="utf-8")
def test_new_and_reset_installations_use_ge_proton(self) -> None:
self.assertEqual(Settings().runtime, "ge-proton")
self.assertEqual(load_settings(self.paths).runtime, "ge-proton")
self.assertFalse(self.paths.settings_file.exists())
self.assertEqual(ensure_settings(self.paths).runtime, "ge-proton")
save_settings(self.paths, Settings(runtime="proton7"))
self.assertEqual(reset_settings(self.paths).runtime, "ge-proton")
self.assertEqual(ensure_settings(self.paths).runtime, "ge-proton")
def test_all_old_profiles_migrate_without_changing_other_preferences(self) -> None:
for runtime in ("auto", "proton7", "proton7-container", "umu", "ge-proton"):
with self.subTest(runtime=runtime):
old_settings = Settings(
runtime=runtime,
proton_path="/Steam Library/Proton 7.0/proton",
renderer="wined3d",
proton_sync="esync",
telemetry_enabled=False,
game_crash_dump_upload_enabled=False,
game_source="/Games/Publisher",
ui_scale="150",
borderless=True,
allow_window_resizing=True,
suppress_unresponsive_warnings=False,
download_speed_limit_mbps=4.0,
low_bandwidth_mode=True,
update_channel="prerelease",
)
self.write_settings({
**asdict(old_settings), "schema": 1,
"title_frame_default_migrated": True,
"future_setting": {"keep": True},
})
before = self.paths.settings_file.read_bytes()
expected = replace(old_settings, runtime="ge-proton")
self.assertEqual(load_settings(self.paths), expected)
self.assertEqual(self.paths.settings_file.read_bytes(), before)
self.assertEqual(ensure_settings(self.paths), expected)
payload = json.loads(self.paths.settings_file.read_text())
self.assertEqual(payload["runtime"], "ge-proton")
self.assertIs(payload["ge_proton_default_migrated"], True)
self.assertEqual(payload["future_setting"], {"keep": True})
self.assertEqual(payload["schema"], 1)
self.assertEqual(self.paths.settings_file.stat().st_mode & 0o777, 0o600)
def test_migration_is_persisted_once_and_later_manual_choices_survive(self) -> None:
self.write_settings({"runtime": "proton7", "ge_proton_default_migrated": False})
self.assertEqual(ensure_settings(self.paths).runtime, "ge-proton")
before = self.paths.settings_file.stat()
self.assertEqual(ensure_settings(self.paths).runtime, "ge-proton")
after = self.paths.settings_file.stat()
self.assertEqual((after.st_ino, after.st_mtime_ns), (before.st_ino, before.st_mtime_ns))
for runtime in ("auto", "proton7", "proton7-container", "umu"):
with self.subTest(runtime=runtime):
update_settings(self.paths, runtime=runtime)
update_settings(self.paths, proton_sync="esync")
self.assertEqual(ensure_settings(self.paths).runtime, runtime)
self.assertEqual(load_settings(self.paths).runtime, runtime)
def test_unrelated_update_migrates_the_runtime(self) -> None:
self.write_settings({"runtime": "umu"})
settings = update_settings(self.paths, low_bandwidth_mode=True)
self.assertEqual(settings.runtime, "ge-proton")
self.assertTrue(settings.low_bandwidth_mode)
self.assertEqual(ensure_settings(self.paths), settings)
def test_explicit_runtime_choice_during_first_save_is_preserved(self) -> None:
for full_save in (False, True):
with self.subTest(full_save=full_save):
self.write_settings({"runtime": "auto"})
if full_save:
save_settings(self.paths, Settings(runtime="umu"))
else:
update_settings(self.paths, runtime="umu")
self.assertEqual(ensure_settings(self.paths).runtime, "umu")
def test_migration_keeps_the_ptr_override_and_ignores_its_path_for_babel(self) -> None:
proton = self.root / "Steam/steamapps/common/Proton 7.0/proton"
proton.parent.mkdir(parents=True)
proton.touch()
for realm in ("PTR Babel", "Test Babel"):
with self.subTest(realm=realm):
self.write_settings({"runtime": "proton7", "realm": realm, "proton_path": str(proton)})
settings = ensure_settings(self.paths)
self.assertEqual(settings.runtime, "ge-proton")
self.assertEqual(settings.realm, realm)
self.assertEqual(runtime_settings_for_realm(settings).runtime, "proton7")
with patch("ptlinux.game.runtime.find_umu_launcher", return_value="/usr/bin/umu-run"):
ptr = select_runtime(settings, {"HOME": str(self.root)})
babel = select_runtime(replace(settings, realm="Babel"), {})
self.assertEqual(ptr.kind, "proton7")
self.assertEqual(ptr.proton_path, str(proton))
self.assertEqual(babel.kind, "ge-proton")
self.assertIsNone(babel.proton_path)
def test_invalid_runtime_and_marker_are_rejected_without_rewriting(self) -> None:
for value in (
{"runtime": "unsupported"}, {"runtime": None}, {"runtime": True},
{"ge_proton_default_migrated": "true"}, {"ge_proton_default_migrated": 1},
):
with self.subTest(value=value):
self.write_settings(value)
before = self.paths.settings_file.read_bytes()
with self.assertRaises(ValueError):
ensure_settings(self.paths)
self.assertEqual(self.paths.settings_file.read_bytes(), before)
def test_default_cannot_silently_fall_back_to_legacy_proton(self) -> None:
with (
patch("ptlinux.game.runtime.find_umu_launcher", return_value=None),
patch("ptlinux.game.runtime.find_steam_proton7") as find_legacy,
):
with self.assertRaisesRegex(RuntimeErrorPT, "umu-run was not found"):
select_runtime(Settings(), {})
find_legacy.assert_not_called()
def test_default_bootstrap_error_does_not_fall_back_to_proton7(self) -> None:
api = MagicMock()
api.provision_umu.side_effect = RuntimeError("network unavailable")
with self.assertRaisesRegex(RuntimeError, "portable UMU.*network unavailable"):
ensure_portable_umu_for_play(api, self.paths, Settings(), dry_run=False)
api.select_runtime.assert_not_called()
def test_cli_migrates_existing_settings_and_dry_runs_remain_read_only(self) -> None:
self.write_settings({"runtime": "proton7"})
before = self.paths.settings_file.read_bytes()
self.assertEqual(_load_or_default_settings(self.paths, persist=False).runtime, "ge-proton")
self.assertEqual(self.paths.settings_file.read_bytes(), before)
self.assertEqual(_load_or_default_settings(self.paths, persist=True).runtime, "ge-proton")
payload = json.loads(self.paths.settings_file.read_text())
self.assertEqual(payload["runtime"], "ge-proton")
self.assertIs(payload["ge_proton_default_migrated"], True)
class LaunchCompatibilityTests(unittest.TestCase):
def setUp(self) -> None:
self.temporary = tempfile.TemporaryDirectory()
self.addCleanup(self.temporary.cleanup)
self.root = Path(self.temporary.name)
self.paths = AppPaths(*(self.root / name for name in ("data", "config", "state", "cache")))
self.paths.runtime_view.mkdir(parents=True)
(self.paths.runtime_view / "Game.exe").touch()
self.runner = MagicMock(dry_run=False)
self.selection = RuntimeSelection("ge-proton", "/usr/bin/umu-run", None, None)
self.host_check = patch("ptlinux.game.runtime._require_container_host")
self.host_check.start()
self.addCleanup(self.host_check.stop)
def test_recovers_legacy_renderer_option_and_persists_migration(self) -> None:
self.paths.config.mkdir(parents=True)
self.paths.settings_file.write_text(json.dumps({"proton_path": "PROTON_USE_WINED3D=1"}))
settings = ensure_settings(self.paths)
self.assertIsNone(settings.proton_path)
self.assertEqual(settings.renderer, "wined3d")
self.assertEqual(load_settings(self.paths), settings)
self.assertIsNone(json.loads(self.paths.settings_file.read_text())["proton_path"])
with patch("ptlinux.game.runtime.find_umu_launcher", return_value="/usr/bin/umu-run"):
selection = select_runtime(settings, {})
run_game(self.paths, settings, selection, self.runner, use_gamebox=False)
environment = self.runner.run.call_args.kwargs["env_overrides"]
self.assertEqual(environment["PROTON_USE_WINED3D"], "1")
self.assertIn("d3d9=b", environment["WINEDLLOVERRIDES"])
def test_does_not_execute_or_migrate_arbitrary_path_commands(self) -> None:
for value in ("PROTON_LOG=1", "env PROTON_USE_WINED3D=1 %command%"):
with self.subTest(value=value):
settings = Settings(runtime="auto", proton_path=value)
with self.assertRaisesRegex(ValueError, "filesystem path"):
save_settings(self.paths, settings)
with self.assertRaisesRegex(RuntimeErrorPT, "filesystem path"):
select_runtime(settings, {})
def test_existing_explicit_renderer_wins_over_misplaced_legacy_option(self) -> None:
self.paths.config.mkdir(parents=True)
self.paths.settings_file.write_text(json.dumps({
"proton_path": "PROTON_USE_WINED3D=1", "renderer": "dxvk",
}))
self.assertEqual(ensure_settings(self.paths).renderer, "dxvk")
def test_proton_script_field_accepts_a_steam_proton_directory_with_spaces(self) -> None:
proton = self.root / "Steam/steamapps/common/Proton 7.0/proton"
proton.parent.mkdir(parents=True)
proton.touch()
selection = select_runtime(Settings(runtime="proton7", proton_path=str(proton.parent)), {})
self.assertEqual(selection.proton_path, str(proton))
def test_auto_selects_ge_across_distributions_and_keeps_explicit_umu(self) -> None:
with patch("ptlinux.game.runtime.find_umu_launcher", return_value="/usr/bin/umu-run"):
for runtime in ("auto", "ge-proton"):
for distribution in ("ubuntu", "cachyos", "arch", "fedora"):
with self.subTest(runtime=runtime, distribution=distribution):
self.assertEqual(select_runtime(Settings(runtime=runtime), {}, distribution_id=distribution).kind, "ge-proton")
self.assertEqual(select_runtime(Settings(runtime="umu"), {}).kind, "umu")
def test_future_publisher_scene_is_copied_unchanged_without_old_texture_repair(self) -> None:
source = self.paths.source_game
for relative, content in (
("Game.exe", b"new executable"),
("game.dll", b"new dll"),
("game.ini", b"[Screen]\nWindowed=1\n"),
("game/maps/WhisperingVale.rovug", b"future publisher scene format"),
):
path = source / relative
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(content)
with patch("ptlinux.game.runtime_view.normalize_wrapped_assbin_textures") as repair:
result = prepare_runtime_view(self.paths, source)
self.assertEqual(result.scene_action, "upstream-unmodified-copy")
self.assertEqual(result.binary_action, "upstream-pair")
self.assertEqual(result.source_scene_sha256, result.runtime_scene_sha256)
self.assertEqual((source / "game/maps/WhisperingVale.rovug").read_bytes(), b"future publisher scene format")
repair.assert_not_called()
def test_unknown_scene_texture_count_does_not_trigger_legacy_patch(self) -> None:
self.assertEqual(_scene_action(MagicMock(file_sha256="new", embedded_textures=())), "upstream-unmodified-copy")
self.assertEqual(select_scene_action("new", [16, 16], [2, 2]), "upstream-unmodified-copy")
self.assertEqual(select_scene_action(KNOWN_SOURCE_SHA256, [16, 16], [2, 2]), "normalize-known-profile")
with self.assertRaises(OverlayError):
select_scene_action(KNOWN_SOURCE_SHA256, [16, 8], [2, 2])
def test_ge_setup_game_and_helpers_use_one_isolated_wow64_prefix(self) -> None:
self.runner.dry_run = True
prepare_game_runtime(self.paths, self.selection, self.runner)
run_game(self.paths, Settings(), self.selection, self.runner, use_gamebox=False)
run_runtime_prefix_command(self.paths, self.selection, self.runner, ["reg.exe", "query"])
self.assertEqual(self.runner.run.call_count, 3)
for call in self.runner.run.call_args_list:
environment = call.kwargs["env_overrides"]
self.assertEqual(environment["PROTONPATH"], "GE-Proton")
self.assertEqual(environment["PROTON_USE_WOW64"], "1")
self.assertEqual(environment["WINEPREFIX"], str(self.paths.ge_proton_prefix))
self.assertNotEqual(self.paths.ge_proton_prefix, self.paths.umu_prefix)
self.assertNotEqual(self.paths.ge_proton_prefix, self.paths.proton_prefix)
def test_failed_directx_postcondition_does_not_mark_runtime_ready(self) -> None:
with self.assertRaisesRegex(RuntimeErrorPT, "DirectX setup completed without"):
prepare_game_runtime(self.paths, self.selection, self.runner)
self.assertFalse(self.paths.game_runtime_marker.exists())
self.assertFalse(game_runtime_ready(self.paths, self.selection))
def test_shared_clients_cannot_reuse_a_prefix_for_another_runtime(self) -> None:
self.paths.state.mkdir(parents=True)
for prefix in (self.paths.umu_prefix, self.paths.ge_proton_prefix):
directory = prefix / "drive_c/windows/syswow64"
directory.mkdir(parents=True)
for name in ("d3dx9_43", "d3dcompiler_43"):
(directory / f"{name}.dll").touch()
(prefix / "user.reg").write_text('"*d3dx9_43"="native"\n"*d3dcompiler_43"="native"\n')
self.paths.game_runtime_marker.write_text(json.dumps({
"configuration_version": 1, "runtime": asdict(self.selection),
}))
self.assertTrue(game_runtime_ready(self.paths, self.selection))
self.assertFalse(game_runtime_ready(self.paths, RuntimeSelection("umu", "/usr/bin/umu-run", None, None)))
def test_shared_ge_clients_keep_same_environment_except_for_verb(self) -> None:
for concurrent in (False, True):
run_game(
self.paths, Settings(), self.selection, self.runner,
use_gamebox=False, concurrent_prefix_launch=concurrent,
)
first, second = [dict(call.kwargs["env_overrides"]) for call in self.runner.run.call_args_list]
self.assertEqual(first.pop("PROTON_VERB"), "waitforexitandrun")
self.assertEqual(second.pop("PROTON_VERB"), "runinprefix")
self.assertEqual(first, second)
def test_renderer_is_environment_and_native_helpers_preserve_unrelated_overrides(self) -> None:
for kind in ("umu", "ge-proton", "proton7", "proton7-container"):
for renderer, value, override in (("dxvk", "0", "d3d9=n,b"), ("wined3d", "1", "d3d9=b")):
with self.subTest(kind=kind, renderer=renderer):
selection = RuntimeSelection(kind, "/usr/bin/umu-run", "/Steam/proton", "/Steam")
run_game(
self.paths, Settings(renderer=renderer), selection, self.runner,
use_gamebox=False,
extra_environment={"WINEDLLOVERRIDES": "*d3dx9_43,foo=b;d3dcompiler_43=;d3d9=b"},
)
environment = self.runner.run.call_args.kwargs["env_overrides"]
self.assertEqual(environment["PROTON_USE_WINED3D"], value)
self.assertEqual(environment["WINEDLLOVERRIDES"],
f"foo=b;d3dx9_43=n,b;d3dcompiler_43=n,b;{override}")
self.assertNotIn("PROTON_USE_WINED3D", " ".join(self.runner.run.call_args.args[0]))
def test_windows_arguments_are_passed_in_order_without_shell_expansion(self) -> None:
folder = self.paths.runtime_view / "New Client"
folder.mkdir()
(folder / "CLIENT.exe").touch()
arguments = ("NEW_TOKEN", r"C:\data\assets", "", "a b", "$(echo literal)")
option = DepotStartOption("New Realm", "New Client/client.exe", subprocess.list2cmdline(arguments))
run_game(self.paths, Settings(), self.selection, self.runner, start_option=option, use_gamebox=False)
call = self.runner.run.call_args
self.assertEqual(call.args[0], ["/usr/bin/umu-run", str(folder / "CLIENT.exe"), *arguments])
self.assertEqual(call.kwargs["cwd"], self.paths.runtime_view)
def test_missing_advertised_executable_fails_before_runtime_start(self) -> None:
with self.assertRaisesRegex(RuntimeErrorPT, "missing New.exe"):
run_game(self.paths, Settings(), self.selection, self.runner,
start_option=DepotStartOption("New", "New.exe", ""), use_gamebox=False)
self.runner.run.assert_not_called()
def test_build_mismatch_blocks_play_before_game_view_or_runtime_changes(self) -> None:
self.paths.state.mkdir(parents=True)
self.paths.native_depot_state.write_text(json.dumps({"build": 100, "dry_run": False}))
api = MagicMock()
api.current_telemetry.return_value = None
api.game_import_requires_verification.return_value = False
api._load_or_default_settings.return_value = Settings()
api.is_running_inside_steam_runtime.return_value = False
for build in (99, 101, None):
api.read_cached_depot_launch_catalog.return_value = (
DepotLaunchCatalog(build, (DepotStartOption("Babel", "Game.exe", "NEW"),), "Babel")
if build is not None else None
)
with self.subTest(build=build), self.assertRaisesRegex(DepotError, "update.*before playing"):
play_locked(api, self.paths, None, False, False, False, False)
api.prepare_runtime_view.assert_not_called()
api.prepare_game_runtime.assert_not_called()
api.run_game.assert_not_called()
def test_proton_telemetry_does_not_report_host_wine_as_bundled_wine(self) -> None:
with patch("ptlinux.telemetry.runtime.shutil.which", return_value="/usr/bin/wine") as which:
for kind in ("umu", "ge-proton"):
self.assertIsNone(_wine_command({"kind": kind, "executable": "/usr/bin/umu-run"}, {}))
which.assert_not_called()
(self.paths.ge_proton_prefix / "drive_c/windows").mkdir(parents=True)
self.assertEqual(_selected_prefix(self.paths, {"runtime": asdict(self.selection)}), self.paths.ge_proton_prefix)
def test_gstreamer_negotiation_errors_are_visible_without_claiming_crash_cause(self) -> None:
report = runtime_issue_summary(
"GStreamer-Video-CRITICAL: gst_video_info_from_caps: assertion 'gst_caps_is_fixed (caps)' failed\n"
"GStreamer-Audio-CRITICAL: gst_audio_info_from_caps: assertion 'gst_caps_is_fixed (caps)' failed\n"
)
self.assertEqual(report["gstreamer_caps_error_count"], 2)
self.assertFalse(report["gstreamer_stream_error"])
class GameKeyboardFocusTests(unittest.TestCase):
def test_startup_hands_keyboard_focus_to_the_desktop_game_window(self) -> None:
# Model a WM that rejects raw X11 focus changes to an inactive client.
mapper = MagicMock(spec=game_window._X11)
mapper.window_state.return_value = 1
active, keyboard = [7], [7]
pending: list[int] = []
def activate(window: int) -> bool:
pending.append(window)
return True
def settle(_seconds: float) -> None:
if pending:
active[0] = pending.pop()
def focus(window: int) -> None:
if active[0] == window:
keyboard[0] = window
mapper.activate_window.side_effect = activate
mapper.window_is_active.side_effect = lambda window: active[0] == window
mapper.window_is_focused.side_effect = lambda window: keyboard[0] == window
mapper.restore_input_focus.side_effect = focus
with patch.object(game_window.time, "sleep", side_effect=settle):
game_window._focus_game_window(mapper, 12)
self.assertEqual((active[0], keyboard[0]), (12, 12))
def test_activation_preserves_minimize_before_and_during_handoff(self) -> None:
for states, requests in (([3], 0), ([1, 3], 1)):
with self.subTest(states=states):
mapper = MagicMock(spec=game_window._X11)
mapper.window_state.side_effect = states
mapper.window_is_active.return_value = False
with patch.object(game_window.time, "sleep"):
game_window._focus_game_window(mapper, 12)
self.assertEqual(mapper.activate_window.call_count, requests)
mapper.focus_window.assert_not_called()
def test_nested_gamescope_focus_does_not_request_desktop_activation(self) -> None:
mapper = MagicMock(spec=game_window._X11)
mapper.window_state.return_value = 1
mapper.window_is_focused.return_value = False
with patch.object(game_window.time, "sleep"):
game_window._focus_game_window(mapper, 12, activate=False)
mapper.activate_window.assert_not_called()
mapper.restore_input_focus.assert_called_once_with(12)
mapper.focus_window.assert_not_called()
def test_activation_uses_only_the_games_own_user_time(self) -> None:
for properties, expected in (
({}, 0),
({(12, b"_NET_WM_USER_TIME"): (12345,)}, 12345),
({(12, b"_NET_WM_USER_TIME_WINDOW"): (13,),
(13, b"_NET_WM_USER_TIME"): (45678,)}, 45678),
({(12, b"_NET_WM_USER_TIME_WINDOW"): (13,),
(12, b"_NET_WM_USER_TIME"): (56789,)}, 56789),
):
with self.subTest(properties=properties):
mapper: game_window._X11 = object.__new__(game_window._X11)
with (
patch.object(mapper, "_intern_atom", return_value=77),
patch.object(
mapper, "_long_property",
side_effect=lambda window, name, _count: properties.get((window, name)),
),
patch.object(mapper, "_send_root_client_message", return_value=True) as send,
):
self.assertTrue(mapper.activate_window(12))
send.assert_called_once_with(12, 77, (1, expected, 0, 0, 0))
def test_focus_diagnostics_distinguish_a_request_from_actual_activation(self) -> None:
mapper = MagicMock(spec=game_window._X11)
mapper.window_state.return_value = 1
mapper.activate_window.return_value = True
mapper.window_is_active.return_value = False
mapper.window_diagnostic_snapshot.return_value = {"active": False, "focused": False}
diagnostics = MagicMock()
with (
patch.object(game_window.time, "sleep"),
patch.object(game_window, "WINDOW_STATE_CONFIRM_TIMEOUT_SECONDS", 0.0),
):
game_window._focus_game_window(mapper, 12, diagnostics=diagnostics)
diagnostics.record.assert_called_once_with(
"focus_handoff", window_id=12, activation_requested=True,
activation_submitted=True, outcome="not_activated", active=False, focused=False,
)
def test_focus_repair_respects_background_and_minimized_windows(self) -> None:
for state, active in ((1, False), (3, True)):
with self.subTest(state=state, active=active):
mapper = MagicMock(spec=game_window._X11)
mapper.window_state.return_value = state
mapper.window_is_active.return_value = active
mapper.window_is_focused.return_value = False
self.assertFalse(game_window._repair_game_window_focus(mapper, 12))
mapper.restore_input_focus.assert_not_called()
mapper.activate_window.assert_not_called()
mapper.focus_window.assert_not_called()
def test_borderless_launch_keeps_focus_repair_without_live_resizing(self) -> None:
mapper = MagicMock(spec=game_window._X11)
mapper.root_window_ids.return_value = set()
mapper.find_first_window.return_value = (12, "PristonTale EU V23.86.96")
process = MagicMock()
process.poll.return_value = None
with (
patch.object(game_window, "_X11", return_value=mapper),
patch.object(game_window.subprocess, "Popen", return_value=process),
patch.object(game_window, "WINDOW_READY_DELAY_SECONDS", 0),
patch.object(game_window, "_prepare_game_window"),
patch.object(game_window, "_focus_game_window"),
patch.object(game_window, "_guard_game_window_startup", return_value=True),
patch.object(game_window, "_maintain_game_window_scaling") as resize,
patch.object(game_window, "_maintain_game_window_focus", return_value=17) as maintain,
):
self.assertEqual(game_window.run_game_window(["unused"], borderless=True), 17)
maintain.assert_called_once_with(
mapper, process, 12, window_title=game_window.WINDOW_TITLE,
existing_window_ids=set(), expected_pid=mapper.window_pid.return_value,
)
resize.assert_not_called()
mapper.close.assert_called_once_with()
def test_child_input_focus_is_preserved_and_foreign_focus_is_rejected(self) -> None:
for focused, parents, expected in (
(12, {}, True),
(14, {14: 13, 13: 12}, True),
(24, {24: 23, 23: 1000, 1000: 0}, False),
(24, {}, False), # The focused window vanished during the read.
(24, {24: 25, 25: 24}, False),
(0, {}, False),
(1, {}, False),
):
with self.subTest(focused=focused, parents=parents):
mapper: game_window._X11 = object.__new__(game_window._X11)
mapper.display = ctypes.c_void_p(1)
mapper.library = MagicMock()
allocations: list[object] = []
def read_focus(_display, pointer, _revert):
pointer._obj.value = focused
return 1
def query_tree(_display, window, root, parent, children, count):
if window not in parents:
return 0
root._obj.value = 1000
parent._obj.value = parents[window]
allocation = (ctypes.c_ulong * 1)(99)
allocations.append(allocation)
ctypes.cast(children, ctypes.POINTER(ctypes.POINTER(ctypes.c_ulong)))[0] = allocation
count._obj.value = 1
return 1
mapper.library.XGetInputFocus.side_effect = read_focus
mapper.library.XQueryTree.side_effect = query_tree
with (
patch.object(mapper, "window_state", return_value=1),
patch.object(mapper, "window_is_active", return_value=True),
patch.object(mapper, "restore_input_focus") as restore,
):
self.assertEqual(game_window._repair_game_window_focus(mapper, 12), expected)
self.assertEqual(restore.call_count, 0 if expected else 1)
self.assertEqual(mapper.library.XFree.call_count, len(allocations))