signal-bot/tests/test_registry.py
Krzysztof kuhy Rudnicki 72e90acb47
Some checks are pending
CI / check (push) Waiting to run
CI / web (push) Waiting to run
Let the operator add aliases from the Commands tab
A custom alias is data the command table is built from, so it cannot be a
live switch like the ones already on the tab. Instead the management page
writes aliases_custom.json in the bot's state directory and then asks the
bot to reload, in one step: whenever the answer is anything but `reloaded`
-- a word another command has, a name no command has, a bot still
connecting -- the file is restored byte for byte and the bot's own reason is
shown next to the field. What is on disk is therefore always what the bot
serves, and a typo cannot sit in the file poisoning every later reload.

The registry appends the operator's words to the shipped list at
registration, which keeps `!pomoc` on the shipped spelling and routes the
new words through the same clash check as aliases.json. An alias for a name
no command has fails the whole build (ConfigError -> `kept`), because a
silently ignored entry is how a file drifts. The tagger treats custom
aliases as reserved words too, so a person cannot be given one.

registry.py crossed the 250-line cap and now re-exports the record and the
error types from command.py; the bot's state directory lookup, previously
duplicated in pack.py and the suggestions export, is state_dir.py.

Proven end to end: !e2ekot is unknown, the file is written and reload.sh
runs, !e2ekot posts a cat, a file naming a command that does not exist is
refused with 409 and the previous table still answers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-04 16:20:05 +02:00

176 lines
6.7 KiB
Python

# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""Trigger resolution."""
from __future__ import annotations
from typing import TYPE_CHECKING
import pytest
from signal_bot.context import CommandContext
from signal_bot.models import parse_envelope
from signal_bot.registry import CommandRegistry, DuplicateTriggerError
from signal_bot.transport_fake import FakeTransport
from tests.conftest import WATCHED_GROUP, envelope
if TYPE_CHECKING:
from signal_bot.config import Config
from signal_bot.store import Store
async def _noop(ctx: CommandContext) -> None:
return None
def test_resolves_a_registered_trigger() -> None:
registry = CommandRegistry()
registry.register("cat", ["!cat", "!kot"], _noop, "cat description")
assert registry.resolve("!kot") is not None
assert registry.resolve("!cat").name == "cat"
def test_resolution_is_case_insensitive_and_trims() -> None:
registry = CommandRegistry()
registry.register("cat", ["!cat"], _noop, "cat description")
assert registry.resolve(" !CAT ") is not None
def test_unknown_and_prefixless_text_resolves_to_nothing() -> None:
registry = CommandRegistry()
registry.register("cat", ["!cat"], _noop, "cat description")
assert registry.resolve("!nope") is None
assert registry.resolve("cat") is None
@pytest.mark.parametrize("text", [None, ""])
def test_empty_text_resolves_to_nothing(text: str | None) -> None:
assert CommandRegistry().resolve(text) is None
def test_duplicate_trigger_is_rejected() -> None:
registry = CommandRegistry()
registry.register("cat", ["!cat"], _noop, "cat description")
with pytest.raises(DuplicateTriggerError, match="!cat"):
registry.register("feline", ["!cat"], _noop, "feline description")
def test_the_operators_aliases_are_appended_and_the_shipped_spelling_stays_primary() -> None:
registry = CommandRegistry({"cat": ["!kotek"], "nosuch": ["!x"]})
command = registry.register("cat", ["!kot", "!cat"], _noop, "cat description")
assert registry.resolve("!kotek") is command
assert command.primary == "!kot"
assert registry.unclaimed_aliases == frozenset({"nosuch"})
def test_an_operators_alias_that_is_another_commands_word_is_a_clash() -> None:
registry = CommandRegistry({"dog": ["!kot"]})
registry.register("cat", ["!kot"], _noop, "cat description")
with pytest.raises(DuplicateTriggerError, match="'cat'"):
registry.register("dog", ["!pies"], _noop, "dog description")
def test_commands_lists_in_registration_order() -> None:
registry = CommandRegistry()
registry.register("cat", ["!cat"], _noop, "cat description")
registry.register("dog", ["!dog"], _noop, "dog description")
assert [c.name for c in registry.commands] == ["cat", "dog"]
def _ctx(transport: FakeTransport, store: Store, config: Config) -> CommandContext:
message = parse_envelope(envelope(text="!papiez"))
assert message is not None
return CommandContext(
message=message,
group_id=WATCHED_GROUP,
transport=transport,
store=store,
config=config,
command_name="pope",
now=100.0,
day="2026-08-30",
)
async def test_reply_image_defaults_to_no_caption(store: Store, config: Config) -> None:
transport = FakeTransport()
await _ctx(transport, store, config).reply_image("Zm9v")
assert transport.sent[0].caption == ""
async def test_reply_image_passes_the_caption_through(store: Store, config: Config) -> None:
"""The caption rides in the same payload, so Signal shows one message."""
transport = FakeTransport()
await _ctx(transport, store, config).reply_image("Zm9v", "8. Telesfor")
sent = transport.sent[0]
assert sent.caption == "8. Telesfor"
assert sent.image_base64 == "Zm9v"
assert sent.quote is not None
async def _nothing(ctx: CommandContext) -> None:
"""A handler that does nothing, for tests about resolution only."""
def test_a_command_with_no_groups_exists_everywhere() -> None:
registry = CommandRegistry()
command = registry.register("open", ["!open"], _nothing, "everywhere")
assert command.allowed_in("group-a==")
assert registry.resolve_with_args("!open", "group-b==") is not None
def test_a_scoped_command_does_not_exist_elsewhere() -> None:
"""Not refused -- absent. A refusal would announce that it exists."""
registry = CommandRegistry()
registry.register("shy", ["!shy"], _nothing, "here only", groups=frozenset({"group-a=="}))
assert registry.resolve_with_args("!shy", "group-a==") is not None
assert registry.resolve_with_args("!shy", "group-b==") is None
def test_words_after_a_trigger_are_arguments_only_where_they_are_wanted() -> None:
""" "!cat blah" has always resolved to nothing, and still does."""
registry = CommandRegistry()
registry.register("plain", ["!plain"], _nothing, "no arguments")
registry.register("takes", ["!takes"], _nothing, "arguments", takes_args=True)
assert registry.resolve_with_args("!plain blah", "g==") is None
resolved = registry.resolve_with_args("!takes one two", "g==")
assert resolved is not None
assert resolved.args == ("one", "two")
def test_a_bare_trigger_has_no_arguments() -> None:
registry = CommandRegistry()
registry.register("takes", ["!takes"], _nothing, "arguments", takes_args=True)
resolved = registry.resolve_with_args("!takes", "g==")
assert resolved is not None
assert resolved.args == ()
def test_nothing_resolves_out_of_an_empty_message() -> None:
registry = CommandRegistry()
registry.register("takes", ["!takes"], _nothing, "arguments", takes_args=True)
assert registry.resolve_with_args(None, "g==") is None
assert registry.resolve_with_args(" ", "g==") is None
assert registry.resolve_with_args("!nobody has this", "g==") is None
def test_triggers_in_returns_only_what_this_group_actually_has() -> None:
"""What the near-miss search reads.
Scoped rather than global, and that is the whole point: a command
another group owns must not be offerable as a suggestion here, because
offering it would name it.
"""
registry = CommandRegistry()
everywhere = registry.register("cat", ["!kot", "!cat"], _noop, "kot")
scoped = registry.register("pack:p1", ["!alfa"], _noop, "zdjęcia", groups=frozenset({"g1"}))
here = registry.triggers_in("g1")
assert here == {"!kot": everywhere, "!cat": everywhere, "!alfa": scoped}
assert registry.triggers_in("g2") == {"!kot": everywhere, "!cat": everywhere}
def test_triggers_in_maps_a_folded_trigger_to_its_command() -> None:
"""Folded, so both sides of a comparison went through the same function."""
registry = CommandRegistry()
registry.register("pope", ["!papież"], _noop, "papież")
assert set(registry.triggers_in("g1")) == {"!papiez"}