mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-10 18:07:29 +01:00
Two commands, food_today and workout_today, each printing the current Warsaw day out of a log another program owns: every meal with its time, calories and source plus the day's total and macros; every workout with every field it carries. Three workout fields are withheld (a reservation phone number, a screenshot path, the sync record id), and the food budget is never printed -- diet_guard keeps that number on the machine on purpose. The spellings the group uses carry a surname, so they are custom aliases in the state directory; aliases.json ships only plain words. The suite points both logs at absent temp files so it can never read the operator's own, and the e2e run writes one invented entry of each under today's date. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
199 lines
6.3 KiB
Python
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)
|