# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence. """The dispatcher branch that used to be a bare `return`. Split out of test_dispatcher.py at the 250-line cap. The seam is real: that file is "does the right command run", this one is "what happens when none does". """ from __future__ import annotations import datetime as dt from typing import TYPE_CHECKING import pytest from signal_bot.context import CommandContext from signal_bot.discovery import GroupDirectory from signal_bot.dispatcher import Dispatcher from signal_bot.registry import CommandRegistry from signal_bot.registry_holder import RegistryHolder from tests.conftest import WATCHED_GROUP, envelope if TYPE_CHECKING: from signal_bot.config import Config from signal_bot.context import CommandContext from signal_bot.discovery import GroupDirectory from signal_bot.store import Store from signal_bot.transport_fake import FakeTransport NOW = dt.datetime(2026, 8, 31, 12, 0, tzinfo=dt.UTC) @pytest.fixture def registry() -> CommandRegistry: """One command, so "!cta" has something to be a near miss for.""" async def handler(ctx: CommandContext) -> None: """Answer nothing; only the routing is under test here.""" reg = CommandRegistry() reg.register("cat", ["!cat"], handler, "cat description") return reg @pytest.fixture def dispatcher( config: Config, directory: GroupDirectory, registry: CommandRegistry, store: Store, transport: FakeTransport, ) -> Dispatcher: """The real dispatcher, wired the way build_bot wires it.""" return Dispatcher( config=config, directory=directory, holder=RegistryHolder(lambda: registry), store=store, transport=transport, clock=lambda: NOW, ) async def test_an_unrecognised_command_is_answered_and_remembered( dispatcher: Dispatcher, transport: FakeTransport, store: Store ) -> None: """The branch that used to be a bare `return`.""" await dispatcher.handle(envelope(text="!cta", timestamp=808)) assert "!cat" in (transport.sent[-1].text or "") (row,) = store.suggestions.unknown() assert row.word == "!cta" async def test_an_unrecognised_command_is_still_not_counted_as_a_command( dispatcher: Dispatcher, store: Store ) -> None: """The bot saying something about a word does not make the word a command.""" await dispatcher.handle(envelope(text="!cta")) row = store.events.connection.execute( "SELECT is_command FROM message_events WHERE group_id = ?", (WATCHED_GROUP,) ).fetchone() assert int(row["is_command"]) == 0 async def test_the_bot_never_answers_its_own_message( dispatcher: Dispatcher, transport: FakeTransport, store: Store ) -> None: """The loop guard, tested with the worst case rather than a polite one. handle() checks is_own nowhere else, so an outbound frame carrying a word is what would loop -- uncapped, because unknown commands are answered every time by design. """ await dispatcher.handle(envelope(text="!cta", sync=True)) assert transport.sent == [] assert store.suggestions.unknown() == [] async def test_a_reaction_is_not_a_mistyped_command( dispatcher: Dispatcher, transport: FakeTransport ) -> None: """should_count gates the branch, and it excludes annotations.""" await dispatcher.handle(envelope(text="!cta", extra="reaction")) assert transport.sent == [] async def test_a_failure_while_answering_does_not_end_the_receive_loop( dispatcher: Dispatcher, monkeypatch: pytest.MonkeyPatch ) -> None: """Bot.listen has no try/except of its own. This branch runs above the one that wraps a handler, so an unguarded raise here would end the websocket consumer and the bot would go silently deaf until the watchdog restarted it. """ async def explode(**_kwargs: object) -> None: msg = "the container went away" raise RuntimeError(msg) monkeypatch.setattr("signal_bot.dispatcher.answer_unknown", explode) await dispatcher.handle(envelope(text="!cta"))