diff --git a/bottles/1/7e946f8d-f1a3-40f9-8d1e-b9f4139ab051/indexeddb.sqlite-shm b/bottles/1/7e946f8d-f1a3-40f9-8d1e-b9f4139ab051/indexeddb.sqlite-shm index 2aad196..2b14358 100644 Binary files a/bottles/1/7e946f8d-f1a3-40f9-8d1e-b9f4139ab051/indexeddb.sqlite-shm and b/bottles/1/7e946f8d-f1a3-40f9-8d1e-b9f4139ab051/indexeddb.sqlite-shm differ diff --git a/signal_bot/bot.py b/signal_bot/bot.py index b36ec8e..d072d5a 100644 --- a/signal_bot/bot.py +++ b/signal_bot/bot.py @@ -24,8 +24,10 @@ 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.backfill import backfilled_day +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 render_day +from signal_bot.stats.render_day import DayView, render_day from signal_bot.versioning import POPE, __version__ if TYPE_CHECKING: @@ -82,27 +84,41 @@ class Bot: group refusing the report -- the bot removed from it, a transient send failure -- must not cost every other group its report. """ - day = periods.previous_day(self.clock()) + latest = periods.previous_day(self.clock()) for group_id in self.directory.groups: - rows = day_rows(self.store.events, group_id, day) - if not rows: - # Logged, not posted. The bot is in every group it was ever - # added to, so posting "nobody spoke" would mean a nightly - # ping into every quiet group, forever. - logger.info("no messages in %s on %s; not posting a report", group_id, day) - continue - named = self.store.events.roster(group_id, self.directory.member_identities(group_id)) - rows = with_silent(rows, [(p.uuid, p.display_name) for p in named]) - text = render_day( - day, - rows, - day_totals(rows), + for day in days_to_report(self.store, group_id, latest): + if not await self._report_one(group_id, day): + # Left unmarked on purpose, so the next run retries it + # rather than skipping the day forever. + break + mark_reported(self.store, group_id, day) + + async def _report_one(self, group_id: str, day: str) -> bool: + """Post one group's report for one day. False means try again later.""" + rows = day_rows(self.store.events, group_id, day) + if not rows: + # Settled, not pending. The bot is in every group it was ever + # added to, so posting "nobody spoke" would mean a nightly ping + # into every quiet group, forever. + logger.info("no messages in %s on %s; not posting a report", group_id, day) + 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]) + text = render_day( + day, + with_zeros, + day_totals(with_zeros), + DayView( since=self.store.events.first_day(group_id), - ) - try: - await self.transport.send_text(text, group_id) - except Exception: - logger.exception("daily report failed for group %s", group_id) + approximate=backfilled_day(self.store, group_id) == day, + ), + ) + try: + await self.transport.send_text(text, group_id) + except Exception: + logger.exception("daily report failed for group %s", group_id) + return False + return True async def announce_release(self) -> None: """Tell every watched group about a version it has not seen yet.""" diff --git a/signal_bot/stats/backfill.py b/signal_bot/stats/backfill.py new file mode 100644 index 0000000..866cf24 --- /dev/null +++ b/signal_bot/stats/backfill.py @@ -0,0 +1,88 @@ +# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence. +"""Carrying the pre-0.1.0 counter into the event log, once. + +Before 0.1.0 the bot kept one integer per person and deleted it after each +nightly post. When the event log arrived there were 319 messages sitting in +that counter, accumulated since the last successful post. Throwing them away +would have been tidier, but they are real messages and they are the group's +only pre-history. + +What the counter cannot say is *when*. It has no timestamp column, and its +window straddles two calendar days -- from the previous night's post to +whenever the migration runs. So a backfilled day is filed under the day that +holds most of that window, and marked, because a merged 22-hour day pretending +to be a normal one would hand somebody a personal record they never set and +would drag every "average day" figure with it. + +Marked days keep their totals and stay out of records and per-hour statistics. +``group_meta`` carries the mark, so this needs no schema change. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Final + +from signal_bot.stats.events import MessageEvent + +if TYPE_CHECKING: + from signal_bot.store import Store + +BACKFILL_KEY: Final = "backfill_day" +# Noon: a backfilled event has no real hour, and midnight would look like a +# deliberate reading rather than an absent one. The day is marked anyway, so +# nothing that cares about hours will read these. +BACKFILL_HOUR: Final = 12 + + +def has_legacy_counter(store: Store) -> bool: + """True when this database predates 0.1.0 and still holds the old table.""" + row = store.events.connection.execute( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'message_counts'" + ).fetchone() + return row is not None + + +def backfilled_day(store: Store, group_id: str) -> str | None: + """The one day whose totals came from the old counter, if any.""" + return store.meta(group_id, BACKFILL_KEY) + + +def seed_from_counter(store: Store, day: str) -> dict[str, int]: + """Copy every surviving tally into the event log as one marked day. + + Args: + store: The open database, which must still hold ``message_counts``. + day: The Warsaw day to file the counts under. Choose the day covering + most of the counter's window. + + Returns: + Messages seeded per group id, for reporting. + """ + rows = store.events.connection.execute( + "SELECT group_id, uuid, display_name, count FROM message_counts WHERE count > 0" + ).fetchall() + + seeded: dict[str, int] = {} + for row in rows: + group_id = row["group_id"] + for index in range(row["count"]): + # Synthetic, strictly increasing, and unique per person per group, + # because (group, uuid, sent_at_ms) is the primary key. They are + # not timestamps anybody should read as one, which is what the + # day marking is for. + store.events.record( + MessageEvent( + group_id=group_id, + uuid=row["uuid"], + display_name=row["display_name"], + sent_at_ms=index + 1, + day=day, + local_hour=BACKFILL_HOUR, + ) + ) + store.events.note_person(group_id, row["uuid"], row["display_name"], "", day) + seeded[group_id] = seeded.get(group_id, 0) + row["count"] + + for group_id in seeded: + store.set_meta(group_id, BACKFILL_KEY, day) + return seeded diff --git a/signal_bot/stats/nightly.py b/signal_bot/stats/nightly.py new file mode 100644 index 0000000..6006ffb --- /dev/null +++ b/signal_bot/stats/nightly.py @@ -0,0 +1,71 @@ +# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence. +"""Which days still owe the group a report, and posting them. + +The event log already survives an outage: a message carries Signal's own send +timestamp, signal-cli keeps receiving while the bot is down, and the replay on +reconnect is absorbed by the primary key. So the *counts* come back on their +own, filed under the day they were actually sent. + +The *report* did not. A bot that was down at 21:37 simply never posted that +day, and nothing ever went back for it -- the old counter merged the day into +the next one instead, which is the bug this release exists to remove. So the +last day reported is written down per group, and every day since then gets +posted when the bot comes back. + +Bounded, because coming back from a fortnight offline should not open with a +fortnight of back-posts. +""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Final + +from signal_bot.stats.periods import shift + +if TYPE_CHECKING: + from signal_bot.store import Store + +logger = logging.getLogger(__name__) + +LAST_REPORTED_KEY: Final = "last_reported_day" +# At most a week of catch-up in one go. Long enough to cover a weekend +# outage nobody noticed, short enough that a month offline is not a wall. +CATCHUP_LIMIT: Final = 7 + + +def days_to_report(store: Store, group_id: str, latest: str) -> list[str]: + """Every day up to ``latest`` that has not been reported yet, oldest first. + + Args: + store: The open database, which holds the per-group marker. + group_id: The group being reported on. + latest: The most recent day eligible for a report, normally yesterday. + + Returns: + The days to post, oldest first. A group with no marker gets only + ``latest``: the marker is absent on a brand-new group and on the first + run after this release, and neither should trigger a backlog. + """ + last = store.meta(group_id, LAST_REPORTED_KEY) + if last is None: + return [latest] + if last >= latest: + return [] + + days = [] + day = shift(last, 1) + while day <= latest: + days.append(day) + day = shift(day, 1) + return days[-CATCHUP_LIMIT:] + + +def mark_reported(store: Store, group_id: str, day: str) -> None: + """Record that a day no longer owes this group a report. + + Called for a day that was posted *and* for a day nobody spoke on -- an + empty day is settled, not pending, and leaving it unmarked would make the + bot reconsider it every night forever. + """ + store.set_meta(group_id, LAST_REPORTED_KEY, day) diff --git a/signal_bot/stats/render_day.py b/signal_bot/stats/render_day.py index 243951e..01c946e 100644 --- a/signal_bot/stats/render_day.py +++ b/signal_bot/stats/render_day.py @@ -14,6 +14,7 @@ off is worse than a shorter leaderboard. from __future__ import annotations +from dataclasses import dataclass from typing import TYPE_CHECKING, Final from signal_bot.stats import text @@ -52,35 +53,63 @@ def silence_line(rows: list[DayRow], unnamed: int) -> str: return "Cisza: " + ", ".join(names) if names else "" -def summary_line(totals: DayTotals) -> str: - """Commands and average length -- the two facts that qualify the count.""" +def summary_line(totals: DayTotals, *, approximate: bool = False) -> str: + """Commands and average length -- the two facts that qualify the count. + + ``approximate`` drops the length claim. It is set for the one day carried + over from the pre-0.1.0 counter, whose messages have a count but no + lengths: reporting "średnio 0 znaków" would be a measurement nobody took. + """ if not totals.messages: return "" - parts = [f"średnio {text.characters(totals.average_length)}"] + parts = [] if approximate else [f"średnio {text.characters(totals.average_length)}"] if totals.commands: parts.insert(0, f"z tego {text.commands(totals.commands)}") + if not parts: + return "" return parts[0].capitalize() + (f", {parts[1]}." if len(parts) > 1 else ".") +@dataclass(frozen=True, slots=True) +class DayView: + """How one day is presented, apart from its numbers. + + A value object rather than a widening keyword list: this renderer is + shared by the nightly post and several commands, each wanting a different + combination of these, and they keep accumulating. + """ + + since: str | None = None + unnamed: int = 0 + highlights: tuple[str, ...] = () + heading: str | None = None + # The day's messages have counts but no lengths -- see stats.backfill. + approximate: bool = False + + def render_day( day: str, rows: list[DayRow], totals: DayTotals, - *, - since: str | None = None, - unnamed: int = 0, - highlights: list[str] | None = None, - heading: str | None = None, + view: DayView | None = None, ) -> str: """Compose the day block, trimmed to MAX_LINES.""" + view = view or DayView() header = ( - heading + view.heading or f"{CHART} {pl_date(day)} — {text.messages(totals.messages)}{_media(totals.media)}" ) - tail = [line for line in (silence_line(rows, unnamed), summary_line(totals)) if line] - picks = highlights or [] - if since: - tail.append(f"(liczymy od {pl_day_month(since)})") + tail = [ + line + for line in ( + silence_line(rows, view.unnamed), + summary_line(totals, approximate=view.approximate), + ) + if line + ] + picks = list(view.highlights) + if view.since: + tail.append(f"(liczymy od {pl_day_month(view.since)})") # Everything except the speakers is mandatory, and at least one highlight # slot is reserved before any speaker is allowed a line. diff --git a/tests/test_bot.py b/tests/test_bot.py index 6f0fd1c..60d6858 100644 --- a/tests/test_bot.py +++ b/tests/test_bot.py @@ -13,6 +13,7 @@ from signal_bot.bot import Bot, build_bot, now_local from signal_bot.discovery import GroupDirectory from signal_bot.liveness import Liveness from signal_bot.stats.events import MessageEvent +from signal_bot.stats.nightly import LAST_REPORTED_KEY, mark_reported from signal_bot.stats.periods import WARSAW from signal_bot.transport_fake import FakeTransport from signal_bot.trust import InviteTrust @@ -98,6 +99,60 @@ async def test_the_report_covers_yesterday_not_today( assert "Bob" not in transport.texts[0] +async def test_a_missed_day_is_posted_when_the_bot_returns( + config: Config, store: Store, transport: FakeTransport, directory: GroupDirectory +) -> None: + """An outage over 21:37 used to lose that day's report entirely.""" + wrote(store, WATCHED_GROUP, "u1", "Alice", 2, day="2026-08-30") + wrote(store, WATCHED_GROUP, "u1", "Alice", 3, day="2026-08-31") + wrote(store, WATCHED_GROUP, "u1", "Alice", 4, day=YESTERDAY) + mark_reported(store, WATCHED_GROUP, "2026-08-29") + + await _bot(config, store, transport, directory).publish_report() + + assert len(transport.texts) == 3 + assert "30 sierpnia" in transport.texts[0] + assert "31 sierpnia" in transport.texts[1] + assert "1 września" in transport.texts[2] + assert store.meta(WATCHED_GROUP, LAST_REPORTED_KEY) == YESTERDAY + + +async def test_a_day_nobody_spoke_is_settled_not_retried_forever( + config: Config, store: Store, transport: FakeTransport, directory: GroupDirectory +) -> None: + wrote(store, WATCHED_GROUP, "u1", "Alice", 1, day=YESTERDAY) + mark_reported(store, WATCHED_GROUP, "2026-08-30") + + await _bot(config, store, transport, directory).publish_report() + + assert len(transport.texts) == 1 + assert store.meta(WATCHED_GROUP, LAST_REPORTED_KEY) == YESTERDAY + + +async def test_a_failed_send_leaves_its_day_unmarked( + config: Config, store: Store, directory: GroupDirectory +) -> None: + """So the next run retries it instead of losing the day silently.""" + wrote(store, WATCHED_GROUP, "u1", "Alice", 1, day=YESTERDAY) + mark_reported(store, WATCHED_GROUP, "2026-08-31") + transport = RefusingTransport(refuses=WATCHED_GROUP) + + await _bot(config, store, transport, directory).publish_report() + + assert store.meta(WATCHED_GROUP, LAST_REPORTED_KEY) == "2026-08-31" + + +async def test_publishing_twice_posts_once( + config: Config, store: Store, transport: FakeTransport, directory: GroupDirectory +) -> None: + """The marker is what stops a restart re-posting the same day.""" + wrote(store, WATCHED_GROUP, "u1", "Alice", 3) + bot = _bot(config, store, transport, directory) + await bot.publish_report() + await bot.publish_report() + assert len(transport.texts) == 1 + + async def test_nothing_is_deleted_after_publishing( config: Config, store: Store, transport: FakeTransport, directory: GroupDirectory ) -> None: @@ -105,8 +160,10 @@ async def test_nothing_is_deleted_after_publishing( wrote(store, WATCHED_GROUP, "u1", "Alice", 3) bot = _bot(config, store, transport, directory) await bot.publish_report() + first = transport.texts[0] + store.set_meta(WATCHED_GROUP, LAST_REPORTED_KEY, "2026-08-31") await bot.publish_report() - assert transport.texts[0] == transport.texts[1] + assert transport.texts[1] == first async def test_publish_report_falls_back_to_uuid_when_unnamed( diff --git a/tests/test_stats_backfill.py b/tests/test_stats_backfill.py new file mode 100644 index 0000000..36b3d96 --- /dev/null +++ b/tests/test_stats_backfill.py @@ -0,0 +1,114 @@ +# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence. +"""Carrying the pre-0.1.0 counter into the event log.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from signal_bot.stats.backfill import ( + BACKFILL_HOUR, + backfilled_day, + has_legacy_counter, + seed_from_counter, +) +from signal_bot.stats.queries_day import day_rows, day_totals + +if TYPE_CHECKING: + from signal_bot.store import Store + +GROUP = "group-a==" +OTHER = "group-b==" +DAY = "2026-08-30" + +LEGACY = """ +CREATE TABLE message_counts ( + group_id TEXT NOT NULL, + uuid TEXT NOT NULL, + display_name TEXT NOT NULL, + count INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (group_id, uuid) +); +""" + + +def give_legacy_counter(store: Store, rows: list[tuple[str, str, str, int]]) -> None: + """Recreate the pre-0.1.0 table and fill it, as a real old database has.""" + store.events.connection.executescript(LEGACY) + store.events.connection.executemany( + "INSERT INTO message_counts (group_id, uuid, display_name, count) VALUES (?, ?, ?, ?)", + rows, + ) + store.events.connection.commit() + + +def test_a_fresh_database_has_no_legacy_counter(store: Store) -> None: + """0.1.0 stopped declaring the table, so a new file never has one.""" + assert has_legacy_counter(store) is False + + +def test_an_old_database_is_detected(store: Store) -> None: + give_legacy_counter(store, [(GROUP, "u1", "Ala", 3)]) + assert has_legacy_counter(store) is True + + +def test_counts_become_events_on_the_named_day(store: Store) -> None: + give_legacy_counter( + store, + [(GROUP, "u1", "Ala", 3), (GROUP, "u2", "kuhy", 2), (OTHER, "u1", "Ala", 1)], + ) + seeded = seed_from_counter(store, DAY) + + assert seeded == {GROUP: 5, OTHER: 1} + rows = day_rows(store.events, GROUP, DAY) + assert [(r.display_name, r.messages) for r in rows] == [("Ala", 3), ("kuhy", 2)] + assert day_totals(rows).messages == 5 + + +def test_the_seeded_day_is_marked_per_group(store: Store) -> None: + """Marked, so records and hour statistics can leave it out.""" + give_legacy_counter(store, [(GROUP, "u1", "Ala", 1), (OTHER, "u2", "kuhy", 1)]) + seed_from_counter(store, DAY) + assert backfilled_day(store, GROUP) == DAY + assert backfilled_day(store, OTHER) == DAY + + +def test_an_unseeded_group_is_not_marked(store: Store) -> None: + give_legacy_counter(store, [(GROUP, "u1", "Ala", 1)]) + seed_from_counter(store, DAY) + assert backfilled_day(store, OTHER) is None + + +def test_zero_counts_are_skipped(store: Store) -> None: + """A row at zero is somebody who was reset, not somebody who spoke.""" + give_legacy_counter(store, [(GROUP, "u1", "Ala", 0)]) + assert seed_from_counter(store, DAY) == {} + assert day_rows(store.events, GROUP, DAY) == [] + assert backfilled_day(store, GROUP) is None + + +def test_seeded_people_are_known_to_the_roster(store: Store) -> None: + """Otherwise they would show as unnamed strangers in the first report.""" + give_legacy_counter(store, [(GROUP, "u1", "Ala", 2)]) + seed_from_counter(store, DAY) + assert [p.display_name for p in store.events.roster(GROUP, ["u1"])] == ["Ala"] + + +def test_seeded_events_carry_no_invented_detail(store: Store) -> None: + """No hours, no lengths, no media -- the counter knew none of them.""" + give_legacy_counter(store, [(GROUP, "u1", "Ala", 2)]) + seed_from_counter(store, DAY) + rows = store.events.connection.execute( + "SELECT local_hour, char_len, media_count, is_command FROM message_events" + ).fetchall() + assert len(rows) == 2 + for row in rows: + assert row["local_hour"] == BACKFILL_HOUR + assert (row["char_len"], row["media_count"], row["is_command"]) == (0, 0, 0) + + +def test_seeding_twice_does_not_double_the_totals(store: Store) -> None: + """The synthetic keys are deterministic, so a rerun is absorbed.""" + give_legacy_counter(store, [(GROUP, "u1", "Ala", 3)]) + seed_from_counter(store, DAY) + seed_from_counter(store, DAY) + assert day_totals(day_rows(store.events, GROUP, DAY)).messages == 3 diff --git a/tests/test_stats_nightly.py b/tests/test_stats_nightly.py new file mode 100644 index 0000000..6fc37b6 --- /dev/null +++ b/tests/test_stats_nightly.py @@ -0,0 +1,75 @@ +# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence. +"""Catching up on reports the bot was offline for.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from signal_bot.stats.nightly import ( + CATCHUP_LIMIT, + LAST_REPORTED_KEY, + days_to_report, + mark_reported, +) + +if TYPE_CHECKING: + from signal_bot.store import Store + +GROUP = "group-a==" + + +def test_a_group_with_no_marker_gets_only_yesterday(store: Store) -> None: + """A new group, and the first run after this release, must not backlog.""" + assert days_to_report(store, GROUP, "2026-09-01") == ["2026-09-01"] + + +def test_an_up_to_date_group_owes_nothing(store: Store) -> None: + mark_reported(store, GROUP, "2026-09-01") + assert days_to_report(store, GROUP, "2026-09-01") == [] + + +def test_the_day_after_the_marker_is_owed(store: Store) -> None: + mark_reported(store, GROUP, "2026-08-31") + assert days_to_report(store, GROUP, "2026-09-01") == ["2026-09-01"] + + +def test_an_outage_is_caught_up_oldest_first(store: Store) -> None: + """Four days offline: all four get posted, in the order they happened.""" + mark_reported(store, GROUP, "2026-08-28") + assert days_to_report(store, GROUP, "2026-09-01") == [ + "2026-08-29", + "2026-08-30", + "2026-08-31", + "2026-09-01", + ] + + +def test_catch_up_is_bounded(store: Store) -> None: + """A month offline must not open with a month of back-posts.""" + mark_reported(store, GROUP, "2026-07-01") + days = days_to_report(store, GROUP, "2026-09-01") + assert len(days) == CATCHUP_LIMIT + # The most recent ones, not the oldest. + assert days[-1] == "2026-09-01" + assert days[0] == "2026-08-26" + + +def test_a_marker_ahead_of_yesterday_owes_nothing(store: Store) -> None: + """Defends the clock going backwards, e.g. an NTP correction.""" + mark_reported(store, GROUP, "2026-09-05") + assert days_to_report(store, GROUP, "2026-09-01") == [] + + +def test_the_marker_is_per_group(store: Store) -> None: + mark_reported(store, GROUP, "2026-09-01") + assert store.meta(GROUP, LAST_REPORTED_KEY) == "2026-09-01" + assert store.meta("group-b==", LAST_REPORTED_KEY) is None + + +def test_a_month_boundary_is_crossed_by_the_date(store: Store) -> None: + mark_reported(store, GROUP, "2026-08-30") + assert days_to_report(store, GROUP, "2026-09-02") == [ + "2026-08-31", + "2026-09-01", + "2026-09-02", + ] diff --git a/tests/test_stats_render_day.py b/tests/test_stats_render_day.py index 24d344c..de1a82a 100644 --- a/tests/test_stats_render_day.py +++ b/tests/test_stats_render_day.py @@ -4,7 +4,7 @@ from __future__ import annotations from signal_bot.stats.queries_day import DayRow, day_totals, with_silent -from signal_bot.stats.render_day import MAX_LINES, render_day, summary_line +from signal_bot.stats.render_day import MAX_LINES, DayView, render_day, summary_line # The real group, so the rendered example is a case that can actually occur. GROUP = [ @@ -39,8 +39,7 @@ def test_the_nightly_block_for_a_real_day() -> None: "2026-09-01", day_rows_, day_totals(day_rows_), - since="2026-08-30", - highlights=["🏆 Ala 76 — rekord życiowy (poprzedni 71)"], + DayView(since="2026-08-30", highlights=("🏆 Ala 76 — rekord życiowy (poprzedni 71)",)), ) assert rendered == ( "📊 Wtorek, 1 września — 220 wiadomości (16 📷)\n" @@ -70,6 +69,14 @@ def test_commands_are_broken_out() -> None: assert summary_line(day_totals(day)) == "Z tego 4 komendy, średnio 47 znaków." +def test_an_approximate_day_claims_no_average_length() -> None: + """The backfilled day has counts but no lengths; 0 would be a lie.""" + day = rows([("kuhy", 10, 0)]) + assert summary_line(day_totals(day), approximate=True) == "" + with_commands = rows([("kuhy", 10, 0)], commands=4) + assert summary_line(day_totals(with_commands), approximate=True) == "Z tego 4 komendy." + + def test_summary_is_empty_on_a_day_nobody_spoke() -> None: assert summary_line(day_totals([])) == "" assert day_totals([]).average_length == 0 @@ -78,7 +85,7 @@ def test_summary_is_empty_on_a_day_nobody_spoke() -> None: def test_unnamed_members_are_counted_never_named() -> None: """Their only spellings are a uuid or a phone number; both would leak.""" day = with_silent(rows([("kuhy", 3, 0)]), [("u9", "Marek")]) - rendered = render_day("2026-09-01", day, day_totals(day), unnamed=2) + rendered = render_day("2026-09-01", day, day_totals(day), DayView(unnamed=2)) assert "Cisza: Marek, i 2 osoby bez ani jednej wiadomości tutaj" in rendered @@ -89,8 +96,7 @@ def test_a_big_group_is_truncated_and_still_fits() -> None: "2026-09-01", big, day_totals(big), - since="2026-08-30", - highlights=["🏆 rekord", "👑 korona", "📅 wtorki"], + DayView(since="2026-08-30", highlights=("🏆 rekord", "👑 korona", "📅 wtorki")), ) lines = rendered.split("\n") assert len(lines) <= MAX_LINES @@ -105,13 +111,12 @@ def test_the_hidden_tail_reports_its_own_total() -> None: "2026-09-01", day, day_totals(day), - since="2026-08-30", - highlights=["h1", "h2", "h3"], + DayView(since="2026-08-30", highlights=("h1", "h2", "h3")), ) assert len(rendered.split("\n")) <= MAX_LINES def test_a_custom_heading_replaces_the_date() -> None: day = rows([("kuhy", 1, 0)]) - rendered = render_day("2026-09-01", day, day_totals(day), heading="Dziś, na razie") + rendered = render_day("2026-09-01", day, day_totals(day), DayView(heading="Dziś, na razie")) assert rendered.startswith("Dziś, na razie\n")