mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-10 19:27:14 +01:00
The periodic diet-guard-sync.timer paid a network round-trip every ~15 min to approximate the two moments the shared log actually changes on this device. Sync now fires on those events instead: * before a lock -- already present in _cli_gate._should_lock, which pulls only when a lock is otherwise due, so a phone-logged meal clears the lock and a not-due tick never touches the network; * after a meal is logged here -- new _sync_events.publish_after_log, wired into all three write paths (_cli_log, _cli_gate, _mcp). This is what stops the PHONE nagging for a slot already logged on the PC, the mirror image of the rule that keeps the phone's own tick unconditional. The gate's publish sits in cmd_gate AFTER MealGate.run() returns, never in the unlock handler: that runs on the Tk thread with the window still up, so a hanging network call would keep the user locked in behind a successful log. It also covers the "Synced from another device" unlock, which hooking log_meal would miss. Accepted trade: a PC that is on but neither logging nor locking no longer pulls, so an inbound phone edit lands at the next log or lock rather than within 15 min. Bounded by slot spacing -- an unlogged day raises a lock. install.sh now disables and removes the installed timer (guarded, since a fresh machine has no such unit and `disable` would abort under `set -euo pipefail`); the .service stays for manual one-off ticks. Every test that logs a meal would otherwise reach the network through the new call sites, so conftest patches publish_after_log at each of the three call sites -- not on _sync_events, so test_sync_events still exercises the real helper. Suite stays at ~8s, which is the actual leak detector. Two files had to be split to commit at all, since file-length passes filenames and both were already over the 250-line cap: _cli.py 466 -> 207, + _cli_args.py, _cli_init.py, _cli_log.py _mcp.py 370 -> 194, + _mcp_server.py (singleton + annotations), _mcp_read.py Their conftest/test patch targets moved in the same commit; a stale target redirects nothing and fails open. Verified: 701 python tests at 100% branch coverage, 555 flutter tests, `python -m diet_guard sync` -> 402 entries/exit 0, `gate --check` -> exit 0, all five MCP tools still register, live ~/.local/share/diet_guard byte-identical. Over-cap files: 44 -> 42. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RA9ynjqXPnexRQnR5oAuCC
221 lines
9.3 KiB
Python
221 lines
9.3 KiB
Python
"""Tests for the gate subcommand's handler, split out of test_cli.py
|
|
alongside its source module (see _cli_gate.py's module docstring).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime, timezone
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
from diet_guard import _cli_gate
|
|
from diet_guard._cli_gate import _should_lock, cmd_gate
|
|
from diet_guard._estimator import Nutrition
|
|
from diet_guard._gate import gate_is_due
|
|
from diet_guard._state import log_meal
|
|
|
|
|
|
def _no_sync() -> object:
|
|
"""Neutralize the pre-lock pull (as if it found nothing) for mode tests."""
|
|
return patch.object(_cli_gate, "pull_shared_log", return_value=None)
|
|
|
|
|
|
class TestCmdGate:
|
|
"""The gate subcommand's three modes."""
|
|
|
|
def test_check_due(self) -> None:
|
|
"""--check exits 1 and announces a due lock."""
|
|
lines: list[str] = []
|
|
with patch.object(_cli_gate, "gate_is_due", return_value=True), _no_sync():
|
|
assert cmd_gate(lines.append, check=True, demo=False) == 1
|
|
assert "due" in lines[0]
|
|
|
|
def test_check_not_due(self) -> None:
|
|
"""--check exits 0 when no lock is needed."""
|
|
with patch.object(_cli_gate, "gate_is_due", return_value=False):
|
|
assert cmd_gate([].append, check=True, demo=False) == 0
|
|
|
|
def test_demo_opens_window(self) -> None:
|
|
"""--demo always builds and runs the gate window."""
|
|
gate = MagicMock()
|
|
with (
|
|
patch.object(_cli_gate, "MealGate", return_value=gate) as factory,
|
|
patch.object(_cli_gate, "acquire_gate_lock", return_value=MagicMock()),
|
|
patch.object(_cli_gate, "release_gate_lock"),
|
|
patch.object(_cli_gate, "wait_for_display", return_value=True),
|
|
):
|
|
assert cmd_gate([].append, check=False, demo=True) == 0
|
|
factory.assert_called_once_with(demo_mode=True)
|
|
gate.run.assert_called_once()
|
|
|
|
def test_bare_gate_not_due(self) -> None:
|
|
"""A bare gate with nothing due just reports and exits."""
|
|
lines: list[str] = []
|
|
with patch.object(_cli_gate, "gate_is_due", return_value=False):
|
|
assert cmd_gate(lines.append, check=False, demo=False) == 0
|
|
assert "no lock needed" in lines[0]
|
|
|
|
def test_bare_gate_due_opens_window(self) -> None:
|
|
"""A bare gate that is still due after a sync opens the real window."""
|
|
gate = MagicMock()
|
|
with (
|
|
patch.object(_cli_gate, "gate_is_due", return_value=True),
|
|
_no_sync(),
|
|
patch.object(_cli_gate, "MealGate", return_value=gate),
|
|
patch.object(_cli_gate, "acquire_gate_lock", return_value=MagicMock()),
|
|
patch.object(_cli_gate, "release_gate_lock"),
|
|
patch.object(_cli_gate, "wait_for_display", return_value=True),
|
|
):
|
|
assert cmd_gate([].append, check=False, demo=False) == 0
|
|
gate.run.assert_called_once()
|
|
|
|
def test_bare_gate_cleared_by_sync_opens_no_window(self) -> None:
|
|
"""A meal pulled in by the pre-lock sync clears the lock: no window."""
|
|
lines: list[str] = []
|
|
with (
|
|
patch.object(_cli_gate, "gate_is_due", side_effect=[True, False]),
|
|
patch.object(_cli_gate, "pull_shared_log", return_value=None),
|
|
patch.object(_cli_gate, "MealGate") as factory,
|
|
):
|
|
assert cmd_gate(lines.append, check=False, demo=False) == 0
|
|
factory.assert_not_called()
|
|
assert "no lock needed" in lines[0]
|
|
|
|
def test_publishes_only_after_the_window_closes(self) -> None:
|
|
"""The publish must not run while the lock is still up.
|
|
|
|
A network call on the Tk thread would keep the user locked in behind a
|
|
successful log, so ordering here is the actual guarantee, not the call.
|
|
"""
|
|
calls: list[str] = []
|
|
gate = MagicMock()
|
|
gate.run.side_effect = lambda: calls.append("run")
|
|
with (
|
|
patch.object(_cli_gate, "gate_is_due", return_value=True),
|
|
_no_sync(),
|
|
patch.object(_cli_gate, "MealGate", return_value=gate),
|
|
patch.object(_cli_gate, "acquire_gate_lock", return_value=MagicMock()),
|
|
patch.object(
|
|
_cli_gate,
|
|
"release_gate_lock",
|
|
side_effect=lambda _h: calls.append("release"),
|
|
),
|
|
patch.object(_cli_gate, "wait_for_display", return_value=True),
|
|
patch.object(
|
|
_cli_gate,
|
|
"publish_after_log",
|
|
side_effect=lambda: calls.append("publish"),
|
|
),
|
|
):
|
|
assert cmd_gate([].append, check=False, demo=False) == 0
|
|
assert calls == ["run", "release", "publish"]
|
|
|
|
def test_publish_outage_reported_after_unlock(self) -> None:
|
|
"""A failed post-unlock publish reports without re-trapping the user."""
|
|
lines: list[str] = []
|
|
with (
|
|
patch.object(_cli_gate, "gate_is_due", return_value=True),
|
|
_no_sync(),
|
|
patch.object(_cli_gate, "MealGate", return_value=MagicMock()),
|
|
patch.object(_cli_gate, "acquire_gate_lock", return_value=MagicMock()),
|
|
patch.object(_cli_gate, "release_gate_lock"),
|
|
patch.object(_cli_gate, "wait_for_display", return_value=True),
|
|
patch.object(_cli_gate, "publish_after_log", return_value="offline"),
|
|
):
|
|
assert cmd_gate(lines.append, check=False, demo=False) == 0
|
|
assert "not yet published (offline)" in lines[-1]
|
|
|
|
def test_gate_already_running(self) -> None:
|
|
"""A held single-instance lock means a second window is not opened."""
|
|
lines: list[str] = []
|
|
with (
|
|
patch.object(_cli_gate, "gate_is_due", return_value=True),
|
|
_no_sync(),
|
|
patch.object(_cli_gate, "acquire_gate_lock", return_value=None),
|
|
patch.object(_cli_gate, "MealGate") as factory,
|
|
):
|
|
assert cmd_gate(lines.append, check=False, demo=False) == 0
|
|
factory.assert_not_called()
|
|
assert "already running" in lines[0]
|
|
|
|
def test_gate_due_but_display_not_ready_defers(self) -> None:
|
|
"""A due gate whose display never comes up defers without a window."""
|
|
lines: list[str] = []
|
|
with (
|
|
patch.object(_cli_gate, "gate_is_due", return_value=True),
|
|
_no_sync(),
|
|
patch.object(_cli_gate, "acquire_gate_lock", return_value=MagicMock()),
|
|
patch.object(_cli_gate, "release_gate_lock"),
|
|
patch.object(_cli_gate, "wait_for_display", return_value=False),
|
|
patch.object(_cli_gate, "MealGate") as factory,
|
|
):
|
|
assert cmd_gate(lines.append, check=False, demo=False) == 0
|
|
factory.assert_not_called()
|
|
assert "display not ready" in lines[0]
|
|
|
|
|
|
class TestShouldLock:
|
|
"""The decision helper only pays the network cost when a lock is due."""
|
|
|
|
def test_not_due_skips_sync(self) -> None:
|
|
"""When nothing is due locally, no pull is attempted."""
|
|
pull = MagicMock(return_value=None)
|
|
with (
|
|
patch.object(_cli_gate, "gate_is_due", return_value=False),
|
|
patch.object(_cli_gate, "pull_shared_log", pull),
|
|
):
|
|
assert _should_lock([].append) is False
|
|
pull.assert_not_called()
|
|
|
|
def test_due_then_cleared_by_sync(self) -> None:
|
|
"""A due lock that the pull clears re-reads as not due."""
|
|
pull = MagicMock(return_value=None)
|
|
with (
|
|
patch.object(_cli_gate, "gate_is_due", side_effect=[True, False]),
|
|
patch.object(_cli_gate, "pull_shared_log", pull),
|
|
):
|
|
assert _should_lock([].append) is False
|
|
pull.assert_called_once()
|
|
|
|
def test_due_and_still_due_after_sync(self) -> None:
|
|
"""A due lock the pull cannot satisfy stays due."""
|
|
pull = MagicMock(return_value=None)
|
|
with (
|
|
patch.object(_cli_gate, "gate_is_due", side_effect=[True, True]),
|
|
patch.object(_cli_gate, "pull_shared_log", pull),
|
|
):
|
|
assert _should_lock([].append) is True
|
|
pull.assert_called_once()
|
|
|
|
def test_pull_failure_is_reported(self) -> None:
|
|
"""A failed pull emits its reason but the local decision still stands."""
|
|
lines: list[str] = []
|
|
with (
|
|
patch.object(_cli_gate, "gate_is_due", side_effect=[True, True]),
|
|
patch.object(
|
|
_cli_gate, "pull_shared_log", return_value="sync unavailable (x)"
|
|
),
|
|
):
|
|
assert _should_lock(lines.append) is True
|
|
assert "sync unavailable (x)" in lines[0]
|
|
|
|
|
|
class TestGateReadsFreshState:
|
|
"""The fix's whole correctness rests on the re-check reading fresh disk.
|
|
|
|
Mocked ``gate_is_due`` tests would pass whether or not production re-reads
|
|
state, so this one exercises the real uncached read path: a slot written to
|
|
``food_log.json`` (what ``run_sync`` does when it pulls a phone meal) must be
|
|
visible to the very next ``gate_is_due`` call.
|
|
"""
|
|
|
|
@staticmethod
|
|
def _nutrition() -> Nutrition:
|
|
return Nutrition(200.0, 10.0, 20.0, 5.0, 100.0, "test")
|
|
|
|
def test_written_slot_clears_the_gate_immediately(self) -> None:
|
|
"""At 09:00 only the 08:00 slot is due; logging it flips the decision."""
|
|
now = datetime(2026, 1, 1, 9, 0, tzinfo=timezone.utc)
|
|
assert gate_is_due(now) is True
|
|
log_meal("oatmeal", self._nutrition(), slot=8)
|
|
assert gate_is_due(now) is False
|