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
248 lines
9.3 KiB
Python
248 lines
9.3 KiB
Python
"""Tests for _cli.py — argument parsing and subcommand dispatch.
|
|
|
|
Subsystems (budget, resolution, logging, the gate window) are mocked so each
|
|
command's branches are exercised without touching real state or opening a
|
|
window; stdin is scripted via ``StringIO`` and stdout captured with ``capsys``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import io
|
|
from typing import TYPE_CHECKING
|
|
from unittest.mock import patch
|
|
|
|
from diet_guard import _cli, _cli_log
|
|
from diet_guard._budget import (
|
|
BudgetFileCorruptError,
|
|
BudgetNotInitializedError,
|
|
write_budget,
|
|
)
|
|
from diet_guard._cli import main
|
|
from diet_guard._cli_log import Portion, eaten_grams
|
|
from diet_guard._estimator import Nutrition
|
|
|
|
if TYPE_CHECKING:
|
|
import pytest
|
|
|
|
_NUT = Nutrition(250, 12, 30, 10, 200, "manual")
|
|
_VALID_INIT = "80\n169\n26\nm\n1.375\n180\n"
|
|
|
|
|
|
def _feed(monkeypatch: pytest.MonkeyPatch, text: str) -> None:
|
|
"""Point stdin at scripted ``text`` for the prompts a command reads."""
|
|
monkeypatch.setattr("sys.stdin", io.StringIO(text))
|
|
|
|
|
|
class TestEatenGrams:
|
|
"""Turning a portion into grams, with the assumption note."""
|
|
|
|
def test_count_of_known_staple(self) -> None:
|
|
"""A count of a known staple multiplies by its unit weight, no note."""
|
|
grams, note = eaten_grams("apple", Portion(grams=None, count=5, per_grams=None))
|
|
assert grams == 5 * 182
|
|
assert note is None
|
|
|
|
def test_count_of_unknown_item_warns(self) -> None:
|
|
"""A count of an unknown item uses the default and flags the assumption."""
|
|
grams, note = eaten_grams(
|
|
"mystery", Portion(grams=None, count=3, per_grams=None)
|
|
)
|
|
assert grams is not None
|
|
assert note is not None
|
|
|
|
def test_explicit_grams(self) -> None:
|
|
"""An explicit gram portion passes straight through."""
|
|
grams, note = eaten_grams("x", Portion(grams=300, count=None, per_grams=None))
|
|
assert grams == 300
|
|
assert note is None
|
|
|
|
|
|
class TestInit:
|
|
"""The budget-setting init command."""
|
|
|
|
def test_valid_male(
|
|
self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
) -> None:
|
|
"""Valid inputs write a budget and print the computed value."""
|
|
_feed(monkeypatch, _VALID_INIT)
|
|
assert main(["init"]) == 0
|
|
assert "computed" in capsys.readouterr().out
|
|
|
|
def test_valid_female(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""The female sex branch is accepted."""
|
|
_feed(monkeypatch, "80\n169\n26\nf\n1.375\n180\n")
|
|
assert main(["init"]) == 0
|
|
|
|
def test_non_number_aborts(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""A non-numeric input sets nothing and returns the error code."""
|
|
_feed(monkeypatch, "heavy\n")
|
|
assert main(["init"]) == 2
|
|
|
|
def test_bad_sex_aborts(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""An unrecognised sex answer sets nothing."""
|
|
_feed(monkeypatch, "80\n169\n26\nx\n1.375\n180\n")
|
|
assert main(["init"]) == 2
|
|
|
|
|
|
class TestSummary:
|
|
"""The budget-remaining summary line."""
|
|
|
|
def test_not_initialized(self, capsys: pytest.CaptureFixture[str]) -> None:
|
|
"""No budget yet -> a guiding hint, no crash."""
|
|
with patch.object(_cli, "daily_budget", side_effect=BudgetNotInitializedError):
|
|
_cli._print_summary()
|
|
assert "budget not set" in capsys.readouterr().out
|
|
|
|
def test_file_corrupt(self, capsys: pytest.CaptureFixture[str]) -> None:
|
|
"""A corrupt budget file is reported plainly."""
|
|
with patch.object(_cli, "daily_budget", side_effect=BudgetFileCorruptError):
|
|
_cli._print_summary()
|
|
assert "corrupt" in capsys.readouterr().out
|
|
|
|
def test_remaining_shown(self, capsys: pytest.CaptureFixture[str]) -> None:
|
|
"""A valid budget prints how much is left."""
|
|
write_budget(2000)
|
|
_cli._print_summary()
|
|
assert "left" in capsys.readouterr().out
|
|
|
|
|
|
class TestAte:
|
|
"""Logging a meal from the command line."""
|
|
|
|
def test_logs_and_summarizes(self, capsys: pytest.CaptureFixture[str]) -> None:
|
|
"""A resolved meal is logged, banked, and summarized."""
|
|
write_budget(2000)
|
|
with patch.object(_cli_log, "resolve_nutrition", return_value=_NUT):
|
|
assert main(["ate", "big mac"]) == 0
|
|
assert "logged:" in capsys.readouterr().out
|
|
|
|
def test_publishes_after_logging(self) -> None:
|
|
"""The meal is published immediately, not left for a periodic tick."""
|
|
write_budget(2000)
|
|
with (
|
|
patch.object(_cli_log, "resolve_nutrition", return_value=_NUT),
|
|
patch.object(_cli_log, "publish_after_log", return_value=None) as publish,
|
|
):
|
|
assert main(["ate", "big mac"]) == 0
|
|
publish.assert_called_once_with()
|
|
|
|
def test_sync_outage_still_logs_locally(
|
|
self, capsys: pytest.CaptureFixture[str]
|
|
) -> None:
|
|
"""A failed publish reports itself without failing the local log."""
|
|
write_budget(2000)
|
|
with (
|
|
patch.object(_cli_log, "resolve_nutrition", return_value=_NUT),
|
|
patch.object(_cli_log, "publish_after_log", return_value="no token"),
|
|
):
|
|
assert main(["ate", "big mac"]) == 0
|
|
out = capsys.readouterr().out
|
|
assert "logged:" in out
|
|
assert "not yet published (no token)" in out
|
|
|
|
def test_note_printed_for_assumed_weight(
|
|
self, capsys: pytest.CaptureFixture[str]
|
|
) -> None:
|
|
"""An assumed per-item weight prints its caveat."""
|
|
write_budget(2000)
|
|
with patch.object(_cli_log, "resolve_nutrition", return_value=_NUT):
|
|
main(["ate", "mystery", "--count", "3"])
|
|
assert "assumed" in capsys.readouterr().out
|
|
|
|
def test_unresolved_food(self, capsys: pytest.CaptureFixture[str]) -> None:
|
|
"""An unresolvable food returns a failure and a manual-entry hint."""
|
|
with patch.object(_cli_log, "resolve_nutrition", return_value=None):
|
|
assert main(["ate", "nonsense"]) == 1
|
|
assert "--kcal" in capsys.readouterr().out
|
|
|
|
|
|
class TestStatus:
|
|
"""The status report."""
|
|
|
|
def test_status_with_entries(self, capsys: pytest.CaptureFixture[str]) -> None:
|
|
"""Logged entries, slots, summary, and macros all print."""
|
|
write_budget(2000)
|
|
main(["ate", "lunch", "--kcal", "500"])
|
|
capsys.readouterr()
|
|
assert main(["status"]) == 0
|
|
out = capsys.readouterr().out
|
|
assert "slots:" in out
|
|
assert "macros:" in out
|
|
|
|
def test_status_empty(self, capsys: pytest.CaptureFixture[str]) -> None:
|
|
"""With nothing logged, status still prints the slot/summary lines."""
|
|
write_budget(2000)
|
|
assert main(["status"]) == 0
|
|
assert "slots:" in capsys.readouterr().out
|
|
|
|
def test_macro_status_with_target(self, capsys: pytest.CaptureFixture[str]) -> None:
|
|
"""When a protein target is known, it is shown alongside the macros."""
|
|
with patch.object(_cli, "protein_target_g", return_value=144.0):
|
|
_cli._print_macro_status()
|
|
assert "protein" in capsys.readouterr().out
|
|
|
|
def test_macro_status_without_target(
|
|
self, capsys: pytest.CaptureFixture[str]
|
|
) -> None:
|
|
"""With no target, only the running macros are shown."""
|
|
with patch.object(_cli, "protein_target_g", return_value=None):
|
|
_cli._print_macro_status()
|
|
out = capsys.readouterr().out
|
|
assert "macros:" in out
|
|
assert "protein" not in out
|
|
|
|
def test_slot_status_all_marks(self, capsys: pytest.CaptureFixture[str]) -> None:
|
|
"""The slot line shows logged / DUE / upcoming together."""
|
|
with (
|
|
patch.object(_cli, "logged_slots_today", return_value={8}),
|
|
patch.object(_cli, "due_slots", return_value=[12]),
|
|
):
|
|
_cli._print_slot_status()
|
|
out = capsys.readouterr().out
|
|
assert "logged" in out
|
|
assert "DUE" in out
|
|
assert "upcoming" in out
|
|
|
|
|
|
class TestUndo:
|
|
"""Removing the most recent entry."""
|
|
|
|
def test_nothing_to_undo(self, capsys: pytest.CaptureFixture[str]) -> None:
|
|
"""An empty day reports nothing to undo."""
|
|
assert main(["undo"]) == 0
|
|
assert "nothing to undo" in capsys.readouterr().out
|
|
|
|
def test_undo_removes_entry(self, capsys: pytest.CaptureFixture[str]) -> None:
|
|
"""Undo removes and reports the last entry."""
|
|
write_budget(2000)
|
|
main(["ate", "snack", "--kcal", "100"])
|
|
capsys.readouterr()
|
|
assert main(["undo"]) == 0
|
|
assert "removed:" in capsys.readouterr().out
|
|
|
|
|
|
class TestGate:
|
|
"""Dispatch wiring for the gate subcommand.
|
|
|
|
cmd_gate()'s own branches are tested directly in test_cli_gate.py,
|
|
where it lives after the 500-line split.
|
|
"""
|
|
|
|
def test_dispatches_to_cmd_gate(self) -> None:
|
|
with patch.object(_cli, "cmd_gate", return_value=0) as mock_cmd_gate:
|
|
assert main(["gate", "--demo"]) == 0
|
|
mock_cmd_gate.assert_called_once_with(_cli._emit, check=False, demo=True)
|
|
|
|
|
|
class TestSync:
|
|
"""Dispatch wiring for the sync subcommand.
|
|
|
|
cmd_sync()'s own branches (success/SyncError/GitHubSyncError) are tested
|
|
directly in test_cli_sync.py, where it lives after the 500-line split.
|
|
"""
|
|
|
|
def test_dispatches_to_cmd_sync(self) -> None:
|
|
with patch.object(_cli, "cmd_sync", return_value=0) as mock_cmd_sync:
|
|
assert main(["sync"]) == 0
|
|
mock_cmd_sync.assert_called_once_with(_cli._emit)
|