"""Tests for lending the lock screen's grab to the workout app. The invariant: the grab moves, nothing else does. VT switching stays disabled and the arbiter claim stays held, because ``close()`` would undo both and hand the user Ctrl+Alt+F2 out of the obligation the lock exists to enforce. """ from __future__ import annotations import tkinter as tk from unittest.mock import MagicMock from gatelock import LockWindow from screen_locker import _workout_handoff from screen_locker._workout_handoff import lock_grab_handoff, start_workout class _FakeRecovery: """Stands in for gatelock's watchdog, which re-grabs every 1000ms.""" def __init__(self) -> None: self.running = True self.events: list[str] = [] def stop(self) -> None: """Stop the watchdog and record the stop.""" self.running = False self.events.append("stop") class _FakeLock: """A lock window that records the order of grab operations.""" def __init__(self, *, release_raises: bool = False) -> None: self.recovery = _FakeRecovery() self.events: list[str] = [] self.closed = False self._release_raises = release_raises outer = self class _Root: def grab_release(self) -> None: """Record the release, raising TclError when told no grab is held.""" outer.events.append("grab_release") if outer._release_raises: msg = "no grab to release" raise tk.TclError(msg) self.root = _Root() def report_grab_held(self, *, held: bool) -> None: """gatelock 0.11: what leisure-watch reads via ``held_grabs``.""" self.events.append(f"held={held}") def grab_input(self) -> None: """Record a grab and restart the watchdog, as the real lock does.""" self.events.append("grab_input") self.recovery.running = True def close(self) -> None: """Mark the lock window as closed.""" self.closed = True class TestLockGrabHandoff: """Releasing and re-taking, in the only safe order.""" def test_stops_the_watchdog_before_releasing(self) -> None: """A tick between release and the app's own grab would steal it back.""" lock = _FakeLock() lock_grab_handoff(lock).release() assert lock.recovery.events == ["stop"] assert lock.events == ["held=False", "grab_release"] assert not lock.recovery.running def test_reacquire_restores_the_grab_and_the_watchdog(self) -> None: """Reacquiring grabs input again and restarts the watchdog after a release.""" handoff = lock_grab_handoff(lock := _FakeLock()) handoff.release() handoff.reacquire() assert lock.events == ["held=False", "grab_release", "grab_input"] assert lock.recovery.running def test_never_closes_the_lock_window(self) -> None: """close() also restores VT switching -- that is the escape hatch.""" handoff = lock_grab_handoff(lock := _FakeLock()) handoff.release() handoff.reacquire() assert not lock.closed def test_tolerates_having_no_grab_to_release(self) -> None: """Tk raises when releasing a grab we do not hold; not fatal.""" handoff = lock_grab_handoff(lock := _FakeLock(release_raises=True)) handoff.release() handoff.reacquire() assert lock.events == ["held=False", "grab_release", "grab_input"] class TestStartWorkout: """The button handler never raises onto a screen with no way out.""" def test_reports_a_failed_launch_as_a_message(self, monkeypatch: object) -> None: """A failed launch comes back as a message with the reason, not an exception.""" class _Result: launched = False reason = "not built" monkeypatch.setattr( _workout_handoff, "launch_workout_app", lambda _h: _Result() ) message = start_workout(_FakeLock()) assert "Could not start the workout" in message assert "not built" in message def test_reports_a_finished_run(self, monkeypatch: object) -> None: """A finished run tells the user the result is being checked.""" class _Result: launched = True reason = "exited with code 0" monkeypatch.setattr( _workout_handoff, "launch_workout_app", lambda _h: _Result() ) assert "checking whether it counted" in start_workout(_FakeLock()) class _Recorder: """A lock stand-in that records every attribute the handoff reads.""" def __init__(self) -> None: self.names: set[str] = set() def __getattr__(self, name: str) -> MagicMock: self.names.add(name) return MagicMock() class TestAgainstTheRealGatelockApi: """The fakes above cannot notice a renamed or private gatelock attribute.""" def test_the_handoff_only_reads_attributes_a_real_lock_window_has(self) -> None: """``lock._recovery`` raised AttributeError live while fakes passed.""" spy = _Recorder() handoff = lock_grab_handoff(spy) handoff.release() handoff.reacquire() missing = { name for name in spy.names if name != "root" and not hasattr(LockWindow, name) } assert not missing