# 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 ALA, BASIA, GROUP, KUHY, 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", ALA, 10, Shape(media=2, commands=1)) wrote(store, "2026-09-01", ALA, 20) wrote(store, "2026-09-01", KUHY, 10) wrote(store, "2026-09-02", BASIA, 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"]