signal-bot/tests/test_popes_command.py
Krzysztof kuhy Rudnicki a925928f12 fix: a refused command no longer re-arms its own cooldown
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 20:39:14 +02:00

152 lines
5.5 KiB
Python

# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""The !papież command: a random pope, captioned and linked."""
from __future__ import annotations
import base64
from pathlib import Path
from typing import TYPE_CHECKING
import httpx
import pytest
from signal_bot.decorators import DISPLAY_MARGIN_SECONDS, cooldown
from signal_bot.http_client import ImageFetcher
from signal_bot.models import parse_envelope
from signal_bot.popes_command import (
COMMAND_NAME,
article_url,
caption_for,
random_pope,
register_pope_command,
)
from signal_bot.portraits import PortraitCache
from signal_bot.registry import CommandContext, CommandRefusedError, CommandRegistry
from signal_bot.versioning import POPES
from tests.conftest import WATCHED_GROUP, envelope
if TYPE_CHECKING:
from signal_bot.config import Config
from signal_bot.store import Store
from signal_bot.transport_fake import FakeTransport
IMAGE = b"portrait-bytes"
ALIASES = {COMMAND_NAME: ["!papież", "!papiez", "!pope"]}
def _cache(tmp_path: Path) -> PortraitCache:
def handler(request: httpx.Request) -> httpx.Response:
if "commons" in str(request.url):
page = {"imageinfo": [{"thumburl": "https://cdn/portrait.jpg"}]}
return httpx.Response(200, json={"query": {"pages": [page]}})
return httpx.Response(200, content=IMAGE)
fetcher = ImageFetcher(httpx.AsyncClient(transport=httpx.MockTransport(handler)))
return PortraitCache(fetcher, "https://commons.example/w/api.php", tmp_path)
def _ctx(transport: FakeTransport, store: Store, config: Config, text: str) -> CommandContext:
message = parse_envelope(envelope(text=text))
assert message is not None
return CommandContext(
message=message,
group_id=WATCHED_GROUP,
transport=transport,
store=store,
config=config,
command_name=COMMAND_NAME,
now=100.0,
day="2026-08-30",
)
@pytest.mark.parametrize("trigger", ["!papież", "!papiez", "!pope"])
def test_every_alias_resolves(trigger: str) -> None:
registry = CommandRegistry()
register_pope_command(registry, _cache(Path("/nonexistent")), aliases=ALIASES)
command = registry.resolve(trigger)
assert command is not None
assert command.name == COMMAND_NAME
def test_the_real_alias_file_carries_the_command() -> None:
registry = CommandRegistry()
register_pope_command(registry, _cache(Path("/nonexistent")))
# The Polish spelling is the one kuhy asked for; folding makes the rest
# of the keyboard layouts work, but this one must be listed outright.
assert registry.resolve("!papież") is not None
async def test_it_posts_a_portrait_with_the_caption_attached(
tmp_path: Path, transport: FakeTransport, store: Store, config: Config
) -> None:
registry = CommandRegistry()
register_pope_command(registry, _cache(tmp_path), aliases=ALIASES)
command = registry.resolve("!papież")
assert command is not None
await command.handler(_ctx(transport, store, config, "!papież"))
sent = transport.sent[0]
assert sent.recipient == WATCHED_GROUP
assert sent.image_base64 is not None
assert base64.b64decode(sent.image_base64) == IMAGE
# One message, not two: the caption rides in the same payload.
assert len(transport.sent) == 1
assert sent.caption.startswith(tuple(str(p.ordinal) for p in POPES))
assert "https://pl.wikipedia.org/wiki/" in sent.caption
async def test_the_cooldown_decorator_applies(
tmp_path: Path, transport: FakeTransport, store: Store, config: Config
) -> None:
registry = CommandRegistry()
register_pope_command(
registry, _cache(tmp_path), decorate=cooldown(10, "WAIT {seconds}"), aliases=ALIASES
)
store.record_use(WATCHED_GROUP, "uuid-alice", COMMAND_NAME, 100.0, "2026-08-30")
command = registry.resolve("!pope")
assert command is not None
with pytest.raises(CommandRefusedError):
await command.handler(_ctx(transport, store, config, "!pope"))
assert transport.texts == [f"WAIT {10 + DISPLAY_MARGIN_SECONDS}"]
def test_the_caption_names_the_pope_in_both_languages() -> None:
pope = POPES[263]
assert pope.name == "Jan Paweł II"
assert caption_for(pope) == (
"264. Jan Paweł II — Ioannes Paulus Secundus (1978-2005)\n"
"https://pl.wikipedia.org/wiki/Jan_Pawe%C5%82_II"
)
def test_the_latin_name_never_contradicts_the_polish_number() -> None:
"""Both names come from the same table row, so they cannot disagree.
la.wikipedia titles #154 "Stephanus X" while every other source calls
him the ninth, and "Stefan IX — Stephanus X" in one caption reads as a
bug. The Latin column of Poczet papieży says "Stephanus Nonus".
"""
assert POPES[153].name == "Stefan IX"
assert POPES[153].latin == "Stephanus Nonus"
assert POPES[266].latin == "Leo Decimus Quartus"
def test_the_url_uses_the_title_so_disambiguated_popes_still_resolve() -> None:
telesfor = POPES[7]
assert telesfor.name == "Telesfor"
# Half the early popes live at "<name> (papież)"; name alone 404s.
assert article_url(telesfor) == "https://pl.wikipedia.org/wiki/Telesfor_%28papie%C5%BC%29"
def test_the_draw_actually_varies() -> None:
# 0.0.3 shipped a !gekon that posted one identical photo forever because
# nobody drew twice. Twenty draws from 267 popes repeating is a 1-in-267^19
# event; anything that pins the choice fails here immediately.
drawn = {random_pope().ordinal for _ in range(20)}
assert len(drawn) > 1