diet-guard/diet_guard/tests/test_cli_gate.py
Krzysztof kuhy Rudnicki 42d15b6a91 Grade against 3.14 too, not just run on it
`requires-python` and the CI pin already said 3.14, but ruff, mypy and pylint
were still grading against 3.10 -- three tools judging an interpreter nothing
runs any more, and ruff withholding the pyupgrade fixes that only become safe
at the newer target. `pin_ci_python.py` rewrites exactly these three knobs.

The fixes ruff then had to make are what the stale target was hiding: 18 files
on `datetime.timezone.utc` instead of `datetime.UTC` (UP017), plus ruff's
stable formatter applying PEP 758 -- `except (A, B):` becomes `except A, B:`.
That second one makes the source 3.14-only at the syntax level, which is
exactly what `requires-python = ">=3.14"` already promised; kuhy chose it
deliberately over holding ruff a release back (2026-08-29).

ruff, ruff-format, mypy --strict and pylint all clean at the new target.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SfnJmxxsZ534AKk16rC2Gr
2026-08-29 11:35:06 +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 UTC, datetime
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=UTC)
assert gate_is_due(now) is True
log_meal("oatmeal", self._nutrition(), slot=8)
assert gate_is_due(now) is False