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

515 lines
23 KiB
Python

"""Focus ownership and supervisor failure-path regressions (no live desktop)."""
from contextlib import ExitStack
import ctypes
from pathlib import Path
import tempfile
import unittest
from unittest.mock import MagicMock, patch
from ptlinux.presentation import game_window as gw
class Clock:
def __init__(self) -> None:
self.now = 0.0
def sleep(self, seconds: float) -> None:
self.now += seconds
class WindowFocusRegressions(unittest.TestCase):
def setUp(self) -> None:
self.context = self.enterContext(ExitStack())
self.clock = Clock()
self.context.enter_context(patch.object(gw.time, "sleep", self.clock.sleep))
self.context.enter_context(patch.object(gw.time, "monotonic", lambda: self.clock.now))
self.mapper = MagicMock(spec=gw._X11)
self.mapper.window_state.return_value = 1
self.mapper.window_is_active.return_value = True
self.mapper.window_is_focused.return_value = False
self.mapper.activate_window.return_value = True
def assert_no_input_change(self) -> None:
self.mapper.focus_window.assert_not_called()
self.mapper.restore_input_focus.assert_not_called()
def test_denied_desktop_activation_is_not_overridden_with_raw_focus(self) -> None:
self.mapper.window_is_active.return_value = False
gw._focus_game_window(self.mapper, 12)
self.assert_no_input_change()
self.mapper.activate_window.assert_called_once_with(12)
self.assertLessEqual(self.clock.now, 2.0)
def test_slow_desktop_activation_is_confirmed_before_input_repair(self) -> None:
self.mapper.window_is_active.side_effect = lambda _window: self.clock.now >= 0.6
self.mapper.window_is_focused.side_effect = [False, True]
gw._focus_game_window(self.mapper, 12)
self.assertGreaterEqual(self.clock.now, 0.6)
self.assertLessEqual(self.clock.now, 2.0)
self.mapper.restore_input_focus.assert_called_once_with(12)
self.mapper.focus_window.assert_not_called()
def test_already_focused_child_is_not_replaced_by_startup_handoff(self) -> None:
self.mapper.window_is_focused.return_value = True
gw._focus_game_window(self.mapper, 12)
self.assert_no_input_change()
self.mapper.activate_window.assert_not_called()
def test_alt_tab_during_focus_inspection_is_not_undone(self) -> None:
self.mapper.window_is_active.side_effect = [True, False]
gw._repair_game_window_focus(self.mapper, 12)
self.assert_no_input_change()
def test_unmapped_or_unknown_window_is_never_focused(self) -> None:
for state in (None, 0, 2, 3):
for repair in (gw._repair_game_window_focus, gw._repair_gamescope_game_window_focus):
with self.subTest(state=state, repair=repair.__name__):
self.mapper.reset_mock()
self.mapper.window_state.return_value = state
with patch.object(gw, "_gamescope_host_focus_is_allowed", return_value=True):
self.assertFalse(repair(self.mapper, 12))
self.assert_no_input_change()
def test_nested_handoff_rechecks_the_host_after_the_dashboard_delay(self) -> None:
with patch.object(gw, "_gamescope_host_focus_status", return_value={"allowed": False}):
gw._focus_game_window(self.mapper, 12, activate=False)
self.assert_no_input_change()
self.mapper.map_window.assert_not_called()
def test_nested_startup_maps_the_ge_client_before_repairing_input(self) -> None:
self.mapper.window_state.return_value = None
self.mapper.window_size.return_value = (1920, 1080)
mapped_at = None
def map_window(_window, *, borderless):
nonlocal mapped_at
self.assertFalse(borderless)
mapped_at = self.clock.now
self.mapper.map_window.side_effect = map_window
self.mapper.window_state.side_effect = lambda _window: (
1 if mapped_at is not None and self.clock.now - mapped_at >= 0.1 else None
)
with patch.object(gw, "_gamescope_host_focus_status", return_value={"allowed": True}):
gw._prepare_gamescope_startup_window(self.mapper, 12, set())
gw._repair_gamescope_game_window_focus(self.mapper, 12)
self.mapper.map_window.assert_called_once_with(12, borderless=False)
self.mapper.restore_input_focus.assert_called_once_with(12)
self.mapper.activate_window.assert_not_called()
self.mapper.force_windowed_state.assert_not_called()
self.mapper.refresh_render_surface.assert_not_called()
def test_nested_startup_does_not_remap_existing_or_destroyed_windows(self) -> None:
for state, size in ((1, (1920, 1080)), (3, (1920, 1080)), (0, (1920, 1080)), (None, None)):
with self.subTest(state=state, size=size):
self.mapper.reset_mock()
self.mapper.window_state.return_value = state
self.mapper.window_size.return_value = size
with patch.object(gw, "_gamescope_host_focus_status", return_value={"allowed": True}):
gw._prepare_gamescope_startup_window(self.mapper, 12, set())
self.mapper.map_window.assert_not_called()
self.mapper.activate_window.assert_not_called()
def test_nested_startup_does_not_map_after_host_focus_changes(self) -> None:
self.mapper.window_state.return_value = None
self.mapper.window_size.return_value = (1920, 1080)
with patch.object(gw, "_gamescope_host_focus_status", side_effect=[
{"allowed": True}, {"allowed": False}, {"allowed": False},
]):
gw._prepare_gamescope_startup_window(self.mapper, 12, set())
self.mapper.map_window.assert_not_called()
self.assert_no_input_change()
def test_nested_startup_mapping_wait_is_bounded_and_does_not_force_focus(self) -> None:
self.mapper.window_state.return_value = None
self.mapper.window_size.return_value = (1920, 1080)
with patch.object(gw, "_gamescope_host_focus_status", return_value={"allowed": True}):
gw._prepare_gamescope_startup_window(self.mapper, 12, set())
self.mapper.map_window.assert_called_once_with(12, borderless=False)
self.assertLessEqual(self.clock.now, 1.0)
self.assert_no_input_change()
def test_nested_repair_rechecks_host_focus_before_setting_input(self) -> None:
with patch.object(gw, "_gamescope_host_focus_status", side_effect=[
{"allowed": True}, {"allowed": False},
]):
gw._repair_gamescope_game_window_focus(self.mapper, 12)
self.assert_no_input_change()
def test_nested_mapping_is_deferred_until_host_focus_returns(self) -> None:
self.mapper.window_state.return_value = None
self.mapper.window_size.return_value = (1920, 1080)
self.mapper.find_first_window.return_value = None
mapped_at = []
def map_window(_window, *, borderless):
mapped_at.append(self.clock.now)
self.mapper.window_state.return_value = 1
self.mapper.map_window.side_effect = map_window
process = MagicMock()
process.poll.side_effect = lambda: 0 if self.clock.now >= 2 else None
process.wait.return_value = 0
with patch.object(gw, "_gamescope_host_focus_status", side_effect=lambda **_kw: {
"allowed": self.clock.now >= 1.0,
}):
gw._focus_game_window(self.mapper, 12, activate=False)
gw._maintain_gamescope_game_window_focus(self.mapper, process, 12)
self.assertEqual(len(mapped_at), 1)
self.assertGreaterEqual(mapped_at[0], 1.0)
self.mapper.activate_window.assert_not_called()
self.mapper.restore_input_focus.assert_called_with(12)
def test_nested_replacement_login_window_gets_its_own_startup_mapping(self) -> None:
mapped = set()
self.mapper.window_state.side_effect = lambda window: (
1 if window in mapped or (window == 12 and self.clock.now < 0.5) else None
)
self.mapper.window_size.side_effect = lambda window: (
(1920, 1080) if window == 21 else None
)
self.mapper.find_first_window.return_value = (21, "PristonTale EU Login")
self.mapper.map_window.side_effect = lambda window, **_kw: mapped.add(window)
process = MagicMock()
process.poll.side_effect = lambda: 0 if self.clock.now >= 2 else None
process.wait.return_value = 0
with patch.object(gw, "_gamescope_host_focus_status", return_value={"allowed": True}):
gw._maintain_gamescope_game_window_focus(self.mapper, process, 12)
self.mapper.map_window.assert_called_once_with(21, borderless=False)
self.mapper.restore_input_focus.assert_called_with(21)
def test_nested_monitor_does_not_keep_remapping_an_unacknowledged_client(self) -> None:
self.mapper.window_state.return_value = None
self.mapper.window_size.return_value = (1920, 1080)
self.mapper.find_first_window.return_value = None
process = MagicMock()
process.poll.side_effect = lambda: 0 if self.clock.now >= 3 else None
process.wait.return_value = 0
with patch.object(gw, "_gamescope_host_focus_status", return_value={"allowed": True}):
gw._maintain_gamescope_game_window_focus(self.mapper, process, 12)
self.mapper.map_window.assert_called_once_with(12, borderless=False)
self.assert_no_input_change()
def test_nested_mapping_stays_deferred_while_the_host_is_minimized(self) -> None:
self.mapper.window_state.return_value = None
self.mapper.window_size.return_value = (1920, 1080)
self.mapper.find_first_window.return_value = None
process = MagicMock()
process.poll.side_effect = lambda: 0 if self.clock.now >= 2 else None
process.wait.return_value = 0
with patch.object(gw, "_gamescope_host_focus_status", return_value={"allowed": False}):
gw._maintain_gamescope_game_window_focus(self.mapper, process, 12)
self.mapper.map_window.assert_not_called()
self.assert_no_input_change()
def test_nested_monitor_does_not_resurrect_a_previously_managed_client(self) -> None:
self.mapper.window_state.side_effect = lambda _window: 1 if self.clock.now < 0.5 else None
self.mapper.window_size.return_value = (1920, 1080)
self.mapper.find_first_window.return_value = None
self.mapper.window_is_focused.return_value = True
process = MagicMock()
process.poll.side_effect = lambda: 0 if self.clock.now >= 2 else None
process.wait.return_value = 0
with patch.object(gw, "_gamescope_host_focus_status", return_value={"allowed": True}):
gw._maintain_gamescope_game_window_focus(self.mapper, process, 12)
self.mapper.map_window.assert_not_called()
self.assert_no_input_change()
def test_nested_mapping_error_does_not_end_the_game_supervisor(self) -> None:
self.mapper.window_state.return_value = None
self.mapper.window_size.return_value = (1920, 1080)
self.mapper.find_first_window.return_value = None
self.mapper.map_window.side_effect = OSError("window disappeared")
process = MagicMock()
process.poll.side_effect = lambda: 0 if self.clock.now >= 2 else None
process.wait.return_value = 0
with patch.object(gw, "_gamescope_host_focus_status", return_value={"allowed": True}):
self.assertEqual(gw._maintain_gamescope_game_window_focus(self.mapper, process, 12), 0)
self.mapper.map_window.assert_called_once_with(12, borderless=False)
self.assert_no_input_change()
def test_nested_startup_rechecks_minimize_before_mapping(self) -> None:
self.mapper.window_state.side_effect = [None, 3]
self.mapper.window_size.return_value = (1920, 1080)
prepared_windows = set()
with patch.object(gw, "_gamescope_host_focus_status", return_value={"allowed": True}):
gw._prepare_gamescope_startup_window(self.mapper, 12, prepared_windows)
# Losing WM_STATE after minimization must not trigger startup mapping.
self.mapper.window_state.side_effect = None
self.mapper.window_state.return_value = None
gw._prepare_gamescope_startup_window(self.mapper, 12, prepared_windows)
self.mapper.map_window.assert_not_called()
self.assert_no_input_change()
def test_activation_errors_do_not_fall_back_to_forced_focus(self) -> None:
self.mapper.window_is_active.return_value = False
self.mapper.activate_window.side_effect = RuntimeError("WM disconnected")
gw._focus_game_window(self.mapper, 12)
self.assert_no_input_change()
def test_initial_preparation_respects_minimize_after_discovery(self) -> None:
self.mapper.root_window_ids.return_value = set()
self.mapper.find_first_window.return_value = (12, "PristonTale EU V23.86.96")
self.mapper.window_state.return_value = 3
process = MagicMock()
process.poll.return_value = None
with (
patch.object(gw, "_X11", return_value=self.mapper),
patch.object(gw.subprocess, "Popen", return_value=process),
patch.object(gw, "WINDOW_READY_DELAY_SECONDS", 0),
patch.object(gw, "_maintain_game_window_focus", return_value=17),
):
self.assertEqual(gw.run_game_window(["unused"], borderless=True), 17)
self.mapper.map_window.assert_not_called()
self.mapper.force_windowed_state.assert_not_called()
self.mapper.refresh_render_surface.assert_not_called()
self.mapper.activate_window.assert_not_called()
self.assert_no_input_change()
def test_preparation_never_raises_a_client_before_desktop_activation(self) -> None:
gw._prepare_game_window(self.mapper, 12,
borderless=False, suppress_wm_ping=False, raise_window=False)
self.mapper.map_window.assert_called_once_with(12, borderless=False, raise_window=False)
self.assert_no_input_change()
def test_layout_recovery_does_not_remap_or_raise_the_x11_client(self) -> None:
mapper: gw._X11 = object.__new__(gw._X11)
mapper.library = MagicMock()
mapper.display = ctypes.c_void_p(1)
with (
patch.object(mapper, "window_size", return_value=(1280, 720)),
patch.object(mapper, "_remove_decorations"),
):
self.assertEqual(mapper.map_window(12, borderless=True, raise_window=False), (1280, 720))
mapper.library.XMapRaised.assert_not_called()
mapper.library.XMapWindow.assert_not_called()
mapper.library.XSetInputFocus.assert_not_called()
def test_bad_bridge_timestamps_do_not_crash_the_supervisor(self) -> None:
with tempfile.TemporaryDirectory() as directory:
state = Path(directory) / "focus"
environment = {gw.GAMESCOPE_HOST_FOCUS_STATE_ENV: str(state)}
for timestamp in ("nan", "inf", "-inf", "1e999", "1e308", "-1e308"):
with self.subTest(timestamp=timestamp):
state.write_text(f"1 {timestamp}\n", encoding="ascii")
self.assertFalse(gw._gamescope_host_focus_is_allowed(environment=environment, now=10))
def test_startup_recovery_does_not_activate_a_background_window(self) -> None:
self.mapper.window_is_active.return_value = False
self.mapper.window_is_fullscreen_or_maximized.return_value = True
process = MagicMock()
process.poll.side_effect = [None, 0]
with patch.object(gw, "_prepare_game_window") as prepare:
gw._guard_game_window_startup(self.mapper, process, 12, borderless=False)
prepare.assert_not_called()
self.mapper.activate_window.assert_not_called()
self.assert_no_input_change()
def test_startup_recovery_does_not_undo_alt_tab_during_surface_refresh(self) -> None:
self.mapper.window_is_fullscreen_or_maximized.return_value = True
process = MagicMock()
process.poll.side_effect = [None, 0]
def switch_away(*_args, **_kwargs) -> None:
self.mapper.window_is_active.return_value = False
with patch.object(gw, "_prepare_game_window", side_effect=switch_away) as prepare:
gw._guard_game_window_startup(self.mapper, process, 12, borderless=False)
self.assertIs(prepare.call_args.kwargs["raise_window"], False)
self.mapper.activate_window.assert_not_called()
self.assert_no_input_change()
def test_scaling_failures_keep_the_focus_supervisor_running(self) -> None:
for failure in ("subscribe", "configure", "poll", "repair"):
with self.subTest(failure=failure):
mapper = MagicMock(spec=gw._X11)
mapper.enable_window_scaling.return_value = failure != "configure"
if failure == "subscribe":
mapper.select_scaling_hint_events.side_effect = RuntimeError("subscription failed")
elif failure == "poll":
mapper.wait_for_scaling_hint_change.side_effect = RuntimeError("poll failed")
elif failure == "repair":
mapper.enable_window_scaling.side_effect = [True] + [RuntimeError("hints failed")] * 3
process = MagicMock()
process.poll.return_value = None
with patch.object(gw, "_maintain_game_window_focus", return_value=17) as maintain:
self.assertEqual(gw._maintain_game_window_scaling(mapper, process, 12), 17)
maintain.assert_called_once_with(
mapper, process, 12, window_title=gw.WINDOW_TITLE,
existing_window_ids=(), expected_pid=None,
)
process.wait.assert_not_called()
def test_delayed_desktop_activation_is_repaired_during_startup_guard(self) -> None:
self.mapper.window_is_fullscreen_or_maximized.return_value = False
self.mapper.window_is_active.side_effect = lambda _window: self.clock.now >= 1.5
process = MagicMock()
process.poll.side_effect = lambda: None if self.clock.now < 4 else 0
repair_times: list[float] = []
def repair(_window: int) -> None:
repair_times.append(self.clock.now)
self.mapper.window_is_focused.return_value = True
self.mapper.restore_input_focus.side_effect = repair
gw._guard_game_window_startup(self.mapper, process, 12, borderless=False)
self.assertEqual(len(repair_times), 1)
self.assertGreaterEqual(repair_times[0], 1.5)
self.assertLess(repair_times[0], 4)
self.mapper.activate_window.assert_not_called()
self.mapper.map_window.assert_not_called()
self.mapper.refresh_render_surface.assert_not_called()
def test_startup_guard_hands_a_vanished_window_to_the_monitor_immediately(self) -> None:
process = MagicMock()
process.poll.return_value = None
for state in (None, 0):
with self.subTest(state=state):
self.mapper.window_state.return_value = state
self.assertFalse(gw._guard_game_window_startup(
self.mapper, process, 12, borderless=False,
))
self.assertEqual(self.clock.now, 0.0)
self.assert_no_input_change()
def test_direct_monitor_follows_the_same_process_without_resizing_or_activating(self) -> None:
self.mapper.window_pid.return_value = 42
self.mapper.window_state.side_effect = lambda window: 0 if window == 12 else 1
self.mapper.find_first_window.return_value = (22, "PristonTale EU V23.86.96")
self.mapper.restore_input_focus.side_effect = lambda _window: setattr(
self.mapper.window_is_focused, "return_value", True,
)
process = MagicMock()
process.poll.side_effect = lambda: None if self.clock.now < 3 else 17
process.wait.return_value = 17
self.assertEqual(gw._maintain_game_window_focus(
self.mapper, process, 12, expected_pid=42, existing_window_ids={8, 9},
), 17)
self.mapper.find_first_window.assert_called_once_with(
gw.WINDOW_TITLE, excluded_window_ids={8, 9, 12}, expected_pid=42,
)
self.mapper.restore_input_focus.assert_called_once_with(22)
self.mapper.activate_window.assert_not_called()
self.mapper.map_window.assert_not_called()
self.mapper.refresh_render_surface.assert_not_called()
def test_a_replaced_background_window_does_not_take_keyboard_focus(self) -> None:
self.mapper.window_pid.return_value = 42
self.mapper.window_state.side_effect = lambda window: None if window == 12 else 1
self.mapper.find_first_window.return_value = (22, "PristonTale EU V23.86.96")
self.mapper.window_is_active.return_value = False
process = MagicMock()
process.poll.side_effect = lambda: None if self.clock.now < 2 else 0
process.wait.return_value = 0
gw._maintain_game_window_focus(self.mapper, process, 12, expected_pid=42)
self.assert_no_input_change()
self.mapper.activate_window.assert_not_called()
def test_minimized_direct_client_is_not_replaced_or_restored(self) -> None:
self.mapper.window_pid.return_value = 42
self.mapper.window_state.return_value = 3
process = MagicMock()
process.poll.side_effect = lambda: None if self.clock.now < 2 else 0
process.wait.return_value = 0
gw._maintain_game_window_focus(self.mapper, process, 12, expected_pid=42)
self.mapper.find_first_window.assert_not_called()
self.mapper.map_window.assert_not_called()
self.mapper.restore_normal_window_state.assert_not_called()
self.assert_no_input_change()
def test_missing_process_identity_does_not_attach_to_another_account(self) -> None:
self.mapper.window_pid.return_value = None
self.mapper.window_state.return_value = None
process = MagicMock()
process.poll.side_effect = lambda: None if self.clock.now < 2 else 0
process.wait.return_value = 0
gw._maintain_game_window_focus(self.mapper, process, 12)
self.mapper.find_first_window.assert_not_called()
self.assert_no_input_change()
def test_reused_window_id_from_another_process_is_not_focused(self) -> None:
self.mapper.window_pid.return_value = 99
self.mapper.find_first_window.return_value = None
process = MagicMock()
process.poll.side_effect = lambda: None if self.clock.now < 2 else 0
process.wait.return_value = 0
gw._maintain_game_window_focus(self.mapper, process, 12, expected_pid=42)
self.assert_no_input_change()
for call in self.mapper.find_first_window.call_args_list:
self.assertEqual(call.kwargs["expected_pid"], 42)
self.assertIn(12, call.kwargs["excluded_window_ids"])
def test_discovery_keeps_the_original_pid_if_the_window_disappears_during_startup(self) -> None:
self.mapper.root_window_ids.return_value = {8, 9}
self.mapper.find_first_window.return_value = (12, "PristonTale EU V23.86.96")
self.mapper.window_pid.return_value = 42
process = MagicMock()
process.poll.return_value = None
def disappear(*_args, **_kwargs) -> bool:
self.mapper.window_pid.return_value = None
return False
with (
patch.object(gw, "_X11", return_value=self.mapper),
patch.object(gw.subprocess, "Popen", return_value=process),
patch.object(gw, "WINDOW_READY_DELAY_SECONDS", 0),
patch.object(gw, "_prepare_game_window"),
patch.object(gw, "_focus_game_window"),
patch.object(gw, "_guard_game_window_startup", side_effect=disappear),
patch.object(gw, "_maintain_game_window_focus", return_value=17) as maintain,
):
self.assertEqual(gw.run_game_window(["unused"]), 17)
maintain.assert_called_once_with(
self.mapper, process, 12, window_title=gw.WINDOW_TITLE,
existing_window_ids={8, 9}, expected_pid=42,
)
class ClientWindowDiscoveryTests(unittest.TestCase):
def test_replacement_discovery_rejects_other_processes_with_identical_titles(self) -> None:
mapper: gw._X11 = object.__new__(gw._X11)
with (
patch.object(mapper, "_root_windows", return_value=[50, 60, 70]),
patch.object(mapper, "_client_window", side_effect={50: 12, 60: 13, 70: 14}.__getitem__),
patch.object(mapper, "window_pid", side_effect={12: 99, 13: None, 14: 42}.__getitem__),
patch.object(mapper, "_window_title", return_value="PristonTale EU V23.86.96"),
):
self.assertEqual(mapper.find_first_window(
gw.WINDOW_TITLE, excluded_window_ids=set(), expected_pid=42,
), (14, "PristonTale EU V23.86.96"))
def test_title_is_read_from_the_client_inside_an_untitled_wm_frame(self) -> None:
mapper: gw._X11 = object.__new__(gw._X11)
with (
patch.object(mapper, "_root_windows", return_value=[50]),
patch.object(mapper, "_client_window", return_value=12),
patch.object(mapper, "_window_title", side_effect=lambda window:
"PristonTale EU V23.86.96" if window == 12 else None),
):
self.assertEqual(mapper.find_first_window("PristonTale EU", excluded_window_ids=set()),
(12, "PristonTale EU V23.86.96"))
def test_an_existing_client_is_excluded_even_if_its_frame_changes(self) -> None:
mapper: gw._X11 = object.__new__(gw._X11)
frames = {50: 12, 60: 12, 70: 13}
with (
patch.object(mapper, "_root_windows", side_effect=[[50], [60, 70]]),
patch.object(mapper, "_client_window", side_effect=frames.__getitem__),
patch.object(mapper, "_window_title", return_value="PristonTale EU V23.86.96"),
):
existing = mapper.root_window_ids()
found = mapper.find_first_window("PristonTale EU", excluded_window_ids=existing)
self.assertIn(12, existing)
self.assertEqual(found, (13, "PristonTale EU V23.86.96"))
def test_a_retired_client_is_excluded_even_when_a_frame_supplies_its_title(self) -> None:
mapper: gw._X11 = object.__new__(gw._X11)
with (
patch.object(mapper, "_root_windows", return_value=[50]),
patch.object(mapper, "_client_window", return_value=12),
patch.object(mapper, "_window_title", return_value="PristonTale EU V23.86.96"),
):
self.assertIsNone(mapper.find_first_window("PristonTale EU", excluded_window_ids={12}))