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

323 lines
12 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 Fixture-Engine: Master32/Layer64, Speed, Universe-Plan
(PLAN.md §16.2–§16.6, §29.1)."""
from __future__ import annotations
import pytest
from hms_artnet.fixtures import (
Layer64Engine,
Master32Engine,
SourceType,
TransportCommand,
UniverseCollisionError,
UniversePlan,
UniverseRange,
map_speed,
)
# ---------- 16-Bit-Dekodierung (MSB zuerst) ----------
def test_speed_mapping_reference_values() -> None:
"""§16.6: Mittelpunkt = Pause (0); untere Hälfte negativ, obere positiv;
definierter Wert 40960 entspricht exakt +1×."""
assert map_speed(32768) == 0.0 # Mittelpunkt = Pause (§16.6)
assert map_speed(0) == pytest.approx(-4.0) # unterer Rand: 4x
assert map_speed(65535) == pytest.approx(4.0, rel=1e-3) # oberer Rand: +4x
assert map_speed(40960) == pytest.approx(1.0) # exakt 1x (definierter Wert)
assert map_speed(16384) == pytest.approx(-2.0) # untere Hälfte: 2x
assert map_speed(49152) == pytest.approx(2.0) # obere Hälfte: +2x
def test_speed_mapping_monotonic_and_centered() -> None:
"""Monotonie je Hälfte; keine Sprünge am Übergang."""
# untere Hälfte steigt monoton von 4x Richtung 0
assert map_speed(0) < map_speed(8192) < map_speed(16384) < map_speed(24576) < 0.0
# obere Hälfte steigt monoton von 0+ Richtung +4x
assert 0.0 <= map_speed(32769) < map_speed(40960) < map_speed(49152) < map_speed(65535)
# Mitte = Pause, kein Sprung zwischen den Hälften
assert map_speed(32767) < 0.0 < map_speed(32769)
# ---------- Master32 (§16.3) ----------
def _master_channels(**overrides) -> bytearray:
"""32 Kanäle, Default neutral; overrides als (kanal_nr_1basiert, wert)."""
ch = bytearray(32)
# neutrale Defaults: Intensität voll (32768), Rest 0
ch[0] = 0x80
ch[1] = 0x00 # 32768/65535 ≈ 0.5 neutral genug für Tests
for k, v in overrides.items():
ch[int(k) - 1] = v
return ch
def test_master32_decodes_16bit_intensity() -> None:
engine = Master32Engine()
control = engine.decode(_master_channels(**{"1": 0xFF, "2": 0xFF}))
assert control.master_intensity == pytest.approx(1.0)
def test_master32_blackout_switch() -> None:
engine = Master32Engine()
control = engine.decode(_master_channels(**{"3": 255}))
assert control.blackout is True # höchste Priorität (§16.3)
assert engine.decode(_master_channels()).blackout is False
def test_master32_preset_recall_rising_edge_only() -> None:
"""§16.3: Preset Recall ist Flanken-Trigger, kein Dauerzustand."""
engine = Master32Engine()
# erster Frame: Schwelle überschritten → Event
control = engine.decode(_master_channels(**{"8": 255}))
assert [e.kind for e in control.events] == ["preset_recall"]
# gehaltener Wert: kein neues Event
control = engine.decode(_master_channels(**{"8": 255}))
assert control.events == []
# zurück unter Schwelle, dann erneut: neues Event
engine.decode(_master_channels(**{"8": 0}))
control = engine.decode(_master_channels(**{"8": 200}))
assert [e.kind for e in control.events] == ["preset_recall"]
def test_master32_tap_tempo_and_release_edges() -> None:
engine = Master32Engine()
control = engine.decode(_master_channels(**{"16": 255, "30": 255}))
kinds = [e.kind for e in control.events]
assert "tap_tempo" in kinds
assert "release_overrides" in kinds
def test_master32_bpm_range_20_to_300() -> None:
engine = Master32Engine()
control = engine.decode(_master_channels(**{"14": 0, "15": 0}))
assert control.bpm == pytest.approx(20.0)
control = engine.decode(_master_channels(**{"14": 0xFF, "15": 0xFF}))
assert control.bpm == pytest.approx(300.0)
def test_master32_reserved_channels_ignored_neutrally() -> None:
"""§16.3: Kanäle 17-21, 31-32 müssen neutral ignoriert werden."""
engine = Master32Engine()
control = engine.decode(
_master_channels(**{"17": 255, "18": 255, "19": 255, "31": 255, "32": 255})
)
assert control.events == [] # keine Fehler, keine Events
def test_master32_requires_32_channels() -> None:
with pytest.raises(ValueError, match="32"):
Master32Engine().decode(bytearray(31))
def test_master32_global_hue_range() -> None:
engine = Master32Engine()
control = engine.decode(_master_channels(**{"27": 255}))
assert control.global_hue == pytest.approx(0.5)
control = engine.decode(_master_channels(**{"27": 0}))
assert control.global_hue == pytest.approx(-0.5)
# ---------- Layer64 (§16.4) ----------
def _layer_channels(**overrides) -> bytearray:
"""64 Kanäle; overrides als (kanal_nr_1basiert, wert)."""
ch = bytearray(64)
ch[0] = 255 # Layer Enable
ch[1] = 0xFF
ch[2] = 0xFF # Opacity voll
for k, v in overrides.items():
ch[int(k) - 1] = v
return ch
def test_layer64_opacity_16bit() -> None:
engine = Layer64Engine()
control = engine.decode(_layer_channels(**{"2": 0x80, "3": 0x00}))
assert control.opacity == pytest.approx(0x8000 / 65535)
def test_layer64_source_type_enum() -> None:
engine = Layer64Engine()
assert engine.decode(_layer_channels(**{"4": 0})).source_type is SourceType.MEDIA
control = engine.decode(_layer_channels(**{"4": 1}))
assert control.source_type is SourceType.GENERATOR
assert engine.decode(_layer_channels(**{"4": 3})).source_type is SourceType.LIVE
# unbekannter Wert fällt auf MEDIA zurück (§16.4: neutral)
assert engine.decode(_layer_channels(**{"4": 99})).source_type is SourceType.MEDIA
def test_layer64_transport_enum() -> None:
engine = Layer64Engine()
control = engine.decode(_layer_channels(**{"10": 3}))
assert control.transport is TransportCommand.RETRIGGER
assert engine.decode(_layer_channels(**{"10": 1})).transport is TransportCommand.PLAY
def test_layer64_media_mode_speed_position_inout() -> None:
"""Media-Modus: Kanäle 13-20 = Speed/Position/In/Out (§16.4)."""
engine = Layer64Engine()
control = engine.decode(
_layer_channels(
**{
"4": 0, # Media
"13": 0xA0,
"14": 0x00, # Speed = 40960 = exakt 1x (§16.6)
"15": 0x40,
"16": 0x00, # Position ~0.25
"17": 0x10,
"18": 0x00, # In ~0.06
"19": 0xF0,
"20": 0x00, # Out ~0.94
}
)
)
assert control.speed == pytest.approx(1.0) # 40960: definierter 1x-Wert
assert control.position == pytest.approx(0x4000 / 65535)
assert control.generator_params == () # Media-Modus: keine G-Slots
def test_layer64_generator_mode_g_slots() -> None:
"""Generator-Modus: Kanäle 13-20 = G1..G8 (§16.4 modusabhängiger Block)."""
engine = Layer64Engine()
control = engine.decode(
_layer_channels(**{"4": 1, **{str(k): (25 * (k - 12)) & 0xFF for k in range(13, 21)}})
)
assert control.source_type is SourceType.GENERATOR
assert len(control.generator_params) == 8
assert control.generator_params[0] == pytest.approx(25 / 255)
assert control.generator_params[7] == pytest.approx(200 / 255)
assert control.speed == 1.0 # Media-Werte im Generator-Modus neutral
def test_layer64_transform_channels() -> None:
engine = Layer64Engine()
control = engine.decode(
_layer_channels(
**{
"23": 0xFF,
"24": 0xFF, # Position X +1
"25": 0x00,
"26": 0x00, # Position Y -1
"27": 0x40,
"28": 0x00, # Scale X ~1x (16384/65535*4)
"31": 0x80,
"32": 0x00, # Rotation 180°
}
)
)
assert control.position_x == pytest.approx(1.0, abs=1e-4)
assert control.position_y == pytest.approx(-1.0)
assert control.scale_x == pytest.approx(16384 / 65535 * 4)
assert control.rotation_deg == pytest.approx(0x8000 / 65535 * 360)
def test_layer64_fx_blocks() -> None:
engine = Layer64Engine()
control = engine.decode(
_layer_channels(
**{
"41": 255, # FX1 Enable
"42": 7, # FX1 Plugin Select
"43": 128, # FX1 Mix ~0.5
"44": 255, # P1 voll
"52": 255, # FX2 Enable
"55": 64, # FX2 P1
}
)
)
assert control.fx1_enabled is True
assert control.fx1_plugin == 7
assert control.fx1_mix == pytest.approx(128 / 255)
assert control.fx1_params[0] == pytest.approx(1.0)
assert control.fx2_enabled is True
assert control.fx2_params[0] == pytest.approx(64 / 255)
assert len(control.fx1_params) == 8 and len(control.fx2_params) == 8
def test_layer64_reserved_channel_64_neutral() -> None:
"""§16.4: Kanal 64 reserviert, muss neutral ignoriert werden."""
engine = Layer64Engine()
control = engine.decode(_layer_channels(**{"64": 255}))
assert control.events == []
def test_layer64_requires_64_channels() -> None:
with pytest.raises(ValueError, match="64"):
Layer64Engine().decode(bytearray(63))
# ---------- Load/Commit-Semantik (§16.5) ----------
def test_layer64_load_commit_rising_edge() -> None:
"""§16.5: Auswahl erzeugt pending; Flanke auf Kanal 9 lädt."""
engine = Layer64Engine()
# Auswahl ändern (Bank 2, Folder 3, Index 42) noch kein Commit
engine.decode(_layer_channels(**{"5": 2, "6": 3, "7": 0, "8": 42}))
assert engine.selection_pending is True # pending, nicht geladen
# Commit-Flanke: Kanal 9 über Schwelle → load_commit-Event mit Auswahl
control = engine.decode(_layer_channels(**{"5": 2, "6": 3, "7": 0, "8": 42, "9": 255}))
assert engine.selection_pending is False # geladen
assert len(control.events) == 1
event = control.events[0]
assert event.kind == "load_commit"
assert event.pending_bank == 2
assert event.pending_folder == 3
assert event.pending_index == 42
# gehaltener Commit-Kanal: kein weiteres Event
control = engine.decode(_layer_channels(**{"5": 2, "6": 3, "7": 0, "8": 42, "9": 255}))
assert control.events == []
def test_layer64_selection_change_remarks_pending() -> None:
"""Änderung nach Commit → wieder pending; langsamer Fader lädt nichts."""
engine = Layer64Engine()
engine.decode(_layer_channels(**{"5": 1, "9": 255})) # erstes Laden
assert engine.selection_pending is False
# Fader bewegt sich langsam über mehrere Werte → nur pending, kein Load
for index in (10, 50, 100):
engine.decode(_layer_channels(**{"7": index >> 8, "8": index & 0xFF}))
assert engine.selection_pending is True # §16.5: kein dutzendfaches Laden
def test_layer64_retrigger_edge() -> None:
engine = Layer64Engine()
engine.decode(_layer_channels()) # Baseline
control = engine.decode(_layer_channels(**{"63": 255}))
assert [e.kind for e in control.events] == ["retrigger"]
# ---------- Universe-Plan (§16.2) ----------
def test_universe_plan_accepts_disjoint_ranges() -> None:
plan = UniversePlan()
plan.add(UniverseRange(node_id="node-a", first=0, last=3))
plan.add(UniverseRange(node_id="node-b", first=4, last=7))
assert len(plan.ranges()) == 2
def test_universe_plan_rejects_collision() -> None:
"""§16.2: Überschneidung = Blocker, kein still zusammenführen."""
plan = UniversePlan()
plan.add(UniverseRange(node_id="node-a", first=0, last=5))
with pytest.raises(UniverseCollisionError, match="Kollision"):
plan.add(UniverseRange(node_id="node-b", first=4, last=9))
def test_universe_plan_overlap_preflight() -> None:
plan = UniversePlan()
plan.add(UniverseRange(node_id="node-a", first=10, last=20))
assert plan.overlaps(UniverseRange(node_id="x", first=15, last=16)) is True
assert plan.overlaps(UniverseRange(node_id="x", first=21, last=30)) is False
def test_eight_layers_exactly_one_universe() -> None:
"""§16.2: 8 Layer-Fixtures à 64 Kanäle = exakt 512 DMX-Kanäle."""
assert 8 * 64 == 512