mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-11 13:27:39 +01:00
Four asks, plus the fixes two review passes turned up. Removals (all unused): - "Repeat last meal" button, and the reward/temptation-bundling prompt it was the sole trigger for. Replaced with a post-log progress card showing kcal against budget, macros, and the adherence streak. - The multi-item meal builder, on both the gate and the phone. The stored `components` field stays: nothing writes it now, but historical composite entries and the food bank still read it and it is part of the wire format. - Meal photos entirely: 13 files, `imagePath`, the photo filter, the wrapper's /blobs/ route, image_picker, and the CAMERA permission. Reminder false alarms had three real causes, all fixed: - Logging never reconciled notifications, so a reminder sat on screen up to 15 minutes after the meal was logged -- on the phone itself. - The background check never pulled, so a PC-logged meal nagged forever until the app was opened. Now mirrors _cli_gate._should_lock: cheap local check, network only when a slot looks due, then re-check. - Meals before 08:00 / after 22:00 were attributed to no slot at all. New slot_for_log/slotForLog clamps them to the first/last slot, byte-identical across Python and Dart. Deliberately separate from elapsed_slots, which would otherwise make every slot fall due at 23:00. Budget history, so lowering the budget stops rewriting the past: - Forward-only (effective_from, kcal) entries; each day is judged against the budget in force then. Verified live: setting 2000 left a 36-day adherence streak and the whole calendar pixel-identical. - Rides as hist:<date> fields on the existing budget record. crdt_sync merges per-field over the union of field names and both devices push the merged log, so a device that predates the feature relays them untouched -- no coordinated rollout, no second document. - The seed guard tests emptiness, not file presence: _sync can write an empty history after merging with a pre-feature peer, and a presence check would then never seed again, silently reclassifying every past day. One shared source of truth for every piece of data: - Body weight now travels as its own `weight` CRDT field. Inside `value` it was collateral damage of whole-map LWW -- any phone budget edit deleted it, and the protein target with it. - Both halves of the food bank sync. The curated half (foods added without eating them) was the last device-local state. The derived half merges with count as the clock, so LWW means max-count-wins; the log stays authoritative for membership, because a CRDT union never shrinks and a peer's stale copy would otherwise resurrect a deleted food permanently. - Bank adapters honour tombstones, and Dart parses bank records defensively: a cast failure there is an Error, not an Exception, so one malformed remote record would have broken phone sync permanently. _test_guard.py blocks any pathlib write to real user state while pytest is in the process, whatever conftest is loaded. A throwaway test run outside the package conftest destroyed a real GitHub token during this work; this makes that class of accident impossible rather than merely discouraged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
395 lines
13 KiB
Python
395 lines
13 KiB
Python
"""Shared fixtures for diet_guard tests.
|
|
|
|
Three safety nets run for every test:
|
|
|
|
* ``_isolate_state`` redirects the food log, sealed budget, gate lock, and
|
|
sync token into ``tmp_path`` so a test can never read or clobber the real
|
|
``~/.local/share`` or ``~/.config/diet_guard``.
|
|
* ``_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:`~diet_guard._gatelock.MealGate` whose widgets
|
|
are functional in-memory stand-ins, shared by ``test_gatelock.py`` and
|
|
``test_gatelock_mealflow.py``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from contextlib import ExitStack
|
|
from types import SimpleNamespace
|
|
from typing import TYPE_CHECKING
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
from gatelock import Output, OutputRect
|
|
import pytest
|
|
|
|
from diet_guard import (
|
|
_gatelock,
|
|
_gatelock_buttons,
|
|
_gatelock_calendar,
|
|
_gatelock_calendar_ui,
|
|
_gatelock_core,
|
|
_gatelock_mealflow,
|
|
_gatelock_nutrition,
|
|
_gatelock_ui,
|
|
)
|
|
from diet_guard._estimator import Nutrition
|
|
from diet_guard._gatelock import MealGate
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Iterator
|
|
from pathlib import Path
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _isolate_state(tmp_path: Path) -> Iterator[None]:
|
|
"""Redirect all on-disk diet_guard state into a temp dir."""
|
|
with (
|
|
patch(
|
|
"diet_guard._budget.BUDGET_FILE",
|
|
tmp_path / ".budget",
|
|
),
|
|
patch(
|
|
"diet_guard._budget_history.BUDGET_HISTORY_FILE",
|
|
tmp_path / ".budget_history",
|
|
),
|
|
patch(
|
|
"diet_guard._state.FOOD_LOG_FILE",
|
|
tmp_path / "food_log.json",
|
|
),
|
|
patch(
|
|
"diet_guard._foodbank.FOOD_BANK_FILE",
|
|
tmp_path / "food_bank.json",
|
|
),
|
|
patch(
|
|
"diet_guard._foodbank_manual.MANUAL_BANK_FILE",
|
|
tmp_path / "food_bank_manual.json",
|
|
),
|
|
patch(
|
|
"diet_guard._gatelock.GATE_LOCK_FILE",
|
|
tmp_path / ".gate.lock",
|
|
),
|
|
patch(
|
|
"diet_guard._sync.SYNC_TOKEN_FILE",
|
|
tmp_path / "sync_token",
|
|
),
|
|
):
|
|
yield
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _block_real_tk() -> Iterator[None]:
|
|
"""Replace tk + the window class in _gatelock so no real window can open."""
|
|
with (
|
|
patch("diet_guard._gatelock.tk", MagicMock()),
|
|
patch("diet_guard._gatelock.GateRoot", MagicMock()),
|
|
):
|
|
yield
|
|
|
|
|
|
FAKE_OUTPUTS = (
|
|
Output("DP-0", connected=True, rect=OutputRect(0, 0, 1920, 1080), primary=True),
|
|
)
|
|
"""One live output by default, so the Tk-screen fallback (which would call
|
|
int() on a MagicMock) is never reached."""
|
|
|
|
TWO_OUTPUTS = (
|
|
Output("DP-0", connected=True, rect=OutputRect(0, 0, 3840, 2160), primary=True),
|
|
Output("HDMI-0", connected=True, rect=OutputRect(3840, 0, 2560, 1440)),
|
|
)
|
|
"""The real desk. Opt in with ``dual_output`` for anything asserting that the
|
|
gate is really built on every monitor."""
|
|
|
|
|
|
def _make_toplevel(_parent: object = None, **_kwargs: object) -> MagicMock:
|
|
"""A Toplevel stand-in for gatelock's per-output surfaces."""
|
|
window = MagicMock()
|
|
window.winfo_children.return_value = []
|
|
return window
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _hermetic_gatelock(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> Iterator[None]:
|
|
"""Keep gatelock v0.2.0's per-output machinery off the real machine.
|
|
|
|
``_block_real_tk`` patches ``tk`` inside *diet_guard*, which is no longer
|
|
enough: ``LockWindow.setup()`` builds one real ``tk.Toplevel`` per live
|
|
output through *gatelock's* own ``tk``. Over a MagicMock root that sends
|
|
real tkinter into unbounded mock recursion, so the suite **hangs** rather
|
|
than failing. The runtime dir is redirected too, so no test can stand a
|
|
production locker down through the arbiter.
|
|
"""
|
|
monkeypatch.setenv("GATELOCK_RUNTIME_DIR", str(tmp_path / "gatelock-runtime"))
|
|
with (
|
|
patch("gatelock._surfaces.tk.Toplevel", side_effect=_make_toplevel),
|
|
patch("gatelock._outputs.RandrBackend.create", return_value=None),
|
|
patch("gatelock._outputs.scan_xrandr", return_value=FAKE_OUTPUTS),
|
|
patch("gatelock._detect._RandrEventSource.start", return_value=False),
|
|
):
|
|
yield
|
|
|
|
|
|
@pytest.fixture
|
|
def dual_output(_hermetic_gatelock: None) -> Iterator[None]:
|
|
"""Re-scan as a two-monitor desk, layered over the single-output default."""
|
|
with patch("gatelock._outputs.scan_xrandr", return_value=TWO_OUTPUTS):
|
|
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.
|
|
|
|
Makes signing/verification work the same in any environment (including CI,
|
|
which has no ``/etc/workout-locker/hmac.key``). Tests that need the
|
|
no-key path patch ``compute_entry_hmac`` to return None locally.
|
|
"""
|
|
key = tmp_path / "hmac.key"
|
|
key.write_bytes(b"diet-guard-test-key-0123456789ab")
|
|
with patch("gatelock.log_integrity.DEFAULT_HMAC_KEY_FILE", key):
|
|
yield
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Gate fixture and its functional tk fakes
|
|
# --------------------------------------------------------------------------
|
|
#
|
|
# A functional fake ``tk`` (stateful Entry/Text/Listbox/StringVar widgets and a
|
|
# real, catchable ``TclError``) replaces the blanket MagicMock above for the
|
|
# duration of each gate test, so the window's *logic* runs for real against
|
|
# in-memory widgets without ever opening a window or grabbing the keyboard.
|
|
|
|
|
|
class _FakeTclError(Exception):
|
|
"""Stand-in for ``tkinter.TclError`` (a real, catchable exception)."""
|
|
|
|
|
|
class FakeVar:
|
|
"""A functional ``StringVar``: stores and returns a string."""
|
|
|
|
def __init__(self, master: object = None, value: str = "") -> None:
|
|
self._value = value
|
|
|
|
def get(self) -> str:
|
|
return self._value
|
|
|
|
def set(self, value: str) -> None:
|
|
self._value = value
|
|
|
|
|
|
class FakeEntry:
|
|
"""A functional one-line entry (delete clears, insert appends).
|
|
|
|
``configure``/``config`` record their kwargs into ``configured``, so a
|
|
test can assert on a read-only/editable state toggle the same way it
|
|
would with :class:`FakeWidget`.
|
|
"""
|
|
|
|
def __init__(self, *args: object, **kwargs: object) -> None:
|
|
self._value = ""
|
|
self.configured: dict[str, object] = dict(kwargs)
|
|
|
|
def get(self) -> str:
|
|
return self._value
|
|
|
|
def delete(self, first: object, last: object = None) -> None:
|
|
self._value = ""
|
|
|
|
def insert(self, index: object, text: str) -> None:
|
|
self._value += text
|
|
|
|
def pack(self, *args: object, **kwargs: object) -> FakeEntry:
|
|
return self
|
|
|
|
def bind(self, *args: object, **kwargs: object) -> None:
|
|
pass
|
|
|
|
def configure(self, *args: object, **kwargs: object) -> None:
|
|
self.configured.update(kwargs)
|
|
|
|
config = configure
|
|
|
|
def focus_set(self) -> None:
|
|
pass
|
|
|
|
def focus_force(self) -> None:
|
|
pass
|
|
|
|
|
|
class FakeText(FakeEntry):
|
|
"""A functional multi-line text box (``get`` ignores the index range)."""
|
|
|
|
def get(self, start: object = None, end: object = None) -> str:
|
|
return self._value
|
|
|
|
|
|
class FakeListbox:
|
|
"""A functional listbox tracking items and the current selection."""
|
|
|
|
def __init__(self, *args: object, **kwargs: object) -> None:
|
|
self._items: list[str] = []
|
|
self._sel: tuple[int, ...] = ()
|
|
|
|
def delete(self, first: object, last: object = None) -> None:
|
|
self._items = []
|
|
|
|
def insert(self, index: object, text: str) -> None:
|
|
self._items.append(text)
|
|
|
|
def curselection(self) -> tuple[int, ...]:
|
|
return self._sel
|
|
|
|
def selection_set(self, index: int) -> None:
|
|
self._sel = (index,)
|
|
|
|
def selection_clear(self, first: object, last: object = None) -> None:
|
|
self._sel = ()
|
|
|
|
def pack(self, *args: object, **kwargs: object) -> FakeListbox:
|
|
return self
|
|
|
|
def bind(self, *args: object, **kwargs: object) -> None:
|
|
pass
|
|
|
|
|
|
class FakeWidget:
|
|
"""A generic no-op widget for Frame/Label/Button/OptionMenu.
|
|
|
|
``configure``/``config`` record their kwargs into ``configured`` (rather
|
|
than discarding them) so a test can assert on a widget's last-set color
|
|
or text, e.g. the calendar's per-cell status coloring.
|
|
"""
|
|
|
|
def __init__(self, *args: object, **kwargs: object) -> None:
|
|
self.configured: dict[str, object] = dict(kwargs)
|
|
|
|
def pack(self, *args: object, **kwargs: object) -> FakeWidget:
|
|
return self
|
|
|
|
def place(self, *args: object, **kwargs: object) -> FakeWidget:
|
|
return self
|
|
|
|
def grid(self, *args: object, **kwargs: object) -> FakeWidget:
|
|
return self
|
|
|
|
def configure(self, *args: object, **kwargs: object) -> FakeWidget:
|
|
self.configured.update(kwargs)
|
|
return self
|
|
|
|
config = configure
|
|
|
|
def bind(self, *args: object, **kwargs: object) -> None:
|
|
pass
|
|
|
|
def register(self, func: object) -> object:
|
|
"""Stand in for Tk's Tcl-command registration: return the callable as-is."""
|
|
return func
|
|
|
|
|
|
class FakeNotebook(FakeWidget):
|
|
"""A functional ``ttk.Notebook``: tracks added tabs and the selection."""
|
|
|
|
def __init__(self, *args: object, **kwargs: object) -> None:
|
|
super().__init__(*args, **kwargs)
|
|
self.tabs: list[tuple[object, str]] = []
|
|
self._selected = 0
|
|
|
|
def add(self, child: object, *, text: str = "") -> None:
|
|
self.tabs.append((child, text))
|
|
|
|
def select(self, tab_id: object = None) -> int | None:
|
|
if tab_id is None:
|
|
return self._selected
|
|
if isinstance(tab_id, int):
|
|
self._selected = tab_id
|
|
else:
|
|
for index, (child, _label) in enumerate(self.tabs):
|
|
if child is tab_id:
|
|
self._selected = index
|
|
break
|
|
return None
|
|
|
|
|
|
class FakeStyle:
|
|
"""A no-op stand-in for ``ttk.Style`` -- records calls, applies nothing."""
|
|
|
|
def __init__(self, *args: object, **kwargs: object) -> None:
|
|
self.theme: str | None = None
|
|
self.configured: dict[str, dict[str, object]] = {}
|
|
|
|
def theme_use(self, theme_name: str) -> None:
|
|
self.theme = theme_name
|
|
|
|
def configure(self, style_name: str, **kwargs: object) -> None:
|
|
self.configured.setdefault(style_name, {}).update(kwargs)
|
|
|
|
def map(self, style_name: str, **kwargs: object) -> None:
|
|
self.configured.setdefault(style_name, {}).update(kwargs)
|
|
|
|
|
|
_FAKE_TK = SimpleNamespace(
|
|
END="end",
|
|
TclError=_FakeTclError,
|
|
StringVar=FakeVar,
|
|
Frame=FakeWidget,
|
|
Label=FakeWidget,
|
|
Button=FakeWidget,
|
|
OptionMenu=FakeWidget,
|
|
Entry=FakeEntry,
|
|
Text=FakeText,
|
|
Listbox=FakeListbox,
|
|
Event=object,
|
|
)
|
|
|
|
_FAKE_TTK = SimpleNamespace(Notebook=FakeNotebook, Style=FakeStyle)
|
|
|
|
# Every mixin module the gate window is built from imports ``tkinter``
|
|
# independently; all of them must see the fake so ``tk.TclError`` etc. are the
|
|
# catchable ``_FakeTclError`` everywhere a test raises it.
|
|
_GATE_TK_MODULES = (
|
|
_gatelock,
|
|
_gatelock_buttons,
|
|
_gatelock_calendar,
|
|
_gatelock_calendar_ui,
|
|
_gatelock_core,
|
|
_gatelock_nutrition,
|
|
_gatelock_mealflow,
|
|
_gatelock_ui,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def gate() -> Iterator[MealGate]:
|
|
"""Build a demo gate whose widgets are functional fakes."""
|
|
with ExitStack() as stack:
|
|
for module in _GATE_TK_MODULES:
|
|
stack.enter_context(patch.object(module, "tk", _FAKE_TK))
|
|
stack.enter_context(patch.object(_gatelock_calendar, "ttk", _FAKE_TTK))
|
|
stack.enter_context(patch.object(_gatelock_calendar_ui, "ttk", _FAKE_TTK))
|
|
yield MealGate(demo_mode=True)
|
|
|
|
|
|
def _nutrition(kcal: float = 100, grams: float = 100) -> Nutrition:
|
|
"""A simple reference nutrition for driving the gate form."""
|
|
return Nutrition(kcal, 10, 20, 5, grams, "food bank")
|