# 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)