# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence. """The `!` commands, end to end through a real store.""" from __future__ import annotations from dataclasses import dataclass, field from typing import TYPE_CHECKING import pytest from signal_bot.animals import load_aliases from signal_bot.folding import fold from signal_bot.registry import CommandRegistry from signal_bot.stats import periods from signal_bot.stats.backfill import BACKFILL_KEY, seed_from_counter from signal_bot.stats.commands import HANDLERS, register_stats_commands from tests.stats_seed import GROUP, IGA, KUHY, MITHRA, Shape, wrote from tests.test_stats_backfill import LEGACY as LEGACY_COUNTER if TYPE_CHECKING: from signal_bot.store import Store TODAY = periods.today() YESTERDAY = periods.previous_day() @dataclass class Sender: """The bit of IncomingMessage the handlers actually read.""" source_uuid: str = KUHY[0] @dataclass class FakeContext: """A CommandContext stand-in that records what was said back.""" store: Store group_id: str = GROUP message: Sender = field(default_factory=Sender) replies: list[str] = field(default_factory=list) async def reply(self, text: str) -> None: """Record the answer instead of sending it.""" self.replies.append(text) def seed_from_counter_rows(store: Store, uuid: str, name: str, count: int) -> None: """Seed a pre-0.1.0 counter and carry it over, as the migration did.""" store.events.connection.executescript(LEGACY_COUNTER) store.events.connection.execute( "INSERT INTO message_counts (group_id, uuid, display_name, count) VALUES (?, ?, ?, ?)", (GROUP, uuid, name, count), ) store.events.connection.commit() seed_from_counter(store, "2026-08-01") async def run(store: Store, name: str, uuid: str = KUHY[0]) -> str: """Run one command and return what it said.""" ctx = FakeContext(store=store, message=Sender(source_uuid=uuid)) await HANDLERS[name](ctx) return ctx.replies[0] def test_every_trigger_is_unique_across_the_whole_registry() -> None: """A collision raises DuplicateTriggerError, which is a ValueError. That matters more than it looks: build_bot runs inside create_runtime, and ValueError is in startup_retry.RETRYABLE. A colliding alias would not crash the bot -- it would retry forever, logging "signal-cli not reachable yet", which is the wrong diagnosis entirely. This test is the thing that catches it instead. """ registry = CommandRegistry() register_stats_commands(registry) names = {command.name for command in registry.commands} assert names == set(HANDLERS) def test_no_trigger_contains_a_digit() -> None: """fold() maps digits onto letters, so "!7dni" resolves as "!tdni".""" table = load_aliases() for name in HANDLERS: for trigger in table[name]: assert not any(character.isdigit() for character in trigger), trigger def test_polish_spellings_fold_together() -> None: """Somebody typing !tydzien and somebody typing !tydzień want the same thing.""" assert fold("!tydzień") == fold("!tydzien") assert fold("!miesiąc") == fold("!miesiac") @pytest.mark.parametrize("name", list(HANDLERS)) async def test_every_command_answers_on_an_empty_history(store: Store, name: str) -> None: """Day one of this release: every command says something, none crashes.""" assert await run(store, name) async def test_today_and_yesterday_are_different_days(store: Store) -> None: wrote(store, TODAY, MITHRA, 4) wrote(store, YESTERDAY, KUHY, 7) assert "Dziś, na razie:" in await run(store, "stats_today") assert "Ala" in await run(store, "stats_today") assert "kuhy" in await run(store, "stats_yesterday") async def test_yesterday_renders_the_nightly_block_verbatim(store: Store) -> None: """The only way to check the headline output without waiting for 21:37.""" wrote(store, YESTERDAY, MITHRA, 5) answer = await run(store, "stats_yesterday") assert answer.startswith("📊 ") assert "👑 Ala — 5" in answer async def test_a_day_nobody_wrote_on_says_so(store: Store) -> None: assert "Nikt jeszcze nic nie napisał" in await run(store, "stats_yesterday") async def test_the_week_shows_a_share_bar(store: Store) -> None: wrote(store, TODAY, MITHRA, 8) wrote(store, TODAY, KUHY, 2) answer = await run(store, "stats_week") assert "Ala — 8" in answer assert "80%" in answer assert "█" in answer async def test_the_month_puts_medals_on_the_podium(store: Store) -> None: wrote(store, TODAY, MITHRA, 8) wrote(store, TODAY, KUHY, 5) wrote(store, TODAY, IGA, 2) answer = await run(store, "stats_month") assert "🥇 Ala" in answer assert "🥈 kuhy" in answer assert "🥉 Basia" in answer async def test_a_first_period_says_so_rather_than_claiming_no_change(store: Store) -> None: wrote(store, TODAY, MITHRA, 8) assert "pierwszy taki okres w historii" in await run(store, "stats_year") async def test_me_reports_the_asker(store: Store) -> None: wrote(store, TODAY, KUHY, 6, Shape(hour=23)) wrote(store, TODAY, MITHRA, 2) answer = await run(store, "stats_me") assert "kuhy — 6 wiadomości w historii" in answer assert "Udział w rozmowie: 75%" in answer assert "Najczęściej piszesz między 23:00 a 00:00" in answer async def test_me_is_honest_with_somebody_who_never_wrote(store: Store) -> None: wrote(store, TODAY, MITHRA, 4) assert "ani jednej wiadomości" in await run(store, "stats_me", uuid="u-nobody") async def test_records_and_crown_fill_in_once_there_is_history(store: Store) -> None: wrote(store, periods.shift(TODAY, -2), MITHRA, 9) wrote(store, periods.shift(TODAY, -1), MITHRA, 4) wrote(store, TODAY, KUHY, 3) records = await run(store, "stats_records") assert "Najwięcej w jeden dzień: 9" in records assert "Rekordy życiowe:" in records crown = await run(store, "stats_crown") assert "Najdłuższa seria: Ala" in crown assert "Dni z koroną łącznie:" in crown async def test_records_skips_somebody_with_no_days_left_after_exclusions( store: Store, ) -> None: """Somebody whose only day was the backfilled one has no record to show. Fixed dates, not periods.today(): on the day this was written those were the same date, and the test passed for the wrong reason. """ wrote(store, "2026-08-01", IGA, 40) wrote(store, "2026-08-15", MITHRA, 5) store.set_meta(GROUP, BACKFILL_KEY, "2026-08-01") rendered = await run(store, "stats_records") assert "Ala" in rendered assert "Basia" not in rendered async def test_the_backfilled_day_cannot_invent_a_favourite_hour( store: Store, ) -> None: """The 0.1.1 bug: everybody's favourite hour came out as 12:00. The backfilled counter had no hours, and the placeholder was noon -- so for every person the 319 seeded rows outvoted every real evening, and !ja told each of them the same thing. """ seed_from_counter_rows(store, KUHY[0], KUHY[1], 300) wrote(store, "2026-08-15", KUHY, 3, Shape(hour=21)) answer = await run(store, "stats_me") assert "między 21:00 a 22:00" in answer assert "12:00" not in answer async def test_somebody_with_only_backfilled_messages_has_no_hour( store: Store, ) -> None: """Nothing is known about when they wrote, so the line is omitted.""" seed_from_counter_rows(store, KUHY[0], KUHY[1], 5) answer = await run(store, "stats_me") assert "Najczęściej piszesz" not in answer async def test_weekdays_marks_today(store: Store) -> None: wrote(store, TODAY, MITHRA, 5) assert "← dziś" in await run(store, "stats_weekdays")