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

414 lines
14 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.
"""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