`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>
195 lines
6.1 KiB
Python
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()
|