signal-bot/tests/test_analytics_command.py
Krzysztof kuhy Rudnicki 405541be9b
Some checks are pending
CI / check (push) Waiting to run
CI / web (push) Waiting to run
release: 0.2.6 «Stephanus I» — dodano !analityka
One row per command attempt, not two aggregates written only on success.

command_uses holds last-use only and daily_uses counts successes; both are
rate-limiter state and both are written by store.record_use, which the
dispatcher calls only when a handler returns cleanly. A gate refusal and a
crash returned before it, so a command that failed every time looked exactly
like one nobody ran, and nothing knew which command a counted message was.

command_events now takes every attempt with its outcome, its duration and
the item it served; upstream_calls takes every outbound request made while
serving one. The two writes stay separate on purpose -- recording a refusal
as a use is what slid the cooldown window forward every time somebody was
told to wait -- and test_dispatcher_outcomes asserts last_used does not move.

Backfilled from journald joined against message_events, with daily_uses
filling the residual so the two sources cannot double-count. Two things were
wrong on the first attempt and both were found by measuring rather than by
reasoning: the window has to be symmetric, because sent_at_ms comes from the
sender's phone and the journal stamp from this host (194 of 299 lines landed
*before* their own message, by up to 3.2 s); and matching has to be a global
closest-pair assignment, because per-row greedy cannot separate two commands
seconds apart. Together those took the dropped rows from 137 to zero, and
recovered 418 invocations back to 29 August.

Also: !analityka in the group, twelve Polish lines, naming at most three
people and only ones whose display name has been seen; an Analytics tab with
Nivo charts; and continuous latency measurement, which answers the open
question in DOCS-latency.md -- a text send is 235 ms and an image send about
2.2 s, so the upload really is where the time goes.

EPHEMERAL_TABLES was doing two unrelated jobs and is now two tuples: what a
schema bump rebuilds, and what leaving a group clears. daily_uses left the
first (it is a backfill source now) and stayed in the second, and
_drop_reshaped took over the repair a bump used to do for it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4QTp3h5Q3MvfrwGV8xqPf
2026-08-31 23:19:51 +02:00

234 lines
8.1 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.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,
registry=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