diff --git a/signal_bot/bot.py b/signal_bot/bot.py index d072d5a..3aff719 100644 --- a/signal_bot/bot.py +++ b/signal_bot/bot.py @@ -12,7 +12,7 @@ import datetime as dt import logging from collections.abc import Callable from dataclasses import dataclass -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Final from signal_bot.animals import register_animal_commands from signal_bot.announce import announce_all @@ -23,8 +23,10 @@ from signal_bot.http_client import ImageFetcher from signal_bot.popes_command import register_pope_command from signal_bot.portraits import PortraitCache from signal_bot.registry import CommandRegistry -from signal_bot.stats import periods +from signal_bot.stats import curiosities, periods from signal_bot.stats.backfill import backfilled_day +from signal_bot.stats.facts import gather +from signal_bot.stats.highlights import select from signal_bot.stats.nightly import days_to_report, mark_reported from signal_bot.stats.queries_day import day_rows, day_totals, with_silent from signal_bot.stats.render_day import DayView, render_day @@ -45,6 +47,9 @@ Clock = Callable[[], dt.datetime] COOLDOWN_MESSAGE = "BEEP BOOP POCZEKAJ {seconds} SEKUND." +# How many highlight lines the nightly post has room for. +HIGHLIGHT_SLOTS: Final = 3 + def now_local() -> dt.datetime: """Current Warsaw time -- the group's day, not the host's. @@ -104,13 +109,17 @@ class Bot: return True named = self.store.events.roster(group_id, self.directory.member_identities(group_id)) with_zeros = with_silent(rows, [(p.uuid, p.display_name) for p in named]) + backfilled = backfilled_day(self.store, group_id) + facts = gather(self.store.events, group_id, day, with_zeros, exclude=backfilled) + picks = select(curiosities.for_day(facts), HIGHLIGHT_SLOTS) text = render_day( day, with_zeros, day_totals(with_zeros), DayView( since=self.store.events.first_day(group_id), - approximate=backfilled_day(self.store, group_id) == day, + approximate=backfilled == day, + highlights=tuple(pick.text for pick in picks), ), ) try: diff --git a/signal_bot/stats/curiosities.py b/signal_bot/stats/curiosities.py new file mode 100644 index 0000000..3201314 --- /dev/null +++ b/signal_bot/stats/curiosities.py @@ -0,0 +1,231 @@ +# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence. +"""The things the bot might notice about a day. + +Every candidate has the same signature -- ``(DayFacts) -> Highlight | None`` -- +and returns None when it has nothing to say or not enough history to say it. +That uniformity is deliberate: on day one of this release almost all of them +return None, and a shape that makes "not yet" the ordinary case is far easier +to get right than one where each guard is bespoke. + +Priorities are set so a record beats a trend, and anything about a person +beats anything about an average. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Final + +from signal_bot.stats import facts as day_facts +from signal_bot.stats import records, streaks, text +from signal_bot.stats.highlights import ( + CROWN, + RECORD, + TIMING, + TREND, + VOICE, + Highlight, + ratio, +) +from signal_bot.stats.periods import WEEKDAY_PLURAL, pl_day_month + +if TYPE_CHECKING: + from signal_bot.stats.facts import DayFacts + +TROPHY: Final = "\U0001f3c6" +CROWN_EMOJI: Final = "\U0001f451" +CALENDAR: Final = "\U0001f4c5" +CAMERA: Final = "\U0001f4f7" +OWL: Final = "\U0001f989" +SPEAKER: Final = "\U0001f4e2" + +# How different from the usual a day has to be before it is worth a line. +WEEKDAY_THRESHOLD: Final = 0.25 +DOMINANT_SHARE: Final = 50 +MIN_BASELINE_DAYS: Final = 3 +LONG_SILENCE: Final = 3 +NIGHT_OWL_HOUR: Final = 2 +# A record, a streak or a monologue is only interesting once there is +# something for it to stand against -- one day is not a history. +MIN_DAYS_FOR_A_RECORD: Final = 2 +MIN_PHOTOS_FOR_A_RECORD: Final = 2 +MIN_STREAK: Final = 2 +MIN_MESSAGES_FOR_A_MONOLOGUE: Final = 2 +MIN_SPEAKERS_FOR_A_SHARE: Final = 2 + + +def personal_record(facts: DayFacts) -> Highlight | None: + """Somebody beat their own best day ever.""" + for row in facts.speakers: + best = day_facts.personal_record(facts, row.uuid) + if best is None: + continue + previous = records.previous_best( + facts.events, facts.group_id, row.uuid, facts.day, exclude=facts.exclude + ) + if previous is None: + continue + beaten = f" (poprzedni {previous.value}, {pl_day_month(previous.day)})" + return Highlight( + kind="personal_record", + family=RECORD, + priority=100, + magnitude=ratio(best.value, previous.value), + text=f"{TROPHY} {row.display_name} {best.value} — rekord życiowy{beaten}", + ) + return None + + +def group_record_day(facts: DayFacts) -> Highlight | None: + """The group's busiest day ever, and it is today.""" + busiest = records.busiest_day(facts.history) + if busiest is None or busiest.day != facts.day or len(facts.history) < MIN_DAYS_FOR_A_RECORD: + return None + others = [n for d, n in facts.history.items() if d != facts.day] + return Highlight( + kind="group_record_day", + family=RECORD, + priority=95, + magnitude=ratio(busiest.value, max(others)), + text=f"{TROPHY} najgadatliwszy dzień w historii: {text.messages(busiest.value)}", + ) + + +def media_record(facts: DayFacts) -> Highlight | None: + """The most photos the group has ever posted in a day.""" + record = records.most_media_day(facts.events, facts.group_id, exclude=facts.exclude) + if record is None or record.day != facts.day or record.value < MIN_PHOTOS_FOR_A_RECORD: + return None + return Highlight( + kind="media_record", + family=RECORD, + priority=90, + magnitude=float(record.value), + text=f"{CAMERA} rekord zdjęć: {record.value} w jeden dzień", + ) + + +def crown_streak(facts: DayFacts) -> Highlight | None: + """Somebody has held the crown several days running.""" + running = [s for s in streaks.current_streaks(facts.winners) if s.last_day == facts.day] + if not running or running[0].length < MIN_STREAK: + return None + best = running[0] + longest = streaks.longest_ever(facts.winners) + record = " — rekord!" if longest and longest.length == best.length else "" + names = " i ".join(s.display_name for s in running if s.length == best.length) + return Highlight( + kind="crown_streak", + family=CROWN, + priority=80, + magnitude=float(best.length), + text=f"{CROWN_EMOJI} {names} z koroną {text.days(best.length)} z rzędu{record}", + ) + + +def silence_broken(facts: DayFacts) -> Highlight | None: + """Somebody spoke after several days of saying nothing.""" + for row in facts.speakers: + theirs = facts.person_days(row.uuid) + before = {d: n for d, n in theirs.items() if d < facts.day} + gap = records.days_silent_before(facts.history, before, facts.day) + if gap >= LONG_SILENCE: + return Highlight( + kind="silence_broken", + family=VOICE, + priority=75, + magnitude=float(gap), + text=f"{SPEAKER} {row.display_name} odezwał się po {text.days(gap)} ciszy", + ) + return None + + +def only_one_spoke(facts: DayFacts) -> Highlight | None: + """A monologue: exactly one person wrote all day.""" + if len(facts.speakers) != 1 or facts.totals.messages < MIN_MESSAGES_FOR_A_MONOLOGUE: + return None + return Highlight( + kind="only_one_spoke", + family=VOICE, + priority=65, + magnitude=float(facts.totals.messages), + text=f"{SPEAKER} cały dzień mówił tylko {facts.speakers[0].display_name}", + ) + + +def dominant_share(facts: DayFacts) -> Highlight | None: + """One person accounted for most of the day on their own.""" + if len(facts.speakers) < MIN_SPEAKERS_FOR_A_SHARE or not facts.totals.messages: + return None + top = facts.speakers[0] + share = round(top.messages / facts.totals.messages * 100) + if share < DOMINANT_SHARE: + return None + return Highlight( + kind="dominant_share", + family=VOICE, + priority=60, + magnitude=share / 100, + text=f"{SPEAKER} {top.display_name} to {share}% dzisiejszych wiadomości", + ) + + +def weekday_average(facts: DayFacts) -> Highlight | None: + """Today stands well away from what this weekday usually looks like.""" + others = facts.same_weekday_before + if len(others) < MIN_BASELINE_DAYS: + return None + mean = sum(others) / len(others) + difference = ratio(facts.totals.messages, mean) + if difference < WEEKDAY_THRESHOLD: + return None + direction = "więcej" if facts.totals.messages > mean else "mniej" + return Highlight( + kind="weekday_average", + family=TREND, + priority=50, + magnitude=difference, + text=( + f"{CALENDAR} {WEEKDAY_PLURAL[facts.weekday]} mają średnio " + f"{round(mean)} — dziś o {round(difference * 100)}% {direction}" + ), + ) + + +def night_owl(facts: DayFacts) -> Highlight | None: + """Somebody was still writing in the small hours.""" + row = facts.events.connection.execute( + """ + SELECT display_name, local_hour + FROM message_events + WHERE group_id = ? AND day = ? AND local_hour BETWEEN ? AND ? + ORDER BY local_hour DESC, sent_at_ms DESC LIMIT 1 + """, + (facts.group_id, facts.day, 0, NIGHT_OWL_HOUR), + ).fetchone() + if row is None: + return None + return Highlight( + kind="night_owl", + family=TIMING, + priority=55, + magnitude=float(NIGHT_OWL_HOUR - row["local_hour"] + 1), + text=f"{OWL} {row['display_name']} pisał o {row['local_hour']:02d}:00", + ) + + +ALL = ( + personal_record, + group_record_day, + media_record, + crown_streak, + silence_broken, + only_one_spoke, + dominant_share, + weekday_average, + night_owl, +) + + +def for_day(facts: DayFacts) -> list[Highlight | None]: + """Run every candidate against a day.""" + return [candidate(facts) for candidate in ALL] diff --git a/signal_bot/stats/facts.py b/signal_bot/stats/facts.py new file mode 100644 index 0000000..c82bf05 --- /dev/null +++ b/signal_bot/stats/facts.py @@ -0,0 +1,107 @@ +# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence. +"""Everything known about one day, gathered once. + +The candidates in ``curiosities`` each need some slice of the same handful of +queries. Gathering them once and passing this around keeps the night's work to +a fixed number of queries however many candidates are added later, and gives +every candidate the same uniform signature -- which is what makes the "not +enough history yet" guards mechanical to write and to test. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from signal_bot.stats import records, streaks +from signal_bot.stats.periods import weekday_of +from signal_bot.stats.queries_day import DayTotals, day_totals +from signal_bot.stats.queries_period import every_day, weekday_buckets + +if TYPE_CHECKING: + from signal_bot.stats.events import EventStore + from signal_bot.stats.queries_day import DayRow + + +@dataclass(frozen=True, slots=True) +class DayFacts: + """One day, plus the history it has to be interesting against.""" + + day: str + rows: list[DayRow] + totals: DayTotals + # Messages per recorded day across the whole history, the backfilled day + # already removed. Missing days are silences, not zeros. + history: dict[str, int] + winners: dict[str, list[tuple[str, str]]] + events: EventStore + group_id: str + exclude: str | None + + @property + def leaders(self) -> list[tuple[str, str]]: + """Whoever holds today's crown, as (uuid, display name). Ties included.""" + return self.winners.get(self.day, []) + + @property + def speakers(self) -> list[DayRow]: + """Everyone who actually wrote today, most talkative first.""" + return [row for row in self.rows if not row.is_silent] + + @property + def weekday(self) -> int: + """0 for Monday through 6 for Sunday.""" + return weekday_of(self.day) + + @property + def same_weekday_before(self) -> list[int]: + """Every earlier day that fell on the same weekday. + + Today is excluded, so "wtorki mają średnio 74" is a claim about the + other Tuesdays rather than one that quietly includes the day it is + being compared against. + """ + buckets = weekday_buckets({d: n for d, n in self.history.items() if d < self.day}) + return buckets.get(self.weekday, []) + + def person_days(self, uuid: str) -> dict[str, int]: + """One person's messages per day, across the whole history.""" + rows = self.events.connection.execute( + """ + SELECT day, COUNT(*) AS messages + FROM message_events + WHERE group_id = ? AND uuid = ? AND day <> ? + GROUP BY day + """, + (self.group_id, uuid, self.exclude or ""), + ).fetchall() + return {r["day"]: r["messages"] for r in rows} + + +def gather( + events: EventStore, + group_id: str, + day: str, + rows: list[DayRow], + *, + exclude: str | None = None, +) -> DayFacts: + """Run the shared queries once and bundle them up.""" + return DayFacts( + day=day, + rows=rows, + totals=day_totals(rows), + history=every_day(events, group_id, exclude=exclude), + winners=streaks.daily_winners(events, group_id, exclude=exclude), + events=events, + group_id=group_id, + exclude=exclude, + ) + + +def personal_record(facts: DayFacts, uuid: str) -> records.DayRecord | None: + """Today's count, if it is that person's best ever, else None.""" + best = records.personal_best(facts.events, facts.group_id, uuid, exclude=facts.exclude) + if best is None or best.day != facts.day: + return None + return best diff --git a/signal_bot/stats/highlights.py b/signal_bot/stats/highlights.py new file mode 100644 index 0000000..b68d642 --- /dev/null +++ b/signal_bot/stats/highlights.py @@ -0,0 +1,82 @@ +# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence. +"""Choosing the two or three things worth saying about a day. + +There are around twenty things the bot could say every night. Saying all of +them is a wall of text nobody reads, and saying whichever three happen to be +computed first is arbitrary. So every candidate declares a priority and a +magnitude, and the picker takes the top few. + +Two rules make the result readable rather than merely short: + +* **One per family.** "Mithra set a personal record", "Mithra had 60% of the + day" and "Mithra kept the crown" are three sentences about the same thing. + Families collapse them to the strongest. +* **Fully deterministic.** No clock, no randomness, and ties break on the + candidate's own name, so the same day always produces the same post. A + nightly report that reshuffles itself between two renders reads as a bug. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING, Final + +if TYPE_CHECKING: + from collections.abc import Sequence + +# Families. Anything in the same one is a restatement of the same fact. +RECORD: Final = "record" +CROWN: Final = "crown" +TREND: Final = "trend" +TIMING: Final = "timing" +VOICE: Final = "voice" + + +@dataclass(frozen=True, slots=True) +class Highlight: + """One thing worth saying, and how much it wants saying.""" + + kind: str + family: str + priority: int + magnitude: float + text: str + + +def select(candidates: Sequence[Highlight | None], slots: int) -> list[Highlight]: + """Pick at most ``slots`` highlights, strongest first, one per family. + + Args: + candidates: Every candidate, with None for the ones that did not + fire. Callers pass the whole list rather than filtering, so a + candidate that never fires is still visible in the code. + slots: How many lines the renderer has room for. + + Returns: + The chosen highlights, most important first. + """ + fired = sorted( + (candidate for candidate in candidates if candidate is not None), + key=lambda c: (-c.priority, -c.magnitude, c.kind), + ) + chosen: list[Highlight] = [] + seen: set[str] = set() + for candidate in fired: + if candidate.family in seen: + continue + seen.add(candidate.family) + chosen.append(candidate) + if len(chosen) == slots: + break + return chosen + + +def ratio(value: float, baseline: float) -> float: + """How far a value stands from its baseline, as a comparable fraction. + + Used as ``magnitude`` so that "double the usual Tuesday" and "three times + the usual message length" can be ranked against each other at all. The + baseline is floored at one so a first-ever measurement does not divide by + zero and does not come out infinitely interesting. + """ + return abs(value - baseline) / max(baseline, 1) diff --git a/signal_bot/stats/records.py b/signal_bot/stats/records.py index 12c86a7..eee446e 100644 --- a/signal_bot/stats/records.py +++ b/signal_bot/stats/records.py @@ -37,14 +37,6 @@ MEDIA_DAYS = """ 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: @@ -169,16 +161,20 @@ def _silence(start: str, end: str) -> Silence: 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``. +def days_silent_before(days: dict[str, int], uuid_days: dict[str, int], until: str) -> int: + """Recorded days between somebody's last message and ``until``, exclusive. - 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. + Both ends are excluded, which is what makes "odezwał się po 3 dniach + ciszy" true rather than off by one: the day they last spoke was not + silent, and neither is the day they came back on. + + Bounded by the group's own recorded days, so somebody who joined last week + is not reported as having been silent for a year, and a stretch the bot + was offline for does not count against them. """ if not days: return 0 if not uuid_days: - return sum(1 for day in days if day <= until) + 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) + return sum(1 for day in days if last < day < until) diff --git a/tests/test_stats_curiosities.py b/tests/test_stats_curiosities.py new file mode 100644 index 0000000..00b3289 --- /dev/null +++ b/tests/test_stats_curiosities.py @@ -0,0 +1,228 @@ +# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence. +"""What the bot notices about a day, and what it stays quiet about.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from signal_bot.stats import curiosities +from signal_bot.stats.facts import DayFacts, gather +from signal_bot.stats.highlights import Highlight, ratio, select +from signal_bot.stats.queries_day import day_rows, with_silent +from tests.stats_seed import GROUP, IGA, KUHY, MITHRA, Shape, wrote + +if TYPE_CHECKING: + from signal_bot.store import Store + + +def facts_for(store: Store, day: str, roster: list[tuple[str, str]] | None = None) -> DayFacts: + """Gather a day the way publish_report does.""" + rows = with_silent(day_rows(store.events, GROUP, day), roster or []) + return gather(store.events, GROUP, day, rows) + + +def test_day_one_notices_nothing(store: Store) -> None: + """Almost every candidate has no history to stand against yet.""" + wrote(store, "2026-09-01", KUHY, 3) + fired = [c for c in curiosities.for_day(facts_for(store, "2026-09-01")) if c] + assert [c.kind for c in fired] == ["only_one_spoke"] + + +def test_facts_expose_the_days_crown_holders(store: Store) -> None: + wrote(store, "2026-09-01", MITHRA, 4) + wrote(store, "2026-09-01", KUHY, 4) + wrote(store, "2026-09-01", IGA, 1) + assert sorted(n for _, n in facts_for(store, "2026-09-01").leaders) == ["Mithra", "kuhy"] + assert facts_for(store, "2026-09-09").leaders == [] + + +def test_a_personal_record_reports_what_it_beat(store: Store) -> None: + wrote(store, "2026-09-01", MITHRA, 5) + wrote(store, "2026-09-02", KUHY, 1) + wrote(store, "2026-09-02", MITHRA, 9) + found = curiosities.personal_record(facts_for(store, "2026-09-02")) + assert found is not None + assert "Mithra 9 — rekord życiowy (poprzedni 5, 1 września)" in found.text + + +def test_a_first_ever_day_is_not_announced_as_a_record(store: Store) -> None: + """Everybody's first day is their best; saying so every time is noise.""" + wrote(store, "2026-09-01", MITHRA, 5) + assert curiosities.personal_record(facts_for(store, "2026-09-01")) is None + + +def test_the_busiest_day_ever(store: Store) -> None: + wrote(store, "2026-09-01", KUHY, 3) + wrote(store, "2026-09-02", KUHY, 9) + found = curiosities.group_record_day(facts_for(store, "2026-09-02")) + assert found is not None + assert "najgadatliwszy dzień w historii: 9 wiadomości" in found.text + + +def test_a_quiet_day_is_not_a_record(store: Store) -> None: + wrote(store, "2026-09-01", KUHY, 9) + wrote(store, "2026-09-02", KUHY, 3) + assert curiosities.group_record_day(facts_for(store, "2026-09-02")) is None + + +def test_a_media_record_needs_more_than_one_photo(store: Store) -> None: + wrote(store, "2026-09-01", KUHY, 3, Shape(media=1)) + assert curiosities.media_record(facts_for(store, "2026-09-01")) is None + wrote(store, "2026-09-02", KUHY, 4, Shape(media=3)) + found = curiosities.media_record(facts_for(store, "2026-09-02")) + assert found is not None + assert "rekord zdjęć: 3" in found.text + + +def test_a_crown_streak_needs_two_days(store: Store) -> None: + wrote(store, "2026-09-01", MITHRA, 5) + wrote(store, "2026-09-01", KUHY, 1) + assert curiosities.crown_streak(facts_for(store, "2026-09-01")) is None + wrote(store, "2026-09-02", MITHRA, 5) + wrote(store, "2026-09-02", KUHY, 1) + found = curiosities.crown_streak(facts_for(store, "2026-09-02")) + assert found is not None + assert "Mithra z koroną 2 dni z rzędu" in found.text + + +def test_a_shared_crown_names_everyone(store: Store) -> None: + for day in ("2026-09-01", "2026-09-02"): + wrote(store, day, MITHRA, 4) + wrote(store, day, KUHY, 4) + found = curiosities.crown_streak(facts_for(store, "2026-09-02")) + assert found is not None + assert "Mithra i kuhy z koroną" in found.text + + +def test_breaking_a_silence(store: Store) -> None: + wrote(store, "2026-09-01", IGA, 2) + for day in ("2026-09-02", "2026-09-03", "2026-09-04"): + wrote(store, day, KUHY, 3) + wrote(store, "2026-09-05", IGA, 1) + wrote(store, "2026-09-05", KUHY, 9) + found = curiosities.silence_broken(facts_for(store, "2026-09-05")) + assert found is not None + assert "Iga odezwał się po 3 dni ciszy" in found.text + + +def test_a_short_gap_is_not_worth_mentioning(store: Store) -> None: + wrote(store, "2026-09-01", IGA, 2) + wrote(store, "2026-09-02", KUHY, 3) + wrote(store, "2026-09-03", IGA, 2) + assert curiosities.silence_broken(facts_for(store, "2026-09-03")) is None + + +def test_a_monologue(store: Store) -> None: + wrote(store, "2026-09-01", KUHY, 7) + found = curiosities.only_one_spoke(facts_for(store, "2026-09-01")) + assert found is not None + assert "cały dzień mówił tylko kuhy" in found.text + + +def test_one_single_message_is_not_a_monologue(store: Store) -> None: + wrote(store, "2026-09-01", KUHY, 1) + assert curiosities.only_one_spoke(facts_for(store, "2026-09-01")) is None + + +def test_dominating_the_day(store: Store) -> None: + wrote(store, "2026-09-01", MITHRA, 8) + wrote(store, "2026-09-01", KUHY, 2) + found = curiosities.dominant_share(facts_for(store, "2026-09-01")) + assert found is not None + assert "Mithra to 80% dzisiejszych wiadomości" in found.text + + +def test_an_evenly_shared_day_is_not_dominated(store: Store) -> None: + wrote(store, "2026-09-01", MITHRA, 5) + wrote(store, "2026-09-01", KUHY, 4) + wrote(store, "2026-09-01", IGA, 4) + assert curiosities.dominant_share(facts_for(store, "2026-09-01")) is None + + +def test_a_weekday_needs_a_baseline_before_it_can_be_unusual(store: Store) -> None: + """Two Tuesdays are not an average worth quoting.""" + wrote(store, "2026-09-01", KUHY, 5) + wrote(store, "2026-09-08", KUHY, 50) + assert curiosities.weekday_average(facts_for(store, "2026-09-08")) is None + + +def test_an_unusual_weekday(store: Store) -> None: + for day in ("2026-09-01", "2026-09-08", "2026-09-15"): + wrote(store, day, KUHY, 10) + wrote(store, "2026-09-22", KUHY, 30) + found = curiosities.weekday_average(facts_for(store, "2026-09-22")) + assert found is not None + assert "wtorki mają średnio 10 — dziś o 200% więcej" in found.text + + +def test_a_typical_weekday_says_nothing(store: Store) -> None: + for day in ("2026-09-01", "2026-09-08", "2026-09-15", "2026-09-22"): + wrote(store, day, KUHY, 10) + assert curiosities.weekday_average(facts_for(store, "2026-09-22")) is None + + +def test_a_quiet_weekday_reads_as_fewer(store: Store) -> None: + for day in ("2026-09-01", "2026-09-08", "2026-09-15"): + wrote(store, day, KUHY, 20) + wrote(store, "2026-09-22", KUHY, 5) + found = curiosities.weekday_average(facts_for(store, "2026-09-22")) + assert found is not None + assert "mniej" in found.text + + +def test_a_night_owl(store: Store) -> None: + wrote(store, "2026-09-01", KUHY, 3, Shape(hour=1)) + found = curiosities.night_owl(facts_for(store, "2026-09-01")) + assert found is not None + assert "kuhy pisał o 01:00" in found.text + + +def test_an_ordinary_evening_is_not_a_night_owl(store: Store) -> None: + wrote(store, "2026-09-01", KUHY, 3, Shape(hour=22)) + assert curiosities.night_owl(facts_for(store, "2026-09-01")) is None + + +def test_one_line_per_family(store: Store) -> None: + """Three sentences about the same person are one fact, not three.""" + picks = select( + [ + Highlight("a", "voice", 60, 1.0, "A"), + Highlight("b", "voice", 50, 1.0, "B"), + Highlight("c", "record", 40, 1.0, "C"), + ], + 3, + ) + assert [p.kind for p in picks] == ["a", "c"] + + +def test_selection_is_ordered_and_bounded() -> None: + picks = select( + [ + Highlight("low", "one", 10, 1.0, ""), + None, + Highlight("high", "two", 90, 1.0, ""), + Highlight("mid", "three", 50, 1.0, ""), + ], + 2, + ) + assert [p.kind for p in picks] == ["high", "mid"] + + +def test_ties_break_on_name_so_a_day_renders_the_same_twice() -> None: + picks = select( + [ + Highlight("zebra", "one", 50, 1.0, ""), + Highlight("alpha", "two", 50, 1.0, ""), + ], + 2, + ) + assert [p.kind for p in picks] == ["alpha", "zebra"] + + +def test_nothing_to_say_is_an_empty_list() -> None: + assert select([None, None], 3) == [] + + +def test_ratio_survives_a_zero_baseline() -> None: + assert ratio(5, 0) == 5.0 + assert ratio(10, 5) == 1.0 diff --git a/tests/test_stats_records.py b/tests/test_stats_records.py index fdd5fd7..552abb0 100644 --- a/tests/test_stats_records.py +++ b/tests/test_stats_records.py @@ -8,12 +8,12 @@ from typing import TYPE_CHECKING from signal_bot.stats.queries_period import every_day from signal_bot.stats.records import ( busiest_day, + days_silent_before, longest_silence, most_media_day, personal_best, previous_best, quietest_day, - silent_since, ) from tests.stats_seed import GROUP, IGA, KUHY, MITHRA, Shape, wrote @@ -109,20 +109,22 @@ def test_an_unbroken_history_has_no_silence(store: Store) -> None: 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".""" +def test_the_gap_excludes_both_the_last_word_and_the_return(store: Store) -> None: + """What makes "odezwał się po 3 dniach" true instead of off by one.""" history(store) days = every_day(store.events, GROUP) mithra = {d: n for d, n in days.items() if d in {"2026-09-01", "2026-09-02"}} - assert silent_since(days, mithra, "2026-09-06") == 2 - assert silent_since(days, {"2026-09-06": 9}, "2026-09-06") == 0 + # Recorded days are 09-01, 09-02, 09-03 and 09-06. Mithra last wrote on + # the 2nd, so only the 3rd sits between that and a return on the 6th. + assert days_silent_before(days, mithra, "2026-09-06") == 1 + assert days_silent_before(days, {"2026-09-06": 9}, "2026-09-06") == 0 -def test_somebody_who_never_wrote_is_silent_for_the_whole_history(store: Store) -> None: +def test_somebody_who_never_wrote_is_silent_for_every_day_before_now(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 + assert days_silent_before(days, {}, "2026-09-06") == len(days) - 1 + assert days_silent_before({}, {}, "2026-09-06") == 0 def test_the_backfilled_day_is_kept_out_of_records(store: Store) -> None: