Files
hms-mediaengine/tests/unit/test_fixture_engine.py
T

323 lines
12 KiB
Python
Raw Normal View History

"""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