diff --git a/diet_guard/_budget.py b/diet_guard/_budget.py index a031700..00825cc 100644 --- a/diet_guard/_budget.py +++ b/diet_guard/_budget.py @@ -30,8 +30,9 @@ import hmac import json import logging +from gatelock.log_integrity import compute_entry_hmac + from python_pkg.diet_guard._constants import BUDGET_FILE -from python_pkg.shared.log_integrity import compute_entry_hmac _logger = logging.getLogger(__name__) diff --git a/diet_guard/_gatelock.py b/diet_guard/_gatelock.py index 323ab6b..a1cc541 100644 --- a/diet_guard/_gatelock.py +++ b/diet_guard/_gatelock.py @@ -1,16 +1,12 @@ """Fullscreen "log your meals to unlock" gate window for diet_guard. -This reuses the proven screen-locker *mechanism* -- an ``overrideredirect`` -fullscreen window with a global input grab and disabled VT switching -- but -hardens two latent gaps in that original so a grabbed window can never become a -trap: - -* **VT switching is restored on every exit path**, not just the clean one: - ``atexit`` covers a crash/uncaught exception, signal handlers cover - SIGTERM/SIGINT, and a ``try/finally`` covers normal return. -* **Every callback error is swallowed and surfaced**, via a - ``report_callback_exception`` override on the Tk root, so no exception can - propagate out of the grabbed event loop and leave a dead window. +The fullscreen/grab/VT-disable/lifecycle mechanics -- an ``overrideredirect`` +window with a global input grab and disabled VT switching, hardened so a +grabbed window can never become a trap (VT switching restored on every exit +path, every callback error swallowed and surfaced) -- now live in the shared +``gatelock`` package, also used by wake_alarm and screen-locker. ``MealGate`` +owns a :class:`~gatelock.LockWindow` and implements +:class:`~gatelock.LockWindowHooks`. The window walks the user through each *missing* meal slot in turn (coming home at 17:00 backfills 08:00, then 12:00, then 16:00) and dismisses only once every @@ -33,13 +29,12 @@ offline, so a dead OFF endpoint can never trap you behind the lock. Building ``MealGate`` spans several sibling modules to keep each under the repo's 500-line limit: :mod:`._gatelock_core` provides the shared leaf -widget/field helpers, root window, and state (``_GateCore``, ``_GateRoot``, -``_GateState``); :mod:`._gatelock_window` provides the fullscreen window setup, -input grab, and exit-path lifecycle (``_GateWindow``); +widget/field helpers and state (``_GateCore``, ``_GateState``); :mod:`._gatelock_nutrition` provides the reference->total nutrition maths and food lookup (``_GateNutrition``); and :mod:`._gatelock_mealflow` provides the -submit/log flow and dashboard (``_GateMealFlow``). ``MealGate`` wires these -mixins together and owns construction, layout, and event binding. +submit/log flow, dashboard, and callback-error handling (``_GateMealFlow``). +``MealGate`` wires these mixins together, owns the ``gatelock.LockWindow``, +and handles construction, layout, and event binding. """ from __future__ import annotations @@ -50,12 +45,18 @@ import sys import tkinter as tk from typing import TYPE_CHECKING +from gatelock import GateRoot, LockConfig, LockWindow + from python_pkg.diet_guard._constants import GATE_LOCK_FILE from python_pkg.diet_guard._gate import due_slots -from python_pkg.diet_guard._gatelock_core import _GateRoot, _GateState +from python_pkg.diet_guard._gatelock_core import _GateState from python_pkg.diet_guard._gatelock_mealflow import _GateMealFlow -from python_pkg.diet_guard._gatelock_ui import GateCallbacks, build_layout, make_vars -from python_pkg.diet_guard._gatelock_window import _GateWindow +from python_pkg.diet_guard._gatelock_ui import ( + BG, + GateCallbacks, + build_layout, + make_vars, +) from python_pkg.diet_guard._slots import current_slot, day_slots from python_pkg.diet_guard._state import now_local @@ -121,7 +122,7 @@ def _pending_slots(*, demo_mode: bool) -> list[int]: return [] -class MealGate(_GateWindow, _GateMealFlow): +class MealGate(_GateMealFlow): """A fullscreen lock that dismisses only once every missing slot is logged.""" def __init__(self, *, demo_mode: bool = True) -> None: @@ -134,20 +135,21 @@ class MealGate(_GateWindow, _GateMealFlow): """ _assert_not_under_pytest() self.demo_mode = demo_mode - self._vt_disabled = False self._pending = _pending_slots(demo_mode=demo_mode) # All mutable logical state (provenance, suggestions, meal-in-progress) # lives in one bundle; see _GateState for the per-field rationale. self._state = _GateState() - self.root = _GateRoot() - self.root.on_callback_error = self._handle_callback_error + self.root = GateRoot() + self.root.on_callback_error = self.on_callback_error self.root.title("Diet Gate" + (" [DEMO]" if demo_mode else "")) + config = LockConfig(mode="soft" if demo_mode else "hard", bg=BG) + self._lock = LockWindow(self.root, config, hooks=self) self._vars = make_vars(self.root) self._build() def _build(self) -> None: """Lay out the UI, wire events, seed the first prompt, and grab input.""" - self._setup_window() + self._lock.setup() callbacks = GateCallbacks( on_unit_change=self._on_unit_change, on_submit=self._on_submit, @@ -165,9 +167,24 @@ class MealGate(_GateWindow, _GateMealFlow): self._refresh_slot_header() self._refresh_dashboard() self._refresh_projection() - self._grab_input() + self._lock.grab_input() self._widgets.desc_text.focus_set() + def on_focus_ready(self) -> None: + """Put keyboard focus on the description entry once it is mapped.""" + self._widgets.desc_text.focus_force() + + def on_close(self) -> None: + """No hardware/state to release; meal-log writes already happened.""" + + def close(self) -> None: + """Restore VT switching and destroy the window (no process exit).""" + self._lock.close() + + def run(self) -> None: + """Run the Tk loop, restoring VT switching on every exit path.""" + self._lock.run() + def _wire_events(self) -> None: """Bind the live per-keystroke events to the freshly built widgets. diff --git a/diet_guard/_gatelock_core.py b/diet_guard/_gatelock_core.py index e8f17e3..7815771 100644 --- a/diet_guard/_gatelock_core.py +++ b/diet_guard/_gatelock_core.py @@ -1,10 +1,11 @@ -"""Shared base class, root window, and state for the MealGate gate. +"""Shared base class and state for the MealGate gate. Split out of :mod:`._gatelock` to keep that module under the repo's 500-line limit. ``_GateCore`` holds the leaf widget/field helpers that every other -gatelock mixin (`_gatelock_window`, `_gatelock_nutrition`, -`_gatelock_mealflow`) derives from, plus the small dataclass (`_GateState`) -and Tk root subclass (`_GateRoot`) that :mod:`._gatelock` itself depends on. +gatelock mixin (`_gatelock_nutrition`, `_gatelock_mealflow`) derives from, +plus the small dataclass (`_GateState`) that :mod:`._gatelock` itself depends +on. The window/lock mechanics and the ``GateRoot`` Tk root subclass that used +to live here now come from the shared ``gatelock`` package. """ from __future__ import annotations @@ -27,7 +28,8 @@ from python_pkg.diet_guard._slots import slot_label if TYPE_CHECKING: from collections.abc import Callable - from types import TracebackType + + from gatelock import GateRoot from python_pkg.diet_guard._estimator import Nutrition from python_pkg.diet_guard._meal import MealItem @@ -45,28 +47,6 @@ def _safe_float(raw: str) -> float | None: return None -class _GateRoot(tk.Tk): - """Tk root that routes callback errors to a handler instead of crashing. - - Overriding ``report_callback_exception`` is the idiomatic, blind-except-free - way to guarantee that no exception raised inside a Tk callback escapes the - event loop -- essential while a global input grab is held. - """ - - on_callback_error: Callable[[], None] | None = None - - def report_callback_exception( - self, - exc: type[BaseException], - val: BaseException, - tb: TracebackType | None, - ) -> None: - """Log a callback error and notify the handler; never re-raise.""" - _logger.error("gate callback error", exc_info=(exc, val, tb)) - if self.on_callback_error is not None: - self.on_callback_error() - - @dataclass class _GateState: """Mutable logical state of the in-progress entry (no widget references). @@ -100,9 +80,8 @@ class _GateCore: no-member check. """ - root: _GateRoot + root: GateRoot demo_mode: bool - _vt_disabled: bool _pending: list[int] _state: _GateState _vars: GateVars diff --git a/diet_guard/_gatelock_mealflow.py b/diet_guard/_gatelock_mealflow.py index 359646f..d5c1346 100644 --- a/diet_guard/_gatelock_mealflow.py +++ b/diet_guard/_gatelock_mealflow.py @@ -292,7 +292,7 @@ class _GateMealFlow(_GateNutrition): lines.append(f" protein {protein:g} / {target:g} g ({left:g} g left)") return "\n".join(lines) - def _handle_callback_error(self) -> None: + def on_callback_error(self) -> None: """Surface an unexpected callback error without dropping the grab.""" self._set_status( "Something went wrong. Enter the calories, then submit again.", diff --git a/diet_guard/_gatelock_window.py b/diet_guard/_gatelock_window.py deleted file mode 100644 index 34aa01a..0000000 --- a/diet_guard/_gatelock_window.py +++ /dev/null @@ -1,171 +0,0 @@ -"""Window mechanics and process lifecycle for the MealGate gate. - -Split out of :mod:`._gatelock` to keep that module under the repo's 500-line -limit. ``_GateWindow`` extends -:class:`~python_pkg.diet_guard._gatelock_core._GateCore` with the -screen-locker-style window setup (fullscreen, VT-switch disable, global input -grab with retry) and the signal/atexit lifecycle that guarantees VT switching -is restored on every exit path. -""" - -from __future__ import annotations - -import atexit -import contextlib -import logging -import shutil -import signal -import subprocess -import tkinter as tk -from typing import TYPE_CHECKING - -from python_pkg.diet_guard._gatelock_core import _GateCore -from python_pkg.diet_guard._gatelock_ui import BG - -if TYPE_CHECKING: - from types import FrameType - -_logger = logging.getLogger(__name__) - -# Periodic no-op so the grabbed, event-starved loop keeps handing control back -# to Python, letting SIGTERM/SIGINT be serviced promptly. -_KEEPALIVE_MS = 250 -# A global input grab fails while another X client already holds one -- most -# often a FULLSCREEN GAME, which takes an exclusive keyboard/pointer grab. A -# single attempt then falls back to a *local* grab, which on an override-redirect -# window the WM refuses to focus means no keystroke ever reaches the field -- the -# "can't type anything" lock-trap. So the grab is retried for the window's whole -# life: the gate waits out the game and captures input the instant it is freed. -_GRAB_RETRY_MS = 200 -# How often (in attempts) to log that the grab is still blocked, so the journal -# shows the gate is alive and waiting rather than hung. ~every 5 s at 200 ms. -_GRAB_LOG_EVERY = 25 - - -class _GateWindow(_GateCore): - """Fullscreen window setup, input grab, and exit-path lifecycle.""" - - # -- window mechanics (reused screen-locker pattern) -------------------- - - def _setup_window(self) -> None: - """Configure the lock window. - - Demo mode stays WM-managed so the window manager still grants it - keyboard focus -- and you can always close it -- making a usable, safe - sandbox. Only the real lock uses ``overrideredirect``, where the tiling - WM refuses focus and input is instead forced in by a global grab. - """ - screen_w = self.root.winfo_screenwidth() - screen_h = self.root.winfo_screenheight() - self.root.geometry(f"{screen_w}x{screen_h}+0+0") - self.root.attributes(topmost=True) - self.root.configure(bg=BG, cursor="arrow") - if self.demo_mode: - self.root.attributes(fullscreen=True) - else: - self.root.overrideredirect(boolean=True) - self.root.attributes(fullscreen=True) - self._disable_vt_switching() - - def _disable_vt_switching(self) -> None: - """Block Ctrl+Alt+Fn TTY switching while the lock is up (best-effort).""" - setxkbmap = shutil.which("setxkbmap") - if setxkbmap is None: - _logger.warning("setxkbmap not found; VT switching stays enabled") - return - subprocess.run([setxkbmap, "-option", "srvrkeys:none"], check=False) - self._vt_disabled = True - - def _restore_vt_switching(self) -> None: - """Re-enable VT switching; idempotent and safe to call on any exit.""" - if not self._vt_disabled: - return - setxkbmap = shutil.which("setxkbmap") - if setxkbmap is not None: - subprocess.run([setxkbmap, "-option", ""], check=False) - self._vt_disabled = False - - def _grab_input(self) -> None: - """Force input to the window, then focus the first field. - - Demo mode relies on normal WM focus (no grab), keeping the window an - escapable sandbox. The real lock forces *all* input here with a global - grab -- the only mechanism that reaches an overrideredirect window the - tiling WM will not focus. The grab is acquired with retries because it - commonly fails on the first attempt while the window is still mapping. - """ - self.root.update_idletasks() - self.root.focus_force() - if not self.demo_mode: - self._acquire_global_grab(attempt=1) - self.root.after(100, self._focus_first_field) - - def _acquire_global_grab(self, *, attempt: int) -> None: - """Acquire the global input grab, retrying until it succeeds. - - A successful global grab is the only way keystrokes reach the - override-redirect window the WM will not focus. When another client - (typically a fullscreen game) holds the grab, the attempt is rescheduled - indefinitely rather than conceding to an unusable local grab, so the gate - waits the other application out and captures input the moment it frees - the grab. On success, focus is forced onto the description field so the - first keystroke lands there. - - Args: - attempt: 1-based attempt counter, used only to throttle the log. - """ - try: - self.root.grab_set_global() - except tk.TclError: - if attempt % _GRAB_LOG_EVERY == 0: - _logger.warning( - "global grab still blocked after %d attempts (another app -- " - "e.g. a fullscreen game -- holds it); waiting for it to free", - attempt, - ) - self.root.after( - _GRAB_RETRY_MS, - lambda: self._acquire_global_grab(attempt=attempt + 1), - ) - return - with contextlib.suppress(tk.TclError): - self.root.focus_force() - self._focus_first_field() - - def _focus_first_field(self) -> None: - """Put keyboard focus on the description entry once it is mapped.""" - with contextlib.suppress(tk.TclError): - self._widgets.desc_text.focus_force() - - # -- lifecycle ------------------------------------------------------------ - - def _install_signal_handlers(self) -> None: - """Ensure VT switching is restored on crash or kill, not just close.""" - atexit.register(self._restore_vt_switching) - for sig in (signal.SIGTERM, signal.SIGINT): - with contextlib.suppress(ValueError): - signal.signal(sig, self._on_signal) - - def _on_signal(self, _signum: int, _frame: FrameType | None) -> None: - """Restore the keyboard escape, then exit, on SIGTERM/SIGINT.""" - self._restore_vt_switching() - raise SystemExit(0) - - def _keepalive(self) -> None: - """Re-arm a periodic no-op so pending signals get serviced promptly.""" - self.root.after(_KEEPALIVE_MS, self._keepalive) - - def close(self) -> None: - """Restore VT switching and destroy the window (no process exit).""" - self._restore_vt_switching() - with contextlib.suppress(tk.TclError): - self.root.destroy() - - def run(self) -> None: - """Run the Tk loop, restoring VT switching on every exit path.""" - self._install_signal_handlers() - self._keepalive() - try: - self.root.mainloop() - finally: - self._restore_vt_switching() diff --git a/diet_guard/_state.py b/diet_guard/_state.py index 68dd910..e4b205e 100644 --- a/diet_guard/_state.py +++ b/diet_guard/_state.py @@ -15,14 +15,15 @@ import json import logging from typing import TYPE_CHECKING -from python_pkg.diet_guard._budget import daily_budget -from python_pkg.diet_guard._constants import BUDGET_WARN_FRACTION, FOOD_LOG_FILE -from python_pkg.shared.coerce import as_float -from python_pkg.shared.log_integrity import ( +from gatelock.log_integrity import ( compute_entry_hmac, verify_entry_hmac, ) +from python_pkg.diet_guard._budget import daily_budget +from python_pkg.diet_guard._constants import BUDGET_WARN_FRACTION, FOOD_LOG_FILE +from python_pkg.shared.coerce import as_float + if TYPE_CHECKING: from python_pkg.diet_guard._estimator import Nutrition diff --git a/diet_guard/tests/conftest.py b/diet_guard/tests/conftest.py index 3ae64c3..41dcd00 100644 --- a/diet_guard/tests/conftest.py +++ b/diet_guard/tests/conftest.py @@ -1,12 +1,15 @@ """Shared fixtures for diet_guard tests. -Two safety nets run for every test: +Three safety nets run for every test: * ``_isolate_state`` redirects the food log, sealed budget, and gate lock into ``tmp_path`` so a test can never read or clobber the real ``~/.local/share``. -* ``_block_real_tk`` swaps ``tk`` and the ``_GateRoot`` window class inside +* ``_block_real_tk`` swaps ``tk`` and the ``GateRoot`` window class inside ``_gatelock`` for mocks, so no test can open a real fullscreen window or grab the keyboard even if it forgets to. +* ``_block_real_vt`` makes ``gatelock``'s VT-switch disable a no-op, so a + prod-mode (``demo_mode=False``) gate built in a test never runs a real + ``setxkbmap`` against the live X session. The ``gate`` fixture and its supporting fakes (``FakeEntry``, ``_FAKE_TK``, ...) build a demo :class:`~python_pkg.diet_guard._gatelock.MealGate` whose widgets @@ -29,7 +32,6 @@ from python_pkg.diet_guard import ( _gatelock_mealflow, _gatelock_nutrition, _gatelock_ui, - _gatelock_window, ) from python_pkg.diet_guard._estimator import Nutrition from python_pkg.diet_guard._gatelock import MealGate @@ -68,11 +70,24 @@ def _block_real_tk() -> Iterator[None]: """Replace tk + the window class in _gatelock so no real window can open.""" with ( patch("python_pkg.diet_guard._gatelock.tk", MagicMock()), - patch("python_pkg.diet_guard._gatelock._GateRoot", MagicMock()), + patch("python_pkg.diet_guard._gatelock.GateRoot", MagicMock()), ): yield +@pytest.fixture(autouse=True) +def _block_real_vt() -> Iterator[None]: + """Make gatelock's VT-switch disable a no-op for every test. + + Belt-and-suspenders alongside ``_block_real_tk``: VT-disable now lives in + ``gatelock``, independent of the (mocked) root, so a test that builds a + real prod-mode (``demo_mode=False``) gate would otherwise run a genuine + ``setxkbmap`` against whatever X session the test happens to run under. + """ + with patch("gatelock._vt.shutil.which", return_value=None): + yield + + @pytest.fixture(autouse=True) def _hmac_key(tmp_path: Path) -> Iterator[None]: """Point the shared HMAC key at a deterministic temp file. @@ -83,7 +98,7 @@ def _hmac_key(tmp_path: Path) -> Iterator[None]: """ key = tmp_path / "hmac.key" key.write_bytes(b"diet-guard-test-key-0123456789ab") - with patch("python_pkg.shared.log_integrity.HMAC_KEY_FILE", key): + with patch("gatelock.log_integrity.DEFAULT_HMAC_KEY_FILE", key): yield @@ -224,7 +239,6 @@ _FAKE_TK = SimpleNamespace( _GATE_TK_MODULES = ( _gatelock, _gatelock_core, - _gatelock_window, _gatelock_nutrition, _gatelock_mealflow, _gatelock_ui, diff --git a/diet_guard/tests/test_gatelock.py b/diet_guard/tests/test_gatelock.py index 15ec60d..4ad7a0c 100644 --- a/diet_guard/tests/test_gatelock.py +++ b/diet_guard/tests/test_gatelock.py @@ -1,6 +1,8 @@ """Tests for _gatelock.py — the fullscreen log-to-unlock gate window. -Window mechanics, construction, and the shared module-level helpers. The +Construction, MealGate's gatelock wiring (LockConfig choice, hooks), and the +shared module-level helpers. The fullscreen/grab/VT-disable mechanics +themselves are tested in the ``gatelock`` package, not here. The nutrition/meal-flow tests live in :mod:`test_gatelock_mealflow`; the functional fake ``tk`` widgets and the ``gate`` fixture live in ``conftest.py`` and are shared by both files. @@ -17,7 +19,6 @@ from python_pkg.diet_guard import ( _gatelock, _gatelock_support, _gatelock_ui, - _gatelock_window, ) from python_pkg.diet_guard._budget import seal_budget from python_pkg.diet_guard._gatelock import ( @@ -30,15 +31,9 @@ from python_pkg.diet_guard._gatelock_core import _safe_float from python_pkg.diet_guard._gatelock_nutrition import _format_preview from python_pkg.diet_guard._gatelock_support import wait_for_display from python_pkg.diet_guard._gatelock_ui import DEFAULT_PER_GRAMS -from python_pkg.diet_guard._gatelock_window import _GRAB_LOG_EVERY from python_pkg.diet_guard._portions import DEFAULT_ITEM_GRAMS from python_pkg.diet_guard.tests.conftest import _FAKE_TK, _FakeTclError, _nutrition -# Captured before any autouse fixture patches the module attribute, so the real -# class (not the conftest MagicMock) is available for its callback-error test. -_REAL_GATE_ROOT = _gatelock._GateRoot - - # -------------------------------------------------------------------------- # Module-level helpers # -------------------------------------------------------------------------- @@ -109,27 +104,6 @@ class TestAssertNotUnderPytest: _gatelock._assert_not_under_pytest() -class TestGateRootCallback: - """The root's callback-exception routing.""" - - def test_routes_to_handler(self) -> None: - """A set handler is invoked on a callback error.""" - root = _REAL_GATE_ROOT.__new__(_REAL_GATE_ROOT) - root.on_callback_error = MagicMock() - _REAL_GATE_ROOT.report_callback_exception( - root, ValueError, ValueError("x"), None - ) - root.on_callback_error.assert_called_once() - - def test_no_handler_is_safe(self) -> None: - """With no handler set, the error is just logged.""" - root = _REAL_GATE_ROOT.__new__(_REAL_GATE_ROOT) - root.on_callback_error = None - _REAL_GATE_ROOT.report_callback_exception( - root, ValueError, ValueError("x"), None - ) - - # -------------------------------------------------------------------------- # Construction # -------------------------------------------------------------------------- @@ -139,18 +113,17 @@ class TestConstruction: """Building the window in both modes.""" def test_demo_builds(self, gate: MealGate) -> None: - """A demo gate constructs with a pending slot and grams basis.""" + """A demo gate constructs with a pending slot, grams basis, and a soft lock.""" assert gate.demo_mode is True assert gate._vars.unit.get() == "grams" + assert gate._lock._config.mode == "soft" def test_production_builds(self) -> None: - """A production gate disables VT switching and grabs input.""" - with ( - patch.object(_gatelock, "tk", _FAKE_TK), - patch.object(_gatelock_window.shutil, "which", return_value=None), - ): + """A production gate builds with a hard lock config.""" + with patch.object(_gatelock, "tk", _FAKE_TK): gate = MealGate(demo_mode=False) assert gate.demo_mode is False + assert gate._lock._config.mode == "hard" # -------------------------------------------------------------------------- @@ -258,93 +231,35 @@ class TestBasisAndAmount: assert gate._widgets.amount_entry.get() == "50" -class TestWindowMechanics: - """VT switching, grabbing, signals, and teardown.""" +class TestLockDelegation: + """MealGate's gatelock wiring: hooks delegate, run()/close() delegate.""" - def test_disable_vt_no_tool(self, gate: MealGate) -> None: - """A missing setxkbmap leaves VT switching enabled.""" - with patch.object(_gatelock_window.shutil, "which", return_value=None): - gate._disable_vt_switching() - assert gate._vt_disabled is False + def test_on_focus_ready_focuses_desc_text(self, gate: MealGate) -> None: + """on_focus_ready puts keyboard focus on the description box.""" + gate._widgets.desc_text.focus_force = MagicMock() + gate.on_focus_ready() + gate._widgets.desc_text.focus_force.assert_called_once() - def test_disable_and_restore_vt(self, gate: MealGate) -> None: - """With the tool present, VT switching toggles off then back on.""" - with ( - patch.object(_gatelock_window.shutil, "which", return_value="/x/setxkbmap"), - patch.object(_gatelock_window.subprocess, "run") as run, - ): - gate._disable_vt_switching() - assert gate._vt_disabled is True - gate._restore_vt_switching() - assert gate._vt_disabled is False - assert run.call_count == 2 - - def test_restore_when_not_disabled(self, gate: MealGate) -> None: - """Restoring when never disabled is a no-op.""" - gate._vt_disabled = False - gate._restore_vt_switching() - - def test_grab_success(self, gate: MealGate) -> None: - """A successful grab focuses the first field.""" - gate.root.grab_set_global = MagicMock() - gate._acquire_global_grab(attempt=1) - - def test_grab_retries_on_conflict(self, gate: MealGate) -> None: - """A held grab reschedules another attempt instead of giving up.""" - gate.root.grab_set_global = MagicMock(side_effect=_FakeTclError) - gate.root.after = MagicMock() - gate._acquire_global_grab(attempt=_GRAB_LOG_EVERY) - gate.root.after.assert_called_once() - - def test_focus_first_field(self, gate: MealGate) -> None: - """Focusing the first field is safe.""" - gate._focus_first_field() - - def test_keepalive_rearms(self, gate: MealGate) -> None: - """The keepalive reschedules itself.""" - gate.root.after = MagicMock() - gate._keepalive() - gate.root.after.assert_called_once() - - def test_signal_restores_and_exits(self, gate: MealGate) -> None: - """A termination signal restores VT switching and exits.""" - with pytest.raises(SystemExit): - gate._on_signal(15, None) - - def test_run_installs_and_loops(self, gate: MealGate) -> None: - """run wires handlers, starts the loop, and restores on exit.""" - gate.root.mainloop = MagicMock() - with ( - patch.object(_gatelock_window.signal, "signal"), - patch.object(_gatelock_window.atexit, "register"), - ): - gate.run() - gate.root.mainloop.assert_called_once() - - def test_close(self, gate: MealGate) -> None: - """Close restores VT switching and destroys the window.""" - gate.root.destroy = MagicMock() - gate.close() - gate.root.destroy.assert_called_once() + def test_on_close_is_a_noop(self, gate: MealGate) -> None: + """on_close has no hardware/state to release; must not raise.""" + gate.on_close() def test_callback_error_status(self, gate: MealGate) -> None: """An unexpected callback error surfaces a recoverable message.""" - gate._handle_callback_error() + gate.on_callback_error() assert "went wrong" in gate._vars.status.get() - def test_restore_vt_without_tool(self, gate: MealGate) -> None: - """Restoring when the tool has since vanished still clears the flag.""" - gate._vt_disabled = True - with patch.object(_gatelock_window.shutil, "which", return_value=None): - gate._restore_vt_switching() - assert gate._vt_disabled is False + def test_run_delegates_to_lock(self, gate: MealGate) -> None: + """run() hands off to the owned LockWindow.""" + with patch.object(gate._lock, "run") as mock_run: + gate.run() + mock_run.assert_called_once_with() - def test_grab_retry_without_log(self, gate: MealGate) -> None: - """An early blocked attempt reschedules without logging.""" - gate.root.grab_set_global = MagicMock(side_effect=_FakeTclError) - gate.root.after = MagicMock() - gate._acquire_global_grab(attempt=1) - gate.root.after.assert_called_once() + def test_close_delegates_to_lock(self, gate: MealGate) -> None: + """close() hands off to the owned LockWindow.""" + with patch.object(gate._lock, "close") as mock_close: + gate.close() + mock_close.assert_called_once_with() class TestDisplayReadiness: