signal-bot/tests/test_food_command.py
Krzysztof kuhy Rudnicki 77795ee6f8
Some checks are pending
CI / check (push) Waiting to run
CI / web (push) Waiting to run
Today's food and workout logs, read from where diet_guard and screen_locker keep them
Two commands, food_today and workout_today, each printing the current
Warsaw day out of a log another program owns: every meal with its time,
calories and source plus the day's total and macros; every workout with
every field it carries. Three workout fields are withheld (a reservation
phone number, a screenshot path, the sync record id), and the food budget
is never printed -- diet_guard keeps that number on the machine on purpose.

The spellings the group uses carry a surname, so they are custom aliases in
the state directory; aliases.json ships only plain words. The suite points
both logs at absent temp files so it can never read the operator's own,
and the e2e run writes one invented entry of each under today's date.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-04 22:07:46 +02:00

179 lines
5.5 KiB
Python

# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""!co_jadł: today's food log, entry by entry, and never the budget."""
from __future__ import annotations
import json
import logging
from pathlib import Path
from typing import TYPE_CHECKING, Any
from signal_bot.animals import ALIAS_FILE
from signal_bot.context import CommandContext
from signal_bot.food_command import (
COMMAND_NAME,
DEFAULT_PATH,
ENV_VAR,
UNAVAILABLE,
entry_line,
register_food_command,
render,
)
from signal_bot.help_command import GROUPS
from signal_bot.models import parse_envelope
from signal_bot.registry import CommandRegistry
from tests.conftest import WATCHED_GROUP, envelope
if TYPE_CHECKING:
import pytest
from signal_bot.config import Config
from signal_bot.registry import Handler
from signal_bot.store import Store
from signal_bot.transport_fake import FakeTransport
ALIASES = {COMMAND_NAME: ["!co_jadł", "!dieta"]}
DAY = "2026-09-04"
CARBONARA: dict[str, Any] = {
"time": "2026-09-04T06:37:19+02:00",
"desc": "carbonara",
"grams": 0,
"kcal": 550,
"protein_g": 20,
"carbs_g": 0,
"fat_g": 30,
"source": "manual",
"id": "x",
"hmac": "y",
}
BANK: dict[str, Any] = {
"time": "2026-09-04T09:59:38+02:00",
"desc": "nothing",
"grams": 400.0,
"kcal": 0.0,
"protein_g": 0.0,
"carbs_g": 0.0,
"fat_g": 0.0,
"source": "food bank",
"slot": 8,
}
def _log(tmp_path: Path, document: object) -> Path:
path = tmp_path / "food_log.json"
path.write_text(json.dumps(document), encoding="utf-8")
return path
async def _run(
store: Store, transport: FakeTransport, config: Config, path: Path | None, text: str
) -> None:
registry = CommandRegistry()
register_food_command(registry, aliases=ALIASES, path=path)
message = parse_envelope(envelope(text=text))
assert message is not None
resolved = registry.resolve_with_args(text, WATCHED_GROUP)
assert resolved is not None
ctx = CommandContext(
message=message,
group_id=WATCHED_GROUP,
transport=transport,
store=store,
config=config,
command_name=resolved.command.name,
now=1_000.0,
day=DAY,
args=resolved.args,
)
await resolved.command.handler(ctx)
def test_the_day_is_the_total_then_every_entry_then_the_macros() -> None:
assert render(DAY, [CARBONARA, BANK]) == (
"🍽️ 4 września — 2 wpisy, 550 kcal\n"
"06:37 carbonara — 550 kcal (manual)\n"
"09:59 nothing — 400 g, 0 kcal (food bank)\n"
"Razem: B 20 g · W 0 g · T 30 g"
)
def test_a_day_with_no_macros_logged_has_no_macro_line() -> None:
text = render(DAY, [BANK])
assert text.startswith("🍽️ 4 września — 1 wpis, 0 kcal\n")
assert "Razem" not in text
def test_an_empty_day_says_so() -> None:
assert render(DAY, []) == "🍽️ 4 września — nic jeszcze nie zalogowano."
def test_an_entry_missing_its_fields_still_renders() -> None:
"""A bool in a number field counts as nothing, an unparsable time as unknown."""
assert entry_line({"desc": "x", "kcal": True, "grams": "9"}) == "??:?? x — 0 kcal"
assert entry_line({"time": "2026-09-04T11:00:00", "source": " "}) == "11:00 ? — 0 kcal"
async def test_the_command_replies_with_todays_log(
store: Store, transport: FakeTransport, config: Config, tmp_path: Path
) -> None:
path = _log(tmp_path, {DAY: [CARBONARA], "2026-09-03": [BANK]})
await _run(store, transport, config, path, "!dieta")
(sent,) = transport.sent
assert "carbonara" in sent.text
assert "nothing" not in sent.text
assert sent.quote is not None
async def test_a_log_that_cannot_be_read_is_said_to_be_unavailable(
store: Store,
transport: FakeTransport,
config: Config,
tmp_path: Path,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Not "nothing today": an absent file is not an empty day."""
with caplog.at_level(logging.WARNING, logger="signal_bot.food_command"):
await _run(store, transport, config, tmp_path / "absent.json", "!co_jadł")
assert transport.texts == [UNAVAILABLE]
assert "food log unavailable" in caplog.text
async def test_without_a_path_the_environment_decides(
store: Store,
transport: FakeTransport,
config: Config,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv(ENV_VAR, str(_log(tmp_path, {DAY: [BANK]})))
await _run(store, transport, config, None, "!co_jadł")
assert "food bank" in transport.texts[0]
def test_the_default_is_where_diet_guard_writes() -> None:
assert DEFAULT_PATH.endswith("diet_guard/food_log.json")
def test_a_decorator_wraps_the_handler() -> None:
seen: list[str] = []
def decorate(handler: Handler) -> Handler:
seen.append("wrapped")
return handler
registry = CommandRegistry()
register_food_command(registry, aliases=ALIASES, decorate=decorate, path=Path("/nowhere"))
assert seen == ["wrapped"]
assert registry.resolve("!dieta", WATCHED_GROUP) is not None
def test_the_shipped_spellings_are_plain_words_and_nothing_more() -> None:
"""The trigger with the surname lives in aliases_custom.json; this list is pinned."""
triggers = json.loads(ALIAS_FILE.read_text(encoding="utf-8"))[COMMAND_NAME]
assert triggers[0] == "!co_jadł"
assert {t[1:].replace("_", "") for t in triggers} == {"cojadł", "cojadl", "jedzenie", "dieta"}
def test_help_files_it_under_the_diary_heading() -> None:
assert any(COMMAND_NAME in names for _, names in GROUPS)