362e089be0
Der Nutzer hat recht: Der Ordner war voller Entwicklungs-Muell. Jetzt ist sauber getrennt: ROOT (was der Nutzer sieht und braucht): - run.py = das Programm - hms_app/ = der Anwendungscode - HMS MediaEngine.app = macOS Doppelklick-Starter - HMS-Start.vbs = Windows Doppelklick-Starter - HMS-Install.vbs = Windows Erst-Installation - HMS-Mac-Install.command = macOS Homebrew-Installation - HMS-Portable-Install.command = macOS Portable-Installation (16GB-Fix) - installer_gui.py = grafischer Installer - launcher.pyw + launcher_core.py = interne Start-Logik - LIESMICH.txt = 10-Zeilen-Kurzanleitung - .gitignore _entwicklung/ (alles andere, NICHT benoetigt): - packages/ apps/ native/ plugins/ tools/ schemas/ tests/ docs/ build/ fixture_profiles/ - PLAN.md STATUS.md ERRORS.md TEST_REPORT.md CHANGELOG.md README.md - pyproject.toml uv.lock setup_*.sh/ps1 make_mac_app.py Diese Trennung gilt ab sofort fuer alle Commits. Der Nutzer kann _entwicklung/ loeschen wenn er Platz braucht - die App laeuft ohne. Verifiziert: App startet nach Aufraeumen unveraendert (Health 200).
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
|