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