Files
hms-mediaengine/tests/unit/test_patch_and_wiring.py
T

414 lines
14 KiB
Python
Raw Normal View History

"""Tests Patch-Verwaltung und DMX-zu-Parameter-Verkabelung
(PLAN.md §16.2, §11, §29.2).
Kette ohne Mocks: DMX-Bytes → Patch → Master32/Layer64-Engines →
ParameterEngine. Prüft Adressvalidierung, Überlappungen, Export,
Blackout-SAFETY, Load/Commit-Events, Signalverlust-Policies.
"""
from __future__ import annotations
import csv
import io
import uuid
import pytest
from hms_artnet import (
DmxToParameterRouter,
DmxUpdate,
FixturePatch,
LossBehavior,
PatchEntry,
PatchError,
)
from hms_parameter.engine import ControlSource, ParameterEngine
def _uuid() -> str:
return str(uuid.uuid4())
# ---------- PatchEntry-Validierung (§16.2) ----------
def test_patch_entry_requires_uuids() -> None:
with pytest.raises(ValueError, match="UUID"):
PatchEntry(
layer_id="keine-uuid",
composition_id=_uuid(),
universe=0,
base_address=1,
layer_number=1,
)
def test_patch_entry_base_address_must_fit_64_channels() -> None:
"""64 Kanäle müssen vollständig ins 512er-Universe passen."""
with pytest.raises(ValueError, match="passt nicht"):
PatchEntry(
layer_id=_uuid(),
composition_id=_uuid(),
universe=0,
base_address=450, # 450+63 > 512
layer_number=1,
)
valid = PatchEntry(
layer_id=_uuid(),
composition_id=_uuid(),
universe=0,
base_address=449, # exakt bis 512
layer_number=1,
)
assert valid.end_address == 512
# ---------- FixturePatch: Überlappungen (§16.2) ----------
def _patch() -> FixturePatch:
return FixturePatch(
node_id=_uuid(),
short_name="HMS Test Node",
master_universe=0,
master_base_address=1,
)
def _layer_entry(universe: int, base: int, number: int) -> PatchEntry:
return PatchEntry(
layer_id=_uuid(),
composition_id=_uuid(),
universe=universe,
base_address=base,
layer_number=number,
)
def test_patch_accepts_eight_layers_one_universe() -> None:
"""§16.2: 8 Layer-Fixtures à 64 Kanäle = exakt 1 Universe."""
patch = FixturePatch(
node_id=_uuid(),
short_name="N",
master_universe=1, # Master separat
master_base_address=1,
)
for i in range(8):
patch.add_layer(_layer_entry(universe=0, base=1 + i * 64, number=i + 1))
assert len(patch.layers()) == 8
assert patch.validate() == []
def test_patch_rejects_overlapping_layers() -> None:
patch = _patch()
patch.add_layer(_layer_entry(universe=0, base=33, number=1))
with pytest.raises(PatchError, match="berlappung"):
patch.add_layer(_layer_entry(universe=0, base=64, number=2)) # 64..127 vs 33..96
def test_patch_allows_same_address_in_different_universes() -> None:
"""Gleiche Adressen in verschiedenen Universen sind erlaubt (§16.2)."""
patch = _patch()
patch.add_layer(_layer_entry(universe=0, base=1, number=1))
patch.add_layer(_layer_entry(universe=1, base=1, number=2))
assert len(patch.layers()) == 2
def test_patch_rejects_duplicate_layer_id() -> None:
patch = _patch()
entry = _layer_entry(universe=0, base=1, number=1)
patch.add_layer(entry)
with pytest.raises(PatchError, match="bereits"):
patch.add_layer(entry)
def test_patch_validate_detects_master_overlap() -> None:
"""validate() meldet Master-Überlappung; Layer-Layer-Konflikt fängt
add_layer selbst (PatchError) hier wird nur Master-Overlap geprüft."""
patch = FixturePatch(
node_id=_uuid(),
short_name="N",
master_universe=0,
master_base_address=1,
)
# Master: 1..32; Layer 1: 33..96 (kein Konflikt untereinander)
patch.add_layer(_layer_entry(universe=0, base=33, number=1))
assert patch.validate() == []
# Master auf 65..96 verschieben: überlappt Layer 1 (33..96)
patch.master_base_address = 65
errors = patch.validate()
assert any("Master" in e for e in errors)
# Master-Adresse zurück auf gültig: keine Fehler mehr
patch.master_base_address = 1
assert patch.validate() == []
def test_patch_validate_detects_invalid_master_address() -> None:
patch = FixturePatch(
node_id=_uuid(),
short_name="N",
master_universe=0,
master_base_address=490, # 490+31 > 512
)
errors = patch.validate()
assert any("Master-Adresse" in e for e in errors)
# ---------- Patch-Export (§16.2) ----------
def test_patch_export_csv_contains_required_fields() -> None:
"""§16.2: Export enthält Node-ID, Short Name, IP, Universe,
Startadresse, Layernummer, Fixture-Version."""
patch = FixturePatch(
node_id="11111111-2222-3333-4444-555555555555",
short_name="HMS Node A",
master_universe=0,
master_base_address=1,
)
patch.add_layer(_layer_entry(universe=0, base=33, number=1))
patch.add_layer(_layer_entry(universe=0, base=97, number=2))
csv_text = patch.export_csv(ip_or_host="10.0.0.9", fixture_version="1.0.0")
rows = list(csv.reader(io.StringIO(csv_text)))
header = rows[0]
assert "node_id" in header
assert "universe" in header
assert "start_address" in header
assert "layer_number" in header
assert "fixture_version" in header
master_row = rows[1]
assert master_row[0] == "11111111-2222-3333-4444-555555555555"
assert master_row[1] == "HMS Node A"
assert master_row[2] == "10.0.0.9"
assert "Master 32ch" in master_row[3]
assert master_row[8] == "1.0.0"
layer_rows = rows[2:]
assert len(layer_rows) == 2
assert "Layer 64ch" in layer_rows[0][3]
assert layer_rows[0][7] == "1"
assert layer_rows[1][7] == "2"
def test_patch_export_sorted_by_universe_and_address() -> None:
patch = _patch()
patch.add_layer(_layer_entry(universe=1, base=1, number=2))
patch.add_layer(_layer_entry(universe=0, base=65, number=1))
csv_text = patch.export_csv()
rows = list(csv.reader(io.StringIO(csv_text)))
# Master zuerst (Universe 0), dann Layer nach Universe/Adresse sortiert
layer_rows = rows[2:]
assert int(layer_rows[0][4]) < int(layer_rows[1][4]) # Universum aufsteigend
# ---------- DmxToParameterRouter: Kette ohne Mocks (§11, §29.2) ----------
def _dmx_update(universe: int, data: bytes) -> DmxUpdate:
return DmxUpdate(
universe=universe,
data=data,
sender_ip="192.168.1.100",
received_ns=0,
sequence=1,
)
def _layer_patch_with_two_layers() -> tuple[FixturePatch, str, str, str, str]:
"""Patch mit Master (Uni 0) und 2 Layern (Uni 0, Adressen 33 und 97)."""
comp_id = _uuid()
layer1_id, layer2_id = _uuid(), _uuid()
patch = FixturePatch(
node_id=_uuid(),
short_name="HMS Router Test",
master_universe=0,
master_base_address=1,
)
patch.add_layer(
PatchEntry(
layer_id=layer1_id,
composition_id=comp_id,
universe=0,
base_address=33,
layer_number=1,
)
)
patch.add_layer(
PatchEntry(
layer_id=layer2_id,
composition_id=comp_id,
universe=0,
base_address=97,
layer_number=2,
)
)
return patch, comp_id, layer1_id, layer2_id, ""
def _full_universe_with_master_and_layers(
master: dict[int, int],
layer1: dict[int, int],
layer2: dict[int, int],
layer1_offset: int = 32, # 0-basiert: Basisadresse 33 - 1
layer2_offset: int = 96, # 0-basiert: Basisadresse 97 - 1
) -> bytes:
"""512 Kanäle; dicts sind (kanal_nr_1basiert_relativ, wert);
Offsets sind 0-basiert (Basisadresse-1)."""
data = bytearray(512)
for k, v in master.items():
data[int(k) - 1] = v
for k, v in layer1.items():
data[layer1_offset + int(k) - 1] = v
for k, v in layer2.items():
data[layer2_offset + int(k) - 1] = v
return bytes(data)
def test_router_full_chain_master_and_layers() -> None:
"""Kette: DMX → Patch → Engines → ParameterEngine; Werte landen korrekt."""
patch, comp, l1, l2, _ = _layer_patch_with_two_layers()
engine = ParameterEngine()
router = DmxToParameterRouter(patch, engine)
data = _full_universe_with_master_and_layers(
master={"1": 0x40, "2": 0x00, "3": 0, "14": 60, "15": 0}, # Intensität ~0.25, BPM
layer1={
"1": 255, # Enable
"2": 0xFF, "3": 0xFF, # Opacity 1.0
"4": 0, # Media
"10": 1, # Play
"13": 0xA0, "14": 0x00, # Speed 1x
"41": 255, # FX1 an
"43": 128, # FX1 Mix
},
layer2={"1": 0, "2": 0x80, "3": 0x00}, # disabled, Opacity 50%
)
router.handle_update(_dmx_update(0, data))
base1 = f"composition/{comp}/layer/{l1}"
base2 = f"composition/{comp}/layer/{l2}"
# Master-Werte
assert engine.effective_value("master/intensity") == pytest.approx(0x4000 / 65535)
assert engine.current_source("master/intensity") is ControlSource.CONSOLE
# Layer 1 aktiv
assert engine.effective_value(f"{base1}/enabled") == pytest.approx(1.0)
assert engine.effective_value(f"{base1}/opacity") == pytest.approx(1.0)
assert engine.effective_value(f"{base1}/source/speed") == pytest.approx(1.0)
assert engine.effective_value(f"{base1}/fx1/enabled") == pytest.approx(1.0)
assert engine.effective_value(f"{base1}/fx1/mix") == pytest.approx(128 / 255)
# Layer 2 disabled
assert engine.effective_value(f"{base2}/enabled") == pytest.approx(0.0)
assert engine.effective_value(f"{base2}/opacity") == pytest.approx(0x8000 / 65535)
# Telemetrie
assert router.stats.updates_processed == 1
assert router.stats.master_updates == 1
assert router.stats.layer_updates == 2
def test_router_blackout_safety_priority() -> None:
"""§11.2/§16.3: Blackout überstimmt alles mit SAFETY; Release beim Aufheben."""
patch, comp, l1, _, _ = _layer_patch_with_two_layers()
engine = ParameterEngine()
router = DmxToParameterRouter(patch, engine)
# Web setzt Intensität hoch (niedrigere Priorität)
engine.set_value("master/intensity", 0.9, ControlSource.WEB)
# Blackout aktivieren (Kanal 3 = 255)
data = _full_universe_with_master_and_layers(
master={"3": 255}, layer1={}, layer2={}
)
router.handle_update(_dmx_update(0, data))
assert engine.effective_value("master/blackout") == pytest.approx(1.0)
assert engine.current_source("master/blackout") is ControlSource.SAFETY
assert router.stats.blackouts == 1
# Blackout aufheben (Kanal 3 = 0)
data = _full_universe_with_master_and_layers(
master={"3": 0}, layer1={}, layer2={}
)
router.handle_update(_dmx_update(0, data))
# SAFETY-Override ist released; Web-Wert wirkt wieder
snap = engine.snapshot()
assert "master/blackout" not in snap
def test_router_load_commit_produces_pending_load() -> None:
"""§16.5: Flanke auf Kanal 9 erzeugt load_commit; pending_load für Renderer."""
patch, comp, l1, _, _ = _layer_patch_with_two_layers()
engine = ParameterEngine()
router = DmxToParameterRouter(patch, engine)
# Frame 1: Auswahl setzen, kein Commit
data = _full_universe_with_master_and_layers(
master={}, layer1={"5": 2, "6": 3, "9": 0}, layer2={}
)
router.handle_update(_dmx_update(0, data))
assert router.pending_load_for(l1) is None # kein Commit
# Frame 2: Commit-Flanke
data = _full_universe_with_master_and_layers(
master={}, layer1={"5": 2, "6": 3, "9": 255}, layer2={}
)
router.handle_update(_dmx_update(0, data))
pending = router.pending_load_for(l1)
assert pending is not None
assert pending["bank"] == 2
assert pending["folder"] == 3
assert pending["universe"] == 0
assert router.stats.load_commits == 1
# Verbrauch entfernt den Eintrag
assert router.pending_load_for(l1) is None
def test_router_signal_loss_hold_keeps_state() -> None:
"""§11.3 HOLD: Signalverlust ändert nichts."""
patch, _, _, _, _ = _layer_patch_with_two_layers()
engine = ParameterEngine()
router = DmxToParameterRouter(patch, engine, LossBehavior.HOLD)
engine.set_value("master/intensity", 0.8, ControlSource.CONSOLE)
loss = DmxUpdate(
universe=0, data=b"", sender_ip="x", received_ns=1, sequence=-1
)
router.handle_update(loss)
assert engine.effective_value("master/intensity") == pytest.approx(0.8)
def test_router_signal_loss_fade_to_black_safety() -> None:
"""§11.3 fade_to_black: Master-Universe-Ausfall setzt SAFETY-Override."""
patch, _, _, _, _ = _layer_patch_with_two_layers()
engine = ParameterEngine()
router = DmxToParameterRouter(patch, engine, LossBehavior.FADE_TO_BLACK)
engine.set_value("master/intensity", 0.8, ControlSource.CONSOLE)
loss = DmxUpdate(
universe=0, data=b"", sender_ip="x", received_ns=1, sequence=-1
)
router.handle_update(loss)
assert engine.effective_value("master/intensity") == pytest.approx(0.0)
assert engine.current_source("master/intensity") is ControlSource.SAFETY
def test_router_ignores_foreign_universe() -> None:
"""Updates für nicht gepatchte Universen ändern nichts."""
patch, comp, l1, _, _ = _layer_patch_with_two_layers()
engine = ParameterEngine()
router = DmxToParameterRouter(patch, engine)
router.handle_update(_dmx_update(99, bytes(512)))
snap = engine.snapshot()
assert f"composition/{comp}/layer/{l1}/enabled" not in snap
assert router.stats.master_updates == 0
assert router.stats.layer_updates == 0
def test_router_short_universe_data_skipped_silently() -> None:
"""Unvollständige Universen werden still ausgelassen (kein Fehler)."""
patch, _, _, _, _ = _layer_patch_with_two_layers()
engine = ParameterEngine()
router = DmxToParameterRouter(patch, engine)
router.handle_update(_dmx_update(0, bytes(16))) # nur 16 Kanäle
assert router.stats.master_updates == 0 # nichts dekodiert