signal-bot/tests/test_commons.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

124 lines
4.6 KiB
Python

# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""The imageinfo query both Commons commands send."""
from __future__ import annotations
import pytest
from signal_bot.commons import (
CAPTION_LIMIT,
SUFFIXES,
THUMB_PATH,
caption_for,
imageinfo_params,
page_of,
)
from signal_bot.http_client import ImageFetchError
def test_a_file_can_be_named_by_title() -> None:
params = imageinfo_params(640, title="File:1868 Mausefalle.jpg")
assert params["titles"] == "File:1868 Mausefalle.jpg"
assert "pageids" not in params
assert params["iiurlwidth"] == "640"
assert params["prop"] == "imageinfo"
def test_a_file_can_be_named_by_page_id() -> None:
# The page id arrives as an int and has to reach Commons as a string;
# sending the int would raise inside httpx rather than here.
params = imageinfo_params(480, page_id=111419226)
assert params["pageids"] == "111419226"
assert "titles" not in params
@pytest.mark.parametrize(
("title", "page_id"),
[(None, None), ("File:x.jpg", 1)],
)
def test_naming_a_file_neither_way_or_both_ways_is_refused(
title: str | None, page_id: int | None
) -> None:
# Preferring one silently would make a caller's mistake look like a 404,
# which is the one failure shape that already exists downstream.
with pytest.raises(ValueError, match="exactly one"):
imageinfo_params(640, title=title, page_id=page_id)
def test_the_thumbnail_path_is_where_commons_actually_puts_it() -> None:
# Pinned because both callers walk it and neither can see it fail until
# a real fetch returns something unwalkable.
assert THUMB_PATH == ("query", "pages", 0, "imageinfo", 0, "thumburl")
def test_the_suffix_allowlist_is_what_signal_can_render() -> None:
assert ".jpg" in SUFFIXES
assert ".svg" not in SUFFIXES
def _page(
description: str | None = None, *, title: str = "File:Molchreuse.JPG", url: str = ""
) -> dict[str, object]:
"""One imageinfo page, shaped the way Commons shapes it."""
info: dict[str, object] = {"thumburl": "https://cdn/x.jpg"}
if url:
info["descriptionurl"] = url
if description is not None:
info["extmetadata"] = {"ImageDescription": {"value": description}}
return {"title": title, "imageinfo": [info]}
def test_asking_for_a_description_widens_the_query() -> None:
plain = imageinfo_params(640, page_id=1)
described = imageinfo_params(640, page_id=1, describe=True)
assert plain["iiprop"] == "url"
assert "iiextmetadatafilter" not in plain
assert described["iiprop"] == "url|extmetadata"
assert described["iiextmetadatafilter"] == "ImageDescription"
def test_the_caption_strips_markup_and_links_the_page() -> None:
page = _page("Eine <b>Molchreuse</b> &amp; ein Korb", url="https://commons/File:M.JPG")
assert caption_for(page) == "Eine Molchreuse & ein Korb\nhttps://commons/File:M.JPG"
def test_the_catalogue_list_is_cut_and_the_sentence_kept() -> None:
# The Fisheries records append their filing fields as an HTML list.
page = _page("Round Fyke (Bollreuse). Prussia\n<ul><li>Subject: Fishing nets</li></ul>")
assert caption_for(page) == "Round Fyke (Bollreuse). Prussia"
@pytest.mark.parametrize("description", [None, "", "<ul><li>Subject: only filing</li></ul>"])
def test_a_description_that_says_nothing_falls_back_to_the_title(description: str | None) -> None:
assert caption_for(_page(description)) == "Molchreuse"
def test_a_long_description_is_cut_rather_than_burying_the_photo() -> None:
page = _page("word " * 200)
caption = caption_for(page)
assert caption.endswith("…")
# rstrip() before the ellipsis means the exact length depends on where
# the cut landed; the ceiling is what matters.
assert len(caption) <= CAPTION_LIMIT + 1
def test_a_page_commons_could_not_find_is_an_image_fetch_error() -> None:
# A deleted or renumbered page id comes back as {"missing": true}.
for document in ({"query": {"pages": [{"missing": True}]}}, {"query": {"pages": []}}, {}, []):
with pytest.raises(ImageFetchError, match="named no image"):
page_of(document)
def test_the_page_comes_back_when_commons_did_find_it() -> None:
assert page_of({"query": {"pages": [_page("x")]}})["title"] == "File:Molchreuse.JPG"
def test_a_template_that_is_not_a_description_falls_back_to_the_title() -> None:
# Measured on a Maltese fish trap: the filename said more than Commons'
# own description field did.
page = _page(
"This photo has been taken in the country: Malta",
title="File:Hand woven fish trap.jpg",
)
assert caption_for(page) == "Hand woven fish trap"