abffff3711
- Master32Engine: 32-Kanal-Dekodierung: 16-Bit Intensitaet/BPM/Dauer, Blackout (hoechste Prioritaet), Freeze, Preset Bank/Index, Flanken-Trigger Preset Recall/Tap Tempo/Release, Audio/AI-Freigabe, Testpattern, Global Hue/Sat, Fallback; reservierte Kanaele neutral - Layer64Engine: 64-Kanal-Dekodierung: Enable, 16-Bit Opacity, Source-Type Media/Generator/Solid/Live, modusabhaengiger Source-Block (Kanal 13-20: Media=Speed/Position/In/Out, Generator=G1-G8), Transport-Enum, Blend, 16-Bit signed Position, Scale, Rotation, Crop, Color, FX1/FX2 mit P1-P8, Load/Commit-Semantik mit pending selection (§16.5: Fader bewegt nichts ohne Flanke), Retrigger - map_speed (§16.6): Mittelpunkt=Pause, untere Haelfte -4x..0, obere 0..+4x, definierter Wert 40960 = exakt 1x; monoton, dokumentiert und getestet (Fehler im ersten Entwurf korrigiert: 32768 war faelschlich 1x statt Pause) - UniversePlan: kollisionsfreie Bereiche je Node, Ueberschneidung = Blocker-Fehler (§16.2), Preflight-Check - 26 Unit-Tests: Speed-Referenzwerte/-Monotonie, Master32-Kanaele, Trigger-Flanken, reservierte Kanaele, Layer64-Modi, Load/Commit, Retrigger, FX-Bloecke, Universe-Kollisionen; 8x64=512-Verifikation - Gesamtsuite 532 gruen, Ruff gruen
323 lines
12 KiB
Python
323 lines
12 KiB
Python
"""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
|