mirror of
https://github.com/samjage/metro-warden.git
synced 2026-06-06 02:50:42 +00:00
Initial commit: Metro Warden TUI network operations center
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Metro Warden test suite."""
|
||||
@@ -0,0 +1,273 @@
|
||||
"""
|
||||
Tests for core.bus.EventBus
|
||||
|
||||
Covers:
|
||||
- Basic subscribe / publish / unsubscribe
|
||||
- Wildcard topic matching (*, **)
|
||||
- Multiple subscribers on the same topic
|
||||
- Async handler dispatch
|
||||
- Unsubscribe removes handler correctly
|
||||
- History recording and retrieval
|
||||
- publish_sync fire-and-forget
|
||||
- Error in one handler does not prevent others
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any, List
|
||||
|
||||
import pytest
|
||||
|
||||
from core.bus import EventBus
|
||||
|
||||
|
||||
# ── Fixtures ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def bus() -> EventBus:
|
||||
return EventBus()
|
||||
|
||||
|
||||
# ── Helper ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def make_collector() -> tuple[list, Any]:
|
||||
"""Return (received_list, async_handler)."""
|
||||
received: List[tuple[str, Any]] = []
|
||||
|
||||
async def handler(topic: str, data: Any) -> None:
|
||||
received.append((topic, data))
|
||||
|
||||
return received, handler
|
||||
|
||||
|
||||
# ── Basic subscribe / publish ─────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_basic_publish_subscribe(bus: EventBus) -> None:
|
||||
received, handler = make_collector()
|
||||
bus.subscribe("test.topic", handler)
|
||||
count = await bus.publish("test.topic", {"key": "value"})
|
||||
assert count == 1
|
||||
assert len(received) == 1
|
||||
assert received[0] == ("test.topic", {"key": "value"})
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_subscribers_returns_zero(bus: EventBus) -> None:
|
||||
count = await bus.publish("orphan.topic", "data")
|
||||
assert count == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_multiple_subscribers_same_topic(bus: EventBus) -> None:
|
||||
received_a, handler_a = make_collector()
|
||||
received_b, handler_b = make_collector()
|
||||
bus.subscribe("multi.topic", handler_a)
|
||||
bus.subscribe("multi.topic", handler_b)
|
||||
count = await bus.publish("multi.topic", 42)
|
||||
assert count == 2
|
||||
assert len(received_a) == 1
|
||||
assert len(received_b) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_publish_to_different_topic_not_received(bus: EventBus) -> None:
|
||||
received, handler = make_collector()
|
||||
bus.subscribe("topic.a", handler)
|
||||
await bus.publish("topic.b", "ignored")
|
||||
assert len(received) == 0
|
||||
|
||||
|
||||
# ── Unsubscribe ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unsubscribe_stops_delivery(bus: EventBus) -> None:
|
||||
received, handler = make_collector()
|
||||
sub_id = bus.subscribe("unsub.test", handler)
|
||||
await bus.publish("unsub.test", "first")
|
||||
assert len(received) == 1
|
||||
bus.unsubscribe(sub_id)
|
||||
await bus.publish("unsub.test", "second")
|
||||
assert len(received) == 1 # no new message
|
||||
|
||||
|
||||
def test_unsubscribe_unknown_id_returns_false(bus: EventBus) -> None:
|
||||
assert bus.unsubscribe("not-a-real-id") is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unsubscribe_all_by_handler(bus: EventBus) -> None:
|
||||
received, handler = make_collector()
|
||||
bus.subscribe("topic.x", handler)
|
||||
bus.subscribe("topic.y", handler)
|
||||
removed = bus.unsubscribe_all(handler)
|
||||
assert removed == 2
|
||||
await bus.publish("topic.x", None)
|
||||
await bus.publish("topic.y", None)
|
||||
assert len(received) == 0
|
||||
|
||||
|
||||
# ── Wildcard matching ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wildcard_single_star(bus: EventBus) -> None:
|
||||
received, handler = make_collector()
|
||||
bus.subscribe("network.*", handler)
|
||||
|
||||
await bus.publish("network.interfaces", {"eth0": "up"})
|
||||
await bus.publish("network.stats", {})
|
||||
await bus.publish("system.cpu", {}) # should NOT match
|
||||
|
||||
assert len(received) == 2
|
||||
topics = [r[0] for r in received]
|
||||
assert "network.interfaces" in topics
|
||||
assert "network.stats" in topics
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wildcard_double_star(bus: EventBus) -> None:
|
||||
received, handler = make_collector()
|
||||
bus.subscribe("network.**", handler)
|
||||
|
||||
await bus.publish("network.interfaces", {})
|
||||
await bus.publish("network.interfaces.eth0", {})
|
||||
await bus.publish("system.cpu", {}) # should NOT match
|
||||
|
||||
assert len(received) == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wildcard_bare_star_matches_any_single_segment(bus: EventBus) -> None:
|
||||
received, handler = make_collector()
|
||||
bus.subscribe("*", handler)
|
||||
|
||||
await bus.publish("anything", 1)
|
||||
await bus.publish("other", 2)
|
||||
await bus.publish("multi.segment", 3) # NOT matched by bare "*"
|
||||
|
||||
assert len(received) == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exact_and_wildcard_subscriber_both_notified(bus: EventBus) -> None:
|
||||
exact, handler_exact = make_collector()
|
||||
wild, handler_wild = make_collector()
|
||||
bus.subscribe("net.iface", handler_exact)
|
||||
bus.subscribe("net.*", handler_wild)
|
||||
|
||||
await bus.publish("net.iface", "data")
|
||||
|
||||
assert len(exact) == 1
|
||||
assert len(wild) == 1
|
||||
|
||||
|
||||
# ── Async handler dispatch ────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_handler_is_awaited(bus: EventBus) -> None:
|
||||
result = []
|
||||
|
||||
async def slow_handler(topic: str, data: Any) -> None:
|
||||
await asyncio.sleep(0.01)
|
||||
result.append(data)
|
||||
|
||||
bus.subscribe("async.test", slow_handler)
|
||||
await bus.publish("async.test", "payload")
|
||||
assert result == ["payload"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_handler_works(bus: EventBus) -> None:
|
||||
result = []
|
||||
|
||||
def sync_handler(topic: str, data: Any) -> None:
|
||||
result.append(data)
|
||||
|
||||
bus.subscribe("sync.test", sync_handler)
|
||||
await bus.publish("sync.test", "sync-payload")
|
||||
assert result == ["sync-payload"]
|
||||
|
||||
|
||||
# ── Error isolation ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handler_error_does_not_block_others(bus: EventBus) -> None:
|
||||
good_received = []
|
||||
|
||||
async def bad_handler(topic: str, data: Any) -> None:
|
||||
raise RuntimeError("intentional test error")
|
||||
|
||||
async def good_handler(topic: str, data: Any) -> None:
|
||||
good_received.append(data)
|
||||
|
||||
bus.subscribe("error.topic", bad_handler)
|
||||
bus.subscribe("error.topic", good_handler)
|
||||
|
||||
# Should not raise
|
||||
count = await bus.publish("error.topic", "test")
|
||||
assert count == 2
|
||||
assert good_received == ["test"]
|
||||
|
||||
|
||||
# ── History ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_history_records_events(bus: EventBus) -> None:
|
||||
await bus.publish("hist.a", 1)
|
||||
await bus.publish("hist.b", 2)
|
||||
await bus.publish("hist.a", 3)
|
||||
|
||||
history = bus.get_history()
|
||||
assert len(history) == 3
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_history_filter_by_topic(bus: EventBus) -> None:
|
||||
await bus.publish("hist.a", 1)
|
||||
await bus.publish("hist.b", 2)
|
||||
await bus.publish("hist.a", 3)
|
||||
|
||||
filtered = bus.get_history(topic_filter="hist.a")
|
||||
assert len(filtered) == 2
|
||||
assert all(e.topic == "hist.a" for e in filtered)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_history_limit(bus: EventBus) -> None:
|
||||
for i in range(20):
|
||||
await bus.publish("flood.topic", i)
|
||||
|
||||
history = bus.get_history(limit=5)
|
||||
assert len(history) == 5
|
||||
# Most recent 5
|
||||
assert [e.data for e in history] == list(range(15, 20))
|
||||
|
||||
|
||||
# ── Introspection ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_subscription_count(bus: EventBus) -> None:
|
||||
_, h1 = make_collector()
|
||||
_, h2 = make_collector()
|
||||
assert bus.subscription_count == 0
|
||||
bus.subscribe("a", h1)
|
||||
bus.subscribe("b", h2)
|
||||
assert bus.subscription_count == 2
|
||||
|
||||
|
||||
def test_patterns(bus: EventBus) -> None:
|
||||
_, h = make_collector()
|
||||
bus.subscribe("network.*", h)
|
||||
bus.subscribe("system.**", h)
|
||||
patterns = bus.patterns
|
||||
assert "network.*" in patterns
|
||||
assert "system.**" in patterns
|
||||
@@ -0,0 +1,269 @@
|
||||
"""
|
||||
Tests for core.state.StateStore
|
||||
|
||||
Covers:
|
||||
- get / set / delete basic operations
|
||||
- default value on missing key
|
||||
- watcher callbacks on change
|
||||
- watcher not called when value unchanged
|
||||
- watcher wildcard patterns
|
||||
- unwatch removes callback
|
||||
- update() for bulk set
|
||||
- snapshot() returns deep copy
|
||||
- bus integration (state publishes to bus)
|
||||
- keys() and __contains__
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, List
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from core.state import StateStore
|
||||
|
||||
|
||||
# ── Fixtures ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def store() -> StateStore:
|
||||
return StateStore()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def store_with_bus():
|
||||
bus = MagicMock()
|
||||
bus.publish_sync = MagicMock()
|
||||
s = StateStore(bus=bus)
|
||||
return s, bus
|
||||
|
||||
|
||||
# ── Basic get / set ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_set_and_get(store: StateStore) -> None:
|
||||
store.set("key.a", 42)
|
||||
assert store.get("key.a") == 42
|
||||
|
||||
|
||||
def test_get_missing_key_returns_default(store: StateStore) -> None:
|
||||
assert store.get("does.not.exist") is None
|
||||
assert store.get("does.not.exist", "fallback") == "fallback"
|
||||
|
||||
|
||||
def test_set_overwrites_value(store: StateStore) -> None:
|
||||
store.set("x", 1)
|
||||
store.set("x", 2)
|
||||
assert store.get("x") == 2
|
||||
|
||||
|
||||
def test_get_returns_deep_copy(store: StateStore) -> None:
|
||||
store.set("obj", {"a": [1, 2, 3]})
|
||||
got = store.get("obj")
|
||||
got["a"].append(99)
|
||||
assert store.get("obj") == {"a": [1, 2, 3]} # original unchanged
|
||||
|
||||
|
||||
# ── Delete ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_delete_existing_key(store: StateStore) -> None:
|
||||
store.set("del.me", "value")
|
||||
assert store.delete("del.me") is True
|
||||
assert store.get("del.me") is None
|
||||
|
||||
|
||||
def test_delete_missing_key_returns_false(store: StateStore) -> None:
|
||||
assert store.delete("ghost.key") is False
|
||||
|
||||
|
||||
# ── Update ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_update_sets_multiple_keys(store: StateStore) -> None:
|
||||
store.update({"a": 1, "b": 2, "c": 3})
|
||||
assert store.get("a") == 1
|
||||
assert store.get("b") == 2
|
||||
assert store.get("c") == 3
|
||||
|
||||
|
||||
# ── Watchers ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_watcher_called_on_change(store: StateStore) -> None:
|
||||
events: List[tuple] = []
|
||||
|
||||
def cb(key: str, old: Any, new: Any) -> None:
|
||||
events.append((key, old, new))
|
||||
|
||||
store.watch("watched.key", cb)
|
||||
store.set("watched.key", "hello")
|
||||
assert len(events) == 1
|
||||
assert events[0] == ("watched.key", None, "hello")
|
||||
|
||||
|
||||
def test_watcher_called_with_old_and_new(store: StateStore) -> None:
|
||||
events: List[tuple] = []
|
||||
|
||||
store.set("my.key", "initial")
|
||||
|
||||
def cb(key: str, old: Any, new: Any) -> None:
|
||||
events.append((old, new))
|
||||
|
||||
store.watch("my.key", cb)
|
||||
store.set("my.key", "updated")
|
||||
|
||||
assert events == [("initial", "updated")]
|
||||
|
||||
|
||||
def test_watcher_not_called_when_value_unchanged(store: StateStore) -> None:
|
||||
events: List[tuple] = []
|
||||
|
||||
def cb(key, old, new):
|
||||
events.append((old, new))
|
||||
|
||||
store.set("stable", 100)
|
||||
store.watch("stable", cb)
|
||||
store.set("stable", 100) # same value
|
||||
assert len(events) == 0
|
||||
|
||||
|
||||
def test_multiple_watchers_on_same_key(store: StateStore) -> None:
|
||||
results_a: list = []
|
||||
results_b: list = []
|
||||
|
||||
store.watch("key", lambda k, o, n: results_a.append(n))
|
||||
store.watch("key", lambda k, o, n: results_b.append(n))
|
||||
store.set("key", "fire")
|
||||
|
||||
assert results_a == ["fire"]
|
||||
assert results_b == ["fire"]
|
||||
|
||||
|
||||
def test_watcher_on_delete(store: StateStore) -> None:
|
||||
events: list = []
|
||||
store.set("dying.key", "alive")
|
||||
store.watch("dying.key", lambda k, o, n: events.append((o, n)))
|
||||
store.delete("dying.key")
|
||||
assert events == [("alive", None)]
|
||||
|
||||
|
||||
# ── Wildcard watchers ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_wildcard_watcher_fires_for_matching_keys(store: StateStore) -> None:
|
||||
events: list = []
|
||||
store.watch("network.*", lambda k, o, n: events.append(k))
|
||||
store.set("network.interfaces", {})
|
||||
store.set("network.stats", {})
|
||||
store.set("system.cpu", {}) # should NOT trigger
|
||||
assert "network.interfaces" in events
|
||||
assert "network.stats" in events
|
||||
assert "system.cpu" not in events
|
||||
|
||||
|
||||
def test_exact_and_wildcard_both_fired(store: StateStore) -> None:
|
||||
exact: list = []
|
||||
wild: list = []
|
||||
store.watch("net.iface", lambda k, o, n: exact.append(n))
|
||||
store.watch("net.*", lambda k, o, n: wild.append(n))
|
||||
store.set("net.iface", "up")
|
||||
assert exact == ["up"]
|
||||
assert wild == ["up"]
|
||||
|
||||
|
||||
# ── Unwatch ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_unwatch_stops_callback(store: StateStore) -> None:
|
||||
events: list = []
|
||||
|
||||
def cb(k, o, n):
|
||||
events.append(n)
|
||||
|
||||
wid = store.watch("remove.me", cb)
|
||||
store.set("remove.me", 1)
|
||||
assert len(events) == 1
|
||||
|
||||
store.unwatch(wid)
|
||||
store.set("remove.me", 2)
|
||||
assert len(events) == 1 # no new call
|
||||
|
||||
|
||||
def test_unwatch_unknown_id_returns_false(store: StateStore) -> None:
|
||||
assert store.unwatch("not-a-real-id") is False
|
||||
|
||||
|
||||
# ── Snapshot ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_snapshot_returns_full_state(store: StateStore) -> None:
|
||||
store.set("a", 1)
|
||||
store.set("b", {"nested": True})
|
||||
snap = store.snapshot()
|
||||
assert snap["a"] == 1
|
||||
assert snap["b"] == {"nested": True}
|
||||
|
||||
|
||||
def test_snapshot_is_deep_copy(store: StateStore) -> None:
|
||||
store.set("obj", [1, 2, 3])
|
||||
snap = store.snapshot()
|
||||
snap["obj"].append(99)
|
||||
assert store.get("obj") == [1, 2, 3]
|
||||
|
||||
|
||||
# ── Keys and contains ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_keys_returns_all_keys(store: StateStore) -> None:
|
||||
store.set("x", 1)
|
||||
store.set("y", 2)
|
||||
assert set(store.keys()) == {"x", "y"}
|
||||
|
||||
|
||||
def test_contains_operator(store: StateStore) -> None:
|
||||
store.set("present", True)
|
||||
assert "present" in store
|
||||
assert "absent" not in store
|
||||
|
||||
|
||||
# ── Bus integration ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_bus_publish_called_on_set(store_with_bus) -> None:
|
||||
store, bus = store_with_bus
|
||||
store.set("some.key", "value")
|
||||
bus.publish_sync.assert_called_once_with(
|
||||
"state.some.key", {"key": "some.key", "value": "value"}
|
||||
)
|
||||
|
||||
|
||||
def test_bus_not_called_when_value_unchanged(store_with_bus) -> None:
|
||||
store, bus = store_with_bus
|
||||
store.set("stable", 42)
|
||||
bus.publish_sync.reset_mock()
|
||||
store.set("stable", 42) # unchanged
|
||||
bus.publish_sync.assert_not_called()
|
||||
|
||||
|
||||
def test_bus_called_on_delete(store_with_bus) -> None:
|
||||
store, bus = store_with_bus
|
||||
store.set("temp", "here")
|
||||
bus.publish_sync.reset_mock()
|
||||
store.delete("temp")
|
||||
bus.publish_sync.assert_called_once_with(
|
||||
"state.temp", {"key": "temp", "value": None}
|
||||
)
|
||||
|
||||
|
||||
# ── Repr ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_repr(store: StateStore) -> None:
|
||||
store.set("k", "v")
|
||||
store.watch("k", lambda a, b, c: None)
|
||||
r = repr(store)
|
||||
assert "StateStore" in r
|
||||
assert "keys=1" in r
|
||||
Reference in New Issue
Block a user