tgtest 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
tgtest/__init__.py ADDED
@@ -0,0 +1,26 @@
1
+ """tgtest - end-to-end testing platform for Telegram bots.
2
+
3
+ Acts as a real Telegram *user* (via Telethon/MTProto) to talk to your bots and
4
+ assert on their replies. Tests can be written either as declarative YAML
5
+ scenarios or as Python/pytest functions using the same client helpers.
6
+ """
7
+ from .config import Settings
8
+ from .logger import configure_logger
9
+ from .client import BotTester, ReplyMatchError
10
+ from .scenario import Scenario, load_scenario, load_scenarios
11
+ from .engine import run_scenario
12
+ from .exceptions import TgTestError, StepError, ScenarioError
13
+
14
+ __all__ = [
15
+ "Settings",
16
+ "configure_logger",
17
+ "BotTester",
18
+ "ReplyMatchError",
19
+ "Scenario",
20
+ "load_scenario",
21
+ "load_scenarios",
22
+ "run_scenario",
23
+ "TgTestError",
24
+ "StepError",
25
+ "ScenarioError",
26
+ ]
tgtest/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ sys.exit(main())
tgtest/cli.py ADDED
@@ -0,0 +1,104 @@
1
+ """Command-line runner: `python -m tgtest run scenarios/`.
2
+
3
+ Discovers YAML scenarios, runs them against the configured bot, and prints a
4
+ per-scenario PASS/FAIL summary. Exit code is non-zero if any scenario fails so
5
+ it slots into CI.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import asyncio
11
+ import sys
12
+ import time
13
+
14
+ from .config import Settings
15
+ from .logger import configure_logger
16
+ from .client import BotTester
17
+ from .scenario import load_scenarios, Scenario
18
+ from .engine import run_scenario
19
+ from .exceptions import StepError, ScenarioError
20
+
21
+ GREEN, RED, DIM, BOLD, RESET = "\033[32m", "\033[31m", "\033[2m", "\033[1m", "\033[0m"
22
+
23
+
24
+ def _c(text: str, color: str, use_color: bool) -> str:
25
+ return f"{color}{text}{RESET}" if use_color else text
26
+
27
+
28
+ async def _run_all(scenarios: list[Scenario], config: Settings, use_color: bool) -> int:
29
+ logger = configure_logger(config)
30
+ passed = failed = 0
31
+ async with BotTester.create(config) as tester:
32
+ for sc in scenarios:
33
+ logger.info("Running scenario: %s", sc.name)
34
+ label = f"{sc.name}" + (f" [{sc.source}]" if sc.source else "")
35
+ start = time.monotonic()
36
+ try:
37
+ await run_scenario(tester, sc)
38
+ except StepError as exc:
39
+ failed += 1
40
+ dur = time.monotonic() - start
41
+ print(f"{_c('FAIL', RED, use_color)} {label} ({dur:.1f}s)")
42
+ loc = f"step {exc.step_index}"
43
+ if exc.step_desc:
44
+ loc += f" — {exc.step_desc}"
45
+ print(_c(f" {loc}", DIM, use_color))
46
+ for line in str(exc).splitlines():
47
+ print(_c(f" {line}", RED, use_color))
48
+ logger.error("FAIL %s at step %s: %s", sc.name, exc.step_index, exc)
49
+ except Exception as exc:
50
+ failed += 1
51
+ dur = time.monotonic() - start
52
+ print(f"{_c('ERROR', RED, use_color)} {label} ({dur:.1f}s)")
53
+ print(_c(f" {type(exc).__name__}: {exc}", RED, use_color))
54
+ logger.exception("ERROR %s: %s", sc.name, exc)
55
+ else:
56
+ passed += 1
57
+ dur = time.monotonic() - start
58
+ print(f"{_c('PASS', GREEN, use_color)} {label} ({dur:.1f}s)")
59
+ logger.info("PASS %s (%.1fs)", sc.name, dur)
60
+
61
+ total = passed + failed
62
+ summary = f"\n{passed}/{total} passed"
63
+ print(_c(summary, GREEN if not failed else RED, use_color))
64
+ return 1 if failed else 0
65
+
66
+
67
+ def main(argv: list[str] | None = None) -> int:
68
+ parser = argparse.ArgumentParser(
69
+ prog="tgtest", description="Telegram bot E2E test runner"
70
+ )
71
+ sub = parser.add_subparsers(dest="cmd", required=True)
72
+
73
+ run = sub.add_parser("run", help="run YAML scenarios")
74
+ run.add_argument("paths", nargs="+", help="scenario files, dirs, or globs")
75
+ run.add_argument("--bot", help="override bot for all scenarios")
76
+ run.add_argument("--env", help="path to a .env file")
77
+ run.add_argument("--no-color", action="store_true", help="disable colored output")
78
+
79
+ args = parser.parse_args(argv)
80
+
81
+ if args.cmd == "run":
82
+ try:
83
+ config = Settings.load(env_file=args.env)
84
+ except RuntimeError as exc:
85
+ print(f"config error: {exc}", file=sys.stderr)
86
+ return 2
87
+ try:
88
+ scenarios = load_scenarios(args.paths)
89
+ except ScenarioError as exc:
90
+ print(f"scenario error: {exc}", file=sys.stderr)
91
+ return 2
92
+ if args.bot:
93
+ for sc in scenarios:
94
+ sc.bot = args.bot
95
+ if not scenarios:
96
+ print("no scenarios found", file=sys.stderr)
97
+ return 2
98
+ use_color = not args.no_color and sys.stdout.isatty()
99
+ return asyncio.run(_run_all(scenarios, config, use_color))
100
+ return 2
101
+
102
+
103
+ if __name__ == "__main__":
104
+ sys.exit(main())
tgtest/client.py ADDED
@@ -0,0 +1,227 @@
1
+ """BotTester - the user-facing client for talking to a bot and asserting replies.
2
+
3
+ Wraps Telethon's `client.conversation()` context, which gives ordered,
4
+ timeout-aware access to a bot's replies (including edits and button clicks).
5
+ This is the single object both the YAML engine and Python/pytest tests drive.
6
+
7
+ Typical Python usage:
8
+
9
+ async with BotTester.create(config) as tester:
10
+ async with tester.conversation("@my_bot") as chat:
11
+ await chat.send("/start")
12
+ await chat.expect(contains="Welcome", buttons=["Settings"])
13
+ await chat.click("Settings")
14
+ await chat.expect_edit(contains="Settings menu")
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import asyncio
19
+ from contextlib import asynccontextmanager
20
+
21
+ from telethon import TelegramClient
22
+ from telethon.errors import TimeoutError as TelethonTimeout
23
+
24
+ from .config import Settings
25
+ from .matchers import Matcher, button_texts
26
+ from .proxy import ProxyConfig, parse_proxy
27
+
28
+ # Re-export so tests can `from tgtest import ReplyMatchError`.
29
+ ReplyMatchError = AssertionError
30
+
31
+
32
+ def _proxy_kwargs(proxy: ProxyConfig | None) -> dict:
33
+ """Translate a ProxyConfig into TelegramClient keyword arguments."""
34
+ if proxy is None:
35
+ return {}
36
+ if proxy.kind == "mtproxy":
37
+ # MTProxy needs a dedicated connection class; proxy is (host, port, secret).
38
+ from telethon import connection
39
+
40
+ return {
41
+ "connection": connection.ConnectionTcpMTProxyRandomizedIntermediate,
42
+ "proxy": (proxy.host, proxy.port, proxy.secret),
43
+ }
44
+ # python-socks tuple: (kind, host, port, rdns, username, password).
45
+ return {
46
+ "proxy": (
47
+ proxy.kind,
48
+ proxy.host,
49
+ proxy.port,
50
+ proxy.rdns,
51
+ proxy.username,
52
+ proxy.password,
53
+ )
54
+ }
55
+
56
+
57
+ def build_client(config: Settings) -> TelegramClient:
58
+ """Create a (not-yet-connected) TelegramClient, applying any proxy config.
59
+
60
+ Shared by BotTester (test runs) and login.py (first-time auth) so both honor
61
+ TG_PROXY identically.
62
+ """
63
+ proxy = parse_proxy(config.proxy)
64
+ return TelegramClient(
65
+ config.session, config.api_id, config.api_hash, **_proxy_kwargs(proxy)
66
+ )
67
+
68
+
69
+ class _Chat:
70
+ """A live conversation with one bot. Tracks the 'current' message so that
71
+ `click`/`expect_buttons`/`expect_edit` operate on the most recent reply."""
72
+
73
+ def __init__(self, conv, bot, default_timeout: float):
74
+ self._conv = conv
75
+ self._bot = bot
76
+ self._default_timeout = default_timeout
77
+ self.last = None # most recent Message we received
78
+
79
+ # --- sending --------------------------------------------------------
80
+ async def send(self, text: str):
81
+ """Send a plain text message to the bot."""
82
+ return await self._conv.send_message(text)
83
+
84
+ async def command(self, cmd: str):
85
+ """Send a bot command, prepending '/' if the caller omitted it."""
86
+ if not cmd.startswith("/"):
87
+ cmd = "/" + cmd
88
+ return await self._conv.send_message(cmd)
89
+
90
+ # --- receiving ------------------------------------------------------
91
+ async def get_reply(self, timeout: float | None = None):
92
+ """Wait for and return the next reply message from the bot."""
93
+ try:
94
+ self.last = await self._conv.get_response(
95
+ timeout=timeout if timeout is not None else self._default_timeout
96
+ )
97
+ except (asyncio.TimeoutError, TelethonTimeout):
98
+ wait = timeout or self._default_timeout
99
+ raise AssertionError(
100
+ f"timed out after {wait}s waiting for a reply"
101
+ ) from None
102
+ return self.last
103
+
104
+ async def expect(self, timeout: float | None = None, **spec):
105
+ """Wait for the next reply and assert it matches the given clauses.
106
+
107
+ Clauses are the same keys as a YAML `expect` block (equals, contains,
108
+ regex, buttons, ...). Returns the matched Message.
109
+ """
110
+ message = await self.get_reply(timeout=timeout)
111
+ self._assert(Matcher.from_spec(spec), message)
112
+ return message
113
+
114
+ async def expect_edit(self, timeout: float | None = None, **spec):
115
+ """Wait for the *current* message to be edited and assert on it.
116
+
117
+ Bots commonly edit a message in place after an inline-button click.
118
+ """
119
+ if self.last is None:
120
+ raise AssertionError("expect_edit called before any reply was received")
121
+ try:
122
+ self.last = await self._conv.get_edit(
123
+ self.last,
124
+ timeout=timeout if timeout is not None else self._default_timeout,
125
+ )
126
+ except (asyncio.TimeoutError, TelethonTimeout):
127
+ wait = timeout or self._default_timeout
128
+ raise AssertionError(
129
+ f"timed out after {wait}s waiting for an edit"
130
+ ) from None
131
+ self._assert(Matcher.from_spec(spec), self.last)
132
+ return self.last
133
+
134
+ async def expect_no_reply(self, within: float = 2.0):
135
+ """Assert the bot sends nothing within `within` seconds."""
136
+ try:
137
+ msg = await self._conv.get_response(timeout=within)
138
+ except (asyncio.TimeoutError, TelethonTimeout):
139
+ return # success: nothing arrived
140
+ raise AssertionError(
141
+ f"expected no reply within {within}s but got: {msg.text!r}"
142
+ )
143
+
144
+ def expect_buttons(self, *labels: str, exact: bool = False):
145
+ """Assert the current message exposes the given inline/reply buttons."""
146
+ if self.last is None:
147
+ raise AssertionError("expect_buttons called before any reply was received")
148
+ actual = button_texts(self.last)
149
+ if exact:
150
+ if actual != list(labels):
151
+ raise AssertionError(
152
+ f"buttons differ\n expected: {list(labels)}\n actual: {actual}"
153
+ )
154
+ else:
155
+ missing = [b for b in labels if b not in actual]
156
+ if missing:
157
+ raise AssertionError(
158
+ f"missing buttons {missing}\n actual buttons: {actual}"
159
+ )
160
+
161
+ # --- interacting ----------------------------------------------------
162
+ async def click(self, text: str | None = None, *, index: int | None = None,
163
+ data: str | None = None):
164
+ """Click an inline button on the current message.
165
+
166
+ Identify the button by visible `text`, by 0-based `index`, or by raw
167
+ callback `data`.
168
+ """
169
+ if self.last is None:
170
+ raise AssertionError("click called before any reply was received")
171
+ if text is not None:
172
+ return await self.last.click(text=text)
173
+ if data is not None:
174
+ payload = data.encode() if isinstance(data, str) else data
175
+ return await self.last.click(data=payload)
176
+ if index is not None:
177
+ return await self.last.click(index)
178
+ raise ValueError("click requires one of: text, index, data")
179
+
180
+ # --- internal -------------------------------------------------------
181
+ def _assert(self, matcher: Matcher, message):
182
+ reason = matcher.check(message)
183
+ if reason is not None:
184
+ raise AssertionError(f"{matcher.describe()} failed:\n {reason}")
185
+
186
+
187
+ class BotTester:
188
+ """Owns the Telethon client connection. Hands out per-bot `_Chat` objects."""
189
+
190
+ def __init__(self, client: TelegramClient, config: Settings):
191
+ self._client = client
192
+ self._config = config
193
+
194
+ @classmethod
195
+ @asynccontextmanager
196
+ async def create(cls, config: Settings):
197
+ """Connect (using an existing session) and yield a ready BotTester.
198
+
199
+ The session must already be authorized; run `python login.py` once to
200
+ create it. We deliberately do NOT prompt for a login code here so that
201
+ test runs never block on interactive input.
202
+ """
203
+ client = build_client(config)
204
+ await client.connect()
205
+ if not await client.is_user_authorized():
206
+ await client.disconnect()
207
+ raise RuntimeError(
208
+ f"Session {config.session!r} is not authorized. "
209
+ "Run `python login.py` once to log in."
210
+ )
211
+ try:
212
+ yield cls(client, config)
213
+ finally:
214
+ await client.disconnect()
215
+
216
+ @asynccontextmanager
217
+ async def conversation(self, bot: str | None = None, timeout: float | None = None):
218
+ """Open a conversation with `bot` (defaults to TG_DEFAULT_BOT)."""
219
+ target = bot or self._config.default_bot
220
+ if not target:
221
+ raise ValueError("no bot specified and TG_DEFAULT_BOT is not set")
222
+ entity = await self._client.get_entity(target)
223
+ conv_timeout = timeout if timeout is not None else self._config.timeout
224
+ async with self._client.conversation(
225
+ entity, timeout=conv_timeout, total_timeout=None
226
+ ) as conv:
227
+ yield _Chat(conv, entity, conv_timeout)
tgtest/config.py ADDED
@@ -0,0 +1,50 @@
1
+ """Application settings, loaded from the environment via pydantic-settings.
2
+
3
+ Mirrors the project template's `Settings(BaseSettings)` pattern. All tgtest
4
+ variables share the ``TG_`` prefix (e.g. ``TG_API_ID`` -> ``api_id``), so both
5
+ the CLI runner and the pytest plugin get identical, validated config.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from pydantic import ValidationError
10
+ from pydantic_settings import BaseSettings, SettingsConfigDict
11
+
12
+
13
+ class Settings(BaseSettings):
14
+ """tgtest settings loaded from environment / ``.env`` (prefix ``TG_``)."""
15
+
16
+ # Telegram user-account credentials (api_id/api_hash from my.telegram.org).
17
+ api_id: int
18
+ api_hash: str
19
+ # Telethon session file; created once by `python login.py`.
20
+ session: str = "tgtest.session"
21
+ # Phone of the test user account, used only for first-time login.
22
+ phone: str | None = None
23
+ # Bot used when a scenario / test does not name one explicitly.
24
+ default_bot: str | None = None
25
+ # Optional proxy URL, e.g. socks5://user:pass@host:1080 or mtproxy://SECRET@host:443.
26
+ proxy: str | None = None
27
+ # Default per-step reply timeout, in seconds.
28
+ timeout: float = 15.0
29
+ # Logging.
30
+ app_name: str = "tgtest"
31
+ log_level: str = "INFO"
32
+
33
+ model_config = SettingsConfigDict(
34
+ env_prefix="TG_",
35
+ env_file=".env",
36
+ env_file_encoding="utf-8",
37
+ extra="ignore",
38
+ )
39
+
40
+ @classmethod
41
+ def load(cls, env_file: str | None = None) -> "Settings":
42
+ """Load settings, raising a friendly error if credentials are missing."""
43
+ try:
44
+ return cls(_env_file=env_file) if env_file else cls()
45
+ except ValidationError as exc:
46
+ raise RuntimeError(
47
+ "Invalid tgtest config (see .env.example). "
48
+ "TG_API_ID and TG_API_HASH are required; get them from "
49
+ f"https://my.telegram.org.\n{exc}"
50
+ ) from exc
tgtest/engine.py ADDED
@@ -0,0 +1,75 @@
1
+ """Execute a parsed Scenario against a live BotTester conversation.
2
+
3
+ The engine maps each YAML step to a call on the `_Chat` helper. All assertion
4
+ failures and timeouts are wrapped in StepError with the offending step's index
5
+ and description, so the runner can pinpoint failures.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+
11
+ from .client import BotTester
12
+ from .matchers import Matcher
13
+ from .scenario import Scenario, Step
14
+ from .exceptions import StepError
15
+
16
+
17
+ async def _run_step(chat, step: Step):
18
+ action, value, opts = step.action, step.value, step.options
19
+ timeout = float(opts["timeout"]) if "timeout" in opts else None
20
+
21
+ if action == "send":
22
+ await chat.send(str(value))
23
+ elif action == "command":
24
+ await chat.command(str(value))
25
+ elif action == "sleep":
26
+ await asyncio.sleep(float(value))
27
+ elif action == "expect":
28
+ message = await chat.get_reply(timeout=timeout)
29
+ reason = Matcher.from_spec(value).check(message)
30
+ if reason:
31
+ raise AssertionError(reason)
32
+ elif action == "expect_edit":
33
+ await chat.expect_edit(timeout=timeout, **(_as_spec(value)))
34
+ elif action == "expect_no_reply":
35
+ within = float(value) if value is not None else float(opts.get("within", 2.0))
36
+ await chat.expect_no_reply(within=within)
37
+ elif action == "expect_buttons":
38
+ labels = value if isinstance(value, list) else [value]
39
+ chat.expect_buttons(*labels, exact=bool(opts.get("exact", False)))
40
+ elif action == "click":
41
+ await chat.click(
42
+ text=value if isinstance(value, str) else None,
43
+ index=opts.get("index"),
44
+ data=opts.get("data"),
45
+ )
46
+ else: # pragma: no cover - parser guarantees valid actions
47
+ raise StepError(f"unknown action {action!r}", step_index=step.index)
48
+
49
+
50
+ def _as_spec(value):
51
+ """expect_edit accepts the same shorthand as expect (string or dict)."""
52
+ if value is None:
53
+ return {}
54
+ if isinstance(value, str):
55
+ return {"equals": value}
56
+ return dict(value)
57
+
58
+
59
+ async def run_scenario(tester: BotTester, scenario: Scenario):
60
+ """Run every step of a scenario. Raises StepError on the first failure."""
61
+ async with tester.conversation(scenario.bot, timeout=scenario.timeout) as chat:
62
+ for step in scenario.steps:
63
+ try:
64
+ await _run_step(chat, step)
65
+ except AssertionError as exc:
66
+ raise StepError(
67
+ str(exc), step_index=step.index, step_desc=step.describe()
68
+ ) from exc
69
+ except StepError:
70
+ raise
71
+ except Exception as exc: # surface unexpected errors with step context
72
+ raise StepError(
73
+ f"{type(exc).__name__}: {exc}",
74
+ step_index=step.index, step_desc=step.describe(),
75
+ ) from exc
tgtest/exceptions.py ADDED
@@ -0,0 +1,23 @@
1
+ """Exception hierarchy for tgtest."""
2
+
3
+
4
+ class TgTestError(Exception):
5
+ """Base class for all tgtest errors."""
6
+
7
+
8
+ class ScenarioError(TgTestError):
9
+ """Raised when a YAML scenario is malformed or cannot be parsed."""
10
+
11
+
12
+ class StepError(TgTestError):
13
+ """Raised when a scenario step fails (assertion failed or timed out).
14
+
15
+ Carries the step index and a human-readable description so the runner can
16
+ report exactly which step in which scenario broke.
17
+ """
18
+
19
+ def __init__(self, message: str, *, step_index: int | None = None,
20
+ step_desc: str | None = None):
21
+ self.step_index = step_index
22
+ self.step_desc = step_desc
23
+ super().__init__(message)
tgtest/logger.py ADDED
@@ -0,0 +1,41 @@
1
+ """Logging setup, modeled on the project template's rotating-file logger."""
2
+ from __future__ import annotations
3
+
4
+ import logging
5
+ from logging.handlers import RotatingFileHandler
6
+ from pathlib import Path
7
+
8
+ from tgtest.config import Settings
9
+
10
+
11
+ def configure_logger(settings: Settings) -> logging.Logger:
12
+ """Configure and return the application logger.
13
+
14
+ Writes rotating log files under ``logs/`` and, at DEBUG level, also echoes
15
+ to the console so test runs can be traced live.
16
+ """
17
+ log_dir = Path("logs")
18
+ log_dir.mkdir(parents=True, exist_ok=True)
19
+
20
+ logger = logging.getLogger(settings.app_name)
21
+ logger.setLevel(settings.log_level.upper())
22
+ logger.propagate = False
23
+
24
+ formatter = logging.Formatter(
25
+ "%(asctime)s | %(levelname)s | %(name)s | %(message)s",
26
+ datefmt="%Y-%m-%d %H:%M:%S",
27
+ )
28
+
29
+ file_handler = RotatingFileHandler(
30
+ log_dir / "tgtest.log",
31
+ maxBytes=5 * 1024 * 1024,
32
+ backupCount=3,
33
+ encoding="utf-8",
34
+ )
35
+ file_handler.setFormatter(formatter)
36
+ file_handler.setLevel(settings.log_level.upper())
37
+
38
+ if not logger.handlers:
39
+ logger.addHandler(file_handler)
40
+
41
+ return logger
tgtest/matchers.py ADDED
@@ -0,0 +1,125 @@
1
+ """Text and button matchers shared by the engine and the Python helper API.
2
+
3
+ A matcher is built from a dict (the body of an `expect` step) and applied to a
4
+ Telethon Message. It returns None on success or a human-readable failure
5
+ reason string. Keeping failures as strings (rather than raising) lets the
6
+ caller assemble a single rich StepError with full context.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from dataclasses import dataclass, field
12
+
13
+ # Keys in an expect block that describe the message *text*.
14
+ _TEXT_KEYS = ("equals", "contains", "icontains", "regex", "iregex", "not_contains")
15
+
16
+
17
+ def _message_text(message) -> str:
18
+ """Best-effort textual content of a message (text, or media caption)."""
19
+ return (getattr(message, "text", None) or getattr(message, "message", None) or "")
20
+
21
+
22
+ def button_texts(message) -> list[str]:
23
+ """Flatten an inline/reply keyboard into a list of button label strings."""
24
+ labels: list[str] = []
25
+ buttons = getattr(message, "buttons", None)
26
+ if not buttons:
27
+ return labels
28
+ for row in buttons:
29
+ for btn in row:
30
+ text = getattr(btn, "text", None)
31
+ if text is not None:
32
+ labels.append(text)
33
+ return labels
34
+
35
+
36
+ @dataclass
37
+ class Matcher:
38
+ equals: str | None = None
39
+ contains: str | None = None
40
+ icontains: str | None = None
41
+ not_contains: str | None = None
42
+ regex: str | None = None
43
+ iregex: str | None = None
44
+ buttons: list[str] | None = None # buttons that must all be present
45
+ buttons_exact: list[str] | None = None # full keyboard must equal this set/order
46
+ has_buttons: bool | None = None # assert presence/absence of any keyboard
47
+ _raw: dict = field(default_factory=dict, repr=False)
48
+
49
+ @classmethod
50
+ def from_spec(cls, spec) -> "Matcher":
51
+ """Build a Matcher from a YAML `expect` value.
52
+
53
+ Accepts a plain string (shorthand for `equals`) or a dict of keys.
54
+ """
55
+ if spec is None:
56
+ return cls(_raw={})
57
+ if isinstance(spec, str):
58
+ return cls(equals=spec, _raw={"equals": spec})
59
+ if not isinstance(spec, dict):
60
+ raise ValueError(
61
+ f"expect must be a string or mapping, got {type(spec).__name__}"
62
+ )
63
+ known = {
64
+ "equals", "contains", "icontains", "not_contains", "regex", "iregex",
65
+ "buttons", "buttons_exact", "has_buttons",
66
+ }
67
+ unknown = set(spec) - known
68
+ if unknown:
69
+ raise ValueError(f"unknown expect keys: {', '.join(sorted(unknown))}")
70
+ return cls(
71
+ equals=spec.get("equals"),
72
+ contains=spec.get("contains"),
73
+ icontains=spec.get("icontains"),
74
+ not_contains=spec.get("not_contains"),
75
+ regex=spec.get("regex"),
76
+ iregex=spec.get("iregex"),
77
+ buttons=spec.get("buttons"),
78
+ buttons_exact=spec.get("buttons_exact"),
79
+ has_buttons=spec.get("has_buttons"),
80
+ _raw=dict(spec),
81
+ )
82
+
83
+ def check(self, message) -> str | None:
84
+ """Return None if the message satisfies every clause, else a reason."""
85
+ return self._check_text(_message_text(message)) or self._check_buttons(
86
+ button_texts(message)
87
+ )
88
+
89
+ def _check_text(self, text: str) -> str | None:
90
+ if self.equals is not None and text != self.equals:
91
+ return f"text != equals\n expected: {self.equals!r}\n actual: {text!r}"
92
+ if self.contains is not None and self.contains not in text:
93
+ return f"text does not contain {self.contains!r}\n actual: {text!r}"
94
+ if self.icontains is not None and self.icontains.lower() not in text.lower():
95
+ return f"text does not contain (ci) {self.icontains!r}\n actual: {text!r}"
96
+ if self.not_contains is not None and self.not_contains in text:
97
+ return (
98
+ f"text unexpectedly contains {self.not_contains!r}\n actual: {text!r}"
99
+ )
100
+ if self.regex is not None and not re.search(self.regex, text):
101
+ return f"text does not match regex {self.regex!r}\n actual: {text!r}"
102
+ if self.iregex is not None and not re.search(self.iregex, text, re.IGNORECASE):
103
+ return f"text does not match regex (ci) {self.iregex!r}\n actual: {text!r}"
104
+ return None
105
+
106
+ def _check_buttons(self, actual: list[str]) -> str | None:
107
+ if self.has_buttons is not None and bool(actual) != self.has_buttons:
108
+ return (
109
+ f"has_buttons expected {self.has_buttons}, "
110
+ f"got {bool(actual)} (buttons={actual})"
111
+ )
112
+ if self.buttons is not None:
113
+ missing = [b for b in self.buttons if b not in actual]
114
+ if missing:
115
+ return f"missing buttons {missing}\n actual buttons: {actual}"
116
+ if self.buttons_exact is not None and actual != list(self.buttons_exact):
117
+ return (
118
+ f"buttons differ\n expected: {list(self.buttons_exact)}\n"
119
+ f" actual: {actual}"
120
+ )
121
+ return None
122
+
123
+ def describe(self) -> str:
124
+ parts = [f"{k}={v!r}" for k, v in self._raw.items()]
125
+ return "expect(" + ", ".join(parts) + ")" if parts else "expect(any reply)"