# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence. """!random: the only door to a custom tag, and the one that mints nothing.""" from __future__ import annotations import json from typing import TYPE_CHECKING import pytest from signal_bot.context import CommandContext from signal_bot.models import parse_envelope from signal_bot.pack_command import NO_PHOTO from signal_bot.pack_custom import CUSTOM_FILE from signal_bot.pack_holder import PackHolder from signal_bot.pack_load import load from signal_bot.pack_random import COMMAND_NAME, register_random_command from signal_bot.registry import CommandRegistry from tests import pack_fixture from tests.conftest import SECOND_GROUP, WATCHED_GROUP, envelope if TYPE_CHECKING: from pathlib import Path from signal_bot.config import Config from signal_bot.registry import Handler from signal_bot.store import Store from signal_bot.transport_fake import FakeTransport TRIGGERS = ("!losowe", "!random") @pytest.fixture def holder(tmp_path: Path) -> PackHolder: root = pack_fixture.build(tmp_path / "pack") (root / CUSTOM_FILE).write_text( json.dumps({"t1": ["wakacje"], "t2": ["gory"]}), encoding="utf-8" ) tags = json.loads((root / "tags.json").read_text(encoding="utf-8")) tags["alfa.png"] = ["p1", "t1"] (root / "tags.json").write_text(json.dumps(tags), encoding="utf-8") return PackHolder(load(root)) def _registry(holder: PackHolder) -> CommandRegistry: registry = CommandRegistry() register_random_command(registry, holder, TRIGGERS) return registry async def _run( holder: PackHolder, transport: FakeTransport, store: Store, config: Config, text: str, group: str = WATCHED_GROUP, ) -> None: registry = _registry(holder) resolved = registry.resolve_with_args(text, group) if resolved is None: return message = parse_envelope(envelope(text=text)) assert message is not None await resolved.command.handler( CommandContext( message=message, group_id=group, transport=transport, store=store, config=config, command_name=resolved.command.name, now=0.0, day="2026-08-31", args=resolved.args, ) ) async def test_a_bare_random_posts_something_from_the_whole_album( holder: PackHolder, transport: FakeTransport, store: Store, config: Config ) -> None: """matching(frozenset()) is already "everything", so there is no new path.""" await _run(holder, transport, store, config, "!losowe") assert transport.sent[-1].image_base64 is not None async def test_a_custom_label_narrows_to_its_own_photos( holder: PackHolder, transport: FakeTransport, store: Store, config: Config ) -> None: await _run(holder, transport, store, config, "!losowe wakacje") assert transport.sent[-1].filename == "alfa.png" async def test_a_label_nobody_has_gets_one_line_and_no_photo( holder: PackHolder, transport: FakeTransport, store: Store, config: Config ) -> None: await _run(holder, transport, store, config, "!losowe niktnietaki") assert transport.texts[-1] == NO_PHOTO async def test_a_typo_on_random_mints_nothing( holder: PackHolder, transport: FakeTransport, store: Store, config: Config ) -> None: """The reason !random does not share !add_photo's resolver.""" before = (holder.pack.directory / CUSTOM_FILE).read_text(encoding="utf-8") await _run(holder, transport, store, config, "!losowe wakacjee") assert (holder.pack.directory / CUSTOM_FILE).read_text(encoding="utf-8") == before assert holder.pack.custom.keys == {"t1", "t2"} async def test_a_tag_nobody_photographed_gets_one_line( holder: PackHolder, transport: FakeTransport, store: Store, config: Config ) -> None: await _run(holder, transport, store, config, "!losowe gory") assert transport.texts[-1] == NO_PHOTO async def test_two_labels_intersect( holder: PackHolder, transport: FakeTransport, store: Store, config: Config ) -> None: """t1 and t2 share no photo, so the intersection is empty.""" await _run(holder, transport, store, config, "!losowe wakacje gory") assert transport.texts[-1] == NO_PHOTO async def test_too_many_labels_are_refused_rather_than_multiplied( holder: PackHolder, transport: FakeTransport, store: Store, config: Config ) -> None: await _run(holder, transport, store, config, "!losowe " + " ".join(["wakacje"] * 7)) assert transport.texts[-1] == NO_PHOTO async def test_random_never_resolves_a_person( holder: PackHolder, transport: FakeTransport, store: Store, config: Config ) -> None: """!alfa already exists; a second entrance would be one too many.""" await _run(holder, transport, store, config, "!losowe alfa") assert transport.texts[-1] == NO_PHOTO async def test_it_does_not_exist_in_a_group_the_album_was_not_given_to( holder: PackHolder, transport: FakeTransport, store: Store, config: Config ) -> None: await _run(holder, transport, store, config, "!losowe wakacje", group=SECOND_GROUP) assert transport.sent == [] def test_it_is_scoped_and_named_by_pseudonym(holder: PackHolder) -> None: command = _registry(holder).commands[0] assert command.name == COMMAND_NAME assert command.groups == holder.pack.groups assert "wakacje" not in command.description async def test_a_tag_minted_after_registration_still_resolves( holder: PackHolder, transport: FakeTransport, store: Store, config: Config ) -> None: """The resolver reads the holder, so a swap is enough -- no re-register. The swap is what ``POST /_reload`` does through the registry rebuild. """ root = holder.pack.directory (root / CUSTOM_FILE).write_text( json.dumps({"t1": ["wakacje"], "t2": ["gory"], "t3": ["nowe"]}), encoding="utf-8" ) tags = json.loads((root / "tags.json").read_text(encoding="utf-8")) tags["gamma.png"] = ["p3", "t3"] (root / "tags.json").write_text(json.dumps(tags), encoding="utf-8") holder.pack = load(root) await _run(holder, transport, store, config, "!losowe nowe") assert transport.sent[-1].filename == "gamma.png" def test_the_cooldown_decorator_is_applied_when_given(holder: PackHolder) -> None: """!random is a photo poster, so it takes the same cooldown as !alfa.""" calls: list[str] = [] def decorate(handler: Handler) -> Handler: calls.append("wrapped") return handler registry = CommandRegistry() register_random_command(registry, holder, TRIGGERS, decorate=decorate) assert calls == ["wrapped"]