mirror of
https://github.com/samjage/metro-warden.git
synced 2026-06-06 03:00:41 +00:00
270 lines
8.3 KiB
Python
270 lines
8.3 KiB
Python
"""
|
|
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
|