signal-bot/tests/test_poem_command.py
Krzysztof kuhy Rudnicki 08599e6056 feat: two commands that answer with somebody else's work
!sztuka posts a work of art, skewed 60% Polish; !wiersz posts one short
public-domain Polish poem, whole. Both follow the rule the rest of the bot
already keeps: the repository commits identifiers, and the words arrive over
the wire when somebody asks.

!wiersz was written the other way first, and the gate caught it. A pack of
short public-domain poems was harvested, name-filtered and committed, and
check_personal_data.sh refused it: three lines carried a real group member's
given name, and two still did after a filter that had discarded 31% of the
corpus. That is structural, not a tuning problem -- Polish verse capitalises
every line, so a line-initial capital carries no signal, and that is exactly
where a name sits. The pack now holds Wolne Lektury slugs; the poem, its
title and its author are fetched at post time and appear nowhere in git.
It is also the better version: with no name filter there are no false
positives, so the pool is larger and the attribution is a full name again.

The gate then refused the art category list too, over a painter whose given
name is somebody's. That is the popes.json case exactly, so it takes the
same answer: a file-scoped allowlist entry, still refused everywhere else.

The Commons harvest is now three modules by what they are, not by where the
line count fell: commons_api is the conversation with Commons, commons_harvest
turns categories into a pool of licensed page ids, commons_cache makes an
interrupted run resumable. Along the way it gained licences in the same
request that lists a category (the second pass was 1,500 requests at 2.3s
each), maxlag instead of a guessed sleep, 5xx retries because one 504 used to
destroy a whole run, and Wikidata for works by creators of Polish
citizenship: the Polish pool grew 15,221 -> 20,092.

Batching categories through CirrusSearch looked like a further 6x and was
measured out again: 2,404 files where the authoritative enumeration returns
2,429, a different ~1% missing on every run.

The offline run is 19s where it was four minutes, almost all of it the
deafness gate waiting out three thirty-second liveness probes to prove
something provable in three; the tick is configuration now, and e2e enforces
a 40s budget on itself so that never silently comes back.
2026-08-31 21:32:07 +02:00

247 lines
9.2 KiB
Python

# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""The !wiersz command: one short public-domain Polish poem, whole."""
from __future__ import annotations
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.poem_command import (
COMMAND_NAME,
MAX_CHARS,
MAX_LINES,
POEMS_FILE,
load_slugs,
random_slug,
register_poem_command,
render,
)
from signal_bot.registry import CommandRefusedError, CommandRegistry
from signal_bot.wolnelektury import PUBLIC_DOMAIN, Poem
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
TEXT_URL = "https://lektury.example/txt/{slug}.txt"
ALIASES = {COMMAND_NAME: ["!wiersz", "!wiersze", "!poem"]}
POOL = ("bol-tabuli", "impas", "nikt-nic")
BODY = "Pierwsza linijka wiersza,\ndruga linijka wiersza."
def _fetcher(seen: list[httpx.URL] | None = None, *, body: str = BODY) -> ImageFetcher:
def handler(request: httpx.Request) -> httpx.Response:
if seen is not None:
seen.append(request.url)
raw = f"Maria Konopnicka\n\nA co wam śpiewać\n\n\n{body}\n\n-----\n{PUBLIC_DOMAIN}\n"
return httpx.Response(200, text=raw)
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", ["!wiersz", "!wiersze", "!poem"])
def test_every_alias_resolves(trigger: str) -> None:
registry = CommandRegistry()
register_poem_command(registry, _fetcher(), TEXT_URL, aliases=ALIASES, slugs=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_poem_command(registry, _fetcher(), TEXT_URL, slugs=POOL)
# The Polish spelling is the primary one !pomoc prints.
assert registry.resolve("!wiersz") is not None
assert registry.resolve("!poem") is not None
async def test_it_posts_the_poem_its_author_and_where_it_came_from(
transport: FakeTransport, store: Store, config: Config
) -> None:
registry = CommandRegistry()
register_poem_command(registry, _fetcher(), TEXT_URL, aliases=ALIASES, slugs=("impas",))
command = registry.resolve("!wiersz")
assert command is not None
await command.handler(_ctx(transport, store, config, "!wiersz"))
expected = (
f"A co wam śpiewać\n\n{BODY}\n\n— Maria Konopnicka\n"
"https://wolnelektury.pl/katalog/lektura/impas/"
)
assert transport.texts == [expected]
# Text, not an attachment: this is the one content command that posts no
# picture, and sending an empty image would be a silent wrong turn.
assert len(transport.sent) == 1
assert transport.sent[0].image_base64 is None
# ...and it still quotes the asker, like every other reply.
assert transport.sent[0].quote is not None
async def test_the_full_author_name_is_posted_though_it_is_never_committed(
transport: FakeTransport, store: Store, config: Config
) -> None:
"""The point of fetching rather than committing, as an assertion.
"Maria" is a Polish given name. Posting it is fine; committing several
hundred like it is what made check_personal_data.py refuse the first
version of this pack. See the module docstring.
"""
registry = CommandRegistry()
register_poem_command(registry, _fetcher(), TEXT_URL, aliases=ALIASES, slugs=POOL)
command = registry.resolve("!wiersz")
assert command is not None
await command.handler(_ctx(transport, store, config, "!wiersz"))
assert "Maria Konopnicka" in transport.texts[0]
async def test_it_asks_for_the_slug_it_drew(
transport: FakeTransport, store: Store, config: Config
) -> None:
seen: list[httpx.URL] = []
registry = CommandRegistry()
register_poem_command(registry, _fetcher(seen), TEXT_URL, aliases=ALIASES, slugs=("impas",))
command = registry.resolve("!wiersz")
assert command is not None
await command.handler(_ctx(transport, store, config, "!wiersz"))
assert len(seen) == 1
assert str(seen[0]) == "https://lektury.example/txt/impas.txt"
async def test_an_upstream_that_stops_serving_a_poem_is_not_posted(
transport: FakeTransport, store: Store, config: Config
) -> None:
"""A half-parsed poem is not something to put in a group.
The dispatcher turns this into a log line and silence, which is what
every other command already does when its upstream misbehaves.
"""
registry = CommandRegistry()
fetcher = ImageFetcher(
httpx.AsyncClient(transport=httpx.MockTransport(lambda _: httpx.Response(200, text="")))
)
register_poem_command(registry, fetcher, TEXT_URL, aliases=ALIASES, slugs=POOL)
command = registry.resolve("!wiersz")
assert command is not None
with pytest.raises(ValueError, match="no longer serves"):
await command.handler(_ctx(transport, store, config, "!wiersz"))
assert transport.sent == []
async def test_the_cooldown_decorator_applies(
transport: FakeTransport, store: Store, config: Config
) -> None:
registry = CommandRegistry()
register_poem_command(
registry,
_fetcher(),
TEXT_URL,
decorate=cooldown(10, "WAIT {seconds}"),
aliases=ALIASES,
slugs=POOL,
)
store.record_use(WATCHED_GROUP, "uuid-alice", COMMAND_NAME, 100.0, "2026-08-31")
command = registry.resolve("!wiersz")
assert command is not None
with pytest.raises(CommandRefusedError):
await command.handler(_ctx(transport, store, config, "!wiersz"))
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."""
assert len({random_slug(POOL) for _ in range(40)}) > 1
def test_render_puts_the_poem_before_the_attribution() -> None:
# The order is the point: a group reads the poem, then who wrote it.
text = render(Poem(title="Impas", author="Bolesław Leśmian", text=BODY), "impas")
assert text.index("Pierwsza") < text.index("— Bolesław") < text.index("wolnelektury.pl")
def test_the_shipped_pack_holds_slugs_and_no_poetry() -> None:
"""The privacy invariant this whole command is shaped around.
Committing the poems themselves is what check_personal_data.py refused:
three lines carried a real group member's name, and two still did after
a filter that had already discarded 31% of the corpus. A slug cannot
carry a name that way. This is the test that keeps the pack to slugs
when somebody regenerates it.
"""
raw = json.loads(POEMS_FILE.read_text(encoding="utf-8"))["poems"]
assert raw
assert all(isinstance(slug, str) and slug for slug in raw)
# A slug is a lowercase hyphenated handle, never prose: no capitals, no
# whitespace, no punctuation that a line of verse would carry.
assert all(slug == slug.lower() for slug in raw)
assert not any(set(slug) & set(" \n.,;:!?\"'") for slug in raw)
assert len(set(raw)) == len(raw)
assert raw == sorted(raw)
# What this does NOT claim: that no slug contains a word that is also
# somebody's name. "Zosia i jej mopsy" is a Konopnicka poem and its slug
# is zosia-i-jej-mopsy, because a capitalised *first* word is how every
# title starts and cannot be told from a name. That residue is acceptable
# where a pack of poems was not, and the difference is repair: the real
# check is scripts/check_personal_data.sh, pre-commit runs it on every
# commit, and a colliding slug is one line to delete out of 1180. The
# text version wedged the repository with "data nobody can edit out by
# hand"; this is a one-line edit and a regeneration.
def test_the_shipped_pack_is_large_enough_to_be_worth_drawing_from() -> None:
# A regeneration that silently truncated would still pass every other
# test here, and would quietly shrink the command's whole world.
assert len(load_slugs()) >= 300
def test_the_caps_are_the_promise_the_pack_makes() -> None:
# Whole poems only. The caps are enforced at harvest time, so they live
# next to the command rather than in the generator that applies them.
assert MAX_LINES == 24
assert MAX_CHARS == 1200
@pytest.mark.parametrize("broken", [[], ["", "impas"], [None], [3]])
def test_a_pool_that_is_not_slugs_is_refused(tmp_path: Path, broken: list[object]) -> None:
path = tmp_path / "poems.json"
path.write_text(json.dumps({"poems": broken}), encoding="utf-8")
with pytest.raises(ValueError, match="slugs"):
load_slugs(path)