173 lines
5.9 KiB
Python
173 lines
5.9 KiB
Python
|
|
"""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
|