diff --git a/signal_bot/aliases.json b/signal_bot/aliases.json index 3ab0363..aacac8e 100644 --- a/signal_bot/aliases.json +++ b/signal_bot/aliases.json @@ -1,18 +1,87 @@ { "_comment": "Command trigger words, including the styled Unicode variants people actually type. These live as data, not source, because they are data: adding an alias should never mean touching Python.", "cat": [ - "!kot", "!koty", "!kots", "!cat", "!cats", "!meow", "!miau", - "!ᴋᴏᴛ", "!𝓴𝓸𝓽", "!𝗸𝗼𝘁" + "!kot", + "!koty", + "!kots", + "!cat", + "!cats", + "!meow", + "!miau", + "!ᴋᴏᴛ", + "!𝓴𝓸𝓽", + "!𝗸𝗼𝘁" ], "dog": [ - "!pies", "!psy", "!dog", "!dogs", "!woof", "!szczek", - "!𝗽𝗶𝗲𝘀", "!͓̽p͓̽i͓̽e͓̽s͓̽" + "!pies", + "!psy", + "!dog", + "!dogs", + "!woof", + "!szczek", + "!𝗽𝗶𝗲𝘀", + "!͓̽p͓̽i͓̽e͓̽s͓̽" ], "gecko": [ - "!gekon", "!gekony", "!geko", "!gecko", "!geckon", "!geckos" + "!gekon", + "!gekony", + "!geko", + "!gecko", + "!geckon", + "!geckos" ], "pope": [ - "!papież", "!papiez", "!papieze", "!papieża", "!papa", - "!pope", "!popes", "!pontifex", "!𝗽𝗮𝗽𝗶𝗲𝘇" + "!papież", + "!papiez", + "!papieze", + "!papieża", + "!papa", + "!pope", + "!popes", + "!pontifex", + "!𝗽𝗮𝗽𝗶𝗲𝘇" + ], + "stats_today": [ + "!staty", + "!statystyki", + "!stats", + "!dziś", + "!dzis" + ], + "stats_yesterday": [ + "!wczoraj", + "!yesterday" + ], + "stats_week": [ + "!tydzień", + "!tydzien", + "!week" + ], + "stats_month": [ + "!miesiąc", + "!miesiac", + "!month" + ], + "stats_year": [ + "!rok", + "!year" + ], + "stats_records": [ + "!rekordy", + "!rekord", + "!records" + ], + "stats_crown": [ + "!korona", + "!crown" + ], + "stats_weekdays": [ + "!dni", + "!days" + ], + "stats_me": [ + "!ja", + "!moje", + "!mnie" ] } diff --git a/signal_bot/bot.py b/signal_bot/bot.py index 3aff719..54e732c 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, Final +from typing import TYPE_CHECKING from signal_bot.animals import register_animal_commands from signal_bot.announce import announce_all @@ -25,8 +25,9 @@ from signal_bot.portraits import PortraitCache from signal_bot.registry import CommandRegistry from signal_bot.stats import curiosities, periods from signal_bot.stats.backfill import backfilled_day +from signal_bot.stats.commands import register_stats_commands from signal_bot.stats.facts import gather -from signal_bot.stats.highlights import select +from signal_bot.stats.highlights import SLOTS, 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 @@ -47,9 +48,6 @@ 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. @@ -111,7 +109,7 @@ class Bot: 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) + picks = select(curiosities.for_day(facts), SLOTS) text = render_day( day, with_zeros, @@ -174,6 +172,7 @@ def build_bot( PortraitCache(fetcher, config.commons_api_url), decorate=rate_limit, ) + register_stats_commands(registry, decorate=rate_limit) dispatcher = Dispatcher( config=config, directory=directory, diff --git a/signal_bot/stats/commands.py b/signal_bot/stats/commands.py new file mode 100644 index 0000000..47b1e9d --- /dev/null +++ b/signal_bot/stats/commands.py @@ -0,0 +1,204 @@ +# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence. +"""The `!` commands that read the statistics. + +None of them takes an argument. ``registry.resolve`` matches the whole folded +message body against a trigger, so `!ja ala` resolves to nothing; teaching +it to split on whitespace would silently make `!kot pls` fire where today it +does nothing, which is a behaviour change with its own blast radius. Each +question gets its own trigger instead. + +`!wczoraj` renders through exactly the same path as the nightly post. That is +not a convenience: the bot cannot trigger its own 21:37 report on demand, so +without a command that produces byte-identical text there would be no way to +check the headline output of this release short of waiting for the evening. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from signal_bot.animals import load_aliases +from signal_bot.stats import curiosities, periods, records, streaks +from signal_bot.stats.backfill import backfilled_day +from signal_bot.stats.facts import gather +from signal_bot.stats.highlights import SLOTS, select +from signal_bot.stats.queries_day import day_rows, day_totals, with_silent +from signal_bot.stats.queries_period import ( + Trend, + every_day, + period_rows, + total_for, + weekday_buckets, +) +from signal_bot.stats.render_day import DayView, render_day +from signal_bot.stats.render_extra import ( + favourite_hour, + render_crown, + render_me, + render_records, + render_weekdays, +) +from signal_bot.stats.render_period import render_period + +if TYPE_CHECKING: + from signal_bot.decorators import Decorator + from signal_bot.registry import CommandContext, CommandRegistry + from signal_bot.stats.periods import Period + + +def _day_block(ctx: CommandContext, day: str, heading: str | None = None) -> str: + """Render one day exactly as the nightly report does.""" + store = ctx.store + rows = with_silent( + day_rows(store.events, ctx.group_id, day), + [(p.uuid, p.display_name) for p in store.events.people(ctx.group_id)], + ) + if not any(not row.is_silent for row in rows): + return "Nikt jeszcze nic nie napisał tego dnia." + backfilled = backfilled_day(store, ctx.group_id) + facts = gather(store.events, ctx.group_id, day, rows, exclude=backfilled) + picks = select(curiosities.for_day(facts), SLOTS) + return render_day( + day, + rows, + day_totals(rows), + DayView( + since=store.events.first_day(ctx.group_id), + approximate=backfilled == day, + highlights=tuple(pick.text for pick in picks), + heading=heading, + ), + ) + + +def _period_block(ctx: CommandContext, period: Period, *, podium: bool = False) -> str: + """Render one period against the one before it.""" + store = ctx.store + before = periods.previous(period) + trend = Trend( + now=total_for(store.events, ctx.group_id, period.start, period.end), + before=total_for(store.events, ctx.group_id, before.start, before.end), + ) + return render_period( + period, + period_rows(store.events, ctx.group_id, period), + trend, + since=store.events.first_day(ctx.group_id), + podium=podium, + ) + + +async def today(ctx: CommandContext) -> None: + """`!staty` -- the day so far.""" + await ctx.reply(_day_block(ctx, periods.today(), heading="Dziś, na razie:")) + + +async def yesterday(ctx: CommandContext) -> None: + """`!wczoraj` -- byte-identical to what the nightly post said.""" + await ctx.reply(_day_block(ctx, periods.previous_day())) + + +async def week(ctx: CommandContext) -> None: + """`!tydzień` -- this week against last week.""" + await ctx.reply(_period_block(ctx, periods.week_of(periods.today()))) + + +async def month(ctx: CommandContext) -> None: + """`!miesiąc` -- this month, with a podium.""" + await ctx.reply(_period_block(ctx, periods.month_of(periods.today()), podium=True)) + + +async def year(ctx: CommandContext) -> None: + """`!rok` -- this year against last year.""" + await ctx.reply(_period_block(ctx, periods.year_of(periods.today()))) + + +async def all_records(ctx: CommandContext) -> None: + """`!rekordy` -- every all-time record the group has set.""" + store = ctx.store + exclude = backfilled_day(store, ctx.group_id) + history = every_day(store.events, ctx.group_id, exclude=exclude) + personal = [] + for person in store.events.people(ctx.group_id): + best = records.personal_best(store.events, ctx.group_id, person.uuid, exclude=exclude) + if best is not None: + personal.append((person.display_name, best)) + personal.sort(key=lambda pair: (-pair[1].value, pair[0])) + await ctx.reply( + render_records( + history, + records.most_media_day(store.events, ctx.group_id, exclude=exclude), + records.longest_silence(history, periods.today()) if history else None, + personal, + ) + ) + + +async def crown(ctx: CommandContext) -> None: + """`!korona` -- who holds it, who held it longest, who has held it most.""" + store = ctx.store + winners = streaks.daily_winners( + store.events, ctx.group_id, exclude=backfilled_day(store, ctx.group_id) + ) + await ctx.reply(render_crown(winners, store.events.people(ctx.group_id))) + + +async def weekdays(ctx: CommandContext) -> None: + """`!dni` -- the Monday-to-Sunday picture.""" + store = ctx.store + history = every_day(store.events, ctx.group_id, exclude=backfilled_day(store, ctx.group_id)) + await ctx.reply(render_weekdays(weekday_buckets(history), periods.weekday_of(periods.today()))) + + +async def me(ctx: CommandContext) -> None: + """`!ja` -- the asker's own numbers.""" + store = ctx.store + uuid = ctx.message.source_uuid + exclude = backfilled_day(store, ctx.group_id) + everyone = period_rows( + store.events, + ctx.group_id, + periods.Period("all", "0000-01-01", "9999-12-31", "wszystko"), + ) + mine = next((row for row in everyone if row.uuid == uuid), None) + if mine is None: + await ctx.reply("Nie mam jeszcze od ciebie ani jednej wiadomości.") + return + total = sum(row.messages for row in everyone) + winners = streaks.daily_winners(store.events, ctx.group_id, exclude=exclude) + running = [s for s in streaks.current_streaks(winners) if s.uuid == uuid] + await ctx.reply( + render_me( + mine.display_name, + mine.messages, + records.personal_best(store.events, ctx.group_id, uuid, exclude=exclude), + round(mine.messages / total * 100) if total else 0, + favourite_hour(store.events, ctx.group_id, uuid), + running[0].length if running else 0, + ) + ) + + +HANDLERS = { + "stats_today": today, + "stats_yesterday": yesterday, + "stats_week": week, + "stats_month": month, + "stats_year": year, + "stats_records": all_records, + "stats_crown": crown, + "stats_weekdays": weekdays, + "stats_me": me, +} + + +def register_stats_commands( + registry: CommandRegistry, + *, + decorate: Decorator | None = None, + aliases: dict[str, list[str]] | None = None, +) -> None: + """Register every statistics command under its triggers from aliases.json.""" + table = load_aliases() if aliases is None else aliases + for name, handler in HANDLERS.items(): + registry.register(name, table[name], decorate(handler) if decorate else handler) diff --git a/signal_bot/stats/highlights.py b/signal_bot/stats/highlights.py index 624fc55..342a767 100644 --- a/signal_bot/stats/highlights.py +++ b/signal_bot/stats/highlights.py @@ -31,6 +31,10 @@ TREND: Final = "trend" TIMING: Final = "timing" VOICE: Final = "voice" +# How many highlight lines a day block has room for. Lives here rather than +# beside either caller, so the nightly post and !wczoraj cannot drift apart. +SLOTS: Final = 3 + @dataclass(frozen=True, slots=True) class Highlight: diff --git a/signal_bot/stats/render_extra.py b/signal_bot/stats/render_extra.py new file mode 100644 index 0000000..e6b0b74 --- /dev/null +++ b/signal_bot/stats/render_extra.py @@ -0,0 +1,146 @@ +# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence. +"""The on-demand answers: records, the crown, one person, the weekdays. + +Each of these is asked for rather than pushed, so each says something even +when there is nothing interesting yet -- "za mało danych" is a useful answer +to a question, where it would be noise in a nightly post nobody asked for. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Final + +from signal_bot.stats import records, streaks, text +from signal_bot.stats.periods import WEEKDAY_PLURAL, pl_day_month + +if TYPE_CHECKING: + from signal_bot.stats.events import EventStore, Person + from signal_bot.stats.records import DayRecord + +TROPHY: Final = "\U0001f3c6" +CROWN: Final = "\U0001f451" +CALENDAR: Final = "\U0001f4c5" +CAMERA: Final = "\U0001f4f7" +QUIET: Final = "\U0001f92b" +NOTHING_YET: Final = "Za mało danych — wróć za kilka dni." + + +def _record_line(label: str, record: DayRecord | None, unit: str) -> str | None: + """One "label: value (date)" line, or None when there is no record yet.""" + if record is None: + return None + return f"{label}: {record.value} {unit} ({pl_day_month(record.day)})" + + +def render_records( + history: dict[str, int], + media: DayRecord | None, + silence: records.Silence | None, + personal: list[tuple[str, DayRecord]], +) -> str: + """Every all-time record the group has set.""" + lines = [f"{TROPHY} Rekordy"] + candidates = [ + _record_line("Najwięcej w jeden dzień", records.busiest_day(history), "wiadomości"), + _record_line("Najciszej (ale ktoś pisał)", records.quietest_day(history), "wiadomości"), + _record_line(f"Najwięcej zdjęć {CAMERA}", media, "zdjęć"), + ] + lines.extend(line for line in candidates if line) + if silence: + lines.append( + f"{QUIET} Najdłuższa cisza: {text.days(silence.days)} " + f"({pl_day_month(silence.start)} - {pl_day_month(silence.end)})" + ) + if personal: + lines.append("") + lines.append("Rekordy życiowe:") + lines.extend( + f" {name} — {record.value} ({pl_day_month(record.day)})" for name, record in personal + ) + return "\n".join(lines) if len(lines) > 1 else NOTHING_YET + + +def render_crown( + winners: dict[str, list[tuple[str, str]]], + people: list[Person], +) -> str: + """Who holds the crown now, who held it longest, and who has held it most.""" + if not winners: + return NOTHING_YET + # Both are guaranteed once winners is non-empty, which the guard above + # already established: the latest day always has at least one holder, and + # a day with a holder is a run of at least one. + current = streaks.current_streaks(winners) + # `or current[0]` is a type narrowing, not a fallback: longest_ever only + # returns None for an empty history, which the guard above ruled out. + longest = streaks.longest_ever(winners) or current[0] + names = " i ".join(s.display_name for s in current if s.length == current[0].length) + lines = [ + f"{CROWN} Korona", + f"Teraz: {names} — {text.days(current[0].length)} z rzędu", + ( + f"Najdłuższa seria: {longest.display_name} — {text.days(longest.length)} " + f"(do {pl_day_month(longest.last_day)})" + ), + ] + held = sorted( + ((person.display_name, streaks.held_days(winners, person.uuid)) for person in people), + key=lambda pair: (-pair[1], pair[0]), + ) + lines.append("") + lines.append("Dni z koroną łącznie:") + lines.extend(f" {name} — {text.days(days)}" for name, days in held if days) + return "\n".join(lines) + + +def render_weekdays(buckets: dict[int, list[int]], today: int) -> str: + """The Monday-to-Sunday picture, with today's weekday called out.""" + if not buckets: + return NOTHING_YET + lines = [f"{CALENDAR} Dni tygodnia"] + for index in range(len(WEEKDAY_PLURAL)): + days = buckets.get(index) + if not days: + continue + mean = round(sum(days) / len(days)) + marker = " ← dziś" if index == today else "" + lines.append( + f"{WEEKDAY_PLURAL[index]}: średnio {mean} ({text.days(len(days))} w historii){marker}" + ) + return "\n".join(lines) + + +def render_me( + name: str, + total: int, + best: DayRecord | None, + share: int, + favourite_hour: int | None, + streak: int, +) -> str: + """One person's own numbers.""" + lines = [f"{name} — {text.messages(total)} w historii"] + if best: + lines.append(f"{TROPHY} Rekord życiowy: {best.value} ({pl_day_month(best.day)})") + lines.append(f"Udział w rozmowie: {share}%") + if favourite_hour is not None: + lines.append(f"Najczęściej piszesz o {favourite_hour:02d}:00") + if streak: + lines.append(f"{CROWN} Korona: {text.days(streak)} z rzędu") + return "\n".join(lines) + + +def favourite_hour(events: EventStore, group_id: str, uuid: str) -> int | None: + """The hour somebody writes most in, or None if they never have.""" + row = events.connection.execute( + """ + SELECT local_hour, COUNT(*) AS messages + FROM message_events + WHERE group_id = ? AND uuid = ? + GROUP BY local_hour + ORDER BY messages DESC, local_hour ASC + LIMIT 1 + """, + (group_id, uuid), + ).fetchone() + return row["local_hour"] if row else None diff --git a/signal_bot/stats/render_period.py b/signal_bot/stats/render_period.py new file mode 100644 index 0000000..31cba21 --- /dev/null +++ b/signal_bot/stats/render_period.py @@ -0,0 +1,85 @@ +# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence. +"""Rendering a week, a month or a year. + +Same rules as the day block: plain text, no padding, and the count separated +from the name by a dash rather than by spaces that only line up in a terminal. + +Longer than the nightly post on purpose. These are asked for, not pushed, so +somebody who typed `!miesiąc` has already agreed to read a month. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Final + +from signal_bot.stats import text +from signal_bot.stats.periods import pl_day_month + +if TYPE_CHECKING: + from signal_bot.stats.periods import Period + from signal_bot.stats.queries_day import DayRow + from signal_bot.stats.queries_period import Trend + +MEDALS: Final = ("\U0001f947", "\U0001f948", "\U0001f949") +CHART: Final = "\U0001f4ca" +BAR_WIDTH: Final = 10 +MAX_ROWS: Final = 12 + + +def bar(share: int) -> str: + """A share of voice as a text bar. Blocks, because Signal has no charts.""" + filled = round(share / 100 * BAR_WIDTH) + return "█" * filled + "░" * (BAR_WIDTH - filled) + + +def trend_line(trend: Trend, what: str) -> str: + """One line comparing a period with the one before it. + + Says "first one" rather than "0%" when there is no baseline: the group + should not be told a period is unchanged when there is nothing to have + changed from. + """ + if trend.percent is None: + return f"({what}: pierwszy taki okres w historii)" + if not trend.delta: + return f"({what}: dokładnie tyle samo co poprzednio)" + direction = "więcej" if trend.delta > 0 else "mniej" + return f"{trend.arrow} {abs(trend.percent)}% {direction} niż {what}" + + +def _row(row: DayRow, place: int, total: int, *, podium: bool) -> str: + """One person's line, with a medal or a rank and a share bar.""" + lead = MEDALS[place - 1] if podium and place <= len(MEDALS) else f"{place}." + share = round(row.messages / total * 100) if total else 0 + return f"{lead} {row.display_name} — {row.messages} {bar(share)} {share}%" + + +def render_period( + period: Period, + rows: list[DayRow], + trend: Trend, + *, + since: str | None = None, + podium: bool = False, +) -> str: + """Compose a period summary.""" + total = sum(row.messages for row in rows) + media = sum(row.media for row in rows) + extra = f" ({media} \U0001f4f7)" if media else "" + lines = [f"{CHART} {period.label} — {text.messages(total)}{extra}"] + + if not rows: + lines.append("Nikt nic nie napisał.") + return "\n".join(lines) + + shown = rows[:MAX_ROWS] + lines.extend(_row(row, i + 1, total, podium=podium) for i, row in enumerate(shown)) + if len(rows) > MAX_ROWS: + hidden = rows[MAX_ROWS:] + rest = sum(row.messages for row in hidden) + lines.append(f"…i {text.people(len(hidden))} więcej (razem {text.messages(rest)})") + + lines.append(trend_line(trend, "poprzednio")) + if since: + lines.append(f"(liczymy od {pl_day_month(since)})") + return "\n".join(lines) diff --git a/tests/test_stats_commands.py b/tests/test_stats_commands.py new file mode 100644 index 0000000..ae12039 --- /dev/null +++ b/tests/test_stats_commands.py @@ -0,0 +1,177 @@ +# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence. +"""The `!` commands, end to end through a real store.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import TYPE_CHECKING + +import pytest + +from signal_bot.animals import load_aliases +from signal_bot.folding import fold +from signal_bot.registry import CommandRegistry +from signal_bot.stats import periods +from signal_bot.stats.backfill import BACKFILL_KEY +from signal_bot.stats.commands import HANDLERS, register_stats_commands +from tests.stats_seed import GROUP, IGA, KUHY, MITHRA, Shape, wrote + +if TYPE_CHECKING: + from signal_bot.store import Store + +TODAY = periods.today() +YESTERDAY = periods.previous_day() + + +@dataclass +class Sender: + """The bit of IncomingMessage the handlers actually read.""" + + source_uuid: str = KUHY[0] + + +@dataclass +class FakeContext: + """A CommandContext stand-in that records what was said back.""" + + store: Store + group_id: str = GROUP + message: Sender = field(default_factory=Sender) + replies: list[str] = field(default_factory=list) + + async def reply(self, text: str) -> None: + """Record the answer instead of sending it.""" + self.replies.append(text) + + +async def run(store: Store, name: str, uuid: str = KUHY[0]) -> str: + """Run one command and return what it said.""" + ctx = FakeContext(store=store, message=Sender(source_uuid=uuid)) + await HANDLERS[name](ctx) + return ctx.replies[0] + + +def test_every_trigger_is_unique_across_the_whole_registry() -> None: + """A collision raises DuplicateTriggerError, which is a ValueError. + + That matters more than it looks: build_bot runs inside create_runtime, and + ValueError is in startup_retry.RETRYABLE. A colliding alias would not + crash the bot -- it would retry forever, logging "signal-cli not reachable + yet", which is the wrong diagnosis entirely. This test is the thing that + catches it instead. + """ + registry = CommandRegistry() + register_stats_commands(registry) + names = {command.name for command in registry.commands} + assert names == set(HANDLERS) + + +def test_no_trigger_contains_a_digit() -> None: + """fold() maps digits onto letters, so "!7dni" resolves as "!tdni".""" + table = load_aliases() + for name in HANDLERS: + for trigger in table[name]: + assert not any(character.isdigit() for character in trigger), trigger + + +def test_polish_spellings_fold_together() -> None: + """Somebody typing !tydzien and somebody typing !tydzień want the same thing.""" + assert fold("!tydzień") == fold("!tydzien") + assert fold("!miesiąc") == fold("!miesiac") + + +@pytest.mark.parametrize("name", list(HANDLERS)) +async def test_every_command_answers_on_an_empty_history(store: Store, name: str) -> None: + """Day one of this release: every command says something, none crashes.""" + assert await run(store, name) + + +async def test_today_and_yesterday_are_different_days(store: Store) -> None: + wrote(store, TODAY, MITHRA, 4) + wrote(store, YESTERDAY, KUHY, 7) + assert "Dziś, na razie:" in await run(store, "stats_today") + assert "Ala" in await run(store, "stats_today") + assert "kuhy" in await run(store, "stats_yesterday") + + +async def test_yesterday_renders_the_nightly_block_verbatim(store: Store) -> None: + """The only way to check the headline output without waiting for 21:37.""" + wrote(store, YESTERDAY, MITHRA, 5) + answer = await run(store, "stats_yesterday") + assert answer.startswith("📊 ") + assert "👑 Ala — 5" in answer + + +async def test_a_day_nobody_wrote_on_says_so(store: Store) -> None: + assert "Nikt jeszcze nic nie napisał" in await run(store, "stats_yesterday") + + +async def test_the_week_shows_a_share_bar(store: Store) -> None: + wrote(store, TODAY, MITHRA, 8) + wrote(store, TODAY, KUHY, 2) + answer = await run(store, "stats_week") + assert "Ala — 8" in answer + assert "80%" in answer + assert "█" in answer + + +async def test_the_month_puts_medals_on_the_podium(store: Store) -> None: + wrote(store, TODAY, MITHRA, 8) + wrote(store, TODAY, KUHY, 5) + wrote(store, TODAY, IGA, 2) + answer = await run(store, "stats_month") + assert "🥇 Ala" in answer + assert "🥈 kuhy" in answer + assert "🥉 Basia" in answer + + +async def test_a_first_period_says_so_rather_than_claiming_no_change(store: Store) -> None: + wrote(store, TODAY, MITHRA, 8) + assert "pierwszy taki okres w historii" in await run(store, "stats_year") + + +async def test_me_reports_the_asker(store: Store) -> None: + wrote(store, TODAY, KUHY, 6, Shape(hour=23)) + wrote(store, TODAY, MITHRA, 2) + answer = await run(store, "stats_me") + assert "kuhy — 6 wiadomości w historii" in answer + assert "Udział w rozmowie: 75%" in answer + assert "Najczęściej piszesz o 23:00" in answer + + +async def test_me_is_honest_with_somebody_who_never_wrote(store: Store) -> None: + wrote(store, TODAY, MITHRA, 4) + assert "ani jednej wiadomości" in await run(store, "stats_me", uuid="u-nobody") + + +async def test_records_and_crown_fill_in_once_there_is_history(store: Store) -> None: + wrote(store, periods.shift(TODAY, -2), MITHRA, 9) + wrote(store, periods.shift(TODAY, -1), MITHRA, 4) + wrote(store, TODAY, KUHY, 3) + records = await run(store, "stats_records") + assert "Najwięcej w jeden dzień: 9" in records + assert "Rekordy życiowe:" in records + crown = await run(store, "stats_crown") + assert "Najdłuższa seria: Ala" in crown + assert "Dni z koroną łącznie:" in crown + + +async def test_records_skips_somebody_with_no_days_left_after_exclusions( + store: Store, +) -> None: + """Somebody whose only day was the backfilled one has no record to show. + + Fixed dates, not periods.today(): on the day this was written those were + the same date, and the test passed for the wrong reason. + """ + wrote(store, "2026-08-01", IGA, 40) + wrote(store, "2026-08-15", MITHRA, 5) + store.set_meta(GROUP, BACKFILL_KEY, "2026-08-01") + rendered = await run(store, "stats_records") + assert "Ala" in rendered + assert "Basia" not in rendered + + +async def test_weekdays_marks_today(store: Store) -> None: + wrote(store, TODAY, MITHRA, 5) + assert "← dziś" in await run(store, "stats_weekdays") diff --git a/tests/test_stats_render_period.py b/tests/test_stats_render_period.py new file mode 100644 index 0000000..e3af347 --- /dev/null +++ b/tests/test_stats_render_period.py @@ -0,0 +1,120 @@ +# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence. +"""Rendering a period, and the on-demand answers.""" + +from __future__ import annotations + +from signal_bot.stats import periods +from signal_bot.stats.events import Person +from signal_bot.stats.queries_day import DayRow +from signal_bot.stats.queries_period import Trend +from signal_bot.stats.records import DayRecord, Silence +from signal_bot.stats.render_extra import ( + NOTHING_YET, + render_crown, + render_me, + render_records, + render_weekdays, +) +from signal_bot.stats.render_period import MAX_ROWS, bar, render_period, trend_line + +WEEK = periods.week_of("2026-09-01") + + +def rows(spec: list[tuple[str, int]]) -> list[DayRow]: + """Build period rows from (name, messages) pairs.""" + return [ + DayRow(f"u{i}", name, messages, 0, 0, messages * 10) + for i, (name, messages) in enumerate(spec) + ] + + +def test_a_period_with_nobody_in_it() -> None: + rendered = render_period(WEEK, [], Trend(0, 0)) + assert "Nikt nic nie napisał." in rendered + + +def test_the_bar_fills_with_the_share() -> None: + assert bar(0) == "░" * 10 + assert bar(100) == "█" * 10 + assert bar(50) == "█████░░░░░" + + +def test_trend_says_first_rather_than_no_change() -> None: + """Zero percent and "nothing to compare against" are different claims.""" + assert "pierwszy taki okres" in trend_line(Trend(now=10, before=0), "poprzednio") + assert "dokładnie tyle samo" in trend_line(Trend(now=10, before=10), "poprzednio") + assert "↑ 50% więcej" in trend_line(Trend(now=15, before=10), "poprzednio") + assert "↓ 50% mniej" in trend_line(Trend(now=5, before=10), "poprzednio") + + +def test_a_long_period_is_truncated_with_its_total() -> None: + many = rows([(f"Osoba {i:02d}", 20 - i) for i in range(MAX_ROWS + 4)]) + rendered = render_period(WEEK, many, Trend(100, 90)) + assert "…i 4 osoby więcej" in rendered + assert len([line for line in rendered.split("\n") if line.startswith(("1.", "2."))]) >= 1 + + +def test_medals_only_appear_on_a_podium() -> None: + three = rows([("A", 3), ("B", 2), ("C", 1)]) + assert "🥇" in render_period(WEEK, three, Trend(6, 5), podium=True) + assert "🥇" not in render_period(WEEK, three, Trend(6, 5)) + + +def test_records_with_nothing_recorded_says_so() -> None: + assert render_records({}, None, None, []) == NOTHING_YET + + +def test_records_reports_each_kind_it_has() -> None: + rendered = render_records( + {"2026-09-01": 10, "2026-09-03": 2}, + DayRecord("2026-09-01", 4), + Silence("2026-09-04", "2026-09-06", 3), + [("Ala", DayRecord("2026-09-01", 9))], + ) + assert "Najwięcej w jeden dzień: 10" in rendered + assert "Najciszej (ale ktoś pisał): 2" in rendered + assert "Najwięcej zdjęć" in rendered + assert "Najdłuższa cisza: 3 dni" in rendered + assert "Ala — 9" in rendered + + +def test_the_crown_with_no_history() -> None: + assert render_crown({}, []) == NOTHING_YET + + +def test_the_crown_reports_now_longest_and_totals() -> None: + winners = { + "2026-09-01": [("u1", "Ala")], + "2026-09-02": [("u1", "Ala")], + "2026-09-03": [("u2", "kuhy")], + } + people = [Person("u1", "Ala", ""), Person("u2", "kuhy", "")] + rendered = render_crown(winners, people) + assert "Teraz: kuhy — 1 dzień z rzędu" in rendered + assert "Najdłuższa seria: Ala — 2 dni" in rendered + assert "Ala — 2 dni" in rendered + + +def test_weekdays_with_no_history() -> None: + assert render_weekdays({}, 0) == NOTHING_YET + + +def test_weekdays_skips_days_never_recorded() -> None: + rendered = render_weekdays({0: [10, 20], 4: [5]}, 4) + assert "poniedziałki: średnio 15" in rendered + assert "piątki: średnio 5" in rendered + assert "← dziś" in rendered + assert "wtorki" not in rendered + + +def test_me_omits_what_it_does_not_know() -> None: + """No record, no favourite hour, no streak: three clauses that vanish.""" + rendered = render_me("kuhy", 3, None, 10, None, 0) + assert rendered == "kuhy — 3 wiadomości w historii\nUdział w rozmowie: 10%" + + +def test_me_reports_everything_it_does_know() -> None: + rendered = render_me("kuhy", 30, DayRecord("2026-09-01", 12), 40, 23, 2) + assert "Rekord życiowy: 12 (1 września)" in rendered + assert "Najczęściej piszesz o 23:00" in rendered + assert "Korona: 2 dni z rzędu" in rendered