signal-bot/tests/test_analytics_command.py
Krzysztof kuhy Rudnicki 0717df646a
Some checks are pending
CI / check (push) Waiting to run
CI / web (push) Waiting to run
Rebuild the command table on demand, and survive a tag whose file is gone
The registry used to be built once, inline in build_bot, so every change to
the data behind it -- a person tagged in the web UI, an alias, soon a command
switched off from the management page -- meant restarting the unit. That is
cheap and lossless, but it is a deploy step somebody has to remember, and a
21:37 report can land in the middle of it.

Now the registration sequence lives in wiring.build_registry, and
RegistryHolder runs it again on POST /_reload: the new table is built whole
and swapped in with one assignment, never patched in place, so a duplicate
trigger is found before anything the dispatcher reads has changed. Bad data
(ConfigError, DuplicateTriggerError) keeps the previous table and answers
409 with the reason; a programming error still surfaces as a 500.
scripts/reload.sh wraps it, with --code for the restart a Python change
needs, and an optional command to type in testbot so "reloaded" means
"answered". No Python hot-reload on purpose.

The loader change is what makes deploying this safe: two rows in the live
tags.json name files that are no longer in media/, and load_pack refused
them, so the next restart would have looped on ConfigError. A stale tag is
drift in a file a page edits mid-session, not a broken album -- it is now
skipped with a warning naming it. A tag that reaches outside the album is
still refused. pack_load.py split at the 250-line cap; pack_tags.py is the
half whose checks are not all fatal.

Proven offline in scripts/e2e.sh: !delta is unknown, a fifth person is
tagged into the synthetic pack, scripts/reload.sh runs, and the same
process posts delta's photo.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-04 10:14:39 +02:00

235 lines
8.2 KiB
Python

# 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