# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence. """The latency run: what it types, what it restores, and how it judges.""" from __future__ import annotations import json from typing import Any import httpx import pytest from signal_bot.latency import ( LatencyError, Report, Sample, measure, percentile, ) from tests.conftest import mock_client BASE = "http://bot" # The first sample, the row read, and the row overridden: the second sample # is the first one made after the cooldown is off. SETUP_CALLS = 3 def _served(own_ms: int, **overrides: int | str) -> dict[str, Any]: base: dict[str, Any] = { "command": "help", "outcome": "ok", "queue_ms": 0, "dispatch_ms": 1, "handler_ms": own_ms + 40, "send_ms": 40, "upstream_ms": 0, "own_ms": own_ms + 1, } return {**base, **overrides} class _Bot: """Enough of the bot's HTTP face for the run: selftest and the switchboard.""" def __init__(self, own_ms: list[int], *, listed: bool = True) -> None: self.own_ms = own_ms self.listed = listed self.requests: list[tuple[str, str, Any]] = [] self.setting: dict[str, Any] = { "enabled": True, "disabled_until": None, "cooldown_seconds": 30.0, } def __call__(self, request: httpx.Request) -> httpx.Response: body = json.loads(request.content) if request.content else None self.requests.append((request.method, request.url.path, body)) if request.url.path == "/_selftest": served = _served(self.own_ms.pop(0)) return httpx.Response( 200, json={"status": "delivered", "group": "g", "text": "!pomoc", "served": served} ) if request.url.path == "/_commands": # Another command first, so the lookup has to walk past it. rows = [{"name": "cat", **self.setting}] if self.listed: rows.append({"name": "help", **self.setting}) return httpx.Response(200, json={"commands": rows}) assert request.url.path == "/_commands/help" self.setting = body return httpx.Response(200, json={"name": "help", **body}) async def test_a_run_types_the_command_with_the_cooldown_off_and_puts_it_back() -> None: bot = _Bot([5, 7, 6]) report = await measure(mock_client(bot), BASE, "testbot", "!pomoc", times=3) assert report.command == "help" assert [s["own_ms"] for s in report.samples] == [6, 8, 7] assert report.within_budget methods = [(m, p) for m, p, _ in bot.requests] assert methods == [ ("POST", "/_selftest"), ("GET", "/_commands"), ("PUT", "/_commands/help"), ("POST", "/_selftest"), ("POST", "/_selftest"), ("PUT", "/_commands/help"), ] # Zero for the run, then exactly the row that was there before. assert bot.requests[2][2]["cooldown_seconds"] == 0 assert bot.setting == {"enabled": True, "disabled_until": None, "cooldown_seconds": 30.0} async def test_the_row_is_put_back_even_when_a_later_reply_fails() -> None: class _Breaks(_Bot): def __call__(self, request: httpx.Request) -> httpx.Response: if request.url.path == "/_selftest" and len(self.requests) >= SETUP_CALLS: self.requests.append((request.method, request.url.path, None)) return httpx.Response(503, json={"status": "connecting", "reason": "down"}) return super().__call__(request) bot = _Breaks([5, 7, 6]) with pytest.raises(LatencyError, match="down"): await measure(mock_client(bot), BASE, "testbot", "!pomoc", times=3) assert bot.setting["cooldown_seconds"] == 30.0 async def test_a_refused_group_is_reported_with_the_bots_reason() -> None: client = mock_client( lambda _: httpx.Response( 403, json={"status": "refused", "reason": "not in SELFTEST_GROUPS"} ) ) with pytest.raises(LatencyError, match="not in SELFTEST_GROUPS"): await measure(client, BASE, "prod", "!pomoc", times=2) async def test_text_that_is_not_a_command_is_refused_rather_than_timed() -> None: client = mock_client( lambda _: httpx.Response(200, json={"status": "delivered", "group": "g", "text": "hej"}) ) with pytest.raises(LatencyError, match="not a command"): await measure(client, BASE, "testbot", "hej", times=2) async def test_a_command_the_bot_ran_but_does_not_list_is_an_error() -> None: bot = _Bot([5], listed=False) with pytest.raises(LatencyError, match="does not list"): await measure(mock_client(bot), BASE, "testbot", "!pomoc", times=1) def test_percentiles_are_nearest_rank() -> None: values = list(range(1, 21)) assert percentile(values, 50) == 10 assert percentile(values, 95) == 19 assert percentile([42], 95) == 42 def test_the_report_judges_the_p95_of_own_time_and_renders_every_column() -> None: samples = tuple( Sample(served=_served(own_ms), round_trip_ms=own_ms + 300) for own_ms in [3, 4, 5, 6, 7, 8, 9, 10, 11, 250] ) report = Report(command="help", samples=samples, budget_ms=100) assert not report.within_budget text = report.render() assert text.startswith("help: 10 replies") for column in ("own_ms", "queue_ms", "dispatch_ms", "handler_ms", "send_ms", "round_trip_ms"): assert column in text assert "own_ms p95 OVER the 100 ms budget" in text assert report.at("round_trip_ms", 50) == 307 assert Report(command="help", samples=samples[:-1]).within_budget assert "within the 100 ms budget" in Report(command="help", samples=samples[:-1]).render()