# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence. """What !analityka says, and the two things it must never say.""" from __future__ import annotations import datetime as dt from typing import TYPE_CHECKING from signal_bot.analytics import render from signal_bot.analytics.demand import Demand, NearMiss from signal_bot.analytics.hours import Peak from signal_bot.analytics.render import Summary from signal_bot.analytics.scope import Window from signal_bot.analytics.usage import CommandUse, PersonUse from signal_bot.analytics_command import register_analytics_command, triggers_for from signal_bot.dispatcher import Dispatcher from signal_bot.registry import CommandRegistry from signal_bot.registry_holder import RegistryHolder from signal_bot.stats.events import MessageEvent from tests.analytics_seed import ALA, BASIA, Shape, ran from tests.conftest import WATCHED_GROUP, envelope if TYPE_CHECKING: from signal_bot.context import CommandContext from signal_bot.store import Store from signal_bot.transport_fake import FakeTransport DAY = "2026-08-29" NOW = dt.datetime(2026, 8, 29, 12, 0, tzinfo=dt.UTC) def a_registry() -> CommandRegistry: """A registry with the command under test on it.""" registry = CommandRegistry() register_analytics_command(registry, aliases={"command_stats": ["!analityka"]}) return registry def seed(store: Store, *, group_id: str = WATCHED_GROUP) -> None: """A small history with a name attached to one of the two people.""" for minute in range(3): ran( store, day=DAY, hour=19, minute=minute, who=ALA, command="pope", shape=Shape(group_id=group_id), ) ran(store, day=DAY, hour=9, who=BASIA, command="cat", shape=Shape(group_id=group_id)) store.events.record( MessageEvent( group_id=group_id, uuid=ALA, display_name="ala", sent_at_ms=1, day=DAY, local_hour=19, is_command=True, ) ) store.events.note_person(group_id, ALA, "ala", "", DAY) async def reply_of(dispatcher: Dispatcher, transport: FakeTransport) -> str: """Run !analityka and return what went into the group.""" await dispatcher.handle(envelope(text="!analityka")) return transport.sent[-1].text def test_an_empty_history_says_so_in_one_line() -> None: """A fresh install must not answer with a blank block.""" summary = render.Summary( measured=Window(), uses=(), people=(), names={}, peak=Peak(), direction="unknown", demand=Demand(), asker=ALA, commands=0, messages=0, ) assert render.render(summary) == render.NOTHING_YET def test_the_reply_is_capped_at_twelve_lines() -> None: """The cap, on a summary that fills every optional line.""" summary = Summary( measured=Window(first_day=DAY, last_day="2026-09-30", days=30, events=99), uses=( CommandUse("pope", 40, 3, 30, 8, 2, DAY, DAY), CommandUse("cat", 10, 2, 10, 0, 0, DAY, DAY), CommandUse("dog", 1, 1, 1, 0, 0, DAY, DAY), ), people=(PersonUse(ALA, 30, 3, "pope"), PersonUse(BASIA, 5, 1, "cat")), names={ALA: "ala", BASIA: "basia"}, peak=Peak(busiest=19, busiest_uses=20, quietest=5, quietest_uses=1), direction="rising", demand=Demand(near_misses=(NearMiss("!pulapka", 17, 4),)), asker=ALA, commands=99, messages=300, ) lines = render.render(summary).split("\n") assert len(lines) <= render.MAX_LINES assert lines[0] == render.HEADING # Every line earned its place; none is blank padding. assert all(line.strip() for line in lines) def test_the_window_is_stated_before_anything_is_claimed(store: Store) -> None: """The bot is days old, and a reader has to be able to see that.""" line = render.window_line(Window(first_day=DAY, last_day=DAY, days=1, events=4)) assert line.startswith("Okno: 1 dzień") assert "4 razy" in line def test_a_span_of_days_names_both_ends() -> None: line = render.window_line( Window(first_day="2026-08-29", last_day="2026-08-31", days=3, events=10) ) assert "3 dni" in line assert "29 sierpnia" in line assert "31 sierpnia" in line def test_no_line_carries_padding() -> None: """Signal renders a proportional font, so ljust lines up on nobody's phone.""" line = render.window_line(Window(first_day=DAY, last_day=DAY, days=1, events=1)) assert line == line.strip() assert " " not in line def test_a_direction_is_only_worded_when_there_is_one() -> None: assert render.trend_line("unknown") == "" assert render.trend_line("rising") == "użycie rośnie" assert render.trend_line("falling") == "użycie spada" assert render.trend_line("flat") == "użycie się trzyma" def test_nobody_is_named_without_a_display_name() -> None: """The roster knows the rest as a uuid or a phone number, and both leak.""" people = (PersonUse(ALA, 5, 2, "pope"), PersonUse(BASIA, 3, 1, "cat")) assert render.people_line(people, {}) == "" named = render.people_line(people, {ALA: "ala"}) assert named == "Kto najwięcej: ala 5" assert BASIA not in named def test_the_asker_gets_their_own_favourite_and_nobody_elses() -> None: """Self-scope: a per-person breakdown of everybody stays off the chat.""" people = (PersonUse(ALA, 5, 2, "pope"), PersonUse(BASIA, 3, 1, "cat")) assert render.mine_line(people, ALA) == "Twoja ulubiona: !pope (5 razy)" assert render.mine_line(people, "uuid-nobody") == "" def test_a_healthy_bot_says_nothing_about_health() -> None: """A line with nothing to say is dropped, never printed empty.""" healthy = (CommandUse("cat", 3, 1, 3, 0, 0, DAY, DAY),) assert render.health_line(healthy) == "" def test_the_worst_command_is_named_with_what_is_wrong() -> None: """Refusals count as much as errors: both read as "it does not work".""" uses = ( CommandUse("cat", 10, 2, 6, 3, 1, DAY, DAY), CommandUse("dog", 4, 1, 4, 0, 0, DAY, DAY), ) line = render.health_line(uses) assert line.startswith("Do poprawki: !cat") assert "błędów: 1" in line assert "odmów: 3" in line def test_only_what_is_actually_wrong_is_listed() -> None: """A command that only crashes says nothing about refusals, and vice versa.""" crashing = (CommandUse("cat", 5, 1, 3, 0, 2, DAY, DAY),) assert render.health_line(crashing) == "Do poprawki: !cat (błędów: 2)" refusing = (CommandUse("cat", 5, 1, 3, 2, 0, DAY, DAY),) assert render.health_line(refusing) == "Do poprawki: !cat (odmów: 2)" def test_share_of_chat_is_omitted_when_there_is_no_chat() -> None: assert render.share_line(0, 0) == "" assert render.share_line(1, 4) == "Komendy to 25% wiadomości w tej grupie" def test_triggers_are_scoped_to_the_group_that_asked() -> None: """The album's triggers are real people's names. Reporting a near miss against one outside its group would announce that the album exists, which is the leak the dispatcher already refuses at resolution time. """ async def handler(_ctx: CommandContext) -> None: pass registry = CommandRegistry() registry.register("cat", ["!kot"], handler, "kot") registry.register("pack:p1", ["!ala"], handler, "ala", groups=frozenset({"album=="})) here = triggers_for(registry.commands, "somewhere-else==") there = triggers_for(registry.commands, "album==") assert "!ala" not in here assert "!ala" in there async def test_the_command_answers_in_the_group_that_asked( config: object, directory: object, store: Store, transport: FakeTransport, ) -> None: """The whole path: a real frame, the real dispatcher, a real reply.""" seed(store) dispatcher = Dispatcher( config=config, directory=directory, holder=RegistryHolder(a_registry), store=store, transport=transport, clock=lambda: NOW, ) text = await reply_of(dispatcher, transport) assert text.startswith(render.HEADING) assert "!pope" in text assert "ala" in text assert len(text.split("\n")) <= render.MAX_LINES