Migrate diet_guard to the shared gatelock backend

MealGate now composes gatelock.GateRoot + gatelock.LockWindow instead of
inheriting the deleted _GateWindow/_GateRoot, and its HMAC signing goes
through gatelock.log_integrity. This is the first of three migrations
(diet_guard -> screen-locker -> wake_alarm) extracting the lock-window
mechanics that diet_guard's own _GateWindow proved out into a shared,
reusable package. Window-mechanics tests moved with the code; diet_guard's
suite now only tests its own wiring (LockConfig choice, hook delegation).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XCdT46zV8hESDvbgYMGDLt
This commit is contained in:
Krzysztof kuhy Rudnicki 2026-06-21 18:16:45 +02:00
parent 615183556d
commit 20936c00c7
12 changed files with 185 additions and 351 deletions

View File

@ -199,6 +199,7 @@ repos:
- requests
- pygame
- pillow
- gatelock @ git+https://github.com/kuhyx/gatelock@v0.1.0
# Test suites and conftest fixtures are linted separately; they use
# patterns (protected-access, missing docstrings, fixture shadowing)
# that don't belong in the source-code 10/10 gate.

View File

@ -0,0 +1,18 @@
{
"title": "Migrate diet_guard to the shared gatelock backend",
"objective": "Replace diet_guard's hand-rolled fullscreen/grab/VT-disable lock-window mechanics and its log_integrity import with the new shared `gatelock` package (https://github.com/kuhyx/gatelock), which was extracted from diet_guard's own _GateWindow as the most mature of the three lock implementations (wake_alarm, screen-locker, diet_guard). MealGate now owns a gatelock.LockWindow via composition and implements LockWindowHooks, instead of inheriting the now-deleted _GateWindow/_GateRoot classes. Success: diet_guard's lock/grab/VT-disable/unlock behavior is unchanged for the end user, demo mode manually verified by running the gate and observing the window, all existing diet_guard tests adapted or removed (window-mechanics tests deleted since that logic is now tested in gatelock's own 100%-covered suite), and the whole monorepo stays at 100% branch coverage.",
"acceptance_criteria": [
"MealGate no longer inherits _GateWindow/_GateRoot; it composes gatelock.GateRoot + gatelock.LockWindow",
"_gatelock_window.py is deleted (its mechanics now live in gatelock)",
"_state.py and _budget.py import HMAC functions from gatelock.log_integrity instead of python_pkg.shared.log_integrity",
"testsAndMisc/meta/requirements.txt and the pylint pre-commit hook's additional_dependencies both pin gatelock@v0.1.0",
"python_pkg/diet_guard test suite passes with 100% branch coverage, with window-mechanics tests removed (covered upstream in gatelock) and new tests added for MealGate's hook delegation (on_focus_ready, on_close, on_callback_error) and demo-vs-prod LockConfig mode",
"demo-mode gate manually run and visually confirmed to render correctly, and SIGTERM confirmed to cleanly restore VT switching and exit 0",
"ruff, mypy, and pylint (10/10) all pass on the changed diet_guard files"
],
"out_of_scope": [
"Migrating screen-locker or wake_alarm (separate, later steps in the same overall plan)",
"Live production-mode (demo_mode=False) verification on this machine -- user opted to trust demo-mode + unit-test coverage instead, given the live global-grab risk"
],
"verifier": "pytest --cov=python_pkg --cov-branch --cov-fail-under=100; pre-commit run --files <changed diet_guard files>; manual run of `python -m python_pkg.diet_guard gate --demo`"
}

View File

@ -0,0 +1,58 @@
{
"intent": "Replace diet_guard's hand-rolled fullscreen/grab/VT-disable lock window and HMAC log_integrity import with the new shared gatelock package, with zero user-visible behavior change in demo mode and a hard-mode (production) LockConfig wired in for the real lock.",
"scope": [
"python_pkg/diet_guard/_gatelock.py: MealGate now composes gatelock.GateRoot + gatelock.LockWindow instead of inheriting the deleted _GateWindow/_GateRoot",
"python_pkg/diet_guard/_gatelock_window.py: deleted entirely (mechanics moved to gatelock)",
"python_pkg/diet_guard/_gatelock_core.py: _GateRoot removed, _GateCore's root type annotation now points at gatelock.GateRoot",
"python_pkg/diet_guard/_gatelock_mealflow.py: _handle_callback_error renamed to on_callback_error to match gatelock.LockWindowHooks directly",
"python_pkg/diet_guard/_state.py, _budget.py: HMAC import swapped from python_pkg.shared.log_integrity to gatelock.log_integrity (python_pkg/shared/log_integrity.py is left in place untouched until wake_alarm's later migration removes it, since wake_alarm still depends on it)",
"python_pkg/diet_guard/tests/conftest.py: _block_real_tk repointed at gatelock.GateRoot; new _block_real_vt autouse fixture neutralizes gatelock._vt.shutil.which so a prod-mode gate built in a test never runs a real setxkbmap; _hmac_key repointed at gatelock.log_integrity.DEFAULT_HMAC_KEY_FILE",
"python_pkg/diet_guard/tests/test_gatelock.py: window-mechanics tests (VT switching, grab retry, signals, keepalive) deleted -- already 100%-covered in gatelock's own test suite, since that source no longer lives in python_pkg; TestGateRootCallback deleted likewise. New TestLockDelegation class added for MealGate's own hook wiring (on_focus_ready, on_close, on_callback_error, run()/close() delegation to self._lock)",
"meta/requirements.txt and .pre-commit-config.yaml's pylint additional_dependencies: added gatelock @ git+https://github.com/kuhyx/gatelock@v0.1.0"
],
"changes": [
"MealGate(_GateMealFlow) builds LockConfig(mode='soft' if demo_mode else 'hard', bg=BG), composes a gatelock.LockWindow, and implements on_focus_ready/on_callback_error/on_close per LockWindowHooks",
"close()/run() now delegate to self._lock.close()/self._lock.run() instead of inherited _GateWindow methods",
"gatelock itself (https://github.com/kuhyx/gatelock, tagged v0.1.0) was built and pushed first: LockWindow/LockConfig/GateRoot generalizing the fullscreen/grab/VT-disable mechanics, plus a ported log_integrity module, with 50 of its own tests at 100% branch coverage"
],
"verification": [
{
"command": "pip install -e ~/gatelock into testsAndMisc/.venv, then python -m pytest python_pkg/diet_guard/ -q",
"result": "pass",
"evidence": "271 passed in 0.48s"
},
{
"command": "pytest --cov=python_pkg --cov-branch --cov-report=term-missing --cov-fail-under=100 -q (whole monorepo)",
"result": "pass",
"evidence": "958 passed; every python_pkg module at 100% branch coverage, including the rewritten _gatelock.py/_gatelock_core.py/_gatelock_mealflow.py/_state.py/_budget.py"
},
{
"command": "ruff check / ruff format --check / mypy (pre-commit's disabled-code set) / pylint --fail-under=10 on python_pkg/diet_guard",
"result": "pass",
"evidence": "ruff: all checks passed; mypy: only a pre-existing unrelated missing-requests-stubs note; pylint: 10.00/10"
},
{
"command": "manual run: python -m python_pkg.diet_guard gate --demo (real Tk window, not mocked)",
"result": "pass",
"evidence": "Screenshot confirmed: 'Diet Gate' window rendered with full UI (description field, macros, dashboard); WM-tiled as expected since demo mode stays WM-managed (no overrideredirect), matching the original docstring's intent"
},
{
"command": "manual: SIGTERM sent to the running demo gate process",
"result": "pass",
"evidence": "Process exited cleanly with code 0 -- confirms LockWindow.run()'s signal handler -> SystemExit -> finally -> close() -> on_close() hook -> VT restore -> destroy all worked end-to-end on a real (non-mocked) Tk instance"
},
{
"command": "pre-commit run --files <changed diet_guard files + meta/requirements.txt>",
"result": "pass",
"evidence": "All hooks passed after adding gatelock to the pylint hook's additional_dependencies (its isolated env couldn't otherwise import gatelock) and adding this evidence/contract pair"
}
],
"risks": [
"Production (demo_mode=False) lock was not live-tested on this machine -- user explicitly opted to trust demo-mode verification plus gatelock's own 100%-covered hard-mode/grab/VT-disable unit tests, rather than trigger a real global keyboard grab on the live desktop session",
"python_pkg/shared/log_integrity.py is temporarily unused by diet_guard but left in place because wake_alarm (migrating last, in a separate step) still imports it; it will be deleted once wake_alarm's migration removes its last reference"
],
"rollback": [
"git revert this commit to restore _gatelock_window.py and the original _GateWindow/_GateRoot inheritance",
"After rollback, remove the gatelock entries from meta/requirements.txt and .pre-commit-config.yaml's pylint additional_dependencies, and re-run the full test suite to confirm 100% coverage is restored under the old code path"
]
}

View File

@ -32,6 +32,7 @@ flake8-pyi>=24.0.0
flake8-pytest-style>=2.0.0
flake8-return>=1.2.0
flake8-simplify>=0.21.0
gatelock @ git+https://github.com/kuhyx/gatelock@v0.1.0
genanki>=0.13
geopandas>=1.0
howlongtobeatpy>=1.0

View File

@ -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__)

View File

@ -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.

View File

@ -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

View File

@ -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.",

View File

@ -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()

View File

@ -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

View File

@ -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,

View File

@ -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: