From 77795ee6f86be631124c1663ff987084b6f7cc42 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Fri, 4 Sep 2026 22:07:46 +0200 Subject: [PATCH] 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 --- DOCS-commands.md | 13 ++ fake_signal/scenarios/food_today.yaml | 7 + fake_signal/scenarios/workout_today.yaml | 6 + scripts/e2e.sh | 5 + signal_bot/aliases.json | 16 ++ signal_bot/day_log.py | 57 +++++++ signal_bot/food_command.py | 114 +++++++++++++ signal_bot/help_command.py | 1 + signal_bot/wiring.py | 6 + signal_bot/workout_command.py | 123 ++++++++++++++ tests/conftest.py | 5 + tests/test_day_log.py | 72 +++++++++ tests/test_food_command.py | 178 ++++++++++++++++++++ tests/test_help_command.py | 4 + tests/test_workout_command.py | 198 +++++++++++++++++++++++ tool/e2e_logs.py | 68 ++++++++ 16 files changed, 873 insertions(+) create mode 100644 fake_signal/scenarios/food_today.yaml create mode 100644 fake_signal/scenarios/workout_today.yaml create mode 100644 signal_bot/day_log.py create mode 100644 signal_bot/food_command.py create mode 100644 signal_bot/workout_command.py create mode 100644 tests/test_day_log.py create mode 100644 tests/test_food_command.py create mode 100644 tests/test_workout_command.py create mode 100755 tool/e2e_logs.py diff --git a/DOCS-commands.md b/DOCS-commands.md index da2ff82..d955229 100644 --- a/DOCS-commands.md +++ b/DOCS-commands.md @@ -33,6 +33,8 @@ This file is the trigger reference — every spelling that resolves, which | random | `!losowe` `!losuj` `!random` `!rand` | | suggest | `!zaproponuj` `!propozycja` `!sugestia` `!suggest` `!pomysl` | | good_bot | `!good bot` `!dobry bot` `!goodbot` `!dobrybot` `!good_bot` `!dobry_bot` | +| food_today | `!co_jadł` `!co_jadl` `!cojadł` `!cojadl` `!jedzenie` `!dieta` + the operator's own in `aliases_custom.json` | +| workout_today | `!co_ćwiczył` `!co_cwiczyl` `!coćwiczył` `!cocwiczyl` `!trening` `!workout` + the operator's own in `aliases_custom.json` | | mute | `!cisza` `!cicho` `!mute` | | unmute | `!odcisz` `!gadaj` `!wracaj` | @@ -66,6 +68,17 @@ It is also the one command that answers with no message: it puts an emoji on the compliment itself (`POST /v1/reactions/`), drawn from a small set, and says nothing in the group. +`food_today` and `workout_today` read two logs other programs keep -- +diet_guard's `food_log.json` and screen_locker's `log.json`, at those +programs' default paths unless `FOOD_LOG_FILE` / `WORKOUT_LOG_FILE` say +otherwise -- and print the current Warsaw day out of them, fresh on every +call. The spelling the group actually uses for each has a surname in it, +so it is a custom alias in the state directory and never appears in this +repository. Food prints every entry and the day's total, never the daily +budget, which diet_guard keeps on the machine on purpose. Workouts print +every field an entry carries except a reservation phone number, a +screenshot path and the sync record id. + ## A word nothing claims An unrecognised `!word` used to be dropped in silence, which made a typo diff --git a/fake_signal/scenarios/food_today.yaml b/fake_signal/scenarios/food_today.yaml new file mode 100644 index 0000000..fa50c71 --- /dev/null +++ b/fake_signal/scenarios/food_today.yaml @@ -0,0 +1,7 @@ +# The operator's food log for today, read from the file the run wrote +# rather than the real one: the meal is invented, the day is real. +name: "!co_jadł lists what the food log holds for today" +inject: + - { text: "!co_jadł", uuid: uuid-alice, name: Alice } +expect: + - { kind: text, to: reply, contains: "carbonara — 350 g, 550 kcal (manual)" } diff --git a/fake_signal/scenarios/workout_today.yaml b/fake_signal/scenarios/workout_today.yaml new file mode 100644 index 0000000..6bbdbfd --- /dev/null +++ b/fake_signal/scenarios/workout_today.yaml @@ -0,0 +1,6 @@ +# Today's workout with every field it carries, from the run's own log. +name: "!trening prints today's workout with all its fields" +inject: + - { text: "!trening", uuid: uuid-alice, name: Alice } +expect: + - { kind: text, to: reply, contains: "duration minutes: 112.7" } diff --git a/scripts/e2e.sh b/scripts/e2e.sh index 80e6827..f87a245 100755 --- a/scripts/e2e.sh +++ b/scripts/e2e.sh @@ -153,6 +153,9 @@ main() { # it does not exist in the second one. echo "== writing a synthetic photo pack" "$PYTHON" "$REPO_ROOT/tool/e2e_pack.py" --dir "${TEMP_DIR}/pack" --group "$WATCHED_GROUP" + # The food and workout logs are the operator's day, kept by other + # programs; the run gets one invented entry of each, dated today. + "$PYTHON" "$REPO_ROOT/tool/e2e_logs.py" --dir "${TEMP_DIR}/logs" echo "== booting the bot on :${BOT_PORT}, pointed at the fake" env -u CAT_API \ @@ -169,6 +172,8 @@ main() { PORTRAIT_CACHE_DIR="${TEMP_DIR}/portraits" \ PHOTO_PACK_DIR="${TEMP_DIR}/pack" \ CUSTOM_ALIASES_FILE="${TEMP_DIR}/aliases_custom.json" \ + FOOD_LOG_FILE="${TEMP_DIR}/logs/food_log.json" \ + WORKOUT_LOG_FILE="${TEMP_DIR}/logs/workout_log.json" \ BIND_PORT="$BOT_PORT" \ SILENCE_SECONDS=0 \ LIVENESS_TICK_SECONDS=1 \ diff --git a/signal_bot/aliases.json b/signal_bot/aliases.json index 8237055..a756258 100644 --- a/signal_bot/aliases.json +++ b/signal_bot/aliases.json @@ -166,6 +166,22 @@ "!good_bot", "!dobry_bot" ], + "food_today": [ + "!co_jadł", + "!co_jadl", + "!cojadł", + "!cojadl", + "!jedzenie", + "!dieta" + ], + "workout_today": [ + "!co_ćwiczył", + "!co_cwiczyl", + "!coćwiczył", + "!cocwiczyl", + "!trening", + "!workout" + ], "mute": [ "!cisza", "!cicho", diff --git a/signal_bot/day_log.py b/signal_bot/day_log.py new file mode 100644 index 0000000..99c9ae6 --- /dev/null +++ b/signal_bot/day_log.py @@ -0,0 +1,57 @@ +# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence. +"""One day out of a day-keyed JSON log another program keeps. + +diet_guard and screen_locker both write the same shape: an object whose keys +are ``YYYY-MM-DD`` and whose values are lists of entry objects. Neither file +belongs to this bot -- they are read where they are, never copied and never +written -- so where they are is the operator's to say, through one +environment variable per log, with the path the two programs use by default. + +The entries are HMAC-signed by their owners. This module does not check the +signatures, because the keys are those programs' secrets: what it reads is +what it shows, and a forged line in the operator's own home directory is a +problem the bot is not the place to solve. +""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +from typing import Any + + +class DayLogError(RuntimeError): + """The log cannot be read at all, as opposed to having nothing for a day.""" + + +def path_from_env(variable: str, default: str) -> Path: + """Where the log is: the variable if it is set, the program's default otherwise.""" + explicit = os.environ.get(variable, "").strip() + return Path(explicit).expanduser() if explicit else Path(default).expanduser() + + +def read_day(path: Path, day: str) -> list[dict[str, Any]]: + """Every entry the log holds for ``day``, in the order the file gives them. + + A day the log has no key for is an empty list, not an error: an empty + day is the normal case before breakfast. + + Raises: + DayLogError: The file is missing, unreadable, not JSON, or not the + object-of-days shape -- anything that makes "nothing today" + a claim this module cannot make. + """ + try: + document = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as error: + msg = f"{path} cannot be read: {error}" + raise DayLogError(msg) from error + if not isinstance(document, dict): + msg = f"{path} is not an object of days" + raise DayLogError(msg) + entries = document.get(day, []) + if not isinstance(entries, list): + msg = f"{path}: {day} is not a list of entries" + raise DayLogError(msg) + return [entry for entry in entries if isinstance(entry, dict)] diff --git a/signal_bot/food_command.py b/signal_bot/food_command.py new file mode 100644 index 0000000..cc8c7cc --- /dev/null +++ b/signal_bot/food_command.py @@ -0,0 +1,114 @@ +# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence. +"""!co_jadł: what the operator has logged in diet_guard today. + +The group asked to see the day's food log, so the day's food log is what +this prints: every entry, its time, its calories and where the number came +from, and the day's total at the top. What it deliberately does not print +is the daily budget or what is left of it -- diet_guard keeps that number +on the machine on purpose, and a chat is the opposite of on the machine. + +The trigger the group actually uses has a surname in it, so it lives in +``aliases_custom.json`` outside this repository; ``aliases.json`` ships +the spellings that are just words. +""" + +from __future__ import annotations + +import datetime as dt +import logging +from typing import TYPE_CHECKING, Any, Final + +from signal_bot.animals import load_aliases +from signal_bot.day_log import DayLogError, path_from_env, read_day +from signal_bot.stats.periods import pl_day_month +from signal_bot.stats.text import plural + +if TYPE_CHECKING: + from pathlib import Path + + from signal_bot.context import CommandContext + from signal_bot.decorators import Decorator + from signal_bot.registry import CommandRegistry, Handler + +logger = logging.getLogger(__name__) + +COMMAND_NAME: Final = "food_today" +DESCRIPTION: Final = "co dziś zjadł operator bota, prosto z dziennika diety" +ENV_VAR: Final = "FOOD_LOG_FILE" +DEFAULT_PATH: Final = "~/.local/share/diet_guard/food_log.json" +PLATE: Final = "\U0001f37d\ufe0f" +UNAVAILABLE: Final = f"{PLATE} Dziennik jedzenia jest dziś niedostępny." + +# (key, label): the macros in the order a label reads them, grams each. +MACROS: Final = (("protein_g", "B"), ("carbs_g", "W"), ("fat_g", "T")) + + +def _number(entry: dict[str, Any], key: str) -> float: + """A numeric field, or zero -- a bool is not a gram count.""" + value = entry.get(key) + return float(value) if isinstance(value, int | float) and not isinstance(value, bool) else 0.0 + + +def _clock(entry: dict[str, Any]) -> str: + """The wall-clock time the entry carries, as written; diet_guard logs local time.""" + try: + return dt.datetime.fromisoformat(str(entry.get("time", ""))).strftime("%H:%M") + except ValueError: + return "??:??" + + +def entry_line(entry: dict[str, Any]) -> str: + """One meal: "06:37 carbonara — 550 kcal (manual)", grams first when logged.""" + parts = [] + grams = _number(entry, "grams") + if grams > 0: + parts.append(f"{grams:g} g") + parts.append(f"{_number(entry, 'kcal'):g} kcal") + source = str(entry.get("source") or "").strip() + tail = f" ({source})" if source else "" + return f"{_clock(entry)} {entry.get('desc', '?')} — {', '.join(parts)}{tail}" + + +def render(day: str, entries: list[dict[str, Any]]) -> str: + """The day's log as one message: total first, then every entry, then macros.""" + if not entries: + return f"{PLATE} {pl_day_month(day)} — nic jeszcze nie zalogowano." + count = len(entries) + kcal = sum(_number(entry, "kcal") for entry in entries) + noun = plural(count, "wpis", "wpisy", "wpisów") + head = f"{PLATE} {pl_day_month(day)} — {count} {noun}, {kcal:g} kcal" + lines = [head, *(entry_line(entry) for entry in entries)] + macros = [(label, sum(_number(entry, key) for entry in entries)) for key, label in MACROS] + if any(total > 0 for _, total in macros): + lines.append("Razem: " + " · ".join(f"{label} {total:g} g" for label, total in macros)) + return "\n".join(lines) + + +def food_handler(path: Path) -> Handler: + """A handler that reads the log fresh on every call; the file changes all day.""" + + async def handle(ctx: CommandContext) -> None: + try: + entries = read_day(path, ctx.day) + except DayLogError as error: + logger.warning("food log unavailable: %s", error) + await ctx.reply(UNAVAILABLE) + return + await ctx.reply(render(ctx.day, entries)) + + return handle + + +def register_food_command( + registry: CommandRegistry, + *, + decorate: Decorator | None = None, + aliases: dict[str, list[str]] | None = None, + path: Path | None = None, +) -> None: + """Register !co_jadł under every spelling aliases.json gives it.""" + table = load_aliases() if aliases is None else aliases + handler = food_handler(path or path_from_env(ENV_VAR, DEFAULT_PATH)) + if decorate is not None: + handler = decorate(handler) + registry.register(COMMAND_NAME, table[COMMAND_NAME], handler, DESCRIPTION) diff --git a/signal_bot/help_command.py b/signal_bot/help_command.py index a787592..61f3cb2 100644 --- a/signal_bot/help_command.py +++ b/signal_bot/help_command.py @@ -43,6 +43,7 @@ GROUPS: Final[tuple[tuple[str, tuple[str, ...]], ...]] = ( ("Wiersze", ("poem",)), ("Propozycje", ("suggest",)), ("Podziękowania", ("good_bot",)), + ("Dziennik", ("food_today", "workout_today")), ("Cisza", ("mute", "unmute")), ( "Statystyki", diff --git a/signal_bot/wiring.py b/signal_bot/wiring.py index 7f66293..77cbf32 100644 --- a/signal_bot/wiring.py +++ b/signal_bot/wiring.py @@ -19,6 +19,7 @@ from signal_bot.art_command import register_art_command from signal_bot.custom_aliases import default_path as custom_aliases_path from signal_bot.custom_aliases import load as load_custom_aliases from signal_bot.decorators import cooldown +from signal_bot.food_command import register_food_command from signal_bot.good_bot_command import register_good_bot_command from signal_bot.groups import ConfigError from signal_bot.help_command import register_help_command @@ -34,6 +35,7 @@ from signal_bot.registry import CommandRegistry from signal_bot.stats.commands import register_stats_commands from signal_bot.suggest_command import register_suggest_command from signal_bot.traps_command import register_trap_command +from signal_bot.workout_command import register_workout_command if TYPE_CHECKING: from pathlib import Path @@ -99,6 +101,10 @@ def build_registry( # being able to fill a table nothing prunes. register_suggest_command(registry, decorate=rate_limit) register_good_bot_command(registry, decorate=rate_limit) + # Two logs other programs keep, read where they lie; the spellings the + # group uses for them carry a surname and come from aliases_custom.json. + register_food_command(registry, decorate=rate_limit) + register_workout_command(registry, decorate=rate_limit) # No cooldown: "wait before you may ask for quiet" is the wrong answer, # and the unmute is gated by trust, not by time. register_mute_commands(registry) diff --git a/signal_bot/workout_command.py b/signal_bot/workout_command.py new file mode 100644 index 0000000..7c9d41d --- /dev/null +++ b/signal_bot/workout_command.py @@ -0,0 +1,123 @@ +# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence. +"""!co_ćwiczył: what screen_locker has on record for the operator today. + +"With all data" was the ask, so every field a workout carries is printed, +one per line under the entry's heading, whatever the entry's shape -- a +verified StrongLifts session has four fields and a padel evening has +twenty, and the log gains new kinds without this file hearing about it. + +Three fields are held back on purpose. A reservation phone number is a +phone number, and this repository's whole personal-data gate exists +because one of those reached a chat once. A screenshot path is a path on +the operator's disk, meaningless anywhere else. A sync record id is +screen_locker's own bookkeeping. Everything else is the workout. +""" + +from __future__ import annotations + +import datetime as dt +import logging +from typing import TYPE_CHECKING, Any, Final + +from signal_bot.animals import load_aliases +from signal_bot.day_log import DayLogError, path_from_env, read_day +from signal_bot.stats.periods import WARSAW, pl_day_month +from signal_bot.stats.text import plural + +if TYPE_CHECKING: + from pathlib import Path + + from signal_bot.context import CommandContext + from signal_bot.decorators import Decorator + from signal_bot.registry import CommandRegistry, Handler + +logger = logging.getLogger(__name__) + +COMMAND_NAME: Final = "workout_today" +DESCRIPTION: Final = "co dziś ćwiczył operator bota, ze wszystkimi danymi" +ENV_VAR: Final = "WORKOUT_LOG_FILE" +DEFAULT_PATH: Final = "~/screen-locker/screen_locker/log.json" +LIFTER: Final = "\U0001f3cb\ufe0f" +UNAVAILABLE: Final = f"{LIFTER} Dziennik treningów jest dziś niedostępny." + +# Printed in the heading, so not repeated as fields. +HEADING_FIELDS: Final = frozenset({"type", "source"}) +# Never printed: a phone number, a local path, and another program's bookkeeping. +WITHHELD: Final = frozenset({"reservation_phone", "proof_screenshot_path", "sync_record_id"}) + + +def _clock(entry: dict[str, Any]) -> str: + """When the entry was written, as Warsaw wall-clock time; screen_locker logs UTC.""" + try: + stamp = dt.datetime.fromisoformat(str(entry.get("timestamp", ""))) + except ValueError: + return "??:??" + if stamp.tzinfo is None: + stamp = stamp.replace(tzinfo=dt.UTC) + return stamp.astimezone(WARSAW).strftime("%H:%M") + + +def _fields(data: dict[str, Any]) -> list[str]: + """Every field worth a line, in the order the log wrote them.""" + lines = [] + for key, value in data.items(): + if key in HEADING_FIELDS or key in WITHHELD: + continue + shown = value.strip() if isinstance(value, str) else value + if shown in ("", None): + continue + lines.append(f" {key.replace('_', ' ')}: {shown}") + return lines + + +def entry_lines(entry: dict[str, Any]) -> list[str]: + """One workout: a heading with the time, the source and the kind, then its fields.""" + data = entry.get("workout_data") + data = data if isinstance(data, dict) else {} + source = str(data.get("source") or "").strip() + kind = str(data.get("type") or "?") + heading = f"• {_clock(entry)} {source} [{kind}]" if source else f"• {_clock(entry)} [{kind}]" + return [heading, *_fields(data)] + + +def render(day: str, entries: list[dict[str, Any]]) -> str: + """The day's workouts as one message, every field of every one.""" + if not entries: + return f"{LIFTER} {pl_day_month(day)} — brak treningu." + count = len(entries) + noun = plural(count, "wpis", "wpisy", "wpisów") + head = f"{LIFTER} {pl_day_month(day)} — {count} {noun}" + lines = [head] + for entry in entries: + lines.extend(entry_lines(entry)) + return "\n".join(lines) + + +def workout_handler(path: Path) -> Handler: + """A handler that reads the log fresh on every call; the day's entry arrives after the run.""" + + async def handle(ctx: CommandContext) -> None: + try: + entries = read_day(path, ctx.day) + except DayLogError as error: + logger.warning("workout log unavailable: %s", error) + await ctx.reply(UNAVAILABLE) + return + await ctx.reply(render(ctx.day, entries)) + + return handle + + +def register_workout_command( + registry: CommandRegistry, + *, + decorate: Decorator | None = None, + aliases: dict[str, list[str]] | None = None, + path: Path | None = None, +) -> None: + """Register !co_ćwiczył under every spelling aliases.json gives it.""" + table = load_aliases() if aliases is None else aliases + handler = workout_handler(path or path_from_env(ENV_VAR, DEFAULT_PATH)) + if decorate is not None: + handler = decorate(handler) + registry.register(COMMAND_NAME, table[COMMAND_NAME], handler, DESCRIPTION) diff --git a/tests/conftest.py b/tests/conftest.py index bf4c46e..869c541 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -112,6 +112,11 @@ def _no_test_ever_reads_a_real_photo_pack(tmp_path: Path, monkeypatch: pytest.Mo monkeypatch.delenv("PHOTO_PACK_DIR", raising=False) monkeypatch.delenv("STATE_DIRECTORY", raising=False) monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path / "share")) + # The operator's food and workout logs are read where diet_guard and + # screen_locker keep them, in $HOME; a suite that reads them would pass + # on his machine and nowhere else. Absent files here, by design. + monkeypatch.setenv("FOOD_LOG_FILE", str(tmp_path / "logs/food_log.json")) + monkeypatch.setenv("WORKOUT_LOG_FILE", str(tmp_path / "logs/workout_log.json")) # The two lines above are not enough on their own: load_config() calls # load_dotenv(), which happily *adds* a PHOTO_PACK_DIR the test just # deleted. test_app.py found this out on 2026-09-04, when the real diff --git a/tests/test_day_log.py b/tests/test_day_log.py new file mode 100644 index 0000000..bf6e2cc --- /dev/null +++ b/tests/test_day_log.py @@ -0,0 +1,72 @@ +# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence. +"""Reading one day out of a log another program keeps.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from signal_bot.day_log import DayLogError, path_from_env, read_day + +DAY = "2026-09-04" + + +def _write(path: Path, document: object) -> Path: + path.write_text(json.dumps(document), encoding="utf-8") + return path + + +def test_the_days_entries_come_back_in_file_order(tmp_path: Path) -> None: + log = _write( + tmp_path / "log.json", + {DAY: [{"desc": "a"}, {"desc": "b"}], "2026-09-03": [{"desc": "old"}]}, + ) + assert read_day(log, DAY) == [{"desc": "a"}, {"desc": "b"}] + + +def test_a_day_the_log_has_not_reached_is_empty_not_an_error(tmp_path: Path) -> None: + """Before breakfast the day has no key yet; that is the normal case.""" + assert read_day(_write(tmp_path / "log.json", {"2026-09-03": [{}]}), DAY) == [] + assert read_day(_write(tmp_path / "log.json", {}), DAY) == [] + + +def test_entries_that_are_not_objects_are_dropped(tmp_path: Path) -> None: + log = _write(tmp_path / "log.json", {DAY: [{"desc": "a"}, "junk", 3, None]}) + assert read_day(log, DAY) == [{"desc": "a"}] + + +def test_a_missing_file_is_an_error_because_nothing_today_would_be_a_lie( + tmp_path: Path, +) -> None: + with pytest.raises(DayLogError, match="cannot be read"): + read_day(tmp_path / "absent.json", DAY) + + +def test_a_file_that_is_not_json_is_an_error(tmp_path: Path) -> None: + broken = tmp_path / "log.json" + broken.write_text("{not json", encoding="utf-8") + with pytest.raises(DayLogError, match="cannot be read"): + read_day(broken, DAY) + + +def test_a_document_that_is_not_an_object_of_days_is_an_error(tmp_path: Path) -> None: + with pytest.raises(DayLogError, match="not an object of days"): + read_day(_write(tmp_path / "log.json", [1, 2]), DAY) + + +def test_a_day_that_is_not_a_list_is_an_error(tmp_path: Path) -> None: + with pytest.raises(DayLogError, match="not a list of entries"): + read_day(_write(tmp_path / "log.json", {DAY: {"desc": "a"}}), DAY) + + +def test_the_path_comes_from_the_environment_or_the_programs_default( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.delenv("SOME_LOG", raising=False) + assert path_from_env("SOME_LOG", "~/x/log.json") == Path.home() / "x/log.json" + monkeypatch.setenv("SOME_LOG", f" {tmp_path / 'mine.json'} ") + assert path_from_env("SOME_LOG", "~/x/log.json") == tmp_path / "mine.json" + monkeypatch.setenv("SOME_LOG", " ") + assert path_from_env("SOME_LOG", "~/x/log.json") == Path.home() / "x/log.json" diff --git a/tests/test_food_command.py b/tests/test_food_command.py new file mode 100644 index 0000000..9a71931 --- /dev/null +++ b/tests/test_food_command.py @@ -0,0 +1,178 @@ +# 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) diff --git a/tests/test_help_command.py b/tests/test_help_command.py index 3e82f91..a9fdadb 100644 --- a/tests/test_help_command.py +++ b/tests/test_help_command.py @@ -13,6 +13,7 @@ from signal_bot.animals import register_animal_commands from signal_bot.art_command import register_art_command from signal_bot.config import DEFAULT_COMMONS_API_URL, DEFAULT_POEM_TEXT_URL from signal_bot.context import CommandContext +from signal_bot.food_command import register_food_command from signal_bot.good_bot_command import register_good_bot_command from signal_bot.help_command import ( COMMAND_NAME, @@ -32,6 +33,7 @@ from signal_bot.registry import CommandRegistry from signal_bot.stats.commands import register_stats_commands from signal_bot.suggest_command import register_suggest_command from signal_bot.traps_command import register_trap_command +from signal_bot.workout_command import register_workout_command from tests.conftest import WATCHED_GROUP, envelope if TYPE_CHECKING: @@ -66,6 +68,8 @@ def full_registry() -> CommandRegistry: register_analytics_command(registry) register_suggest_command(registry) register_good_bot_command(registry) + register_food_command(registry) + register_workout_command(registry) register_mute_commands(registry) register_help_command(registry) return registry diff --git a/tests/test_workout_command.py b/tests/test_workout_command.py new file mode 100644 index 0000000..adb2231 --- /dev/null +++ b/tests/test_workout_command.py @@ -0,0 +1,198 @@ +# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence. +"""!co_ćwiczył: today's workouts with every field, minus the three held back.""" + +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.help_command import GROUPS +from signal_bot.models import parse_envelope +from signal_bot.registry import CommandRegistry +from signal_bot.workout_command import ( + COMMAND_NAME, + DEFAULT_PATH, + ENV_VAR, + UNAVAILABLE, + WITHHELD, + entry_lines, + register_workout_command, + render, +) +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_ćwiczył", "!trening"]} +DAY = "2026-09-04" +LIFTS: dict[str, Any] = { + "timestamp": "2026-09-04T07:31:13.697960+00:00", + "workout_data": { + "type": "pc_workout_verified", + "source": "StrongLifts workout B (113 min, all succeeded)", + "duration_minutes": "112.7", + "sync_record_id": "2026-09-04T09:31:13.697960", + }, + "workout_id": "pc:2026-09-04", + "hmac": "y", +} +PADEL: dict[str, Any] = { + "timestamp": "2026-09-04T18:45:00+00:00", + "workout_data": { + "type": "manual_workout", + "source": "padel at the court", + "sport": "other", + "duration_minutes": "90.0", + "reservation_phone": "+48123456789", + "proof_screenshot_path": "/home/somebody/proof.png", + "techniques_practiced": "", + "rpe": 4, + "went_well": " serves ", + "note": None, + }, +} +SKIP: dict[str, Any] = { + "timestamp": "2026-09-04T06:58:36+00:00", + "workout_data": {"type": "relaxed_day_skip"}, +} + + +def _log(tmp_path: Path, document: object) -> Path: + path = tmp_path / "workout_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_workout_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_every_field_is_printed_under_its_heading_in_warsaw_time() -> None: + """07:31 UTC is 09:31 in Warsaw on a September day; the log writes UTC.""" + assert render(DAY, [LIFTS, SKIP]) == ( + "🏋️ 4 września — 2 wpisy\n" + "• 09:31 StrongLifts workout B (113 min, all succeeded) [pc_workout_verified]\n" + " duration minutes: 112.7\n" + "• 08:58 [relaxed_day_skip]" + ) + + +def test_the_three_withheld_fields_and_the_empty_ones_stay_out() -> None: + lines = entry_lines(PADEL) + assert lines[0] == "• 20:45 padel at the court [manual_workout]" + assert lines[1:] == [ + " sport: other", + " duration minutes: 90.0", + " rpe: 4", + " went well: serves", + ] + assert not any(field in "\n".join(lines) for field in WITHHELD) + assert "+48" not in "\n".join(lines) + + +def test_an_entry_with_no_data_or_a_bad_timestamp_still_renders() -> None: + assert entry_lines({"timestamp": "yesterday", "workout_data": "junk"}) == ["• ??:?? [?]"] + naive = {"timestamp": "2026-09-04T07:00:00", "workout_data": {"type": "phone_verified"}} + assert entry_lines(naive) == ["• 09:00 [phone_verified]"] + + +def test_an_empty_day_says_so() -> None: + assert render(DAY, []) == "🏋️ 4 września — brak treningu." + + +async def test_the_command_replies_with_todays_workouts( + store: Store, transport: FakeTransport, config: Config, tmp_path: Path +) -> None: + path = _log(tmp_path, {DAY: [LIFTS], "2026-09-03": [PADEL]}) + await _run(store, transport, config, path, "!trening") + (sent,) = transport.sent + assert "StrongLifts" in sent.text + assert "padel" 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: + with caplog.at_level(logging.WARNING, logger="signal_bot.workout_command"): + await _run(store, transport, config, tmp_path / "absent.json", "!co_ćwiczył") + assert transport.texts == [UNAVAILABLE] + assert "workout 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: [SKIP]}))) + await _run(store, transport, config, None, "!trening") + assert "relaxed_day_skip" in transport.texts[0] + + +def test_the_default_is_where_screen_locker_writes() -> None: + assert DEFAULT_PATH.endswith("screen_locker/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_workout_command(registry, aliases=ALIASES, decorate=decorate, path=Path("/nowhere")) + assert seen == ["wrapped"] + assert registry.resolve("!trening", 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_ćwiczył" + assert {t[1:].replace("_", "") for t in triggers} == { + "coćwiczył", + "cocwiczyl", + "trening", + "workout", + } + + +def test_help_files_it_under_the_diary_heading() -> None: + assert any(COMMAND_NAME in names for _, names in GROUPS) diff --git a/tool/e2e_logs.py b/tool/e2e_logs.py new file mode 100755 index 0000000..4895bff --- /dev/null +++ b/tool/e2e_logs.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence. +"""Write a day of invented food and workout logs for the offline end-to-end run. + +The real logs are the operator's day, kept by diet_guard and screen_locker +outside this repository; the e2e run must not read them, and could not +count on what they hold. This writes one entry of each under today's +Warsaw date -- the day the bot will ask for -- so the two scenarios have +a known word to look for. + + python tool/e2e_logs.py --dir /tmp/logs +""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path +from typing import Final + +from signal_bot.stats.periods import today + +FOOD_FILE: Final = "food_log.json" +WORKOUT_FILE: Final = "workout_log.json" + +MEAL_TIME: Final = "T12:15:00+02:00" +WORKOUT_TIME: Final = "T07:31:13+00:00" +MEAL: Final[dict[str, object]] = { + "desc": "carbonara", + "grams": 350, + "kcal": 550, + "protein_g": 22, + "carbs_g": 60, + "fat_g": 24, + "source": "manual", +} +WORKOUT: Final[dict[str, object]] = { + "workout_data": { + "type": "pc_workout_verified", + "source": "StrongLifts workout B (113 min, all succeeded)", + "duration_minutes": "112.7", + }, +} + + +def write(directory: Path) -> None: + """Both logs, one entry each, dated today.""" + day = today() + directory.mkdir(parents=True, exist_ok=True) + food = {day: [{**MEAL, "time": day + MEAL_TIME}]} + workout = {day: [{**WORKOUT, "timestamp": day + WORKOUT_TIME}]} + (directory / FOOD_FILE).write_text(json.dumps(food, indent=2), encoding="utf-8") + (directory / WORKOUT_FILE).write_text(json.dumps(workout, indent=2), encoding="utf-8") + + +def main() -> int: + """Write the logs asked for on the command line.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--dir", required=True, type=Path, help="where to write the logs") + arguments = parser.parse_args() + write(arguments.dir) + sys.stdout.write(f"== wrote synthetic food and workout logs to {arguments.dir}\n") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())