mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-07-04 13:43:30 +02:00
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:
parent
84898c0272
commit
205b214d78
@ -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__)
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.",
|
||||
|
||||
@ -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()
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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:
|
||||
|
||||
Loading…
Reference in New Issue
Block a user