diet-guard/diet_guard/tests/test_cli_gate.py
Krzysztof kuhy Rudnicki b53ac92c59 perf(gate): narrow the pre-lock pull and defer the heavy imports
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
2026-08-21 12:58:44 +02:00

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