mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-10 19:27:20 +01:00
feat: periods, records and the crown
The queries the statistics are made of, all derived rather than cached -- two sources of truth for one number is how a leaderboard comes to disagree with itself, and this history is small enough that recomputing is free. Periods take an inclusive span of stored Warsaw day strings, never a range of timestamps, so a daylight-saving change cannot move a boundary. Trend reports None rather than 0 when there is no previous period: "nothing to compare against yet" and "no change" are different statements and the group will read them differently. A tie shares the crown and keeps every tied run alive, per kuhy's call. Streaks count *recorded* days, so a day nobody spoke does not end a run -- there was no crown to lose -- and neither does a day the bot was offline for, which it could not have judged. Every record excludes the backfilled day. It is a 22-hour window filed as a day, and leaving it in would hand somebody a personal best they never set. Two bugs found by running the code rather than reading it: - current_streaks reported every streak as zero. Ending somebody's run dropped them from the tally along with the length they had already accumulated, so the surviving dict was empty by the time it returned. Now the run ends and the count it reached is kept. - daily_winners depended on SQLite's grouping order to find a day's leader. It happened to work because uuids sorted conveniently in the fixtures. Ordering by messages DESC makes the first row per day the leader by construction, which is both correct and obviously so. No suppressions: the period queries compare an exclusion parameter against '' instead of pasting half a WHERE clause into an f-string, which is a SQL-injection shape even when every input is a constant. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015d48t4qsQLBJnDwDeo2Nom
This commit is contained in:
parent
0f824c441e
commit
a9ffd02f33
173
signal_bot/stats/queries_period.py
Normal file
173
signal_bot/stats/queries_period.py
Normal file
@ -0,0 +1,173 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
||||
"""Weeks, months, years, and the per-weekday picture.
|
||||
|
||||
Every query takes an inclusive span of stored Warsaw day strings, so a
|
||||
daylight-saving change cannot move a boundary -- see ``stats.periods``.
|
||||
|
||||
Several of them take ``exclude``: the one day carried over from the pre-0.1.0
|
||||
counter has real totals but no hours, no lengths, and a 22-hour window
|
||||
pretending to be a day. It belongs in a total and nowhere near an average.
|
||||
|
||||
The exclusion is a bound parameter compared against ``''`` rather than a
|
||||
clause pasted into the statement. No real day is the empty string, so one
|
||||
static query serves both cases -- and the alternative, an f-string holding
|
||||
half a WHERE clause, is a SQL-injection shape even when every input is a
|
||||
module constant.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from signal_bot.stats.periods import weekday_of
|
||||
from signal_bot.stats.queries_day import DayRow
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from signal_bot.stats.events import EventStore
|
||||
from signal_bot.stats.periods import Period
|
||||
|
||||
PERIOD_ROWS = """
|
||||
SELECT uuid,
|
||||
display_name,
|
||||
COUNT(*) AS messages,
|
||||
SUM(media_count) AS media,
|
||||
SUM(is_command) AS commands,
|
||||
SUM(char_len) AS chars
|
||||
FROM message_events
|
||||
WHERE group_id = ? AND day BETWEEN ? AND ? AND day <> ?
|
||||
GROUP BY uuid
|
||||
ORDER BY messages DESC, display_name ASC
|
||||
"""
|
||||
|
||||
DAILY_SERIES = """
|
||||
SELECT day, COUNT(*) AS messages
|
||||
FROM message_events
|
||||
WHERE group_id = ? AND day BETWEEN ? AND ? AND day <> ?
|
||||
GROUP BY day
|
||||
ORDER BY day
|
||||
"""
|
||||
|
||||
EVERY_DAY = """
|
||||
SELECT day, COUNT(*) AS messages
|
||||
FROM message_events
|
||||
WHERE group_id = ? AND day <> ?
|
||||
GROUP BY day
|
||||
ORDER BY day
|
||||
"""
|
||||
|
||||
|
||||
def period_rows(
|
||||
events: EventStore,
|
||||
group_id: str,
|
||||
period: Period,
|
||||
*,
|
||||
exclude: str | None = None,
|
||||
) -> list[DayRow]:
|
||||
"""Everyone who wrote during a period, most talkative first."""
|
||||
rows = events.connection.execute(
|
||||
PERIOD_ROWS, (group_id, period.start, period.end, exclude or "")
|
||||
).fetchall()
|
||||
return [
|
||||
DayRow(
|
||||
uuid=r["uuid"],
|
||||
display_name=r["display_name"],
|
||||
messages=r["messages"],
|
||||
media=r["media"],
|
||||
commands=r["commands"],
|
||||
chars=r["chars"],
|
||||
)
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
def daily_series(
|
||||
events: EventStore,
|
||||
group_id: str,
|
||||
start: str,
|
||||
end: str,
|
||||
*,
|
||||
exclude: str | None = None,
|
||||
) -> dict[str, int]:
|
||||
"""Messages per day across a span, missing days absent rather than zero.
|
||||
|
||||
The caller decides what a missing day means: for a streak it is silence,
|
||||
for an average it is a day nobody was there to record.
|
||||
"""
|
||||
rows = events.connection.execute(DAILY_SERIES, (group_id, start, end, exclude or "")).fetchall()
|
||||
return {r["day"]: r["messages"] for r in rows}
|
||||
|
||||
|
||||
def every_day(
|
||||
events: EventStore,
|
||||
group_id: str,
|
||||
*,
|
||||
exclude: str | None = None,
|
||||
) -> dict[str, int]:
|
||||
"""Messages per day across the whole history."""
|
||||
rows = events.connection.execute(EVERY_DAY, (group_id, exclude or "")).fetchall()
|
||||
return {r["day"]: r["messages"] for r in rows}
|
||||
|
||||
|
||||
def weekday_buckets(days: dict[str, int]) -> dict[int, list[int]]:
|
||||
"""Bucket a day series by weekday, 0=Monday through 6=Sunday.
|
||||
|
||||
Returns the days themselves rather than a mean, so a caller can see how
|
||||
many a figure rests on: "wtorki mają średnio 74" off a single Tuesday is a
|
||||
number that should not be said out loud.
|
||||
"""
|
||||
buckets: dict[int, list[int]] = {}
|
||||
for day, messages in days.items():
|
||||
buckets.setdefault(weekday_of(day), []).append(messages)
|
||||
return buckets
|
||||
|
||||
|
||||
def total_for(
|
||||
events: EventStore,
|
||||
group_id: str,
|
||||
start: str,
|
||||
end: str,
|
||||
*,
|
||||
exclude: str | None = None,
|
||||
) -> int:
|
||||
"""How many messages a span holds."""
|
||||
return sum(daily_series(events, group_id, start, end, exclude=exclude).values())
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Trend:
|
||||
"""One period measured against the one before it."""
|
||||
|
||||
now: int
|
||||
before: int
|
||||
|
||||
@property
|
||||
def delta(self) -> int:
|
||||
"""Absolute change, which may be negative."""
|
||||
return self.now - self.before
|
||||
|
||||
@property
|
||||
def percent(self) -> int | None:
|
||||
"""Rounded percentage change, or None when there is no baseline.
|
||||
|
||||
None rather than 0: "nothing to compare against yet" is a different
|
||||
statement from "no change", and the renderers say each differently.
|
||||
"""
|
||||
if not self.before:
|
||||
return None
|
||||
return round(self.delta / self.before * 100)
|
||||
|
||||
@property
|
||||
def arrow(self) -> str:
|
||||
"""An arrow, or "" when there is nothing to compare against."""
|
||||
if self.percent is None or self.delta == 0:
|
||||
return ""
|
||||
return "↑" if self.delta > 0 else "↓"
|
||||
|
||||
|
||||
def share_of_voice(rows: list[DayRow]) -> list[tuple[str, int]]:
|
||||
"""Each person's percentage of the messages, largest first."""
|
||||
total = sum(row.messages for row in rows)
|
||||
if not total:
|
||||
return []
|
||||
return [(row.display_name, round(row.messages / total * 100)) for row in rows]
|
||||
184
signal_bot/stats/records.py
Normal file
184
signal_bot/stats/records.py
Normal file
@ -0,0 +1,184 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
||||
"""All-time records: busiest days, quietest days, personal bests, silences.
|
||||
|
||||
Everything here is derived by query rather than kept in a cache table. Two
|
||||
sources of truth for one number is how a leaderboard comes to disagree with
|
||||
itself, and the history is small enough that recomputing is free.
|
||||
|
||||
The backfilled day is excluded from every record. It is a 22-hour window
|
||||
filed as a day, so leaving it in would hand somebody a personal best they
|
||||
never set -- see ``stats.backfill``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from signal_bot.stats.periods import shift
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from signal_bot.stats.events import EventStore
|
||||
|
||||
PERSON_DAYS = """
|
||||
SELECT day, COUNT(*) AS messages
|
||||
FROM message_events
|
||||
WHERE group_id = ? AND uuid = ? AND day <> ?
|
||||
GROUP BY day
|
||||
ORDER BY messages DESC, day ASC
|
||||
"""
|
||||
|
||||
MEDIA_DAYS = """
|
||||
SELECT day, SUM(media_count) AS media
|
||||
FROM message_events
|
||||
WHERE group_id = ? AND day <> ?
|
||||
GROUP BY day
|
||||
HAVING media > 0
|
||||
ORDER BY media DESC, day ASC
|
||||
"""
|
||||
|
||||
DAILY_LEADERS = """
|
||||
SELECT day, uuid, display_name, COUNT(*) AS messages
|
||||
FROM message_events
|
||||
WHERE group_id = ? AND day <> ?
|
||||
GROUP BY day, uuid
|
||||
ORDER BY day ASC
|
||||
"""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DayRecord:
|
||||
"""One day and the number that makes it a record."""
|
||||
|
||||
day: str
|
||||
value: int
|
||||
|
||||
|
||||
def busiest_day(days: dict[str, int]) -> DayRecord | None:
|
||||
"""The day with the most messages, earliest day winning a tie."""
|
||||
if not days:
|
||||
return None
|
||||
day = min(days, key=lambda d: (-days[d], d))
|
||||
return DayRecord(day, days[day])
|
||||
|
||||
|
||||
def quietest_day(days: dict[str, int]) -> DayRecord | None:
|
||||
"""The quietest day on which anybody spoke at all.
|
||||
|
||||
Days with no messages are absent from the series rather than zero, and
|
||||
that is the right reading: a day nobody spoke is a silence, which
|
||||
``longest_silence`` measures. A "quietest day" of zero would just name
|
||||
whichever day the bot happened to be offline.
|
||||
"""
|
||||
if not days:
|
||||
return None
|
||||
day = min(days, key=lambda d: (days[d], d))
|
||||
return DayRecord(day, days[day])
|
||||
|
||||
|
||||
def personal_best(
|
||||
events: EventStore,
|
||||
group_id: str,
|
||||
uuid: str,
|
||||
*,
|
||||
exclude: str | None = None,
|
||||
) -> DayRecord | None:
|
||||
"""Somebody's busiest day ever, or None if they have never written."""
|
||||
row = events.connection.execute(PERSON_DAYS, (group_id, uuid, exclude or "")).fetchone()
|
||||
return DayRecord(row["day"], row["messages"]) if row else None
|
||||
|
||||
|
||||
def previous_best(
|
||||
events: EventStore,
|
||||
group_id: str,
|
||||
uuid: str,
|
||||
before: str,
|
||||
*,
|
||||
exclude: str | None = None,
|
||||
) -> DayRecord | None:
|
||||
"""Somebody's best day *excluding* one day, for "poprzedni rekord".
|
||||
|
||||
Announcing "rekord życiowy: 76" is worth little without what it beat, and
|
||||
the day being announced must not be its own previous best.
|
||||
"""
|
||||
rows = events.connection.execute(PERSON_DAYS, (group_id, uuid, exclude or "")).fetchall()
|
||||
for row in rows:
|
||||
if row["day"] != before:
|
||||
return DayRecord(row["day"], row["messages"])
|
||||
return None
|
||||
|
||||
|
||||
def most_media_day(
|
||||
events: EventStore,
|
||||
group_id: str,
|
||||
*,
|
||||
exclude: str | None = None,
|
||||
) -> DayRecord | None:
|
||||
"""The day carrying the most photos and videos."""
|
||||
row = events.connection.execute(MEDIA_DAYS, (group_id, exclude or "")).fetchone()
|
||||
return DayRecord(row["day"], row["media"]) if row else None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Silence:
|
||||
"""A stretch of days on which nobody wrote anything."""
|
||||
|
||||
start: str
|
||||
end: str
|
||||
days: int
|
||||
|
||||
|
||||
def longest_silence(days: dict[str, int], until: str) -> Silence | None:
|
||||
"""The longest run of consecutive silent days, up to and including ``until``.
|
||||
|
||||
Walks the calendar rather than the recorded days, because the whole point
|
||||
is the days that are *missing* from the series. Returns None when the
|
||||
history is too short to contain a gap.
|
||||
"""
|
||||
if not days:
|
||||
return None
|
||||
spoke = set(days)
|
||||
first = min(spoke)
|
||||
best: Silence | None = None
|
||||
run_start: str | None = None
|
||||
day = first
|
||||
|
||||
while day <= until:
|
||||
if day in spoke:
|
||||
if run_start is not None:
|
||||
run = _silence(run_start, shift(day, -1))
|
||||
best = run if best is None or run.days > best.days else best
|
||||
run_start = None
|
||||
elif run_start is None:
|
||||
run_start = day
|
||||
day = shift(day, 1)
|
||||
|
||||
if run_start is not None:
|
||||
run = _silence(run_start, until)
|
||||
best = run if best is None or run.days > best.days else best
|
||||
return best
|
||||
|
||||
|
||||
def _silence(start: str, end: str) -> Silence:
|
||||
"""Build a Silence, counting both ends."""
|
||||
length = 1
|
||||
day = start
|
||||
while day < end:
|
||||
day = shift(day, 1)
|
||||
length += 1
|
||||
return Silence(start, end, length)
|
||||
|
||||
|
||||
def silent_since(days: dict[str, int], uuid_days: dict[str, int], until: str) -> int:
|
||||
"""How many days somebody has gone without writing, as of ``until``.
|
||||
|
||||
Counts back from ``until`` to their last message. Zero means they wrote
|
||||
today; the group's own recorded days bound it, so a person who joined
|
||||
yesterday is not reported as silent for a year.
|
||||
"""
|
||||
if not days:
|
||||
return 0
|
||||
if not uuid_days:
|
||||
return sum(1 for day in days if day <= until)
|
||||
last = max(uuid_days)
|
||||
return sum(1 for day in days if last < day <= until)
|
||||
126
signal_bot/stats/streaks.py
Normal file
126
signal_bot/stats/streaks.py
Normal file
@ -0,0 +1,126 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
||||
"""Who holds the crown, and for how long.
|
||||
|
||||
A tie shares the crown and keeps every tied streak alive. That is a decision,
|
||||
not an oversight: breaking somebody's forty-day run on a technicality is the
|
||||
kind of thing that starts an argument in a group chat, and "we were both first"
|
||||
is what the people involved would say anyway.
|
||||
|
||||
Streaks count *recorded* days, not calendar days. A day nobody spoke does not
|
||||
end a run -- there was no crown to lose -- and neither does a day the bot was
|
||||
offline for, which it could not have judged.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterable
|
||||
|
||||
from signal_bot.stats.events import EventStore
|
||||
|
||||
DAILY_COUNTS = """
|
||||
SELECT day, uuid, display_name, COUNT(*) AS messages
|
||||
FROM message_events
|
||||
WHERE group_id = ? AND day <> ?
|
||||
GROUP BY day, uuid
|
||||
ORDER BY day ASC, messages DESC, display_name ASC
|
||||
"""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Streak:
|
||||
"""A run of consecutive recorded days holding the crown."""
|
||||
|
||||
uuid: str
|
||||
display_name: str
|
||||
length: int
|
||||
last_day: str
|
||||
|
||||
|
||||
def daily_winners(
|
||||
events: EventStore,
|
||||
group_id: str,
|
||||
*,
|
||||
exclude: str | None = None,
|
||||
) -> dict[str, list[tuple[str, str]]]:
|
||||
"""Who led each recorded day, as (uuid, display_name), ties included."""
|
||||
rows = events.connection.execute(DAILY_COUNTS, (group_id, exclude or "")).fetchall()
|
||||
best: dict[str, int] = {}
|
||||
winners: dict[str, list[tuple[str, str]]] = {}
|
||||
for row in rows:
|
||||
day, count = row["day"], row["messages"]
|
||||
# Ordered messages-first, so the day's leader is always the row that
|
||||
# creates the entry and everything after it either ties or loses.
|
||||
if day not in best:
|
||||
best[day] = count
|
||||
winners[day] = [(row["uuid"], row["display_name"])]
|
||||
elif count == best[day]:
|
||||
winners[day].append((row["uuid"], row["display_name"]))
|
||||
return winners
|
||||
|
||||
|
||||
def current_streaks(winners: dict[str, list[tuple[str, str]]]) -> list[Streak]:
|
||||
"""Everyone whose run reaches the most recent recorded day.
|
||||
|
||||
Sorted longest first, then by name, so two people on the same run are
|
||||
reported in a stable order rather than whichever the dict happened to
|
||||
yield.
|
||||
"""
|
||||
if not winners:
|
||||
return []
|
||||
days = sorted(winners)
|
||||
latest = days[-1]
|
||||
holders = dict(winners[latest])
|
||||
|
||||
lengths = dict.fromkeys(holders, 0)
|
||||
running = set(holders)
|
||||
for day in reversed(days):
|
||||
today = {uuid for uuid, _ in winners[day]}
|
||||
for uuid in sorted(running):
|
||||
if uuid in today:
|
||||
lengths[uuid] += 1
|
||||
else:
|
||||
# Their run ended on this day. What they had already
|
||||
# accumulated is kept -- dropping them from the tally, as an
|
||||
# earlier spelling did, reported every streak as zero.
|
||||
running.discard(uuid)
|
||||
if not running:
|
||||
break
|
||||
|
||||
return sorted(
|
||||
(Streak(uuid, holders[uuid], length, latest) for uuid, length in lengths.items()),
|
||||
key=lambda s: (-s.length, s.display_name),
|
||||
)
|
||||
|
||||
|
||||
def longest_ever(winners: dict[str, list[tuple[str, str]]]) -> Streak | None:
|
||||
"""The longest crown run anybody has ever put together."""
|
||||
running: dict[str, int] = {}
|
||||
names: dict[str, str] = {}
|
||||
best: Streak | None = None
|
||||
|
||||
for day in sorted(winners):
|
||||
today = dict(winners[day])
|
||||
names.update(today)
|
||||
running = {uuid: running.get(uuid, 0) + 1 for uuid in today}
|
||||
for uuid, length in running.items():
|
||||
if (
|
||||
best is None
|
||||
or length > best.length
|
||||
or (length == best.length and names[uuid] < best.display_name)
|
||||
):
|
||||
best = Streak(uuid, names[uuid], length, day)
|
||||
return best
|
||||
|
||||
|
||||
def held_days(winners: dict[str, list[tuple[str, str]]], uuid: str) -> int:
|
||||
"""How many days somebody has held the crown in total, ties included."""
|
||||
return sum(1 for holders in winners.values() if any(u == uuid for u, _ in holders))
|
||||
|
||||
|
||||
def names_of(holders: Iterable[tuple[str, str]]) -> list[str]:
|
||||
"""Display names of a day's crown holders, alphabetical for stability."""
|
||||
return sorted(name for _, name in holders)
|
||||
64
tests/stats_seed.py
Normal file
64
tests/stats_seed.py
Normal file
@ -0,0 +1,64 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
||||
"""A deterministic history to run the period, record and streak queries over.
|
||||
|
||||
Not a conftest fixture: it is a builder several test modules call with a
|
||||
different history each, rather than one shared shape. Counts come from
|
||||
arithmetic, never from ``random`` -- a fixture that changes between runs turns
|
||||
a real failure into a flake.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from signal_bot.stats.events import MessageEvent
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from signal_bot.store import Store
|
||||
|
||||
GROUP = "group-a=="
|
||||
MITHRA = ("u-ala", "Ala")
|
||||
KUHY = ("u-kuhy", "kuhy")
|
||||
IGA = ("u-iga", "Basia")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Shape:
|
||||
"""The optional detail of a seeded day, so ``wrote`` stays legible."""
|
||||
|
||||
media: int = 0
|
||||
hour: int = 12
|
||||
chars: int = 10
|
||||
commands: int = 0
|
||||
group_id: str = GROUP
|
||||
|
||||
|
||||
def wrote(
|
||||
store: Store,
|
||||
day: str,
|
||||
who: tuple[str, str],
|
||||
count: int,
|
||||
shape: Shape | None = None,
|
||||
) -> None:
|
||||
"""Put ``count`` messages from one person into one group on one day."""
|
||||
shape = shape or Shape()
|
||||
uuid, name = who
|
||||
for index in range(count):
|
||||
store.events.record(
|
||||
MessageEvent(
|
||||
group_id=shape.group_id,
|
||||
uuid=uuid,
|
||||
display_name=name,
|
||||
# Unique within (group, uuid) so the primary key does not
|
||||
# absorb them, and stable so two runs agree.
|
||||
sent_at_ms=int(day.replace("-", "")) * 1000 + index,
|
||||
day=day,
|
||||
local_hour=shape.hour,
|
||||
media_kind="image" if index < shape.media else "",
|
||||
media_count=1 if index < shape.media else 0,
|
||||
char_len=shape.chars,
|
||||
is_command=index < shape.commands,
|
||||
)
|
||||
)
|
||||
store.events.note_person(shape.group_id, uuid, name, "", day)
|
||||
121
tests/test_stats_queries_period.py
Normal file
121
tests/test_stats_queries_period.py
Normal file
@ -0,0 +1,121 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
||||
"""Weeks, months, years, trends and the per-weekday picture."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from signal_bot.stats import periods
|
||||
from signal_bot.stats.queries_period import (
|
||||
Trend,
|
||||
daily_series,
|
||||
every_day,
|
||||
period_rows,
|
||||
share_of_voice,
|
||||
total_for,
|
||||
weekday_buckets,
|
||||
)
|
||||
from tests.stats_seed import GROUP, IGA, KUHY, MITHRA, Shape, wrote
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from signal_bot.store import Store
|
||||
|
||||
# 2026-08-31 is a Monday, so this spans one ISO week exactly.
|
||||
WEEK = periods.week_of("2026-09-01")
|
||||
PREVIOUS = periods.previous(WEEK)
|
||||
|
||||
|
||||
def history(store: Store) -> None:
|
||||
"""A full week, plus one day in the week before it."""
|
||||
wrote(store, "2026-08-25", KUHY, 8)
|
||||
wrote(store, "2026-08-31", MITHRA, 10, Shape(media=2, commands=1))
|
||||
wrote(store, "2026-09-01", MITHRA, 20)
|
||||
wrote(store, "2026-09-01", KUHY, 10)
|
||||
wrote(store, "2026-09-02", IGA, 5)
|
||||
|
||||
|
||||
def test_period_rows_are_ordered_by_volume(store: Store) -> None:
|
||||
history(store)
|
||||
rows = period_rows(store.events, GROUP, WEEK)
|
||||
assert [(r.display_name, r.messages) for r in rows] == [
|
||||
("Ala", 30),
|
||||
("kuhy", 10),
|
||||
("Basia", 5),
|
||||
]
|
||||
|
||||
|
||||
def test_period_rows_carry_media_commands_and_length(store: Store) -> None:
|
||||
history(store)
|
||||
ala = period_rows(store.events, GROUP, WEEK)[0]
|
||||
assert (ala.media, ala.commands, ala.chars) == (2, 1, 300)
|
||||
|
||||
|
||||
def test_a_period_stops_at_its_own_boundary(store: Store) -> None:
|
||||
"""The 25th belongs to the previous week and must not leak in."""
|
||||
history(store)
|
||||
assert total_for(store.events, GROUP, WEEK.start, WEEK.end) == 45
|
||||
assert total_for(store.events, GROUP, PREVIOUS.start, PREVIOUS.end) == 8
|
||||
|
||||
|
||||
def test_daily_series_omits_days_nobody_spoke(store: Store) -> None:
|
||||
"""Absent, not zero: the caller decides what a missing day means."""
|
||||
history(store)
|
||||
series = daily_series(store.events, GROUP, WEEK.start, WEEK.end)
|
||||
assert series == {"2026-08-31": 10, "2026-09-01": 30, "2026-09-02": 5}
|
||||
|
||||
|
||||
def test_every_day_spans_the_whole_history(store: Store) -> None:
|
||||
history(store)
|
||||
assert set(every_day(store.events, GROUP)) == {
|
||||
"2026-08-25",
|
||||
"2026-08-31",
|
||||
"2026-09-01",
|
||||
"2026-09-02",
|
||||
}
|
||||
|
||||
|
||||
def test_weekday_buckets_index_monday_as_zero(store: Store) -> None:
|
||||
history(store)
|
||||
buckets = weekday_buckets(every_day(store.events, GROUP))
|
||||
# 2026-08-25 is a Tuesday, 08-31 a Monday, 09-01 a Tuesday, 09-02 a Wednesday.
|
||||
assert buckets[0] == [10]
|
||||
assert sorted(buckets[1]) == [8, 30]
|
||||
assert buckets[2] == [5]
|
||||
|
||||
|
||||
def test_trend_reports_direction_and_size() -> None:
|
||||
trend = Trend(now=45, before=30)
|
||||
assert (trend.delta, trend.percent, trend.arrow) == (15, 50, "↑")
|
||||
down = Trend(now=15, before=30)
|
||||
assert (down.delta, down.percent, down.arrow) == (-15, -50, "↓")
|
||||
|
||||
|
||||
def test_a_flat_period_gets_no_arrow() -> None:
|
||||
assert Trend(now=30, before=30).arrow == ""
|
||||
assert Trend(now=30, before=30).percent == 0
|
||||
|
||||
|
||||
def test_no_baseline_is_not_the_same_as_no_change() -> None:
|
||||
"""The first week ever has nothing to compare against, and says so."""
|
||||
first = Trend(now=45, before=0)
|
||||
assert first.percent is None
|
||||
assert first.arrow == ""
|
||||
assert first.delta == 45
|
||||
|
||||
|
||||
def test_share_of_voice(store: Store) -> None:
|
||||
history(store)
|
||||
rows = period_rows(store.events, GROUP, WEEK)
|
||||
assert share_of_voice(rows) == [("Ala", 67), ("kuhy", 22), ("Basia", 11)]
|
||||
|
||||
|
||||
def test_share_of_voice_of_nothing_is_empty() -> None:
|
||||
assert share_of_voice([]) == []
|
||||
|
||||
|
||||
def test_the_backfilled_day_can_be_left_out_of_every_query(store: Store) -> None:
|
||||
history(store)
|
||||
assert total_for(store.events, GROUP, WEEK.start, WEEK.end, exclude="2026-09-01") == 15
|
||||
assert "2026-09-01" not in every_day(store.events, GROUP, exclude="2026-09-01")
|
||||
rows = period_rows(store.events, GROUP, WEEK, exclude="2026-09-01")
|
||||
assert [r.display_name for r in rows] == ["Ala", "Basia"]
|
||||
135
tests/test_stats_records.py
Normal file
135
tests/test_stats_records.py
Normal file
@ -0,0 +1,135 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
||||
"""All-time records, and the silences between them."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from signal_bot.stats.queries_period import every_day
|
||||
from signal_bot.stats.records import (
|
||||
busiest_day,
|
||||
longest_silence,
|
||||
most_media_day,
|
||||
personal_best,
|
||||
previous_best,
|
||||
quietest_day,
|
||||
silent_since,
|
||||
)
|
||||
from tests.stats_seed import GROUP, IGA, KUHY, MITHRA, Shape, wrote
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from signal_bot.store import Store
|
||||
|
||||
|
||||
def history(store: Store) -> None:
|
||||
"""Four days, a two-day gap, ties, and somebody who stops writing."""
|
||||
wrote(store, "2026-09-01", MITHRA, 10, Shape(media=3))
|
||||
wrote(store, "2026-09-01", KUHY, 5)
|
||||
wrote(store, "2026-09-02", MITHRA, 4)
|
||||
wrote(store, "2026-09-02", KUHY, 4)
|
||||
wrote(store, "2026-09-03", IGA, 2)
|
||||
# 09-04 and 09-05 silent.
|
||||
wrote(store, "2026-09-06", KUHY, 9, Shape(media=1))
|
||||
|
||||
|
||||
def test_no_history_yields_no_records(store: Store) -> None:
|
||||
"""Day one of this release: everything empty, nothing crashing."""
|
||||
days = every_day(store.events, GROUP)
|
||||
assert busiest_day(days) is None
|
||||
assert quietest_day(days) is None
|
||||
assert longest_silence(days, "2026-09-06") is None
|
||||
assert most_media_day(store.events, GROUP) is None
|
||||
assert personal_best(store.events, GROUP, "u-kuhy") is None
|
||||
|
||||
|
||||
def test_busiest_and_quietest(store: Store) -> None:
|
||||
history(store)
|
||||
days = every_day(store.events, GROUP)
|
||||
assert busiest_day(days).day == "2026-09-01"
|
||||
assert busiest_day(days).value == 15
|
||||
assert quietest_day(days).day == "2026-09-03"
|
||||
assert quietest_day(days).value == 2
|
||||
|
||||
|
||||
def test_the_quietest_day_is_one_somebody_spoke_on(store: Store) -> None:
|
||||
"""A silent day is a silence, not a quiet day, and is measured separately."""
|
||||
history(store)
|
||||
days = every_day(store.events, GROUP)
|
||||
assert quietest_day(days).value > 0
|
||||
assert "2026-09-04" not in days
|
||||
|
||||
|
||||
def test_a_tie_on_the_busiest_day_goes_to_the_earlier(store: Store) -> None:
|
||||
wrote(store, "2026-09-02", KUHY, 7)
|
||||
wrote(store, "2026-09-01", MITHRA, 7)
|
||||
assert busiest_day(every_day(store.events, GROUP)).day == "2026-09-01"
|
||||
|
||||
|
||||
def test_personal_best_and_what_it_beat(store: Store) -> None:
|
||||
history(store)
|
||||
best = personal_best(store.events, GROUP, "u-kuhy")
|
||||
assert (best.day, best.value) == ("2026-09-06", 9)
|
||||
previous = previous_best(store.events, GROUP, "u-kuhy", "2026-09-06")
|
||||
assert (previous.day, previous.value) == ("2026-09-01", 5)
|
||||
|
||||
|
||||
def test_a_first_ever_day_has_nothing_to_beat(store: Store) -> None:
|
||||
"""The "poprzedni rekord" clause has to be omitted, not printed as 0."""
|
||||
wrote(store, "2026-09-01", IGA, 3)
|
||||
assert previous_best(store.events, GROUP, "u-iga", "2026-09-01") is None
|
||||
|
||||
|
||||
def test_most_media_day(store: Store) -> None:
|
||||
history(store)
|
||||
record = most_media_day(store.events, GROUP)
|
||||
assert (record.day, record.value) == ("2026-09-01", 3)
|
||||
|
||||
|
||||
def test_a_history_with_no_media_has_no_media_record(store: Store) -> None:
|
||||
wrote(store, "2026-09-01", KUHY, 4)
|
||||
assert most_media_day(store.events, GROUP) is None
|
||||
|
||||
|
||||
def test_longest_silence(store: Store) -> None:
|
||||
history(store)
|
||||
gap = longest_silence(every_day(store.events, GROUP), "2026-09-06")
|
||||
assert (gap.start, gap.end, gap.days) == ("2026-09-04", "2026-09-05", 2)
|
||||
|
||||
|
||||
def test_a_silence_running_to_today_counts(store: Store) -> None:
|
||||
"""The gap the group is currently in is the one worth mentioning."""
|
||||
wrote(store, "2026-09-01", KUHY, 1)
|
||||
gap = longest_silence(every_day(store.events, GROUP), "2026-09-05")
|
||||
assert (gap.start, gap.end, gap.days) == ("2026-09-02", "2026-09-05", 4)
|
||||
|
||||
|
||||
def test_an_unbroken_history_has_no_silence(store: Store) -> None:
|
||||
wrote(store, "2026-09-01", KUHY, 1)
|
||||
wrote(store, "2026-09-02", KUHY, 1)
|
||||
assert longest_silence(every_day(store.events, GROUP), "2026-09-02") is None
|
||||
|
||||
|
||||
def test_silent_since_counts_recorded_days_only(store: Store) -> None:
|
||||
"""Bounded by the group's own history, so a newcomer is not "silent for a year"."""
|
||||
history(store)
|
||||
days = every_day(store.events, GROUP)
|
||||
ala = {d: n for d, n in days.items() if d in {"2026-09-01", "2026-09-02"}}
|
||||
assert silent_since(days, ala, "2026-09-06") == 2
|
||||
assert silent_since(days, {"2026-09-06": 9}, "2026-09-06") == 0
|
||||
|
||||
|
||||
def test_somebody_who_never_wrote_is_silent_for_the_whole_history(store: Store) -> None:
|
||||
history(store)
|
||||
days = every_day(store.events, GROUP)
|
||||
assert silent_since(days, {}, "2026-09-06") == len(days)
|
||||
assert silent_since({}, {}, "2026-09-06") == 0
|
||||
|
||||
|
||||
def test_the_backfilled_day_is_kept_out_of_records(store: Store) -> None:
|
||||
"""A 22-hour window filed as a day must not become somebody's best."""
|
||||
wrote(store, "2026-08-30", MITHRA, 98)
|
||||
wrote(store, "2026-09-01", MITHRA, 10)
|
||||
assert personal_best(store.events, GROUP, "u-ala").value == 98
|
||||
excluded = personal_best(store.events, GROUP, "u-ala", exclude="2026-08-30")
|
||||
assert (excluded.day, excluded.value) == ("2026-09-01", 10)
|
||||
assert "2026-08-30" not in every_day(store.events, GROUP, exclude="2026-08-30")
|
||||
118
tests/test_stats_streaks.py
Normal file
118
tests/test_stats_streaks.py
Normal file
@ -0,0 +1,118 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
||||
"""The crown: who holds it, for how long, and what a tie does to a run."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from signal_bot.stats.streaks import (
|
||||
current_streaks,
|
||||
daily_winners,
|
||||
held_days,
|
||||
longest_ever,
|
||||
names_of,
|
||||
)
|
||||
from tests.stats_seed import GROUP, IGA, KUHY, MITHRA, wrote
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from signal_bot.store import Store
|
||||
|
||||
|
||||
def test_no_history_has_no_crown(store: Store) -> None:
|
||||
winners = daily_winners(store.events, GROUP)
|
||||
assert winners == {}
|
||||
assert current_streaks(winners) == []
|
||||
assert longest_ever(winners) is None
|
||||
assert held_days(winners, "u-kuhy") == 0
|
||||
|
||||
|
||||
def test_the_busiest_person_holds_the_day(store: Store) -> None:
|
||||
wrote(store, "2026-09-01", MITHRA, 10)
|
||||
wrote(store, "2026-09-01", KUHY, 5)
|
||||
winners = daily_winners(store.events, GROUP)
|
||||
assert names_of(winners["2026-09-01"]) == ["Ala"]
|
||||
|
||||
|
||||
def test_a_tie_shares_the_crown(store: Store) -> None:
|
||||
"""kuhy's call: shared, because the alternative starts arguments."""
|
||||
wrote(store, "2026-09-01", MITHRA, 4)
|
||||
wrote(store, "2026-09-01", KUHY, 4)
|
||||
wrote(store, "2026-09-01", IGA, 1)
|
||||
winners = daily_winners(store.events, GROUP)
|
||||
assert names_of(winners["2026-09-01"]) == ["Ala", "kuhy"]
|
||||
|
||||
|
||||
def test_a_tie_keeps_every_tied_run_alive(store: Store) -> None:
|
||||
"""Three days, both tied throughout: both hold a three-day streak."""
|
||||
for day in ("2026-09-01", "2026-09-02", "2026-09-03"):
|
||||
wrote(store, day, MITHRA, 3)
|
||||
wrote(store, day, KUHY, 3)
|
||||
streaks = current_streaks(daily_winners(store.events, GROUP))
|
||||
assert [(s.display_name, s.length) for s in streaks] == [("Ala", 3), ("kuhy", 3)]
|
||||
|
||||
|
||||
def test_a_run_survives_a_day_nobody_spoke(store: Store) -> None:
|
||||
"""There was no crown to lose on a silent day, so the run continues."""
|
||||
wrote(store, "2026-09-01", KUHY, 5)
|
||||
# 09-02 and 09-03 silent.
|
||||
wrote(store, "2026-09-04", KUHY, 5)
|
||||
streaks = current_streaks(daily_winners(store.events, GROUP))
|
||||
assert [(s.display_name, s.length) for s in streaks] == [("kuhy", 2)]
|
||||
|
||||
|
||||
def test_losing_a_day_ends_a_run_but_keeps_what_it_reached(store: Store) -> None:
|
||||
"""The bug this test exists for reported every streak as zero."""
|
||||
wrote(store, "2026-09-01", MITHRA, 10)
|
||||
wrote(store, "2026-09-02", MITHRA, 10)
|
||||
wrote(store, "2026-09-03", MITHRA, 4)
|
||||
wrote(store, "2026-09-03", KUHY, 4)
|
||||
wrote(store, "2026-09-06", KUHY, 9)
|
||||
streaks = current_streaks(daily_winners(store.events, GROUP))
|
||||
assert [(s.display_name, s.length) for s in streaks] == [("kuhy", 2)]
|
||||
assert streaks[0].last_day == "2026-09-06"
|
||||
|
||||
|
||||
def test_longest_ever_survives_the_run_that_set_it_ending(store: Store) -> None:
|
||||
wrote(store, "2026-09-01", MITHRA, 10)
|
||||
wrote(store, "2026-09-02", MITHRA, 10)
|
||||
wrote(store, "2026-09-03", MITHRA, 10)
|
||||
wrote(store, "2026-09-04", KUHY, 10)
|
||||
best = longest_ever(daily_winners(store.events, GROUP))
|
||||
assert (best.display_name, best.length, best.last_day) == ("Ala", 3, "2026-09-03")
|
||||
|
||||
|
||||
def test_held_days_counts_shared_days_for_everyone_tied(store: Store) -> None:
|
||||
wrote(store, "2026-09-01", MITHRA, 10)
|
||||
wrote(store, "2026-09-02", MITHRA, 4)
|
||||
wrote(store, "2026-09-02", KUHY, 4)
|
||||
winners = daily_winners(store.events, GROUP)
|
||||
assert held_days(winners, "u-ala") == 2
|
||||
assert held_days(winners, "u-kuhy") == 1
|
||||
assert held_days(winners, "u-iga") == 0
|
||||
|
||||
|
||||
def test_somebody_who_merely_took_part_holds_nothing(store: Store) -> None:
|
||||
"""Neither leader nor tied: the common case, and the one worth pinning."""
|
||||
wrote(store, "2026-09-01", MITHRA, 10)
|
||||
wrote(store, "2026-09-01", KUHY, 4)
|
||||
wrote(store, "2026-09-01", IGA, 1)
|
||||
winners = daily_winners(store.events, GROUP)
|
||||
assert names_of(winners["2026-09-01"]) == ["Ala"]
|
||||
assert held_days(winners, "u-iga") == 0
|
||||
|
||||
|
||||
def test_streak_order_is_stable(store: Store) -> None:
|
||||
"""Longest first, then by name -- not whichever the dict happened to yield."""
|
||||
wrote(store, "2026-09-01", MITHRA, 3)
|
||||
wrote(store, "2026-09-01", KUHY, 3)
|
||||
wrote(store, "2026-09-01", IGA, 3)
|
||||
streaks = current_streaks(daily_winners(store.events, GROUP))
|
||||
assert [s.display_name for s in streaks] == ["Basia", "Ala", "kuhy"]
|
||||
|
||||
|
||||
def test_the_backfilled_day_can_be_left_out(store: Store) -> None:
|
||||
wrote(store, "2026-08-30", MITHRA, 98)
|
||||
wrote(store, "2026-09-01", KUHY, 5)
|
||||
winners = daily_winners(store.events, GROUP, exclude="2026-08-30")
|
||||
assert "2026-08-30" not in winners
|
||||
assert [s.display_name for s in current_streaks(winners)] == ["kuhy"]
|
||||
Loading…
Reference in New Issue
Block a user