# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence. """Families D, E and F: demand, sittings, adoption and pool exhaustion.""" from __future__ import annotations from typing import TYPE_CHECKING import pytest from signal_bot.analytics import adoption, content, demand, sessions from signal_bot.analytics.scope import SESSION_GAP_MS, Scope from signal_bot.stats.events import MessageEvent from tests.analytics_seed import ALA, BASIA, CELINA, GROUP, Shape, backfilled, ran if TYPE_CHECKING: import sqlite3 from signal_bot.store import Store DAY = "2026-08-29" NEXT = "2026-08-30" TRIGGERS = ("!kot", "!pies", "!pulapka", "!papiez") def conn_of(store: Store) -> sqlite3.Connection: return store.commands.connection # -- family D ---------------------------------------------------------------- def test_near_misses_are_grouped_by_the_command_they_nearly_hit(store: Store) -> None: """Seventeen typos of one trigger mean it wants another alias. Ungrouped they read as seventeen unrelated mistakes, which is exactly the list the Ideas tab already shows and the reason this adds something. """ for at_ms, word in enumerate(("!pulapla", "!pulapak", "!pulakpa", "!kto")): store.suggestions.record_unknown(GROUP, word, word, at_ms) found = demand.collect(conn_of(store), Scope(), TRIGGERS) assert [(miss.target, miss.hits) for miss in found.near_misses] == [ ("!pulapka", 3), ("!kot", 1), ] def test_the_same_typo_twice_counts_twice(store: Store) -> None: store.suggestions.record_unknown(GROUP, "!kto", "!kto", 1) store.suggestions.record_unknown(GROUP, "!kto", "!kto raz jeszcze", 2) found = demand.collect(conn_of(store), Scope(), TRIGGERS) assert found.near_misses[0].hits == 2 assert found.near_misses[0].variants == 1 def test_a_word_close_to_nothing_is_a_new_idea_not_a_typo(store: Store) -> None: """Filing a real request as noise is the failure worth avoiding.""" store.suggestions.record_unknown(GROUP, "!pogodynka", "!pogodynka", 1) found = demand.collect(conn_of(store), Scope(), TRIGGERS) assert found.near_misses == () assert (found.unclaimed, found.unclaimed_words) == (1, 1) def test_deliberate_ideas_are_counted_beside_the_typos(store: Store) -> None: store.suggestions.record_suggestion(GROUP, "!pogoda", "na jutro", 1) assert demand.collect(conn_of(store), Scope(), TRIGGERS).filed == 1 def test_an_empty_wish_list_reads_as_empty(store: Store) -> None: assert demand.collect(conn_of(store), Scope(), TRIGGERS) == demand.Demand() # -- family E ---------------------------------------------------------------- def test_commands_close_together_are_one_sitting(store: Store) -> None: ran(store, day=DAY, hour=12, minute=0, command="cat") ran(store, day=DAY, hour=12, minute=1, command="dog") found = sessions.collect(conn_of(store), Scope()) assert (found.count, found.commands, found.longest) == (1, 2, 2) assert found.average_length == 2.0 def test_a_long_gap_starts_a_new_sitting(store: Store) -> None: gap_minutes = SESSION_GAP_MS // 60_000 + 1 ran(store, day=DAY, hour=12, minute=0, command="cat") ran(store, day=DAY, hour=12, minute=gap_minutes, command="dog") assert sessions.collect(conn_of(store), Scope()).count == 2 def test_two_people_never_share_a_sitting(store: Store) -> None: ran(store, day=DAY, hour=12, minute=0, who=ALA, command="cat") ran(store, day=DAY, hour=12, minute=1, who=BASIA, command="dog") found = sessions.collect(conn_of(store), Scope()) assert found.count == 2 assert found.pairs == () def test_commands_used_together_are_paired_once(store: Store) -> None: """(cat, dog) and (dog, cat) are one fact, not two.""" ran(store, day=DAY, hour=12, minute=0, command="dog") ran(store, day=DAY, hour=12, minute=1, command="cat") assert sessions.collect(conn_of(store), Scope()).pairs == (("cat", "dog", 1),) def test_re_rolling_the_same_command_is_counted_as_repeats(store: Store) -> None: """Three !papież in a row is a fact about the pool, not about the person.""" for minute in range(3): ran(store, day=DAY, hour=12, minute=minute, command="pope") found = sessions.collect(conn_of(store), Scope()) assert found.repeats == (("pope", 2),) assert found.pairs == () def test_rows_with_no_real_time_never_form_a_sitting(store: Store) -> None: """A daily_uses row's timestamp is built from midnight, not observed. Treating those as a sequence invents a sitting in which somebody ran forty commands in forty milliseconds. """ for _ in range(3): backfilled(store, day=DAY, command="cat") assert sessions.collect(conn_of(store), Scope()) == sessions.Sessions() def test_share_of_chat_is_commands_over_messages(store: Store) -> None: for at_ms in range(4): store.events.record( MessageEvent( group_id=GROUP, uuid=ALA, display_name="ala", sent_at_ms=at_ms, day=DAY, local_hour=12, is_command=at_ms < 1, ) ) found = adoption.share_of_chat(conn_of(store), Scope()) assert (found.messages, found.commands) == (4, 1) assert found.fraction == 0.25 def test_share_of_an_empty_group_is_zero(store: Store) -> None: assert adoption.share_of_chat(conn_of(store), Scope()).fraction == 0.0 assert adoption.Share().fraction == 0.0 def test_newcomers_are_dated_by_their_first_use(store: Store) -> None: ran(store, day=DAY, who=ALA) ran(store, day=NEXT, who=ALA, minute=1) ran(store, day=NEXT, who=BASIA, minute=2) assert adoption.newcomers(conn_of(store), Scope()) == [(DAY, 1), (NEXT, 1)] def test_retention_counts_who_came_back(store: Store) -> None: """Of the people who started, how many were still there N days later.""" ran(store, day=DAY, who=ALA) ran(store, day=NEXT, who=ALA, minute=1) ran(store, day=DAY, who=BASIA, minute=2) curve = adoption.retention(conn_of(store), Scope(), 2) assert curve == [(0, 2, 2), (1, 1, 2), (2, 0, 2)] def test_retention_of_nobody_is_empty(store: Store) -> None: assert adoption.retention(conn_of(store), Scope(), 3) == [] def test_a_release_is_compared_against_equal_windows(store: Store) -> None: """A release on the first recorded day gets a before-window of zero. Unequal windows would read that as a spike when it is an edge. """ series = [ ("2026-08-28", 2), ("2026-08-29", 3), ("2026-08-30", 10), ("2026-08-31", 12), ] found = adoption.around_releases(series, [("0.2.5", "2026-08-30")], 1) assert found == [("0.2.5", "2026-08-30", 22, 3)] # -- family F ---------------------------------------------------------------- def test_a_pool_that_never_repeats_reads_as_varied(store: Store) -> None: for minute, item in enumerate(("Q1", "Q2", "Q3")): ran(store, day=DAY, minute=minute, command="pope", shape=Shape(item_id=item)) (pool,) = content.pools(conn_of(store), Scope()) assert (pool.draws, pool.distinct) == (3, 3) assert pool.repeat_rate == 0.0 def test_a_repeating_pool_is_measurable(store: Store) -> None: """The bug this exists to catch reached a live group chat once. iNaturalist accepted order_by=random and silently ignored it, so one identical photograph came back forever. A repeat rate would have said so. """ for minute in range(4): ran(store, day=DAY, minute=minute, command="pope", shape=Shape(item_id="Q1")) (pool,) = content.pools(conn_of(store), Scope()) assert (pool.draws, pool.distinct) == (4, 1) assert pool.repeat_rate == 0.75 assert content.repeats(conn_of(store), Scope()) == [("pope", "Q1", 4)] def test_a_command_that_names_nothing_has_no_pool(store: Store) -> None: ran(store, day=DAY, command="stats_today") assert content.pools(conn_of(store), Scope()) == [] def test_an_undrawn_pool_has_no_repeat_rate() -> None: assert content.Pool("pope", 0, 0).repeat_rate == 0.0 def test_expected_distinct_says_what_uniform_would_have_looked_like() -> None: """The comparison that turns a repeat rate into a verdict.""" assert content.expected_distinct(0, 10) == 0.0 assert content.expected_distinct(266, 0) == 0.0 # 266 traps drawn 19 times should give about 18.4 distinct ones. assert 18.0 < content.expected_distinct(266, 19) < 18.9 # Drawing once always gives exactly one -- approx because the identity # holds in real arithmetic and not in binary floating point. assert content.expected_distinct(10, 1) == pytest.approx(1.0) def test_everything_is_scoped_to_one_group(store: Store) -> None: ran(store, day=DAY, command="pope", who=CELINA, shape=Shape(group_id=GROUP, item_id="Q1")) ran(store, day=DAY, command="pope", who=CELINA, shape=Shape(group_id="group-b==", item_id="Q2")) assert content.pools(conn_of(store), Scope(GROUP))[0].draws == 1 assert content.pools(conn_of(store), Scope())[0].draws == 2