diff --git a/gatelock/gatelock/tests/conftest.py b/gatelock/gatelock/tests/conftest.py index 2c9ca17..c7d5015 100644 --- a/gatelock/gatelock/tests/conftest.py +++ b/gatelock/gatelock/tests/conftest.py @@ -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")) diff --git a/gatelock/gatelock/tests/test_recovery.py b/gatelock/gatelock/tests/test_recovery.py index 3043759..aa20ab8 100644 --- a/gatelock/gatelock/tests/test_recovery.py +++ b/gatelock/gatelock/tests/test_recovery.py @@ -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.""" diff --git a/gatelock/gatelock/tests/test_recovery_invariants.py b/gatelock/gatelock/tests/test_recovery_invariants.py new file mode 100644 index 0000000..7f48ce3 --- /dev/null +++ b/gatelock/gatelock/tests/test_recovery_invariants.py @@ -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 diff --git a/gatelock/gatelock/tests/test_recovery_reassertion.py b/gatelock/gatelock/tests/test_recovery_reassertion.py new file mode 100644 index 0000000..088bbdc --- /dev/null +++ b/gatelock/gatelock/tests/test_recovery_reassertion.py @@ -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))