signal-bot/tests/test_analytics_hours.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

178 lines
6.7 KiB
Python

# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""Family B: hours, weekdays, day series, months and dying commands.
Every hour test seeds a ``daily_uses`` row and asserts it is absent. That is
how the exclusion gets proved rather than remembered: those rows carry
UNKNOWN_HOUR because their source knew only a date, and letting one through
files it under hour -1 -- or under midnight, if a later edit "tidies" the
sentinel away.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from signal_bot.analytics import hours, trend
from signal_bot.analytics.scope import Scope, Window, window
from tests.analytics_seed import ALA, BASIA, backfilled, ran, reconstructed
if TYPE_CHECKING:
import sqlite3
from signal_bot.store import Store
# A Saturday, so the weekday index is not accidentally right at 0.
DAY = "2026-08-29"
NEXT = "2026-08-30"
SATURDAY = 5
def conn_of(store: Store) -> sqlite3.Connection:
return store.commands.connection
def test_the_hour_histogram_has_a_bucket_for_every_hour(store: Store) -> None:
"""A missing 04:00 draws the wrong shape, and the empty hours are data."""
ran(store, day=DAY, hour=9)
buckets = hours.by_hour(conn_of(store), Scope())
assert len(buckets) == hours.HOURS_IN_DAY
assert buckets[9] == 1
assert sum(buckets) == 1
def test_a_row_with_no_hour_is_left_out(store: Store) -> None:
"""The exclusion, asserted rather than trusted."""
ran(store, day=DAY, hour=9)
backfilled(store, day=DAY)
assert sum(hours.by_hour(conn_of(store), Scope())) == 1
def test_a_reconstructed_row_keeps_its_hour(store: Store) -> None:
"""journald rows were matched to a real message, so their hour is real.
Excluding them would throw away most of the history for no gain, which
is why scope names two predicates and not one.
"""
reconstructed(store, day=DAY, hour=21)
buckets = hours.by_hour(conn_of(store), Scope())
assert buckets[21] == 1
def test_the_weekday_grid_is_seven_by_twenty_four(store: Store) -> None:
ran(store, day=DAY, hour=13)
grid = hours.by_weekday_hour(conn_of(store), Scope())
assert len(grid) == hours.DAYS_IN_WEEK
assert all(len(row) == hours.HOURS_IN_DAY for row in grid)
assert grid[SATURDAY][13] == 1
def test_each_command_gets_its_own_hours(store: Store) -> None:
ran(store, day=DAY, hour=8, command="cat")
ran(store, day=DAY, hour=22, command="pope")
grid = hours.by_command_hour(conn_of(store), Scope())
assert grid["cat"][8] == 1
assert grid["pope"][22] == 1
assert sum(grid["cat"]) == 1
def test_weekdays_carry_the_days_they_were_seen_on(store: Store) -> None:
"""An average needs a denominator, and one Tuesday is not a weekly rate."""
ran(store, day=DAY, hour=9)
ran(store, day=DAY, hour=10, minute=5)
assert hours.by_weekday(conn_of(store), Scope()) == [(SATURDAY, 2, 1)]
def test_the_peak_ignores_hours_nobody_ever_used(store: Store) -> None:
"""At three days old most of the night is empty.
"The deadest hour is 04:00 with zero" would be true of nine hours and
interesting about none of them.
"""
ran(store, day=DAY, hour=19)
ran(store, day=DAY, hour=19, minute=1)
ran(store, day=DAY, hour=6)
found = hours.peak(hours.by_hour(conn_of(store), Scope()))
assert (found.busiest, found.busiest_uses) == (19, 2)
assert (found.quietest, found.quietest_uses) == (6, 1)
def test_a_silent_history_has_no_peak(store: Store) -> None:
assert hours.peak([0] * hours.HOURS_IN_DAY) == hours.Peak()
def test_a_silent_day_is_drawn_as_a_zero(store: Store) -> None:
"""A sparse series draws a line straight over a day nobody used it."""
ran(store, day="2026-08-29", hour=9)
ran(store, day="2026-08-31", hour=9)
dense = trend.dense_days(trend.by_day(conn_of(store), Scope()))
assert dense == [("2026-08-29", 1), ("2026-08-30", 0), ("2026-08-31", 1)]
def test_an_empty_series_densifies_to_nothing() -> None:
assert trend.dense_days([]) == []
def test_months_carry_how_many_days_they_actually_saw(store: Store) -> None:
"""A partial month is not a quiet one, and the panel must be able to say."""
ran(store, day=DAY, hour=9)
ran(store, day=NEXT, hour=9)
assert trend.by_month(conn_of(store), Scope()) == [("2026-08", 2, 2)]
def test_per_command_day_series(store: Store) -> None:
ran(store, day=DAY, hour=9, command="cat")
ran(store, day=NEXT, hour=9, command="cat")
ran(store, day=NEXT, hour=10, command="pope")
series = trend.by_command_day(conn_of(store), Scope())
assert series["cat"] == {DAY: 1, NEXT: 1}
assert series["pope"] == {NEXT: 1}
def test_no_direction_is_claimed_from_a_short_history(store: Store) -> None:
"""Three points of a new bot's novelty curve are not a trend."""
series = [("2026-08-29", 1), ("2026-08-30", 5), ("2026-08-31", 20)]
assert trend.direction(series, Window(days=3)) == trend.UNKNOWN
def test_a_direction_is_claimed_once_there_is_enough(store: Store) -> None:
rising = [(f"2026-08-{day:02d}", day) for day in range(1, 15)]
falling = [(f"2026-08-{day:02d}", 15 - day) for day in range(1, 15)]
flat = [(f"2026-08-{day:02d}", 10) for day in range(1, 15)]
measured = Window(days=14)
assert trend.direction(rising, measured) == trend.RISING
assert trend.direction(falling, measured) == trend.FALLING
assert trend.direction(flat, measured) == trend.FLAT
def test_a_direction_needs_two_points() -> None:
assert trend.direction([("2026-08-29", 3)], Window(days=20)) == trend.UNKNOWN
assert trend.direction([], Window(days=20)) == trend.UNKNOWN
def test_growth_from_nothing_is_rising_and_nothing_is_flat() -> None:
"""A first half of zero cannot be divided by, and both cases are real."""
measured = Window(days=20)
from_zero = [("a", 0), ("b", 0), ("c", 5), ("d", 5)]
stayed_zero = [("a", 0), ("b", 0), ("c", 0), ("d", 0)]
assert trend.direction(from_zero, measured) == trend.RISING
assert trend.direction(stayed_zero, measured) == trend.FLAT
def test_a_command_that_stopped_being_used_is_named(store: Store) -> None:
ran(store, day="2026-08-20", hour=9, command="gecko")
ran(store, day="2026-08-31", hour=9, command="cat")
dying = trend.dying(conn_of(store), Scope(), "2026-08-31")
assert [(entry.command, entry.idle_days) for entry in dying] == [("gecko", 11)]
def test_a_command_used_today_is_not_dying(store: Store) -> None:
ran(store, day="2026-08-31", hour=9, command="cat")
assert trend.dying(conn_of(store), Scope(), "2026-08-31") == []
def test_two_people_on_one_day_are_counted_once_each(store: Store) -> None:
ran(store, day=DAY, hour=9, who=ALA)
ran(store, day=DAY, hour=10, who=BASIA)
assert trend.by_day(conn_of(store), Scope()) == [(DAY, 2, 2)]
assert window(conn_of(store), Scope()).days == 1