mcbluetooth-esp32/tests/test_event_queue.py
Ryan Malloy 6398a5223a ESP32 Bluetooth test harness MCP server
UART-controlled ESP32 peripheral for automated E2E Bluetooth testing.
Dual-mode (Classic BT + BLE) via Bluedroid on original ESP32.

Firmware (ESP-IDF v5.x, 2511 lines C):
- NDJSON protocol over UART1 (115200 baud)
- System commands: ping, reset, get_info, get_status
- Classic BT: GAP, SPP, all 4 SSP pairing modes
- BLE: GATTS, advertising, GATT service/characteristic management
- 6 device personas: headset, speaker, keyboard, sensor, phone, bare
- Event reporter: thread-safe async event queue to host

Python MCP server (FastMCP, 1626 lines):
- Async serial client with command/response correlation
- Event queue with wait_for pattern matching
- Tools: connection, configure, classic, ble, persona, events
- MCP resources: esp32://status, esp32://events, esp32://personas

Tests: 74 unit tests passing, 5 integration test stubs (skip without hardware)
2026-02-02 15:12:28 -07:00

286 lines
8.9 KiB
Python

"""Unit tests for mcbluetooth_esp32.event_queue."""
from __future__ import annotations
import asyncio
import pytest
from mcbluetooth_esp32.event_queue import EventQueue
from mcbluetooth_esp32.protocol import Event, MsgType
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_event(name: str, data: dict | None = None, ts: int | None = None) -> Event:
return Event(type=MsgType.EVENT, event=name, data=data or {}, ts=ts)
# ---------------------------------------------------------------------------
# Tests: push / get_events basics
# ---------------------------------------------------------------------------
class TestPushAndGetEvents:
"""push() and get_events() without any async waiting."""
async def test_push_and_retrieve(self):
q = EventQueue()
q.push(_make_event("boot"))
q.push(_make_event("connect"))
events = q.get_events()
assert len(events) == 2
assert events[0].event == "boot"
assert events[1].event == "connect"
async def test_get_events_filtered_by_name(self):
q = EventQueue()
q.push(_make_event("boot"))
q.push(_make_event("connect"))
q.push(_make_event("boot"))
events = q.get_events(event_name="boot")
assert len(events) == 2
assert all(e.event == "boot" for e in events)
async def test_get_events_with_limit(self):
q = EventQueue()
for i in range(10):
q.push(_make_event("tick", data={"i": i}))
events = q.get_events(limit=3)
assert len(events) == 3
# Should be the *most recent* three
assert events[0].data["i"] == 7
assert events[1].data["i"] == 8
assert events[2].data["i"] == 9
async def test_get_events_with_since_ts(self):
q = EventQueue()
q.push(_make_event("a", ts=100))
q.push(_make_event("b", ts=200))
q.push(_make_event("c", ts=300))
events = q.get_events(since_ts=200)
assert len(events) == 2
assert events[0].event == "b"
assert events[1].event == "c"
async def test_get_events_with_none_ts_excluded_by_since_ts(self):
q = EventQueue()
q.push(_make_event("a", ts=None))
q.push(_make_event("b", ts=500))
events = q.get_events(since_ts=100)
assert len(events) == 1
assert events[0].event == "b"
async def test_get_events_empty_queue(self):
q = EventQueue()
assert q.get_events() == []
async def test_len(self):
q = EventQueue()
assert len(q) == 0
q.push(_make_event("a"))
assert len(q) == 1
q.push(_make_event("b"))
assert len(q) == 2
# ---------------------------------------------------------------------------
# Tests: wait_for
# ---------------------------------------------------------------------------
class TestWaitFor:
"""Async waiting for specific events."""
async def test_wait_for_resolves_on_push(self):
q = EventQueue()
async def _push_later():
await asyncio.sleep(0.05)
q.push(_make_event("pair_complete", data={"addr": "AA:BB"}))
task = asyncio.create_task(_push_later())
event = await q.wait_for(event_name="pair_complete", timeout=2.0)
await task
assert event.event == "pair_complete"
assert event.data["addr"] == "AA:BB"
async def test_wait_for_timeout(self):
q = EventQueue()
with pytest.raises(asyncio.TimeoutError):
await q.wait_for(event_name="never_arrives", timeout=0.1)
async def test_wait_for_finds_existing_event(self):
"""If a matching event is already in the history, return immediately."""
q = EventQueue()
q.push(_make_event("boot", data={"v": "1.0"}))
# Should return instantly without waiting
event = await q.wait_for(event_name="boot", timeout=0.5)
assert event.event == "boot"
assert event.data["v"] == "1.0"
async def test_wait_for_returns_most_recent_existing(self):
"""When scanning history, the most recent match is returned."""
q = EventQueue()
q.push(_make_event("boot", data={"v": "old"}))
q.push(_make_event("boot", data={"v": "new"}))
event = await q.wait_for(event_name="boot", timeout=0.5)
assert event.data["v"] == "new"
async def test_wait_for_with_custom_match(self):
q = EventQueue()
async def _push_later():
await asyncio.sleep(0.05)
q.push(_make_event("pair_complete", data={"success": False}))
q.push(_make_event("pair_complete", data={"success": True}))
task = asyncio.create_task(_push_later())
event = await q.wait_for(
event_name="pair_complete",
match=lambda e: e.data.get("success") is True,
timeout=2.0,
)
await task
assert event.data["success"] is True
async def test_wait_for_requires_at_least_one_filter(self):
q = EventQueue()
with pytest.raises(ValueError, match="at least one"):
await q.wait_for(timeout=1.0)
async def test_wait_for_with_match_only(self):
"""Using only a custom match function (no event_name)."""
q = EventQueue()
async def _push():
await asyncio.sleep(0.05)
q.push(_make_event("x", data={"val": 42}))
task = asyncio.create_task(_push())
event = await q.wait_for(
match=lambda e: e.data.get("val") == 42,
timeout=2.0,
)
await task
assert event.data["val"] == 42
# ---------------------------------------------------------------------------
# Tests: clear
# ---------------------------------------------------------------------------
class TestClear:
async def test_clear_removes_all_events(self):
q = EventQueue()
q.push(_make_event("a"))
q.push(_make_event("b"))
assert len(q) == 2
q.clear()
assert len(q) == 0
assert q.get_events() == []
async def test_clear_does_not_cancel_waiters(self):
"""Waiters registered before clear() should still fire on new events."""
q = EventQueue()
async def _push_after_clear():
await asyncio.sleep(0.05)
q.clear()
await asyncio.sleep(0.05)
q.push(_make_event("target"))
task = asyncio.create_task(_push_after_clear())
event = await q.wait_for(event_name="target", timeout=2.0)
await task
assert event.event == "target"
# ---------------------------------------------------------------------------
# Tests: max_events boundary
# ---------------------------------------------------------------------------
class TestMaxEvents:
async def test_max_events_trims_oldest(self):
q = EventQueue(max_events=10)
for i in range(15):
q.push(_make_event("tick", data={"i": i}))
assert len(q) == 10
events = q.get_events()
# Oldest five (0..4) should have been trimmed
assert events[0].data["i"] == 5
assert events[-1].data["i"] == 14
async def test_max_events_exact_boundary(self):
q = EventQueue(max_events=1000)
for i in range(1001):
q.push(_make_event("x", data={"i": i}))
assert len(q) == 1000
events = q.get_events(limit=1000)
assert events[0].data["i"] == 1
assert events[-1].data["i"] == 1000
# ---------------------------------------------------------------------------
# Tests: multiple waiters
# ---------------------------------------------------------------------------
class TestMultipleWaiters:
async def test_different_event_waiters(self):
"""Two waiters for different events should each get their own match."""
q = EventQueue()
async def _push_events():
await asyncio.sleep(0.05)
q.push(_make_event("alpha", data={"n": 1}))
q.push(_make_event("beta", data={"n": 2}))
task = asyncio.create_task(_push_events())
evt_a, evt_b = await asyncio.gather(
q.wait_for(event_name="alpha", timeout=2.0),
q.wait_for(event_name="beta", timeout=2.0),
)
await task
assert evt_a.event == "alpha"
assert evt_a.data["n"] == 1
assert evt_b.event == "beta"
assert evt_b.data["n"] == 2
async def test_same_event_wakes_all_matching_waiters(self):
"""If multiple waiters match the same event, they all get resolved."""
q = EventQueue()
async def _push():
await asyncio.sleep(0.05)
q.push(_make_event("boom"))
task = asyncio.create_task(_push())
e1, e2 = await asyncio.gather(
q.wait_for(event_name="boom", timeout=2.0),
q.wait_for(event_name="boom", timeout=2.0),
)
await task
assert e1.event == "boom"
assert e2.event == "boom"