diet-guard/diet_guard/tests/test_cli.py
Krzysztof kuhy Rudnicki 38d66a8492 Make PC sync event-driven; retire the 15-min timer
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
2026-08-15 19:21:30 +02:00

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)