Today's food and workout logs, read from where diet_guard and screen_locker keep them
Some checks are pending
CI / check (push) Waiting to run
CI / web (push) Waiting to run

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>
This commit is contained in:
Krzysztof Rudnicki 2026-09-04 22:07:46 +02:00
parent c195e6b4c1
commit 77795ee6f8
16 changed files with 873 additions and 0 deletions

View File

@ -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/<number>`), 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

View File

@ -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)" }

View File

@ -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" }

View File

@ -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 \

View File

@ -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",

57
signal_bot/day_log.py Normal file
View File

@ -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)]

114
signal_bot/food_command.py Normal file
View File

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

View File

@ -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",

View File

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

View File

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

View File

@ -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

72
tests/test_day_log.py Normal file
View File

@ -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"

178
tests/test_food_command.py Normal file
View File

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

View File

@ -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

View File

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

68
tool/e2e_logs.py Executable file
View File

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