"""Unit-Tests DMX-Mapping: Flanken, 16-Bit-Decoder, Signalverlust (§16.4–16.6).""" from __future__ import annotations import uuid import pytest from hms_artnet.mapping import DmxLayerMapper, LayerDmxMapping, RisingEdge from hms_artnet.receiver import DmxUpdate, LossBehavior from hms_parameter.engine import ControlSource, ParameterEngine def _opacity(comp: str, layer: str) -> str: return f"composition/{comp}/layer/{layer}/opacity" def _enabled(comp: str, layer: str) -> str: return f"composition/{comp}/layer/{layer}/enabled" # ---------- RisingEdge (§16.3/§16.5: Trigger = Flanke, kein Dauerzustand) ---------- def test_rising_edge_triggers_once_per_crossing() -> None: edge = RisingEdge(threshold=64) assert edge.feed(0) is False assert edge.feed(64) is True # steigende Flanke assert edge.feed(100) is False # gehaltener Wert: kein erneuter Trigger assert edge.feed(200) is False assert edge.feed(10) is False # Rückfall assert edge.feed(70) is True # neue Flanke nach Rückkehr def test_rising_edge_boundary() -> None: with pytest.raises(ValueError): RisingEdge(threshold=256) with pytest.raises(ValueError): RisingEdge(threshold=-1) # ---------- LayerDmxMapping ---------- @pytest.fixture() def ids() -> tuple[str, str]: return str(uuid.uuid4()), str(uuid.uuid4()) def test_mapping_paths_use_stable_uuids(ids: tuple[str, str]) -> None: comp, layer = ids m = LayerDmxMapping(universe=0, base_address=1, composition_id=comp, layer_id=layer) assert m.opacity_path == _opacity(comp, layer) assert m.enable_path == _enabled(comp, layer) def test_mapping_validates_universe_and_address(ids: tuple[str, str]) -> None: comp, layer = ids with pytest.raises(ValueError): LayerDmxMapping(universe=0x8000, base_address=1, composition_id=comp, layer_id=layer) with pytest.raises(ValueError): LayerDmxMapping(universe=0, base_address=511, composition_id=comp, layer_id=layer) # ---------- DmxLayerMapper (§36 Nr. 8: DMX-Kanal → Opacity) ---------- def _update(universe: int, data: bytes, sequence: int = 1) -> DmxUpdate: return DmxUpdate( universe=universe, data=data, sender_ip="10.0.0.9", received_ns=0, sequence=sequence, ) def _loss(universe: int, sender: str = "10.0.0.9") -> DmxUpdate: return DmxUpdate(universe=universe, data=b"", sender_ip=sender, received_ns=1, sequence=-1) def test_mapper_sets_opacity_from_16bit_channels(ids: tuple[str, str]) -> None: comp, layer = ids engine = ParameterEngine() mapper = DmxLayerMapper( LayerDmxMapping(universe=0, base_address=1, composition_id=comp, layer_id=layer), engine, ) # Kanal 2-3 = Opacity 16 Bit: MSB zuerst → 0x8000/0xFFFF mapper.handle(_update(0, bytes([255, 0x80, 0x00]))) assert engine.effective_value(_opacity(comp, layer)) == pytest.approx(0x8000 / 65535) # Voll auf: 0xFFFF mapper.handle(_update(0, bytes([255, 0xFF, 0xFF]))) assert engine.effective_value(_opacity(comp, layer)) == pytest.approx(1.0) # Kanal 1 < 128 → Layer disabled mapper.handle(_update(0, bytes([0, 0xFF, 0xFF]))) assert engine.effective_value(_enabled(comp, layer)) == pytest.approx(0.0) def test_mapper_ignores_other_universe(ids: tuple[str, str]) -> None: comp, layer = ids engine = ParameterEngine() mapper = DmxLayerMapper( LayerDmxMapping(universe=0, base_address=1, composition_id=comp, layer_id=layer), engine, ) mapper.handle(_update(7, bytes([255, 0xFF, 0xFF]))) assert _opacity(comp, layer) not in engine.snapshot() def test_mapper_base_address_offset(ids: tuple[str, str]) -> None: comp, layer = ids engine = ParameterEngine() mapper = DmxLayerMapper( LayerDmxMapping(universe=1, base_address=65, composition_id=comp, layer_id=layer), engine, ) # Zweiter Layer im selben Universe: Startadresse 65 → Kanal 66/67 data = bytearray(128) data[64] = 255 # Kanal 65: Enable data[65] = 0x40 # Kanal 66: Opacity MSB data[66] = 0x00 # Kanal 67: Opacity LSB mapper.handle(_update(1, bytes(data))) assert engine.effective_value(_opacity(comp, layer)) == pytest.approx(0x4000 / 65535) def test_mapper_hold_on_signal_loss(ids: tuple[str, str]) -> None: comp, layer = ids engine = ParameterEngine() m = LayerDmxMapping( universe=0, base_address=1, composition_id=comp, layer_id=layer, loss_behavior=LossBehavior.HOLD, ) mapper = DmxLayerMapper(m, engine) mapper.handle(_update(0, bytes([255, 0xFF, 0x00]))) before = engine.effective_value(_opacity(comp, layer)) mapper.handle(_loss(0)) assert engine.effective_value(_opacity(comp, layer)) == pytest.approx(before) def test_mapper_fade_to_black_releases_on_signal_loss(ids: tuple[str, str]) -> None: comp, layer = ids engine = ParameterEngine() m = LayerDmxMapping( universe=0, base_address=1, composition_id=comp, layer_id=layer, loss_behavior=LossBehavior.FADE_TO_BLACK, ) mapper = DmxLayerMapper(m, engine) mapper.handle(_update(0, bytes([255, 0xFF, 0xFF]))) mapper.handle(_loss(0)) snap = engine.snapshot() assert _opacity(comp, layer) not in snap # Override freigegeben def test_dmx_source_priority_is_console(ids: tuple[str, str]) -> None: """Art-Net wirkt als CONSOLE (Priorität 3) und überstimmt Web (§11.2).""" comp, layer = ids engine = ParameterEngine() path = _opacity(comp, layer) engine.set_value(path, 0.1, ControlSource.WEB) mapper = DmxLayerMapper( LayerDmxMapping(universe=0, base_address=1, composition_id=comp, layer_id=layer), engine, ) mapper.handle(_update(0, bytes([255, 0xFF, 0xFF]))) assert engine.effective_value(path) == pytest.approx(1.0) # Pult gewinnt assert engine.current_source(path) is ControlSource.CONSOLE