mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-10 19:27:20 +01:00
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>
179 lines
5.5 KiB
Python
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)
|