# 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)