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