Files
thomas 1f110866f5
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
Update docs and screenshots
2026-07-25 10:26:29 +02:00

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