signal-bot/tests/test_traps_command.py
Krzysztof kuhy Rudnicki 14d422295f feat: a word with two meanings gets the one made of steel
!pułapka (and !trap, !sidła, !wnyk, !potrzask, 26 spellings in all) posts a
photograph of the literal device -- a bear trap, a mousetrap, a snare, a fish
weir -- captioned with what Commons says it is.

The pool is 2218 Commons page ids, harvested by tool/gen_traps.py from a
curated seed list and committed. Page ids and not filenames on purpose: a
filename carries photographers' and sitters' names, and one of those
colliding with a label somebody mints from the chat would wedge
check_personal_data.py permanently, over data nobody can edit by hand.
_repo_vocabulary() subtracts aliases.json and nothing else, so it would not
have caught it. An integer cannot carry a name.

Nothing is cached. !papież caches because 267 portraits are a closed set a
group works through; a uniform draw from 2218 warms a cache essentially
never, so this fetches per call the way !kot and !pies do.

What the harvest measured, and now enforces:

- five plausible seed categories do not exist (Snares, Man traps, Hunting
  traps, Wasp traps, Animal trapping). A missing category is an empty answer,
  not an error, so verify_seeds() asks Commons before harvesting and fails
  loudly naming all of them.
- depth three from Category:Fish traps reaches Alfred Sisley's net paintings.
  Depth one, from seeds picked by hand.
- "<thing> in art" categories are paintings: Mousetraps in art put a Robert
  Campin altarpiece in the pool. Pruned by pattern, since Commons mints a new
  one whenever somebody needs it. Booby traps, the Wolfsangel, camera traps
  and animals dying in traps are pruned by name.
- Commons rate-limits hard, so a partial harvest must not be committed
  silently: 429 backs off, and a short pool refuses to write at all.

Descriptions are HTML and sometimes say nothing, so the tags come out, the
Fisheries records' trailing catalogue list is cut, a description naming only
the country falls back to the filename, and the file's own page is always
linked.

!łapka is not an alias: it resolves to !papa at distance two, which
test_nearest.py fails on, and rightly -- the suggestion would be a coin toss.

signal_bot/commons.py is the imageinfo query both Commons commands now share;
portraits.py keeps its behaviour and its tests unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01416TegJ3sa8xL5qGGgrygP
2026-08-31 18:01:27 +02:00

192 lines
6.7 KiB
Python

# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""The !pułapka command: a photograph of the device, with nothing attached."""
from __future__ import annotations
import base64
import json
from typing import TYPE_CHECKING
import httpx
import pytest
from signal_bot.context import CommandContext
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.registry import CommandRefusedError, CommandRegistry
from signal_bot.traps_command import (
COMMAND_NAME,
TRAPS_FILE,
load_traps,
random_trap,
register_trap_command,
)
from tests.conftest import WATCHED_GROUP, envelope
if TYPE_CHECKING:
from pathlib import Path
from signal_bot.config import Config
from signal_bot.store import Store
from signal_bot.transport_fake import FakeTransport
IMAGE = b"trap-bytes"
API = "https://commons.example/w/api.php"
ALIASES = {COMMAND_NAME: ["!pułapka", "!pulapka", "!trap"]}
POOL = (111419226, 124481655, 151918804)
def _fetcher(seen: list[httpx.URL] | None = None) -> ImageFetcher:
def handler(request: httpx.Request) -> httpx.Response:
if "commons" in str(request.url):
if seen is not None:
seen.append(request.url)
page = {
"title": "File:Molchreuse.JPG",
"imageinfo": [
{
"thumburl": "https://cdn/trap.jpg",
"descriptionurl": "https://commons.wikimedia.org/wiki/File:Molchreuse.JPG",
"extmetadata": {
"ImageDescription": {"value": "Eine <b>Molchreuse</b>"},
},
}
],
}
return httpx.Response(200, json={"query": {"pages": [page]}})
return httpx.Response(200, content=IMAGE)
return ImageFetcher(httpx.AsyncClient(transport=httpx.MockTransport(handler)))
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-31",
)
@pytest.mark.parametrize("trigger", ["!pułapka", "!pulapka", "!trap"])
def test_every_alias_resolves(trigger: str) -> None:
registry = CommandRegistry()
register_trap_command(registry, _fetcher(), API, aliases=ALIASES, traps=POOL)
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_trap_command(registry, _fetcher(), API, traps=POOL)
# The Polish spelling is the primary one !pomoc prints; the English
# !trap is the word that prompted the command in the first place.
assert registry.resolve("!pułapka") is not None
assert registry.resolve("!trap") is not None
async def test_it_posts_an_image_captioned_with_what_the_thing_is(
transport: FakeTransport, store: Store, config: Config
) -> None:
registry = CommandRegistry()
register_trap_command(registry, _fetcher(), API, aliases=ALIASES, traps=POOL)
command = registry.resolve("!trap")
assert command is not None
await command.handler(_ctx(transport, store, config, "!trap"))
sent = transport.sent[0]
assert sent.recipient == WATCHED_GROUP
assert sent.image_base64 is not None
assert base64.b64decode(sent.image_base64) == IMAGE
assert len(transport.sent) == 1
# Commons is full of files whose subject is unguessable, so the caption
# says what it is and links the page that says more. Tags come out.
assert sent.caption == (
"Eine Molchreuse\nhttps://commons.wikimedia.org/wiki/File:Molchreuse.JPG"
)
async def test_it_names_the_file_by_page_id_and_never_by_title(
transport: FakeTransport, store: Store, config: Config
) -> None:
# A title would be free text carrying people's names into the repository;
# see load_traps. This is the assertion that keeps the query on page ids.
seen: list[httpx.URL] = []
registry = CommandRegistry()
register_trap_command(registry, _fetcher(seen), API, aliases=ALIASES, traps=POOL)
command = registry.resolve("!trap")
assert command is not None
await command.handler(_ctx(transport, store, config, "!trap"))
assert len(seen) == 1
assert seen[0].params["pageids"] in {str(page) for page in POOL}
assert "titles" not in seen[0].params
async def test_the_cooldown_decorator_applies(
transport: FakeTransport, store: Store, config: Config
) -> None:
registry = CommandRegistry()
register_trap_command(
registry,
_fetcher(),
API,
decorate=cooldown(10, "WAIT {seconds}"),
aliases=ALIASES,
traps=POOL,
)
store.record_use(WATCHED_GROUP, "uuid-alice", COMMAND_NAME, 100.0, "2026-08-31")
command = registry.resolve("!trap")
assert command is not None
with pytest.raises(CommandRefusedError):
await command.handler(_ctx(transport, store, config, "!trap"))
assert transport.texts == [f"WAIT {10 + DISPLAY_MARGIN_SECONDS}"]
def test_the_draw_actually_varies() -> None:
"""0.0.3 posted one identical gecko forever; that is what this catches."""
drawn = {random_trap(POOL) for _ in range(20)}
assert len(drawn) > 1
def test_the_shipped_pool_is_page_ids_and_nothing_else() -> None:
"""The personal-data invariant, as a unit test.
A filename here would carry photographers' and sitters' names into a
public repository, and one of them colliding with a label somebody minted
from the chat wedges check_personal_data.py permanently. Integers cannot.
"""
raw = json.loads(TRAPS_FILE.read_text(encoding="utf-8"))["traps"]
assert raw
assert all(isinstance(page, int) for page in raw)
assert len(set(raw)) == len(raw)
assert raw == sorted(raw)
def test_the_shipped_pool_is_large_enough_to_be_worth_drawing_from() -> None:
# kuhy asked for a set "as big as possible", and a regeneration that
# silently truncated would still pass every other test here.
assert len(load_traps()) >= 500
@pytest.mark.parametrize("traps", [[], ["111419226.jpg"], [None]])
def test_a_pool_that_is_not_page_ids_is_refused(tmp_path: Path, traps: list[object]) -> None:
path = tmp_path / "traps.json"
path.write_text(json.dumps({"traps": traps}), encoding="utf-8")
with pytest.raises(ValueError, match="page ids"):
load_traps(path)