diet-guard/diet_guard/tests/conftest.py
Krzysztof kuhy Rudnicki e5b80fd610 Add cross-device log sync (Python half of Milestone 3)
Pulls every other device's pushed log from GitHub-backed dumb storage,
merges it with the local log, and pushes this device's own merged copy
back -- the PC half of the diet-guard-app sync plan.

- _sync_merge.py: pure union-by-id merge, tombstone always wins, legacy
  (time, desc) dedup for pre-id entries. Commutative and idempotent.
- _sync_github.py: minimal GitHub Contents API client (list/get/put),
  distinguishing a 404 on an unused path from the repo itself being
  unreachable.
- _sync.py: orchestration -- pull, merge, re-sign every persisted entry
  regardless of origin, write, rebuild the food bank, push. Re-signing
  unconditionally is load-bearing: an unsigned phone-origin entry would
  otherwise be silently dropped on the very next read once a machine
  holds the shared HMAC key.
- _foodbank.rebuild_food_bank(): the "replay a full log into a fresh
  bank" entrypoint the Python side was missing (the Dart port already
  had its equivalent). Backs sync's bank-rebuild step.
- New diet-guard-sync.service/.timer (15-minute cadence, headless, a
  separate unit from the gate so a held lock can't stall sync) and a
  new install.sh step to install them.
- Created the private kuhyx/diet-guard-sync GitHub repo for storage.

Incidental to this feature: adding the `sync` subcommand pushed _cli.py
past the repo's 500-line cap, so `gate`'s CLI glue moved out alongside
sync's into _cli_gate.py/_cli_sync.py -- same split pattern already used
for the gate window logic itself, not a sync-specific design choice.

338 tests, 100% branch coverage. Verified importing and running cleanly
under /usr/bin/python (the production interpreter), not just the dev
venv -- the gap that caused the earlier 3-day outage.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FU3f5KQ1GHXsbbSecfVEyF
2026-06-22 19:36:27 +02:00

265 lines
7.7 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
import pytest
from diet_guard import (
_gatelock,
_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._state.FOOD_LOG_FILE",
tmp_path / "food_log.json",
),
patch(
"diet_guard._foodbank.FOOD_BANK_FILE",
tmp_path / "food_bank.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
@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)."""
def __init__(self, *args: object, **kwargs: object) -> None:
self._value = ""
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:
pass
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."""
def __init__(self, *args: object, **kwargs: object) -> None:
pass
def pack(self, *args: object, **kwargs: object) -> FakeWidget:
return self
def place(self, *args: object, **kwargs: object) -> FakeWidget:
return self
def configure(self, *args: object, **kwargs: object) -> FakeWidget:
return self
config = configure
def bind(self, *args: object, **kwargs: object) -> None:
pass
_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,
)
# 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_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))
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")