signal-bot/tests/test_scenario.py
Krzysztof kuhy Rudnicki e3c7bbfe23 !good bot: thank the asker with an emoji on their message, not a reply
A compliment gets a reaction drawn from a small warm set (❤️ 🥰 😊 👍 🎉 🙏
😸) put on the compliment itself, and nothing in the group -- the one
answer that adds no line. Same cooldown as every other command.

The transport learns reactions: send_reaction on the Protocol, POST
/v1/reactions/<number> on the REST adapter with the four fields
api.SendReactionRequest requires (204 back), a recorded list on the fake.
CommandContext.react() names the target the way a quote does, and falls
back to the emoji as text for an envelope with no timestamp, so a shape
this bot has never seen is visible rather than swallowed.

The fake container gains the endpoint, strict about the schema, and
records reactions in order with the sends; a scenario can now expect
{kind: reaction, quotes: true}. First two-word trigger: fold() keeps the
inner space and the registry matches whole text first, so "!good bot"
resolves and "!good" does not; one-word spellings cover a double space.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-04 21:48:03 +02:00

215 lines
7.7 KiB
Python

# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""Scenario loading, rendering and adjudication."""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from fake_signal.scenario import (
Expectation,
GroupArrival,
Injection,
Scenario,
ScenarioError,
check,
describe,
load_scenario,
quotes,
)
from signal_bot.models import parse_envelope
GROUPS = {"watched": "W==", "reply": "R==", "other": "O=="}
def _write(tmp_path: Path, document: str) -> Path:
path = tmp_path / "s.yaml"
path.write_text(document, encoding="utf-8")
return path
def test_ships_scenarios_load_and_are_valid() -> None:
paths = sorted(Path("fake_signal/scenarios").glob("*.yaml"))
assert paths
for path in paths:
assert load_scenario(path).name
def test_injection_renders_a_parseable_group_envelope() -> None:
frame = Injection(text="!cat").to_frame(GROUPS)
message = parse_envelope(frame)
assert message is not None
assert message.text == "!cat"
assert message.group_id == "W=="
def test_injection_renders_a_direct_message() -> None:
message = parse_envelope(Injection(text="hi", group=None).to_frame(GROUPS))
assert message is not None
assert message.is_direct_message
def test_injection_renders_sync_sticker_and_reaction() -> None:
sticker = parse_envelope(Injection(sticker=True, sync=True).to_frame(GROUPS))
reaction = parse_envelope(Injection(reaction=True).to_frame(GROUPS))
assert sticker is not None
assert sticker.has_sticker
assert reaction is not None
assert reaction.is_reaction
def test_unknown_group_alias_is_rejected() -> None:
with pytest.raises(ScenarioError, match="unknown group alias"):
Injection(group="nope").to_frame(GROUPS)
def test_loads_a_full_scenario(tmp_path: Path) -> None:
path = _write(
tmp_path,
"name: demo\ninject:\n - {text: '!cat'}\nexpect:\n - {kind: image}\n",
)
scenario = load_scenario(path)
assert scenario.name == "demo"
assert scenario.inject[0].text == "!cat"
assert scenario.expect[0].kind == "image"
@pytest.mark.parametrize(
("document", "match"),
[
("- a list\n", "must contain a mapping"),
("inject: []\n", "missing a name"),
("name: x\ninject:\n - {bogus: 1}\n", "unknown inject keys"),
("name: x\nexpect:\n - {bogus: 1}\n", "unknown expect keys"),
("name: x\ninject:\n - not-a-mapping\n", "must be a mapping"),
],
)
def test_malformed_scenarios_are_rejected(tmp_path: Path, document: str, match: str) -> None:
with pytest.raises(ScenarioError, match=match):
load_scenario(_write(tmp_path, document))
def test_describe_classifies_sends() -> None:
assert describe({"message": "hi", "recipients": ["R=="]}) == ("text", "R==", "hi")
assert describe({"base64_attachments": ["A"], "recipients": ["R=="]})[0] == "image"
assert describe({})[1] == ""
def test_describe_tells_a_reaction_from_a_send() -> None:
reaction = {"reaction": "❤️", "recipient": "R==", "target_author": "a", "timestamp": 17}
assert describe(reaction) == ("reaction", "R==", "❤️")
assert describe({"reaction": "❤️"})[1] == ""
def test_quotes_detects_a_pointed_reply() -> None:
assert quotes({"quote_timestamp": 1719177917717}) is True
assert quotes({"message": "hi"}) is False
# A reaction has nothing to do but point, and names its target as timestamp.
assert quotes({"reaction": "❤️", "timestamp": 17}) is True
def test_check_enforces_a_quoted_reply_in_both_directions() -> None:
"""Without this the e2e gate could not tell a quoted reply from a plain one."""
quoted = [{"base64_attachments": ["A"], "recipients": ["R=="], "quote_timestamp": 17}]
plain = [{"base64_attachments": ["A"], "recipients": ["R=="]}]
wants_quote = Scenario("s", expect=[Expectation(kind="image", to="reply", quotes=True)])
wants_none = Scenario("s", expect=[Expectation(kind="image", to="reply", quotes=False)])
assert check(wants_quote, quoted, GROUPS) == []
assert check(wants_none, plain, GROUPS) == []
assert "quotes: expected True" in check(wants_quote, plain, GROUPS)[0]
assert "quotes: expected False" in check(wants_none, quoted, GROUPS)[0]
def test_check_passes_on_a_match() -> None:
scenario = Scenario("s", expect=[Expectation(kind="image", to="reply")])
sent = [{"base64_attachments": ["A"], "recipients": ["R=="]}]
assert check(scenario, sent, GROUPS) == []
def test_check_reports_a_count_mismatch() -> None:
scenario = Scenario("s", expect=[Expectation()])
failures = check(scenario, [], GROUPS)
assert "expected 1 send(s), captured 0" in failures[0]
assert any("missing" in line for line in failures)
def test_check_reports_kind_recipient_and_body() -> None:
scenario = Scenario("s", expect=[Expectation(kind="image", to="reply", contains="nope")])
sent = [{"message": "hello", "recipients": ["W=="]}]
failures = check(scenario, sent, GROUPS)
assert any("kind:" in f for f in failures)
assert any("recipient:" in f for f in failures)
assert any("body:" in f for f in failures)
def test_check_accepts_a_literal_recipient() -> None:
scenario = Scenario("s", expect=[Expectation(to="literal==")])
assert check(scenario, [{"message": "", "recipients": ["literal=="]}], GROUPS) == []
def test_unexpected_sends_are_a_failure() -> None:
"""A scoping regression shows up here: expect [] and the bot replied."""
failures = check(Scenario("s"), [{"message": "leak", "recipients": ["R=="]}], GROUPS)
assert "expected 0 send(s), captured 1" in failures[0]
def test_frame_is_json() -> None:
assert json.loads(Injection().to_frame(GROUPS))["account"] == "+15550000000"
def test_group_arrival_renders_a_listing_row() -> None:
"""A scenario names aliases; the fake needs both real spellings."""
entry = GroupArrival(id="reply", internal_id="watched", name="added").to_entry(GROUPS)
assert entry == {
"id": GROUPS["reply"],
"internal_id": GROUPS["watched"],
"name": "added",
"member": True,
"joinable": True,
}
@pytest.mark.parametrize("field", ["id", "internal_id"])
def test_group_arrival_rejects_an_unknown_alias(field: str) -> None:
arrival = GroupArrival(**{"id": "reply", "internal_id": "watched", field: "nope"})
with pytest.raises(ScenarioError, match="unknown group alias"):
arrival.to_entry(GROUPS)
def test_arrivals_are_parsed_from_a_file(tmp_path: Path) -> None:
path = tmp_path / "s.yaml"
path.write_text(
"name: demo\narrive:\n - {id: reply, internal_id: watched}\n", encoding="utf-8"
)
assert load_scenario(path).arrive[0].internal_id == "watched"
def test_an_injection_can_carry_an_inbound_attachment() -> None:
"""What makes !add_photo expressible offline at all."""
frame = Injection(text="!dodaj alfa", attachment="att-1").to_frame(GROUPS)
message = parse_envelope(frame)
assert message is not None
assert message.attachment_ids == ("att-1",)
assert message.attachment_types == ("image/jpeg",)
def test_an_attachment_type_can_be_stated() -> None:
frame = Injection(text="!dodaj alfa", attachment="att-2", attachment_type="video/mp4").to_frame(
GROUPS
)
message = parse_envelope(frame)
assert message is not None
assert message.attachment_types == ("video/mp4",)
def test_the_attachment_lands_at_the_top_level_not_inside_a_quote() -> None:
"""A reply's quote carries the *quoted* message's media; taking it would
let !add_photo re-file a photo somebody else posted."""
frame = json.loads(Injection(text="!dodaj alfa", attachment="att-1").to_frame(GROUPS))
payload = frame["envelope"]["dataMessage"]
assert "attachments" in payload
assert "quote" not in payload