diet-guard/diet_guard/tests/test_mcp.py
Krzysztof kuhy Rudnicki c310861855 slots: minute-of-day consumers (phase 2 of 3: Python side)
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
2026-10-10 19:53:39 +02:00

220 lines
8.5 KiB
Python

"""Tests for the MCP server tools in ``_mcp``.
Leaf helpers are patched at the ``_mcp`` module namespace (where they are
imported), keeping each tool's own logic under test while isolating the
already-tested state/resolution functions. No test touches the real food log,
budget seal, or shared HMAC key.
"""
from __future__ import annotations
from unittest.mock import patch
from diet_guard import _mcp, _mcp_read
from diet_guard._budget import BudgetNotInitializedError
from diet_guard._estimator import Nutrition
def _nutrition(kcal: float = 250.0) -> Nutrition:
return Nutrition(
kcal=kcal,
protein_g=10.0,
carbs_g=20.0,
fat_g=5.0,
grams=150.0,
source="manual",
)
class TestGetStatus:
def test_band_and_intake(self) -> None:
with (
patch.object(_mcp_read, "today_total_kcal", return_value=640.0),
patch.object(
_mcp_read, "today_total_macros", return_value=(30.0, 50.0, 12.0)
),
patch.object(_mcp_read, "consumption_band", return_value="on track"),
patch.object(_mcp_read, "due_slots", return_value=(720,)),
patch.object(_mcp_read, "logged_slots_today", return_value={480}),
patch.object(_mcp_read, "current_slot", return_value=12),
patch.object(_mcp, "now_local"),
patch.object(_mcp_read, "slot_label", return_value="12:00"),
):
out = _mcp.get_status()
assert out["consumed_kcal"] == 640.0
assert out["consumed_macros_g"] == {"protein": 30.0, "carbs": 50.0, "fat": 12.0}
assert out["consumption_band"] == "on track"
assert out["budget_initialized"] is True
assert out["due_slots"] == ["12:00"]
assert out["logged_slots"] == [480]
assert out["current_slot"] == 12
def test_no_budget_hides_band(self) -> None:
with (
patch.object(_mcp_read, "today_total_kcal", return_value=0.0),
patch.object(_mcp_read, "today_total_macros", return_value=(0.0, 0.0, 0.0)),
patch.object(
_mcp_read,
"consumption_band",
side_effect=BudgetNotInitializedError(),
),
patch.object(_mcp_read, "due_slots", return_value=()),
patch.object(_mcp_read, "logged_slots_today", return_value=set()),
patch.object(_mcp_read, "current_slot", return_value=None),
patch.object(_mcp, "now_local"),
):
out = _mcp.get_status()
assert out["consumption_band"] is None
assert out["budget_initialized"] is False
assert out["due_slots"] == []
assert out["logged_slots"] == []
class TestListToday:
def test_projects_entries_and_drops_hmac(self) -> None:
entries = [
{
"time": "2026-07-10T12:30:00",
"desc": "big mac",
"kcal": 550.0,
"protein_g": 25.0,
"carbs_g": 45.0,
"fat_g": 30.0,
"grams": 215.0,
"source": "openfoodfacts: Big Mac",
"slot": 12,
"hmac": "deadbeef",
},
]
with patch.object(_mcp_read, "today_entries", return_value=entries):
out = _mcp.list_today()
assert out["count"] == 1
view = out["entries"][0]
assert view["desc"] == "big mac"
assert view["kcal"] == 550.0
# The legacy on-disk hour is reported as a minute, like every slot.
assert view["slot"] == 720
assert "hmac" not in view
def test_empty(self) -> None:
with patch.object(_mcp_read, "today_entries", return_value=[]):
out = _mcp.list_today()
assert out == {"count": 0, "entries": []}
class TestGetSlots:
def test_lists_slots_and_current(self) -> None:
with (
patch.object(_mcp_read, "day_slots", return_value=(435, 720, 960, 1200)),
patch.object(_mcp_read, "current_slot", return_value=960),
patch.object(_mcp, "now_local"),
):
out = _mcp.get_slots()
assert out["current_slot"] == 960
assert out["day_slots"][0] == {"minute": 435, "label": "07:15"}
assert len(out["day_slots"]) == 4
class TestLogMealGate:
def test_unresolvable_returns_reason(self) -> None:
with patch.object(_mcp, "resolve_nutrition", return_value=None):
out = _mcp.log_meal("mystery stew")
assert out["ok"] is False
assert "could not resolve" in out["reason"]
def test_preview_manual_macros_does_not_mutate(self) -> None:
with (
patch.object(
_mcp, "resolve_nutrition", return_value=_nutrition()
) as resolve,
patch.object(_mcp, "ManualMacros") as manual,
patch.object(_mcp, "record_meal") as record,
):
out = _mcp.log_meal(
"protein shake",
macros=_mcp.Macros(kcal=180.0, protein=30.0, carbs=5.0, fat=2.0),
slot=12,
)
manual.assert_called_once_with(kcal=180.0, protein=30.0, carbs=5.0, fat=2.0)
resolve.assert_called_once_with(
"protein shake", grams=None, manual_macros=manual.return_value
)
record.assert_not_called()
assert out["preview"] is True
assert out["target_slot"] == 720 # the legacy int hour, as minutes
assert out["resolved"]["kcal"] == 250.0
assert out["confirm_required"] is True
def test_preview_no_manual_defaults_slot_to_current(self) -> None:
with (
patch.object(_mcp, "resolve_nutrition", return_value=_nutrition()),
patch.object(_mcp, "ManualMacros") as manual,
patch.object(_mcp, "slot_for_log", return_value=480),
patch.object(_mcp, "now_local"),
patch.object(_mcp, "record_meal") as record,
):
out = _mcp.log_meal("apple")
manual.assert_not_called()
record.assert_not_called()
assert out["preview"] is True
assert out["target_slot"] == 480
def test_confirm_applies_signed(self) -> None:
entry = {"desc": "apple", "hmac": "sig"}
with (
patch.object(_mcp, "resolve_nutrition", return_value=_nutrition()),
patch.object(_mcp, "record_meal", return_value=entry) as record,
):
out = _mcp.log_meal("apple", slot=8, confirm=True)
record.assert_called_once()
assert out["applied"] is True
assert out["signed"] is True
assert out["target_slot"] == 480
def test_confirm_applies_unsigned(self) -> None:
entry = {"desc": "apple"}
with (
patch.object(_mcp, "resolve_nutrition", return_value=_nutrition()),
patch.object(_mcp, "record_meal", return_value=entry),
):
out = _mcp.log_meal("apple", slot=8, confirm=True)
assert out["applied"] is True
assert out["signed"] is False
def test_confirm_publishes_and_reports_it(self) -> None:
"""A successful write publishes immediately and says so."""
with (
patch.object(_mcp, "resolve_nutrition", return_value=_nutrition()),
patch.object(_mcp, "record_meal", return_value={"desc": "apple"}),
patch.object(_mcp, "publish_after_log", return_value=None) as publish,
):
out = _mcp.log_meal("apple", slot=8, confirm=True)
publish.assert_called_once_with()
assert out["published"] is True
def test_confirm_applies_even_when_publish_fails(self) -> None:
"""A sync outage leaves the local write applied, flagged unpublished."""
with (
patch.object(_mcp, "resolve_nutrition", return_value=_nutrition()),
patch.object(_mcp, "record_meal", return_value={"desc": "apple"}),
patch.object(_mcp, "publish_after_log", return_value="offline"),
):
out = _mcp.log_meal("apple", slot=8, confirm=True)
assert out["applied"] is True
assert out["published"] is False
def test_confirm_write_failure_returns_gracefully(self) -> None:
with (
patch.object(_mcp, "resolve_nutrition", return_value=_nutrition()),
patch.object(_mcp, "record_meal", side_effect=OSError("disk full")),
):
out = _mcp.log_meal("apple", slot=8, confirm=True)
assert out["ok"] is False
assert "could not write" in out["reason"]
def test_main_runs_stdio_server() -> None:
with patch.object(_mcp.mcp, "run") as run:
_mcp.main()
run.assert_called_once_with()