Files
MeshTerm/tests/test_event_hub.py
T
Jean-Pierre MartineauandClaude Opus 5 92d31a3191 Every file is spelled the way the formatter spells it
`ruff format .` across the repo: 153 files, no behaviour change. This is the
second gate CONTRIBUTING.md promises, and until now it reported 154 files needing
reformat — so a first contributor ran the command the guide told them to run and
could not tell their own diff from the background.

Landed on its own, with nothing else in it, because a whole-repo reformat is only
safe to skip past in blame if it is provably nothing but layout. It was checked
that way rather than assumed: every changed file was formatted in memory and
compared by full `ast.dump()`, and 152 of 153 come out structurally identical
(the one exception was a docstring opening on a quote character, fixed in the
commit before this one). The string and comment token streams were diffed
separately, so a quote flip or an edited comment could not hide inside a
re-layout — five quote-style normalisations and sixteen implicit-concatenation
joins, all value-identical, and one commented-out line gaining the space after
its `#`. `meshterm specimen` is byte-identical on both platforms, and the suite
is 1888 green, gallery gate and `test_theme16` included.

What actually changed, in order of how much of the diff it is: wrapped calls and
signatures rejoin onto one line where they fit inside the hundred columns; the
magic trailing comma expands what it is there to expand; complex slices gain
their spaces (`[len(noise) :]`); a few redundant parens go. The tables that are
laid out to be read were fenced in the previous commit and are untouched here.

The aligned trailing-comment column is gone from about ninety lanes that were not
fenced — including the `heat.*` ladder in `MESH_THEME`, which cannot be fenced
where it sits. Every comment stays on the entry it describes, so nothing there
reads wrong; it just no longer lines up.

Add this commit to `.git-blame-ignore-revs`, which follows immediately.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 01:07:19 -04:00

195 lines
6.1 KiB
Python

"""Tests for the always-on event hub: fan-out, filtering, streaming, and lifecycle.
All run against the :class:`MockDevice` simulator; no hardware required.
"""
from __future__ import annotations
import asyncio
import logging
from meshterm.core.connection import (
_MOCK_MONITOR_INTERVAL_S as _MOCK_INTERVAL,
)
from meshterm.core.connection import MockDevice
from meshterm.core.events import EventKind, MeshEvent
from meshterm.core.models import Observation
from meshterm.services.event_hub import EventHub
class _StubContext:
"""Minimal stand-in for :class:`~meshterm.context.AppContext` for hub tests."""
def __init__(self, device: MockDevice) -> None:
self._device = device
self.log = logging.getLogger("test.event_hub")
async def device(self) -> MockDevice:
await self._device.connect()
return self._device
def _obs_event(node: str) -> MeshEvent:
"""Build an observation event for a node id (test helper)."""
return MeshEvent.observation_event(Observation(node=node))
def test_publish_fans_out_to_all_subscribers() -> None:
"""Every subscriber receives each published event."""
hub = EventHub(_StubContext(MockDevice()))
a: list[MeshEvent] = []
b: list[MeshEvent] = []
hub.subscribe(a.append)
hub.subscribe(b.append)
event = _obs_event("a1")
hub.publish(event)
assert a == [event]
assert b == [event]
def test_kind_filter_only_delivers_requested_kinds() -> None:
"""A subscriber filtered to a kind only sees that kind; an unfiltered one sees all."""
hub = EventHub(_StubContext(MockDevice()))
observations: list[MeshEvent] = []
everything: list[MeshEvent] = []
hub.subscribe(observations.append, EventKind.OBSERVATION)
hub.subscribe(everything.append) # no kinds = all
obs_event = _obs_event("a1")
other = MeshEvent(kind=EventKind.OBSERVATION, payload=None) # still an OBSERVATION kind
hub.publish(obs_event)
hub.publish(other)
assert observations == [obs_event, other] # both are OBSERVATION kind
assert everything == [obs_event, other]
def test_unsubscribe_stops_delivery() -> None:
"""A removed subscriber receives nothing further, and unsubscribe is idempotent."""
hub = EventHub(_StubContext(MockDevice()))
seen: list[MeshEvent] = []
unsubscribe = hub.subscribe(seen.append)
hub.publish(_obs_event("a1"))
unsubscribe()
hub.publish(_obs_event("b2"))
unsubscribe() # second call is a harmless no-op
assert len(seen) == 1
def test_failing_subscriber_does_not_break_others() -> None:
"""A subscriber that raises is skipped; the others still receive the event."""
hub = EventHub(_StubContext(MockDevice()))
good: list[MeshEvent] = []
def boom(_event: MeshEvent) -> None:
raise RuntimeError("subscriber blew up")
hub.subscribe(boom)
hub.subscribe(good.append)
event = _obs_event("a1")
hub.publish(event) # must not raise
assert good == [event]
async def test_async_handler_is_scheduled() -> None:
"""A handler returning a coroutine is run as a task rather than awaited inline."""
hub = EventHub(_StubContext(MockDevice()))
ran = asyncio.Event()
async def handler(_event: MeshEvent) -> None:
ran.set()
hub.subscribe(handler)
hub.publish(_obs_event("a1"))
assert not ran.is_set() # not awaited inline
await asyncio.wait_for(ran.wait(), timeout=1.0) # but scheduled and runs on the loop
async def test_stream_yields_matching_events_then_unsubscribes() -> None:
"""stream() yields published events and drops its subscription when closed."""
hub = EventHub(_StubContext(MockDevice()))
stream = hub.stream(EventKind.OBSERVATION)
event = _obs_event("a1")
hub.publish(event)
received = await asyncio.wait_for(stream.__anext__(), timeout=1.0)
assert received is event
await stream.aclose() # closing removes the underlying subscription
assert hub._subs == []
async def test_message_events_delivered_from_device() -> None:
"""The device's inbound messages reach a MESSAGE subscriber as message events."""
device = MockDevice()
hub = EventHub(_StubContext(device))
messages: list[MeshEvent] = []
hub.subscribe(messages.append, EventKind.MESSAGE)
await hub.start() # the simulator's first burst carries one message synchronously
assert messages
assert all(e.kind is EventKind.MESSAGE and e.message is not None for e in messages)
assert messages[0].message.text
await hub.stop()
await device.disconnect()
async def test_wait_for_returns_matching_event() -> None:
"""wait_for resolves with the next event of the requested kind."""
device = MockDevice()
hub = EventHub(_StubContext(device))
await hub.start()
event = await hub.wait_for(EventKind.OBSERVATION, timeout=1.0)
assert event is not None
assert event.observation is not None
await hub.stop()
await device.disconnect()
async def test_wait_for_honors_predicate_and_times_out() -> None:
"""wait_for returns None when no event satisfies the predicate before the timeout."""
device = MockDevice()
hub = EventHub(_StubContext(device))
await hub.start()
event = await hub.wait_for(EventKind.OBSERVATION, predicate=lambda _e: False, timeout=0.2)
assert event is None
await hub.stop()
await device.disconnect()
async def test_start_pumps_device_observations_then_stops() -> None:
"""Starting subscribes to the device; observations arrive as events until stopped."""
device = MockDevice()
hub = EventHub(_StubContext(device))
events: list[MeshEvent] = []
hub.subscribe(events.append, EventKind.OBSERVATION)
assert not hub.active
await hub.start()
assert hub.active
await asyncio.sleep(_MOCK_INTERVAL * 3)
assert events # the simulator's adverts flowed through as observation events
assert all(e.kind is EventKind.OBSERVATION for e in events)
assert all(e.observation is not None for e in events)
await hub.stop()
assert not hub.active
frozen = len(events)
await asyncio.sleep(_MOCK_INTERVAL * 3)
assert len(events) == frozen # nothing more after stopping
await device.disconnect()