mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-11 14:07:40 +01:00
The gate's pre-lock refresh ran a *full* sync tick -- peer logs, budget,
both food banks, a rebuild and a push -- in front of the fullscreen lock.
Measured against the live remote: ~18-27s, of which the curated-bank loop
alone was 17s spent fetching nothing (absent on Firebase for every peer, so
each of ~21 peers fell through to a GitHub round trip returning `{}`).
It was also failing: five `sync unavailable (network error trying to refresh
the session)` entries in the last five days, one of them a meal that logged
locally but never published.
The gate has one question -- "has a peer already logged this slot?" -- and
only the peer logs can answer it. `_sync_refresh` does that and nothing
else: one bulk revision-map read, a fetch of only the peers whose revision
moved, merge, re-sign, write. No budget, no banks, no push. The full tick
still runs, after the window closes, so nothing is permanently skipped --
the exhaustive pass just moves off the path the user waits on.
Three state invariants have their own regression tests, because each is
silent when broken: `pushed_rev` survives verbatim (this pass never pushes,
and overwriting it would make the device stop publishing forever),
`peer_revs` is merged rather than replaced (only changed peers are visited),
and the log is written before the state.
Separately, `gate --check` was importing an HTTP stack and a GUI it never
uses. `requests` (~78ms) arrived via `_cli_sync` -> `crdt_sync` and via
`_resolve` -> `_estimator_off`; both are now resolved through module-level
`__getattr__` (PEP 562) and `import_module`, which keeps every existing
`patch.object` target and needs no lint suppression.
Measured: `gate --check` 216ms -> ~110ms; narrow pull 92ms against the live
remote. Accepted trade, documented in `_sync_refresh`: a budget edited on
the phone is not visible in the lock dashboard until the post-window tick.
It cannot affect the lock decision, which is why it is safe to defer.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LJ5YnA3CERitQ57HjMu2Qz
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_peer_logs", 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_peer_logs", 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_peer_logs", 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_peer_logs", 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_peer_logs", 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_peer_logs", 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
|