# 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_quotes_detects_a_pointed_reply() -> None: assert quotes({"quote_timestamp": 1719177917717}) is True assert quotes({"message": "hi"}) is False 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"