Phase 2: State-Sync-Fundament – State-Store, Gruppen, Clock, Aktivierung
- ProjectStateStore (§6.4, §24.2): monotone State-/Projekt-Revisionen, Snapshot nach Verbindung, Delta mit neu/geaendert/geloescht, Szenenabruf als Zielzustand (Uebergang macht der Renderer, §18.1), Projekt-/Livezustand strikt getrennt - GroupRouter (§6.3, §10.1): Ziele All/Node/Output/ServerGroup, Regeln selected/tag_query/all, Commit-Vorschau (§17.5) - ClockEstimator (§6.4): RTT-Min-Filter (<=2x Min gegen Jitter), Offset-/Drift-Schaetzung (Drift erst ab 1 s Fenster belastbar), Showzeit -> lokale Node-Zeit - ActivationCoordinator (§6.5): zeitgestempelte Preset-Aktivierung, 200 ms Vorlauf, Arm/Execute/Ack-Kette, FAILED bei fehlender Arm-Bestaetigung (kein stilles Ueberbruecken) - 35 neue Unit-Tests; Gesamtsuite 293 gruen, Ruff gruen
This commit is contained in:
@@ -0,0 +1,258 @@
|
||||
"""Unit-Tests Zielrouting, Clock-Sync, zeitgestempelte Aktivierung
|
||||
(PLAN.md §6.3, §6.4, §6.5)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from hms_cluster import (
|
||||
ActivationCoordinator,
|
||||
ArmState,
|
||||
ClockEstimator,
|
||||
ClockSample,
|
||||
GroupRouter,
|
||||
GroupRule,
|
||||
ServerGroup,
|
||||
TargetKind,
|
||||
)
|
||||
|
||||
# ---------- GroupRouter (§6.3 Bedienmodelle) ----------
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def router() -> GroupRouter:
|
||||
r = GroupRouter()
|
||||
r.set_node_tags("node-a", frozenset({"stage-left"}))
|
||||
r.set_node_tags("node-b", frozenset({"stage-right"}))
|
||||
r.set_node_tags("node-c", frozenset({"stage-left", "stage-right"}))
|
||||
r.set_node_outputs("node-a", ["out-1"])
|
||||
r.set_node_outputs("node-c", ["out-2"])
|
||||
return r
|
||||
|
||||
|
||||
def test_resolve_all_targets(router: GroupRouter) -> None:
|
||||
assert router.resolve(TargetKind.ALL) == frozenset({"node-a", "node-b", "node-c"})
|
||||
|
||||
|
||||
def test_resolve_single_node(router: GroupRouter) -> None:
|
||||
assert router.resolve(TargetKind.NODE, "node-a") == frozenset({"node-a"})
|
||||
assert router.resolve(TargetKind.NODE, "unbekannt") == frozenset()
|
||||
|
||||
|
||||
def test_resolve_by_output(router: GroupRouter) -> None:
|
||||
assert router.resolve(TargetKind.OUTPUT, "out-2") == frozenset({"node-c"})
|
||||
assert router.resolve(TargetKind.OUTPUT, "out-9") == frozenset()
|
||||
|
||||
|
||||
def test_group_rule_selected(router: GroupRouter) -> None:
|
||||
router.upsert_group(
|
||||
ServerGroup(
|
||||
id="g1",
|
||||
name="Links",
|
||||
rule=GroupRule.SELECTED,
|
||||
node_ids=frozenset({"node-a", "unbekannt"}),
|
||||
)
|
||||
)
|
||||
# unbekannte Mitglieder werden still gefiltert, bekannte bleiben
|
||||
assert router.resolve(TargetKind.SERVER_GROUP, "g1") == frozenset({"node-a"})
|
||||
|
||||
|
||||
def test_group_rule_tag_query(router: GroupRouter) -> None:
|
||||
router.upsert_group(
|
||||
ServerGroup(
|
||||
id="g2",
|
||||
name="Beide Bühnen",
|
||||
rule=GroupRule.TAG_QUERY,
|
||||
tags=frozenset({"stage-left"}),
|
||||
)
|
||||
)
|
||||
assert router.resolve(TargetKind.SERVER_GROUP, "g2") == frozenset(
|
||||
{"node-a", "node-c"}
|
||||
)
|
||||
|
||||
|
||||
def test_group_rule_all(router: GroupRouter) -> None:
|
||||
router.upsert_group(ServerGroup(id="g3", name="Alle", rule=GroupRule.ALL))
|
||||
assert router.resolve(TargetKind.SERVER_GROUP, "g3") == router.resolve(TargetKind.ALL)
|
||||
|
||||
|
||||
def test_preview_matches_resolve(router: GroupRouter) -> None:
|
||||
"""§17.5: Commit-Vorschau zeigt dieselben Ziele wie der Versand."""
|
||||
router.upsert_group(
|
||||
ServerGroup(id="g4", name="X", rule=GroupRule.SELECTED, node_ids=frozenset({"node-b"}))
|
||||
)
|
||||
assert router.preview_targets(TargetKind.SERVER_GROUP, "g4") == router.resolve(
|
||||
TargetKind.SERVER_GROUP, "g4"
|
||||
)
|
||||
|
||||
|
||||
def test_unknown_group_resolves_empty(router: GroupRouter) -> None:
|
||||
assert router.resolve(TargetKind.SERVER_GROUP, "gibts-nicht") == frozenset()
|
||||
|
||||
|
||||
def test_remove_group(router: GroupRouter) -> None:
|
||||
router.upsert_group(ServerGroup(id="g", name="Weg"))
|
||||
router.remove_group("g")
|
||||
assert router.get_group("g") is None
|
||||
|
||||
|
||||
# ---------- ClockEstimator (§6.4 Clock Sync) ----------
|
||||
|
||||
|
||||
def _sample(t0: int, rtt: int, offset: int) -> ClockSample:
|
||||
"""Probe mit definiertem echtem Offset: node_time = t0 + rtt/2 + offset."""
|
||||
node_time = t0 + rtt // 2 + offset
|
||||
return ClockSample(t0_ns=t0, t1_ns=t0 + rtt, node_time_ns=node_time)
|
||||
|
||||
|
||||
def test_clock_offset_estimated_from_low_rtt_samples() -> None:
|
||||
"""Min-Filter: Proben mit Rauschen (hohe RTT) verschieben den Schätzer
|
||||
nicht; die niedrigste RTT dominiert (§6.4)."""
|
||||
est = ClockEstimator()
|
||||
# echter Offset: +5 ms; einige Proben mit Jitter
|
||||
est.feed(_sample(0, 1_000_000, 5_000_000)) # 1 ms RTT
|
||||
est.feed(_sample(1_000_000, 50_000_000, 30_000_000)) # 50 ms RTT, Jitter
|
||||
est.feed(_sample(2_000_000, 2_000_000, 5_500_000))
|
||||
est.feed(_sample(3_000_000, 1_500_000, 4_800_000))
|
||||
offset = est.offset_ns
|
||||
assert offset is not None
|
||||
assert 4_000_000 < offset < 6_000_000 # nahe am echten 5 ms
|
||||
|
||||
|
||||
def test_clock_best_rtt_reported() -> None:
|
||||
est = ClockEstimator()
|
||||
est.feed(_sample(0, 20_000_000, 0))
|
||||
est.feed(_sample(1, 5_000_000, 0))
|
||||
assert est.rtt_ns == 5_000_000
|
||||
|
||||
|
||||
def test_clock_no_data_returns_none() -> None:
|
||||
est = ClockEstimator()
|
||||
assert est.offset_ns is None
|
||||
assert est.rtt_ns is None
|
||||
assert est.drift_ppm is None
|
||||
|
||||
|
||||
def test_clock_drift_estimated_over_time() -> None:
|
||||
"""Drift: Offset wächst um 100 µs pro Sekunde = 100 ppm (§6.4)."""
|
||||
est = ClockEstimator()
|
||||
est.feed(_sample(t0=0, rtt=1_000_000, offset=0))
|
||||
# 2 s später: 200 µs mehr Offset (200_000 ns) → 100 µs/s = 100 ppm
|
||||
est.feed(_sample(t0=2_000_000_000, rtt=1_000_000, offset=200_000))
|
||||
drift = est.drift_ppm
|
||||
assert drift is not None
|
||||
assert 80.0 <= drift <= 120.0 # ~100 ppm
|
||||
|
||||
|
||||
def test_clock_drift_none_below_one_second_window() -> None:
|
||||
"""Zu kurzes Fenster: Drift ist nicht belastbar (§6.4 Grenze)."""
|
||||
est = ClockEstimator()
|
||||
est.feed(_sample(0, 1_000_000, 0))
|
||||
est.feed(_sample(100_000_000, 1_000_000, 50)) # nur 100 ms Abstand
|
||||
assert est.drift_ppm is None
|
||||
|
||||
|
||||
def test_clock_rejects_negative_rtt() -> None:
|
||||
with pytest.raises(ValueError, match="negative RTT"):
|
||||
ClockEstimator().feed(ClockSample(t0_ns=10, t1_ns=5, node_time_ns=0))
|
||||
|
||||
|
||||
def test_clock_maps_show_time_to_node_time() -> None:
|
||||
"""§29.1: Showzeit → lokale Monotonic über den Offset."""
|
||||
est = ClockEstimator()
|
||||
est.feed(_sample(0, 1_000_000, offset=10_000_000)) # +10 ms
|
||||
mapped = est.map_show_time(1_000_000_000)
|
||||
assert mapped is not None
|
||||
assert mapped == 1_010_000_000 # Showzeit + Offset
|
||||
# ohne Proben: keine Abbildung möglich
|
||||
assert ClockEstimator().map_show_time(0) is None
|
||||
|
||||
|
||||
def test_clock_sample_window_bounded() -> None:
|
||||
"""Kein unbeschränkter Zustand (§33): Fenster bleibt begrenzt."""
|
||||
est = ClockEstimator(max_samples=4)
|
||||
for i in range(10):
|
||||
est.feed(_sample(i * 1_000_000, 1_000_000, 0))
|
||||
assert len(est._samples) <= 4
|
||||
|
||||
|
||||
# ---------- ActivationCoordinator (§6.5) ----------
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def two_node_setup():
|
||||
router = GroupRouter()
|
||||
router.set_node_tags("node-a", frozenset({"x"}))
|
||||
router.set_node_tags("node-b", frozenset({"x"}))
|
||||
coord = ActivationCoordinator(router)
|
||||
return coord, router
|
||||
|
||||
|
||||
def test_schedule_plans_with_lead_time(two_node_setup) -> None:
|
||||
coord, _ = two_node_setup
|
||||
planned = coord.schedule("scene-1", now_show_ns=1_000_000_000, lead_ns=200_000_000)
|
||||
assert planned.execute_at_show_ns == 1_200_000_000 # §6.4: 100–300 ms Vorlauf
|
||||
assert planned.state is ArmState.CREATED
|
||||
|
||||
|
||||
def test_due_requires_all_arms(two_node_setup) -> None:
|
||||
"""§6.5: Execute nur, wenn ALLE Ziel-Nodes armed; sonst FAILED."""
|
||||
coord, _ = two_node_setup
|
||||
planned = coord.schedule("scene-1", now_show_ns=0, lead_ns=100)
|
||||
coord.acknowledge_arm(planned.command_id, "node-a")
|
||||
# Showzeit erreicht, aber node-b fehlt → FAILED, nicht still ausgeführt
|
||||
due = coord.due(now_show_ns=1_000_000)
|
||||
assert due == []
|
||||
assert coord.get(planned.command_id).state is ArmState.FAILED
|
||||
|
||||
|
||||
def test_due_executes_when_all_armed(two_node_setup) -> None:
|
||||
coord, _ = two_node_setup
|
||||
planned = coord.schedule("scene-1", now_show_ns=0, lead_ns=100)
|
||||
coord.acknowledge_arm(planned.command_id, "node-a")
|
||||
coord.acknowledge_arm(planned.command_id, "node-b")
|
||||
due = coord.due(now_show_ns=1_000_000)
|
||||
assert len(due) == 1 and due[0].command_id == planned.command_id
|
||||
assert coord.get(planned.command_id).state is ArmState.ARMED
|
||||
|
||||
|
||||
def test_execute_completes_when_all_nodes_report(two_node_setup) -> None:
|
||||
coord, _ = two_node_setup
|
||||
planned = coord.schedule("scene-1", now_show_ns=0, lead_ns=100)
|
||||
coord.acknowledge_arm(planned.command_id, "node-a")
|
||||
coord.acknowledge_arm(planned.command_id, "node-b")
|
||||
coord.due(now_show_ns=1_000_000)
|
||||
# beide Nodes melden ausgeführt (mit Ist-Zeit, §6.5)
|
||||
coord.acknowledge_execute(planned.command_id, "node-a")
|
||||
assert coord.get(planned.command_id).state is ArmState.ARMED # noch nicht komplett
|
||||
coord.acknowledge_execute(planned.command_id, "node-b")
|
||||
assert coord.get(planned.command_id).state is ArmState.EXECUTED
|
||||
|
||||
|
||||
def test_expected_nodes_preview(two_node_setup) -> None:
|
||||
"""§17.5: Vor dem Commit sichtbar, welche Nodes die Szene erhalten."""
|
||||
coord, _ = two_node_setup
|
||||
planned = coord.schedule("scene-1", now_show_ns=0, lead_ns=100)
|
||||
assert coord.expected_nodes(planned) == frozenset({"node-a", "node-b"})
|
||||
|
||||
|
||||
def test_acknowledge_unknown_command_rejected(two_node_setup) -> None:
|
||||
coord, _ = two_node_setup
|
||||
with pytest.raises(KeyError):
|
||||
coord.acknowledge_arm("gibts-nicht", "node-a")
|
||||
|
||||
|
||||
def test_due_ignores_already_handled(two_node_setup) -> None:
|
||||
"""Erledigte/gescheiterte Aktivierungen werden nicht erneut geliefert."""
|
||||
coord, _ = two_node_setup
|
||||
planned = coord.schedule("scene-1", now_show_ns=0, lead_ns=100)
|
||||
coord.acknowledge_arm(planned.command_id, "node-a")
|
||||
coord.acknowledge_arm(planned.command_id, "node-b")
|
||||
coord.due(now_show_ns=1_000_000) # erstmalig fällig → ARMED
|
||||
second = coord.due(now_show_ns=2_000_000) # erneut aufgerufen: kein Duplikat
|
||||
assert second == []
|
||||
|
||||
|
||||
def test_due_before_showtime_returns_empty(two_node_setup) -> None:
|
||||
coord, _ = two_node_setup
|
||||
coord.schedule("scene-1", now_show_ns=1_000_000_000, lead_ns=200_000_000)
|
||||
assert coord.due(now_show_ns=1_100_000_000) == [] # Showzeit noch nicht erreicht
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Unit-Tests Project-State-Store (PLAN.md §6.4 State Sync, §24.2, §18.1)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from hms_domain import PresetScene
|
||||
from hms_persistence.state_store import ProjectStateStore
|
||||
|
||||
|
||||
def test_revisions_start_at_zero() -> None:
|
||||
store = ProjectStateStore()
|
||||
assert store.state_revision == 0
|
||||
assert store.project_revision == 0
|
||||
assert store.snapshot()["values"] == {}
|
||||
|
||||
|
||||
def test_activate_project_resets_live_state() -> None:
|
||||
"""Kein Mischzustand: Projektwechsel leert den Livezustand (§24.2)."""
|
||||
from hms_domain import Project
|
||||
|
||||
store = ProjectStateStore()
|
||||
store.set_value("master/intensity", 0.5)
|
||||
store.activate_project(Project(name="Show A"))
|
||||
assert store.project_revision == 1
|
||||
assert store.project is not None and store.project.name == "Show A"
|
||||
assert store.get_value("master/intensity") is None # Live geleert
|
||||
assert store.state_revision == 2 # set_value (1) + Projektwechsel (2)
|
||||
|
||||
|
||||
def test_set_and_clear_value_increase_state_revision() -> None:
|
||||
store = ProjectStateStore()
|
||||
r1 = store.set_value("master/intensity", 0.7)
|
||||
assert r1 == 1
|
||||
r2 = store.set_value("master/intensity", 0.9)
|
||||
assert r2 == 2
|
||||
r3 = store.clear_value("master/intensity")
|
||||
assert r3 == 3
|
||||
assert store.get_value("master/intensity") is None
|
||||
# Löschen eines nicht existierenden Pfades erhöht trotzdem deterministisch
|
||||
r4 = store.clear_value("master/intensity")
|
||||
assert r4 == 4
|
||||
|
||||
|
||||
def test_snapshot_contains_revisions_and_values() -> None:
|
||||
store = ProjectStateStore()
|
||||
store.set_value("master/intensity", 1.0)
|
||||
snap = store.snapshot()
|
||||
assert snap["state_revision"] == 1
|
||||
assert snap["project_revision"] == 0
|
||||
assert snap["values"] == {"master/intensity": 1.0}
|
||||
assert snap["monotonic_ns"] > 0
|
||||
|
||||
|
||||
def test_delta_since_reports_changes_and_deletions() -> None:
|
||||
"""Delta überträgt Änderungen; gelöschte Pfade als None (§6.4)."""
|
||||
store = ProjectStateStore()
|
||||
store.set_value("a", 1.0)
|
||||
seen = store.state_revision
|
||||
# nach „Disconnect": neue Änderungen
|
||||
store.set_value("a", 2.0)
|
||||
store.set_value("b", 3.0)
|
||||
store.clear_value("c") # c war nie gesetzt – bleibt neutral
|
||||
delta = store.delta_since(seen, pending={"a": 1.0, "c": 0.0})
|
||||
assert delta is not None
|
||||
assert delta.state_revision == store.state_revision
|
||||
assert delta.changes["a"] == 2.0 # geändert
|
||||
assert "b" in delta.changes and delta.changes["b"] == 3.0 # neu
|
||||
assert delta.changes.get("c") is None # gelöscht
|
||||
|
||||
|
||||
def test_delta_none_when_up_to_date() -> None:
|
||||
store = ProjectStateStore()
|
||||
store.set_value("a", 1.0)
|
||||
assert store.delta_since(store.state_revision, pending={}) is None
|
||||
|
||||
|
||||
def test_delta_rejects_future_revision() -> None:
|
||||
store = ProjectStateStore()
|
||||
with pytest.raises(ValueError, match="Zukunft"):
|
||||
store.delta_since(99, pending={})
|
||||
|
||||
|
||||
def test_apply_scene_overwrites_live_as_target_state() -> None:
|
||||
"""Szenenabruf erzeugt Zielzustand; Übergänge macht der Renderer (§18.1)."""
|
||||
store = ProjectStateStore()
|
||||
store.set_value("layer/x/opacity", 0.2)
|
||||
scene = PresetScene(
|
||||
name="Look",
|
||||
composition_snapshot={
|
||||
"values": {"layer/x/opacity": 0.8, "master/intensity": 1.0}
|
||||
},
|
||||
)
|
||||
rev = store.apply_scene(scene)
|
||||
assert rev == 2 # set + scene
|
||||
assert store.get_value("layer/x/opacity") == pytest.approx(0.8)
|
||||
assert store.get_value("master/intensity") == pytest.approx(1.0)
|
||||
|
||||
|
||||
def test_apply_scene_rejects_scene_without_values() -> None:
|
||||
scene = PresetScene(name="Kaputt", composition_snapshot={"values": "kein dict"})
|
||||
store = ProjectStateStore()
|
||||
with pytest.raises(ValueError, match="Werte"):
|
||||
store.apply_scene(scene)
|
||||
|
||||
|
||||
def test_bump_project_revision_independent_of_state() -> None:
|
||||
"""Projektinhalt (Medien/Szenen) und Livezustand getrennt (§24.2)."""
|
||||
store = ProjectStateStore()
|
||||
state_before = store.state_revision
|
||||
store.bump_project_revision()
|
||||
assert store.project_revision == 1
|
||||
assert store.state_revision == state_before # Live unverändert
|
||||
Reference in New Issue
Block a user