mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-11 13:47:38 +01:00
Every Python consumer now speaks slot minutes:
- satisfaction is nearest-slot snapping (_slot_wire.satisfied_by_entries)
at every "is this slot logged?" site: gate, CLI status, MCP, catering
occupancy and dedup. Nothing on disk is rewritten; 07:00/10:00 entries
keep covering 07:15/10:15 after a schedule move.
- log_meal writes slot_fields(minute) before signing (slot + slot_min).
- schedule store and sync encode via schedule_to_wire/from_wire; a
whole-hour schedule stays byte-identical (f/l/n only).
- CLI ate --hour takes HH or HH:MM; MCP log_meal takes "HH:MM" or a
legacy int hour, validated against today's slots; get_slots returns
{minute, label}.
- gate History tab: first/last are HH:MM ttk.Comboboxes (quarter-hour
list, typed times, Up/Down step without posting the popdown), locked
as disabled; validation mirrors normalized().
- gate timer fires every 15 min; docs rewritten for minutes.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015npsYVB2PdtWkG7MPAi4tn
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 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=480)
|
|
assert gate_is_due(now) is False
|