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