Files
HMS MediaEngine Agent 362e089be0 AUFGERAUMT: Root auf 10 sichtbare Elemente reduziert
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).
2026-09-11 23:44:06 +02:00

173 lines
5.9 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Unit-Tests DMX-Mapping: Flanken, 16-Bit-Decoder, Signalverlust (§16.416.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