mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 19:07:17 +01:00
gatelock: split test_recovery.py 477 -> 208 lines by test layer
The file's own docstring described three layers; they are now three files:
test_recovery_invariants.py (52) — the static AST check that _recovery.py
contains no weakening call at all
test_recovery_reassertion.py (218) — what a tick re-asserts: grab, focus, VT
test_recovery.py (208) — the 2026-07-25 monotonic-sequence walk,
tick mechanics, and scheduling
Splitting the classes made pylint's R0801 fire: both halves built a
RecoveryLoop with the same RecoveryCollaborators block. Rather than suppress
it (banned) the shared construction became conftest.build_loop(root, config),
which the `loop` fixture now uses too — so the tests that need a non-default
config ask for one instead of restating the wiring. The output scans both
halves drive moved to conftest alongside it.
389 passed, 100% branch coverage — same tests, same count, three files.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFSGeWxHPY5rHUGrPqhcmG
This commit is contained in:
parent
48f6c7420f
commit
408c7a2585
@ -16,7 +16,9 @@ from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from gatelock._outputs import Output, OutputRect
|
||||
from gatelock._outputs import Output, OutputRect, OutputScan
|
||||
from gatelock._recovery import RecoveryCollaborators, RecoveryLoop
|
||||
from gatelock._surfaces import SurfaceSet
|
||||
from gatelock._window import LockConfig, LockWindow
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@ -110,3 +112,51 @@ def make_window(
|
||||
hooks = hooks if hooks is not None else MagicMock()
|
||||
window = LockWindow(root, config or LockConfig(), hooks)
|
||||
return window, hooks
|
||||
|
||||
|
||||
DP0 = Output("DP-0", connected=True, rect=OutputRect(0, 0, 3840, 2160), primary=True)
|
||||
HDMI = Output("HDMI-0", connected=True, rect=OutputRect(3840, 0, 2560, 1440))
|
||||
DP0_DARK = Output("DP-0", connected=True, rect=None, primary=True)
|
||||
HDMI_DARK = Output("HDMI-0", connected=True, rect=None)
|
||||
|
||||
BOTH = OutputScan((DP0, HDMI), "randr", ok=True)
|
||||
DEAD_PRIMARY = OutputScan((DP0_DARK, HDMI), "randr", ok=True)
|
||||
ALL_DARK = OutputScan((DP0_DARK, HDMI_DARK), "randr", ok=True)
|
||||
FAILED = OutputScan((), "none", ok=False)
|
||||
|
||||
|
||||
def build_loop(
|
||||
root: MagicMock, config: LockConfig
|
||||
) -> tuple[RecoveryLoop, MagicMock, SurfaceSet]:
|
||||
"""A recovery loop over mocked surroundings, for an arbitrary config.
|
||||
|
||||
Args:
|
||||
root: The mocked Tk root the loop drives.
|
||||
config: The lock configuration under test.
|
||||
|
||||
Returns:
|
||||
The loop, its mocked enumerator, and the surface set it manages.
|
||||
"""
|
||||
hooks = MagicMock()
|
||||
surfaces = SurfaceSet(root, config, hooks)
|
||||
enumerator = MagicMock()
|
||||
return (
|
||||
RecoveryLoop(
|
||||
root,
|
||||
RecoveryCollaborators(
|
||||
config=config,
|
||||
surfaces=surfaces,
|
||||
enumerator=enumerator,
|
||||
detector=MagicMock(),
|
||||
hooks=hooks,
|
||||
),
|
||||
),
|
||||
enumerator,
|
||||
surfaces,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def loop(mock_root: MagicMock) -> tuple[RecoveryLoop, MagicMock, SurfaceSet]:
|
||||
"""A recovery loop in hard mode, the configuration most tests want."""
|
||||
return build_loop(mock_root, LockConfig(mode="hard"))
|
||||
|
||||
@ -7,92 +7,18 @@ lock never weakens, and per-branch coverage.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import logging
|
||||
from pathlib import Path
|
||||
import tkinter as tk
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from gatelock.tests.conftest import ALL_DARK, BOTH, DEAD_PRIMARY, FAILED
|
||||
|
||||
from gatelock._outputs import Output, OutputRect, OutputScan
|
||||
from gatelock._recovery import RecoveryCollaborators, RecoveryLoop
|
||||
from gatelock._surfaces import SurfaceSet
|
||||
from gatelock._window import LockConfig
|
||||
if TYPE_CHECKING:
|
||||
import pytest
|
||||
|
||||
DP0 = Output("DP-0", connected=True, rect=OutputRect(0, 0, 3840, 2160), primary=True)
|
||||
HDMI = Output("HDMI-0", connected=True, rect=OutputRect(3840, 0, 2560, 1440))
|
||||
DP0_DARK = Output("DP-0", connected=True, rect=None, primary=True)
|
||||
HDMI_DARK = Output("HDMI-0", connected=True, rect=None)
|
||||
|
||||
BOTH = OutputScan((DP0, HDMI), "randr", ok=True)
|
||||
DEAD_PRIMARY = OutputScan((DP0_DARK, HDMI), "randr", ok=True)
|
||||
ALL_DARK = OutputScan((DP0_DARK, HDMI_DARK), "randr", ok=True)
|
||||
FAILED = OutputScan((), "none", ok=False)
|
||||
|
||||
WEAKENING_CALLS = frozenset(
|
||||
{"grab_release", "restore_vt_switching", "close", "destroy", "quit", "withdraw"}
|
||||
)
|
||||
SOURCE_ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def symbols(module: str) -> set[str]:
|
||||
"""Every attribute and name referenced in a module's source."""
|
||||
tree = ast.parse((SOURCE_ROOT / module).read_text(encoding="utf-8"))
|
||||
return {n.attr for n in ast.walk(tree) if isinstance(n, ast.Attribute)} | {
|
||||
n.id for n in ast.walk(tree) if isinstance(n, ast.Name)
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def loop(mock_root: MagicMock) -> tuple[RecoveryLoop, MagicMock, SurfaceSet]:
|
||||
"""A recovery loop over mocked surroundings."""
|
||||
config = LockConfig(mode="hard")
|
||||
hooks = MagicMock()
|
||||
surfaces = SurfaceSet(mock_root, config, hooks)
|
||||
enumerator = MagicMock()
|
||||
detector = MagicMock()
|
||||
return (
|
||||
RecoveryLoop(
|
||||
mock_root,
|
||||
RecoveryCollaborators(
|
||||
config=config,
|
||||
surfaces=surfaces,
|
||||
enumerator=enumerator,
|
||||
detector=detector,
|
||||
hooks=hooks,
|
||||
),
|
||||
),
|
||||
enumerator,
|
||||
surfaces,
|
||||
)
|
||||
|
||||
|
||||
class TestStaticInvariant:
|
||||
"""The separation that makes monotonicity mechanical rather than hoped-for."""
|
||||
|
||||
def test_recovery_contains_no_weakening_call(self) -> None:
|
||||
"""_recovery.py must not be able to release, restore or destroy."""
|
||||
assert symbols("_recovery.py") & WEAKENING_CALLS == set()
|
||||
|
||||
def test_recovery_may_still_strengthen(self) -> None:
|
||||
"""The asymmetry is the point: strengthening calls ARE present."""
|
||||
found = symbols("_recovery.py")
|
||||
assert "disable_vt_switching" in found
|
||||
assert "grab_set_global" in found
|
||||
|
||||
def test_surfaces_never_touches_grab_or_vt(self) -> None:
|
||||
"""Window ownership and lock strength stay separate concerns."""
|
||||
banned = {"grab_release", "grab_set", "grab_set_global", "restore_vt_switching"}
|
||||
assert symbols("_surfaces.py") & banned == set()
|
||||
|
||||
def test_detect_thread_never_touches_tk(self) -> None:
|
||||
"""Tk is not thread-safe; the RandR thread may only queue."""
|
||||
source = (SOURCE_ROOT / "_detect.py").read_text(encoding="utf-8")
|
||||
loop_body = source.split("def _loop(")[1].split("def ")[0]
|
||||
assert "self._sink.put" in loop_body
|
||||
for forbidden in ("geometry(", "deiconify(", "lift(", "grab_"):
|
||||
assert forbidden not in loop_body
|
||||
from gatelock._recovery import RecoveryLoop
|
||||
from gatelock._surfaces import SurfaceSet
|
||||
|
||||
|
||||
class TestMonotonicSequence:
|
||||
@ -178,200 +104,6 @@ class TestTick:
|
||||
assert recovery.ticks == 2
|
||||
|
||||
|
||||
class TestGrabReassertion:
|
||||
"""Re-taking a lost grab, never releasing one."""
|
||||
|
||||
def test_reasserts_when_lost(
|
||||
self, loop: tuple[RecoveryLoop, MagicMock, SurfaceSet], mock_root: MagicMock
|
||||
) -> None:
|
||||
"""Something stole the grab; we take it back."""
|
||||
recovery, enumerator, _surfaces = loop
|
||||
enumerator.scan.return_value = BOTH
|
||||
mock_root.grab_current.return_value = None
|
||||
report = recovery.tick()
|
||||
assert report.grab_reasserted is True
|
||||
mock_root.grab_set_global.assert_called()
|
||||
|
||||
def test_noop_when_still_held(
|
||||
self, loop: tuple[RecoveryLoop, MagicMock, SurfaceSet], mock_root: MagicMock
|
||||
) -> None:
|
||||
"""Holding the grab already means no work."""
|
||||
recovery, enumerator, _surfaces = loop
|
||||
enumerator.scan.return_value = BOTH
|
||||
mock_root.grab_current.return_value = mock_root
|
||||
assert recovery.tick().grab_reasserted is False
|
||||
|
||||
def test_noop_when_our_own_child_holds_the_grab(
|
||||
self, loop: tuple[RecoveryLoop, MagicMock, SurfaceSet], mock_root: MagicMock
|
||||
) -> None:
|
||||
"""A posted menu owns the grab; we must not steal it back.
|
||||
|
||||
Regression: the old ``grab_current() is root`` test read a Tk menu's
|
||||
own grab as "lost" and re-grabbed a second later, which made the
|
||||
screen-locker sport selector unusable while locked.
|
||||
"""
|
||||
recovery, enumerator, _surfaces = loop
|
||||
enumerator.scan.return_value = BOTH
|
||||
mock_root.grab_current.return_value = MagicMock(name="posted menu")
|
||||
assert recovery.tick().grab_reasserted is False
|
||||
mock_root.grab_set_global.assert_not_called()
|
||||
|
||||
def test_reasserts_when_the_holder_is_not_one_of_our_windows(
|
||||
self,
|
||||
loop: tuple[RecoveryLoop, MagicMock, SurfaceSet],
|
||||
mock_root: MagicMock,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Fail closed: a name we cannot resolve is not provably ours.
|
||||
|
||||
A ``ttk.Combobox`` popdown is a Tcl-created toplevel absent from
|
||||
Tkinter's widget map, so ``nametowidget`` raises ``KeyError``. Keeping
|
||||
the lock beats keeping a banned widget's popdown.
|
||||
"""
|
||||
recovery, enumerator, _surfaces = loop
|
||||
enumerator.scan.return_value = BOTH
|
||||
mock_root.grab_current.side_effect = KeyError("!popdown")
|
||||
with caplog.at_level(logging.WARNING):
|
||||
assert recovery.tick().grab_reasserted is True
|
||||
mock_root.grab_set_global.assert_called()
|
||||
assert "not one of our windows" in caplog.text
|
||||
|
||||
def test_a_persistent_lost_grab_warns_once_not_once_per_tick(
|
||||
self,
|
||||
loop: tuple[RecoveryLoop, MagicMock, SurfaceSet],
|
||||
mock_root: MagicMock,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""A state that lasts the whole lock must not stream to the journal."""
|
||||
recovery, enumerator, _surfaces = loop
|
||||
enumerator.scan.return_value = BOTH
|
||||
mock_root.grab_current.side_effect = tk.TclError("gone")
|
||||
with caplog.at_level(logging.WARNING):
|
||||
for _ in range(5):
|
||||
recovery.tick()
|
||||
lost = [r for r in caplog.records if "current grab" in r.message]
|
||||
assert len(lost) == 1, f"warned {len(lost)} times over 5 ticks"
|
||||
|
||||
def test_tclerror_while_regrabbing_is_swallowed(
|
||||
self, loop: tuple[RecoveryLoop, MagicMock, SurfaceSet], mock_root: MagicMock
|
||||
) -> None:
|
||||
"""A failed re-grab is retried next tick, not raised."""
|
||||
recovery, enumerator, _surfaces = loop
|
||||
enumerator.scan.return_value = BOTH
|
||||
mock_root.grab_current.return_value = None
|
||||
mock_root.grab_set_global.side_effect = tk.TclError("held")
|
||||
assert recovery.tick().grab_reasserted is False
|
||||
|
||||
def test_grab_query_tclerror(
|
||||
self, loop: tuple[RecoveryLoop, MagicMock, SurfaceSet], mock_root: MagicMock
|
||||
) -> None:
|
||||
"""A TclError querying the grab counts as not holding it."""
|
||||
recovery, enumerator, _surfaces = loop
|
||||
enumerator.scan.return_value = BOTH
|
||||
mock_root.grab_current.side_effect = tk.TclError("gone")
|
||||
assert recovery.tick().grab_reasserted is True
|
||||
|
||||
def test_no_reassert_when_grab_not_global(self, mock_root: MagicMock) -> None:
|
||||
"""Soft mode takes no grab, so there is nothing to re-assert."""
|
||||
config = LockConfig(mode="soft")
|
||||
surfaces = SurfaceSet(mock_root, config, MagicMock())
|
||||
enumerator = MagicMock()
|
||||
enumerator.scan.return_value = BOTH
|
||||
recovery = RecoveryLoop(
|
||||
mock_root,
|
||||
RecoveryCollaborators(
|
||||
config=config,
|
||||
surfaces=surfaces,
|
||||
enumerator=enumerator,
|
||||
detector=MagicMock(),
|
||||
hooks=MagicMock(),
|
||||
),
|
||||
)
|
||||
assert recovery.tick().grab_reasserted is False
|
||||
|
||||
|
||||
class TestFocusReassertion:
|
||||
"""Re-focusing a surface that was (re)built after the first tick.
|
||||
|
||||
Regression coverage for the 2026-08 bug: a monitor blip tears down and
|
||||
rebuilds a surface's widgets mid-lock, but ``LockWindow`` only ever
|
||||
focuses the entry once, on first grab acquisition. Without this, the new
|
||||
widget renders correctly but is never the Tk focus target, and the held
|
||||
global grab means nothing else can take focus either -- a silent,
|
||||
permanent input dead end that looks identical to a working lock.
|
||||
"""
|
||||
|
||||
def test_refocuses_when_a_surface_is_recreated(
|
||||
self, loop: tuple[RecoveryLoop, MagicMock, SurfaceSet]
|
||||
) -> None:
|
||||
"""A surface coming back live re-triggers on_focus_ready."""
|
||||
recovery, enumerator, surfaces = loop
|
||||
hooks = surfaces._builder
|
||||
enumerator.scan.return_value = BOTH
|
||||
recovery.tick()
|
||||
hooks.on_focus_ready.reset_mock()
|
||||
enumerator.scan.return_value = DEAD_PRIMARY
|
||||
recovery.tick()
|
||||
enumerator.scan.return_value = BOTH
|
||||
recovery.tick()
|
||||
hooks.on_focus_ready.assert_called_once()
|
||||
|
||||
def test_no_refocus_when_nothing_was_created(
|
||||
self, loop: tuple[RecoveryLoop, MagicMock, SurfaceSet]
|
||||
) -> None:
|
||||
"""A tick with no new surfaces must not fight the user mid-typing."""
|
||||
recovery, enumerator, surfaces = loop
|
||||
hooks = surfaces._builder
|
||||
enumerator.scan.return_value = BOTH
|
||||
recovery.tick()
|
||||
hooks.on_focus_ready.reset_mock()
|
||||
recovery.tick()
|
||||
hooks.on_focus_ready.assert_not_called()
|
||||
|
||||
def test_tclerror_during_refocus_is_swallowed(
|
||||
self, loop: tuple[RecoveryLoop, MagicMock, SurfaceSet], mock_root: MagicMock
|
||||
) -> None:
|
||||
"""A focus call on a half-torn-down widget must not crash the tick."""
|
||||
recovery, enumerator, surfaces = loop
|
||||
hooks = surfaces._builder
|
||||
hooks.on_focus_ready.side_effect = tk.TclError("gone")
|
||||
enumerator.scan.return_value = BOTH
|
||||
recovery.tick()
|
||||
|
||||
|
||||
class TestVtReassertion:
|
||||
"""Periodically re-disabling VT switching, never re-enabling it."""
|
||||
|
||||
def test_reasserts_on_the_thirtieth_tick(
|
||||
self, loop: tuple[RecoveryLoop, MagicMock, SurfaceSet]
|
||||
) -> None:
|
||||
"""VT is re-disabled every 30 ticks."""
|
||||
recovery, enumerator, _surfaces = loop
|
||||
enumerator.scan.return_value = BOTH
|
||||
with patch("gatelock._recovery.disable_vt_switching") as vt:
|
||||
reports = [recovery.tick() for _ in range(30)]
|
||||
assert [i for i, r in enumerate(reports, 1) if r.vt_reasserted] == [30]
|
||||
vt.assert_called_once_with()
|
||||
|
||||
def test_never_when_vt_not_managed(self, mock_root: MagicMock) -> None:
|
||||
"""An app that does not disable VT never re-asserts it."""
|
||||
config = LockConfig(mode="soft")
|
||||
surfaces = SurfaceSet(mock_root, config, MagicMock())
|
||||
enumerator = MagicMock()
|
||||
enumerator.scan.return_value = BOTH
|
||||
recovery = RecoveryLoop(
|
||||
mock_root,
|
||||
RecoveryCollaborators(
|
||||
config=config,
|
||||
surfaces=surfaces,
|
||||
enumerator=enumerator,
|
||||
detector=MagicMock(),
|
||||
hooks=MagicMock(),
|
||||
),
|
||||
)
|
||||
assert all(not recovery.tick().vt_reasserted for _ in range(31))
|
||||
|
||||
|
||||
class TestScheduling:
|
||||
"""The two cadences."""
|
||||
|
||||
|
||||
52
gatelock/gatelock/tests/test_recovery_invariants.py
Normal file
52
gatelock/gatelock/tests/test_recovery_invariants.py
Normal file
@ -0,0 +1,52 @@
|
||||
"""Static checks that the recovery loop *cannot* weaken the lock.
|
||||
|
||||
Split from ``test_recovery.py`` (250-line cap). These read the source with
|
||||
``ast`` rather than running anything: monotonicity is enforced by which calls
|
||||
a module is allowed to contain at all, so the check belongs at the source
|
||||
level where no amount of mocking can fake it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
from pathlib import Path
|
||||
|
||||
WEAKENING_CALLS = frozenset(
|
||||
{"grab_release", "restore_vt_switching", "close", "destroy", "quit", "withdraw"}
|
||||
)
|
||||
SOURCE_ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def symbols(module: str) -> set[str]:
|
||||
"""Every attribute and name referenced in a module's source."""
|
||||
tree = ast.parse((SOURCE_ROOT / module).read_text(encoding="utf-8"))
|
||||
return {n.attr for n in ast.walk(tree) if isinstance(n, ast.Attribute)} | {
|
||||
n.id for n in ast.walk(tree) if isinstance(n, ast.Name)
|
||||
}
|
||||
|
||||
|
||||
class TestStaticInvariant:
|
||||
"""The separation that makes monotonicity mechanical rather than hoped-for."""
|
||||
|
||||
def test_recovery_contains_no_weakening_call(self) -> None:
|
||||
"""_recovery.py must not be able to release, restore or destroy."""
|
||||
assert symbols("_recovery.py") & WEAKENING_CALLS == set()
|
||||
|
||||
def test_recovery_may_still_strengthen(self) -> None:
|
||||
"""The asymmetry is the point: strengthening calls ARE present."""
|
||||
found = symbols("_recovery.py")
|
||||
assert "disable_vt_switching" in found
|
||||
assert "grab_set_global" in found
|
||||
|
||||
def test_surfaces_never_touches_grab_or_vt(self) -> None:
|
||||
"""Window ownership and lock strength stay separate concerns."""
|
||||
banned = {"grab_release", "grab_set", "grab_set_global", "restore_vt_switching"}
|
||||
assert symbols("_surfaces.py") & banned == set()
|
||||
|
||||
def test_detect_thread_never_touches_tk(self) -> None:
|
||||
"""Tk is not thread-safe; the RandR thread may only queue."""
|
||||
source = (SOURCE_ROOT / "_detect.py").read_text(encoding="utf-8")
|
||||
loop_body = source.split("def _loop(")[1].split("def ")[0]
|
||||
assert "self._sink.put" in loop_body
|
||||
for forbidden in ("geometry(", "deiconify(", "lift(", "grab_"):
|
||||
assert forbidden not in loop_body
|
||||
196
gatelock/gatelock/tests/test_recovery_reassertion.py
Normal file
196
gatelock/gatelock/tests/test_recovery_reassertion.py
Normal file
@ -0,0 +1,196 @@
|
||||
"""Tests for what the recovery loop re-asserts on each tick.
|
||||
|
||||
Split from ``test_recovery.py`` (250-line cap). That file keeps the static
|
||||
invariant, the 2026-07-25 monotonic-sequence walk, the tick mechanics and
|
||||
scheduling; this one covers the three things a tick re-asserts -- the input
|
||||
grab, focus, and VT switching -- and the branches within each.
|
||||
|
||||
The ``loop`` fixture and the output scans both halves drive come from
|
||||
``conftest.py``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import tkinter as tk
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from gatelock._window import LockConfig
|
||||
from gatelock.tests.conftest import BOTH, DEAD_PRIMARY, build_loop
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import pytest
|
||||
|
||||
from gatelock._recovery import RecoveryLoop
|
||||
from gatelock._surfaces import SurfaceSet
|
||||
|
||||
|
||||
class TestGrabReassertion:
|
||||
"""Re-taking a lost grab, never releasing one."""
|
||||
|
||||
def test_reasserts_when_lost(
|
||||
self, loop: tuple[RecoveryLoop, MagicMock, SurfaceSet], mock_root: MagicMock
|
||||
) -> None:
|
||||
"""Something stole the grab; we take it back."""
|
||||
recovery, enumerator, _surfaces = loop
|
||||
enumerator.scan.return_value = BOTH
|
||||
mock_root.grab_current.return_value = None
|
||||
report = recovery.tick()
|
||||
assert report.grab_reasserted is True
|
||||
mock_root.grab_set_global.assert_called()
|
||||
|
||||
def test_noop_when_still_held(
|
||||
self, loop: tuple[RecoveryLoop, MagicMock, SurfaceSet], mock_root: MagicMock
|
||||
) -> None:
|
||||
"""Holding the grab already means no work."""
|
||||
recovery, enumerator, _surfaces = loop
|
||||
enumerator.scan.return_value = BOTH
|
||||
mock_root.grab_current.return_value = mock_root
|
||||
assert recovery.tick().grab_reasserted is False
|
||||
|
||||
def test_noop_when_our_own_child_holds_the_grab(
|
||||
self, loop: tuple[RecoveryLoop, MagicMock, SurfaceSet], mock_root: MagicMock
|
||||
) -> None:
|
||||
"""A posted menu owns the grab; we must not steal it back.
|
||||
|
||||
Regression: the old ``grab_current() is root`` test read a Tk menu's
|
||||
own grab as "lost" and re-grabbed a second later, which made the
|
||||
screen-locker sport selector unusable while locked.
|
||||
"""
|
||||
recovery, enumerator, _surfaces = loop
|
||||
enumerator.scan.return_value = BOTH
|
||||
mock_root.grab_current.return_value = MagicMock(name="posted menu")
|
||||
assert recovery.tick().grab_reasserted is False
|
||||
mock_root.grab_set_global.assert_not_called()
|
||||
|
||||
def test_reasserts_when_the_holder_is_not_one_of_our_windows(
|
||||
self,
|
||||
loop: tuple[RecoveryLoop, MagicMock, SurfaceSet],
|
||||
mock_root: MagicMock,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Fail closed: a name we cannot resolve is not provably ours.
|
||||
|
||||
A ``ttk.Combobox`` popdown is a Tcl-created toplevel absent from
|
||||
Tkinter's widget map, so ``nametowidget`` raises ``KeyError``. Keeping
|
||||
the lock beats keeping a banned widget's popdown.
|
||||
"""
|
||||
recovery, enumerator, _surfaces = loop
|
||||
enumerator.scan.return_value = BOTH
|
||||
mock_root.grab_current.side_effect = KeyError("!popdown")
|
||||
with caplog.at_level(logging.WARNING):
|
||||
assert recovery.tick().grab_reasserted is True
|
||||
mock_root.grab_set_global.assert_called()
|
||||
assert "not one of our windows" in caplog.text
|
||||
|
||||
def test_a_persistent_lost_grab_warns_once_not_once_per_tick(
|
||||
self,
|
||||
loop: tuple[RecoveryLoop, MagicMock, SurfaceSet],
|
||||
mock_root: MagicMock,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""A state that lasts the whole lock must not stream to the journal."""
|
||||
recovery, enumerator, _surfaces = loop
|
||||
enumerator.scan.return_value = BOTH
|
||||
mock_root.grab_current.side_effect = tk.TclError("gone")
|
||||
with caplog.at_level(logging.WARNING):
|
||||
for _ in range(5):
|
||||
recovery.tick()
|
||||
lost = [r for r in caplog.records if "current grab" in r.message]
|
||||
assert len(lost) == 1, f"warned {len(lost)} times over 5 ticks"
|
||||
|
||||
def test_tclerror_while_regrabbing_is_swallowed(
|
||||
self, loop: tuple[RecoveryLoop, MagicMock, SurfaceSet], mock_root: MagicMock
|
||||
) -> None:
|
||||
"""A failed re-grab is retried next tick, not raised."""
|
||||
recovery, enumerator, _surfaces = loop
|
||||
enumerator.scan.return_value = BOTH
|
||||
mock_root.grab_current.return_value = None
|
||||
mock_root.grab_set_global.side_effect = tk.TclError("held")
|
||||
assert recovery.tick().grab_reasserted is False
|
||||
|
||||
def test_grab_query_tclerror(
|
||||
self, loop: tuple[RecoveryLoop, MagicMock, SurfaceSet], mock_root: MagicMock
|
||||
) -> None:
|
||||
"""A TclError querying the grab counts as not holding it."""
|
||||
recovery, enumerator, _surfaces = loop
|
||||
enumerator.scan.return_value = BOTH
|
||||
mock_root.grab_current.side_effect = tk.TclError("gone")
|
||||
assert recovery.tick().grab_reasserted is True
|
||||
|
||||
def test_no_reassert_when_grab_not_global(self, mock_root: MagicMock) -> None:
|
||||
"""Soft mode takes no grab, so there is nothing to re-assert."""
|
||||
recovery, enumerator, _surfaces = build_loop(mock_root, LockConfig(mode="soft"))
|
||||
enumerator.scan.return_value = BOTH
|
||||
assert recovery.tick().grab_reasserted is False
|
||||
|
||||
|
||||
class TestFocusReassertion:
|
||||
"""Re-focusing a surface that was (re)built after the first tick.
|
||||
|
||||
Regression coverage for the 2026-08 bug: a monitor blip tears down and
|
||||
rebuilds a surface's widgets mid-lock, but ``LockWindow`` only ever
|
||||
focuses the entry once, on first grab acquisition. Without this, the new
|
||||
widget renders correctly but is never the Tk focus target, and the held
|
||||
global grab means nothing else can take focus either -- a silent,
|
||||
permanent input dead end that looks identical to a working lock.
|
||||
"""
|
||||
|
||||
def test_refocuses_when_a_surface_is_recreated(
|
||||
self, loop: tuple[RecoveryLoop, MagicMock, SurfaceSet]
|
||||
) -> None:
|
||||
"""A surface coming back live re-triggers on_focus_ready."""
|
||||
recovery, enumerator, surfaces = loop
|
||||
hooks = surfaces._builder
|
||||
enumerator.scan.return_value = BOTH
|
||||
recovery.tick()
|
||||
hooks.on_focus_ready.reset_mock()
|
||||
enumerator.scan.return_value = DEAD_PRIMARY
|
||||
recovery.tick()
|
||||
enumerator.scan.return_value = BOTH
|
||||
recovery.tick()
|
||||
hooks.on_focus_ready.assert_called_once()
|
||||
|
||||
def test_no_refocus_when_nothing_was_created(
|
||||
self, loop: tuple[RecoveryLoop, MagicMock, SurfaceSet]
|
||||
) -> None:
|
||||
"""A tick with no new surfaces must not fight the user mid-typing."""
|
||||
recovery, enumerator, surfaces = loop
|
||||
hooks = surfaces._builder
|
||||
enumerator.scan.return_value = BOTH
|
||||
recovery.tick()
|
||||
hooks.on_focus_ready.reset_mock()
|
||||
recovery.tick()
|
||||
hooks.on_focus_ready.assert_not_called()
|
||||
|
||||
def test_tclerror_during_refocus_is_swallowed(
|
||||
self, loop: tuple[RecoveryLoop, MagicMock, SurfaceSet], mock_root: MagicMock
|
||||
) -> None:
|
||||
"""A focus call on a half-torn-down widget must not crash the tick."""
|
||||
recovery, enumerator, surfaces = loop
|
||||
hooks = surfaces._builder
|
||||
hooks.on_focus_ready.side_effect = tk.TclError("gone")
|
||||
enumerator.scan.return_value = BOTH
|
||||
recovery.tick()
|
||||
|
||||
|
||||
class TestVtReassertion:
|
||||
"""Periodically re-disabling VT switching, never re-enabling it."""
|
||||
|
||||
def test_reasserts_on_the_thirtieth_tick(
|
||||
self, loop: tuple[RecoveryLoop, MagicMock, SurfaceSet]
|
||||
) -> None:
|
||||
"""VT is re-disabled every 30 ticks."""
|
||||
recovery, enumerator, _surfaces = loop
|
||||
enumerator.scan.return_value = BOTH
|
||||
with patch("gatelock._recovery.disable_vt_switching") as vt:
|
||||
reports = [recovery.tick() for _ in range(30)]
|
||||
assert [i for i, r in enumerate(reports, 1) if r.vt_reasserted] == [30]
|
||||
vt.assert_called_once_with()
|
||||
|
||||
def test_never_when_vt_not_managed(self, mock_root: MagicMock) -> None:
|
||||
"""An app that does not disable VT never re-asserts it."""
|
||||
recovery, enumerator, _surfaces = build_loop(mock_root, LockConfig(mode="soft"))
|
||||
enumerator.scan.return_value = BOTH
|
||||
assert all(not recovery.tick().vt_reasserted for _ in range(31))
|
||||
Loading…
Reference in New Issue
Block a user