# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence. """The cooldown gate: when it refuses, and what it says when it does.""" from __future__ import annotations from typing import TYPE_CHECKING import pytest from signal_bot.decorators import DISPLAY_MARGIN_SECONDS, cooldown, wait_display from tests.conftest import SECOND_GROUP, WATCHED_GROUP from tests.gate_helpers import DAY, gate_context, refused if TYPE_CHECKING: from signal_bot.config import Config from signal_bot.registry import CommandContext, Handler from signal_bot.store import Store from signal_bot.transport_fake import FakeTransport @pytest.fixture def runs() -> list[str]: return [] @pytest.fixture def handler(runs: list[str]) -> Handler: async def _handler(ctx: CommandContext) -> None: runs.append(ctx.message.source_uuid) return _handler async def test_cooldown_allows_first_use( store: Store, transport: FakeTransport, config: Config, handler: Handler, runs: list[str] ) -> None: await cooldown(10, "wait {seconds}")(handler)(gate_context(store, transport, config)) assert runs == ["u1"] async def test_cooldown_blocks_and_reports_remaining( store: Store, transport: FakeTransport, config: Config, handler: Handler, runs: list[str] ) -> None: store.record_use(WATCHED_GROUP, "u1", "ping", 100.0, DAY) ctx = gate_context(store, transport, config, now=104.0) await refused(cooldown(10, "wait {seconds}")(handler), ctx) assert runs == [] # 6s left, rounded up, plus the deliberate margin. assert transport.texts == [f"wait {6 + DISPLAY_MARGIN_SECONDS}"] async def test_cooldown_expires( store: Store, transport: FakeTransport, config: Config, handler: Handler, runs: list[str] ) -> None: store.record_use(WATCHED_GROUP, "u1", "ping", 100.0, DAY) await cooldown(10, "wait {seconds}")(handler)(gate_context(store, transport, config, now=111.0)) assert runs == ["u1"] async def test_cooldown_is_per_user( store: Store, transport: FakeTransport, config: Config, handler: Handler, runs: list[str] ) -> None: """One person's cat picture used to silence everybody.""" store.record_use(WATCHED_GROUP, "u1", "ping", 100.0, DAY) await cooldown(10, "wait {seconds}")(handler)( gate_context(store, transport, config, uuid="u2", now=101.0) ) assert runs == ["u2"] async def test_cooldown_is_per_group( store: Store, transport: FakeTransport, config: Config, handler: Handler, runs: list[str] ) -> None: """A test-group command must not throttle the production group.""" store.record_use(WATCHED_GROUP, "u1", "ping", 100.0, DAY) await cooldown(10, "wait {seconds}")(handler)( gate_context(store, transport, config, now=101.0, group=SECOND_GROUP) ) assert runs == ["u1"] assert transport.texts == [] @pytest.mark.parametrize( ("shown", "unit"), [ (1, "SEKUNDĘ"), (2, "SEKUNDY"), (3, "SEKUNDY"), (4, "SEKUNDY"), (5, "SEKUND"), (11, "SEKUND"), (12, "SEKUND"), (13, "SEKUND"), (14, "SEKUND"), (21, "SEKUND"), (22, "SEKUNDY"), (25, "SEKUND"), (112, "SEKUND"), ], ) def test_the_wait_agrees_in_polish(shown: int, unit: str) -> None: """One SEKUND for every number is what the group noticed first. Driven through the display helper rather than the gate: the gate can only ever show 3 or more, because a live refusal always has some fraction of a second left and the margin sits on top of that. """ assert wait_display(shown - DISPLAY_MARGIN_SECONDS) == (shown, unit) async def test_the_shown_wait_rounds_up_and_carries_the_margin( store: Store, transport: FakeTransport, config: Config, handler: Handler ) -> None: """Never below the real wait: a short number sends somebody back refused.""" store.record_use(WATCHED_GROUP, "u1", "ping", 100.0, DAY) await refused( cooldown(10, "POCZEKAJ {seconds} {unit}")(handler), gate_context(store, transport, config, now=103.5), ) # 6.5s really left -> 7 rounded up, plus the margin. assert transport.texts == [f"POCZEKAJ {7 + DISPLAY_MARGIN_SECONDS} SEKUND"]