CI / backend (pull_request) Canceled after 0s
CI / shell (pull_request) Canceled after 0s
CI / frontend (pull_request) Canceled after 0s
CI / arm64-smoke (pull_request) Canceled after 0s
CI / backend (push) Canceled after 0s
CI / shell (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / arm64-smoke (push) Canceled after 0s
323 lines
12 KiB
Python
323 lines
12 KiB
Python
from __future__ import annotations
|
|
|
|
from uuid import uuid4
|
|
|
|
from fastapi.testclient import TestClient
|
|
|
|
from app.core.dependencies import app_state
|
|
from app.main import app
|
|
|
|
|
|
def _create_token(client: TestClient, bridge_id: str | None = None) -> str:
|
|
response = client.post(
|
|
"/api/v1/integrations/midi/tokens",
|
|
json={
|
|
"label": f"pytest-{uuid4().hex[:8]}",
|
|
"bridge_id": bridge_id,
|
|
"scopes": ["midi:connect", "midi:events", "midi:heartbeat"],
|
|
},
|
|
)
|
|
assert response.status_code == 200
|
|
return response.json()["token"]
|
|
|
|
|
|
def _create_scene(client: TestClient, slug: str) -> int:
|
|
response = client.post(
|
|
"/api/v1/scenes",
|
|
json={
|
|
"name": slug,
|
|
"slug": slug,
|
|
"priority": 25,
|
|
"values": [{"channel": 10, "value": 255, "precedence": "htp", "source": "scene"}],
|
|
"targets": [],
|
|
},
|
|
)
|
|
assert response.status_code == 200
|
|
return int(response.json()["id"])
|
|
|
|
|
|
def test_midi_heartbeat_updates_bridge_status() -> None:
|
|
with TestClient(app) as client:
|
|
token = _create_token(client, bridge_id="bridge-heartbeat")
|
|
heartbeat = client.post(
|
|
"/api/v1/integrations/midi/bridge/heartbeat",
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
json={
|
|
"type": "heartbeat",
|
|
"protocol_version": 1,
|
|
"bridge_id": "bridge-heartbeat",
|
|
"device": "USB MIDI Test",
|
|
"timestamp": "2026-07-24T20:30:00+00:00",
|
|
},
|
|
)
|
|
assert heartbeat.status_code == 200
|
|
bridges = client.get("/api/v1/integrations/midi/bridges")
|
|
assert bridges.status_code == 200
|
|
item = next(entry for entry in bridges.json()["items"] if entry["bridge_id"] == "bridge-heartbeat")
|
|
assert item["device_name"] == "USB MIDI Test"
|
|
assert item["online"] is True
|
|
assert item["last_heartbeat_at"] is not None
|
|
|
|
|
|
def test_midi_event_can_activate_scene_through_mapping() -> None:
|
|
slug = f"midi-scene-{uuid4().hex[:8]}"
|
|
with TestClient(app) as client:
|
|
scene_id = _create_scene(client, slug)
|
|
token = _create_token(client, bridge_id="bridge-scenes")
|
|
mapping = client.post(
|
|
"/api/v1/integrations/midi/mappings",
|
|
json={
|
|
"name": "Scene trigger",
|
|
"enabled": True,
|
|
"bridge_id": "bridge-scenes",
|
|
"device_name": "USB*",
|
|
"message_type": "note_on",
|
|
"channel": 0,
|
|
"number": 36,
|
|
"action": "activate_scene",
|
|
"target_type": "scene",
|
|
"target_id": slug,
|
|
"mode": "trigger",
|
|
"minimum_value": 1,
|
|
"maximum_value": 127,
|
|
},
|
|
)
|
|
assert mapping.status_code == 200
|
|
|
|
event = client.post(
|
|
"/api/v1/integrations/midi/bridge/events",
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
json={
|
|
"type": "midi_event",
|
|
"protocol_version": 1,
|
|
"bridge_id": "bridge-scenes",
|
|
"device": "USB MIDI Controller",
|
|
"timestamp": "2026-07-24T20:30:00+00:00",
|
|
"message": {"type": "note_on", "channel": 0, "number": 36, "value": 127},
|
|
},
|
|
)
|
|
assert event.status_code == 200
|
|
assert event.json()["executed"] == 1
|
|
assert f"scene:{slug}" in app_state.engine.layers
|
|
|
|
client.delete(f"/api/v1/integrations/midi/mappings/{mapping.json()['id']}")
|
|
client.delete(f"/api/v1/scenes/{scene_id}")
|
|
|
|
|
|
def test_midi_flash_hold_applies_and_releases_scene_layer() -> None:
|
|
slug = f"midi-flash-{uuid4().hex[:8]}"
|
|
with TestClient(app) as client:
|
|
scene_id = _create_scene(client, slug)
|
|
token = _create_token(client, bridge_id="bridge-flash")
|
|
mapping_response = client.post(
|
|
"/api/v1/integrations/midi/mappings",
|
|
json={
|
|
"name": "Flash scene",
|
|
"enabled": True,
|
|
"bridge_id": "bridge-flash",
|
|
"device_name": "*",
|
|
"message_type": "note_on",
|
|
"channel": 0,
|
|
"number": 40,
|
|
"action": "flash_scene",
|
|
"target_type": "scene",
|
|
"target_id": slug,
|
|
"mode": "hold",
|
|
"minimum_value": 1,
|
|
"maximum_value": 127,
|
|
},
|
|
)
|
|
assert mapping_response.status_code == 200
|
|
mapping_id = mapping_response.json()["id"]
|
|
layer_name = f"midi:flash:{mapping_id}:{slug}"
|
|
|
|
note_on = client.post(
|
|
"/api/v1/integrations/midi/bridge/events",
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
json={
|
|
"type": "midi_event",
|
|
"protocol_version": 1,
|
|
"bridge_id": "bridge-flash",
|
|
"device": "USB MIDI Controller",
|
|
"timestamp": "2026-07-24T20:30:01+00:00",
|
|
"message": {"type": "note_on", "channel": 0, "number": 40, "value": 127},
|
|
},
|
|
)
|
|
assert note_on.status_code == 200
|
|
assert layer_name in app_state.engine.layers
|
|
|
|
note_off = client.post(
|
|
"/api/v1/integrations/midi/bridge/events",
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
json={
|
|
"type": "midi_event",
|
|
"protocol_version": 1,
|
|
"bridge_id": "bridge-flash",
|
|
"device": "USB MIDI Controller",
|
|
"timestamp": "2026-07-24T20:30:02+00:00",
|
|
"message": {"type": "note_off", "channel": 0, "number": 40, "value": 0},
|
|
},
|
|
)
|
|
assert note_off.status_code == 200
|
|
assert layer_name not in app_state.engine.layers
|
|
|
|
client.delete(f"/api/v1/integrations/midi/mappings/{mapping_id}")
|
|
client.delete(f"/api/v1/scenes/{scene_id}")
|
|
|
|
|
|
def test_midi_continuous_master_and_scene_intensity_are_scaled() -> None:
|
|
slug = f"midi-intensity-{uuid4().hex[:8]}"
|
|
with TestClient(app) as client:
|
|
scene_id = _create_scene(client, slug)
|
|
token = _create_token(client, bridge_id="bridge-cc")
|
|
master_mapping = client.post(
|
|
"/api/v1/integrations/midi/mappings",
|
|
json={
|
|
"name": "Master dimmer",
|
|
"enabled": True,
|
|
"bridge_id": "bridge-cc",
|
|
"device_name": "*",
|
|
"message_type": "control_change",
|
|
"channel": 0,
|
|
"number": 14,
|
|
"action": "set_master_dimmer",
|
|
"target_type": "global",
|
|
"target_id": None,
|
|
"mode": "continuous",
|
|
"minimum_value": 0,
|
|
"maximum_value": 127,
|
|
},
|
|
)
|
|
assert master_mapping.status_code == 200
|
|
|
|
intensity_mapping = client.post(
|
|
"/api/v1/integrations/midi/mappings",
|
|
json={
|
|
"name": "Scene intensity",
|
|
"enabled": True,
|
|
"bridge_id": "bridge-cc",
|
|
"device_name": "*",
|
|
"message_type": "control_change",
|
|
"channel": 0,
|
|
"number": 15,
|
|
"action": "set_scene_intensity",
|
|
"target_type": "scene",
|
|
"target_id": slug,
|
|
"mode": "continuous",
|
|
"minimum_value": 0,
|
|
"maximum_value": 127,
|
|
},
|
|
)
|
|
assert intensity_mapping.status_code == 200
|
|
|
|
master_event = client.post(
|
|
"/api/v1/integrations/midi/bridge/events",
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
json={
|
|
"type": "midi_event",
|
|
"protocol_version": 1,
|
|
"bridge_id": "bridge-cc",
|
|
"device": "USB MIDI Controller",
|
|
"timestamp": "2026-07-24T20:31:00+00:00",
|
|
"message": {"type": "control_change", "channel": 0, "number": 14, "value": 64},
|
|
},
|
|
)
|
|
assert master_event.status_code == 200
|
|
assert 120 <= app_state.engine.master <= 132
|
|
|
|
intensity_event = client.post(
|
|
"/api/v1/integrations/midi/bridge/events",
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
json={
|
|
"type": "midi_event",
|
|
"protocol_version": 1,
|
|
"bridge_id": "bridge-cc",
|
|
"device": "USB MIDI Controller",
|
|
"timestamp": "2026-07-24T20:31:01+00:00",
|
|
"message": {"type": "control_change", "channel": 0, "number": 15, "value": 64},
|
|
},
|
|
)
|
|
assert intensity_event.status_code == 200
|
|
layer_name = f"midi:intensity:{intensity_mapping.json()['id']}:{slug}"
|
|
layer = app_state.engine.layers[layer_name]
|
|
assert 120 <= layer.values_by_universe[1][10] <= 132
|
|
|
|
client.delete(f"/api/v1/integrations/midi/mappings/{master_mapping.json()['id']}")
|
|
client.delete(f"/api/v1/integrations/midi/mappings/{intensity_mapping.json()['id']}")
|
|
client.delete(f"/api/v1/scenes/{scene_id}")
|
|
|
|
|
|
def test_midi_learn_captures_next_event_without_triggering_existing_mapping() -> None:
|
|
slug = f"midi-learn-{uuid4().hex[:8]}"
|
|
with TestClient(app) as client:
|
|
scene_id = _create_scene(client, slug)
|
|
token = _create_token(client, bridge_id="bridge-learn")
|
|
mapping = client.post(
|
|
"/api/v1/integrations/midi/mappings",
|
|
json={
|
|
"name": "Learn target",
|
|
"enabled": True,
|
|
"bridge_id": "bridge-learn",
|
|
"device_name": "*",
|
|
"message_type": "note_on",
|
|
"channel": 0,
|
|
"number": 41,
|
|
"action": "activate_scene",
|
|
"target_type": "scene",
|
|
"target_id": slug,
|
|
"mode": "trigger",
|
|
"minimum_value": 1,
|
|
"maximum_value": 127,
|
|
},
|
|
)
|
|
assert mapping.status_code == 200
|
|
|
|
start = client.post(
|
|
"/api/v1/integrations/midi/learn/start",
|
|
json={"timeout_seconds": 15, "allow_passthrough": False},
|
|
)
|
|
assert start.status_code == 200
|
|
assert start.json()["active"] is True
|
|
|
|
event = client.post(
|
|
"/api/v1/integrations/midi/bridge/events",
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
json={
|
|
"type": "midi_event",
|
|
"protocol_version": 1,
|
|
"bridge_id": "bridge-learn",
|
|
"device": "USB MIDI Controller",
|
|
"timestamp": "2026-07-24T20:32:00+00:00",
|
|
"message": {"type": "note_on", "channel": 0, "number": 41, "value": 127},
|
|
},
|
|
)
|
|
assert event.status_code == 200
|
|
assert event.json()["captured_for_learning"] is True
|
|
assert f"scene:{slug}" not in app_state.engine.layers
|
|
|
|
state = client.get("/api/v1/integrations/midi/learn")
|
|
assert state.status_code == 200
|
|
assert state.json()["active"] is False
|
|
assert state.json()["captured_event"]["message"]["number"] == 41
|
|
|
|
client.delete(f"/api/v1/integrations/midi/mappings/{mapping.json()['id']}")
|
|
client.delete(f"/api/v1/scenes/{scene_id}")
|
|
|
|
|
|
def test_midi_bridge_token_can_be_restricted_to_bridge_id() -> None:
|
|
with TestClient(app) as client:
|
|
token = _create_token(client, bridge_id="bridge-locked")
|
|
response = client.post(
|
|
"/api/v1/integrations/midi/bridge/events",
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
json={
|
|
"type": "midi_event",
|
|
"protocol_version": 1,
|
|
"bridge_id": "wrong-bridge",
|
|
"device": "USB MIDI Controller",
|
|
"timestamp": "2026-07-24T20:33:00+00:00",
|
|
"message": {"type": "note_on", "channel": 0, "number": 50, "value": 127},
|
|
},
|
|
)
|
|
assert response.status_code == 401
|