signal-bot/tests/test_workout_command.py
Krzysztof kuhy Rudnicki 77795ee6f8
Some checks are pending
CI / check (push) Waiting to run
CI / web (push) Waiting to run
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 <noreply@anthropic.com>
2026-09-04 22:07:46 +02:00

199 lines
6.3 KiB
Python

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