"""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