Update docs and screenshots
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

This commit is contained in:
2026-07-25 10:26:29 +02:00
parent d6cda77aaf
commit 1f110866f5
170 changed files with 24657 additions and 3 deletions
+4
View File
@@ -0,0 +1,4 @@
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
+593
View File
@@ -0,0 +1,593 @@
from uuid import uuid4
from app.core.dependencies import app_state
from app.main import app
from fastapi.testclient import TestClient
def test_health_endpoint() -> None:
with TestClient(app) as client:
response = client.get("/api/v1/health")
assert response.status_code == 200
payload = response.json()
assert payload["status"] == "ok"
assert payload["simulator_enabled"] is True
def test_trigger_endpoint_returns_202() -> None:
with TestClient(app) as client:
response = client.post(
"/api/v1/triggers/raid",
json={"eventType": "raid", "displayName": "Tester", "eventId": "evt-1"},
)
assert response.status_code == 202
assert response.json()["accepted"] is True
def test_dmx_status_exposes_backend_counters() -> None:
with TestClient(app) as client:
response = client.get("/api/v1/dmx/status")
assert response.status_code == 200
payload = response.json()
assert payload["backend"] in {"simulator", "ola", "artnet"}
assert payload["selected_universe"] == 1
assert "selected_output_port" in payload
assert payload["frames_sent"] >= 0
assert payload["send_errors"] >= 0
assert "last_successful_frame" in payload
def test_dmx_output_config_can_be_saved_and_read_back() -> None:
with TestClient(app) as client:
original = client.get("/api/v1/dmx/config").json()
response = client.put(
"/api/v1/dmx/config",
json={
"backend": "artnet",
"universe": 7,
"output_port": "WLED stue",
"target_host": "192.168.2.50",
},
)
assert response.status_code == 200
payload = response.json()
assert payload["backend"] == "artnet"
assert payload["universe"] == 7
assert payload["output_port"] == "WLED stue"
assert payload["target_host"] == "192.168.2.50"
read_back = client.get("/api/v1/dmx/config")
assert read_back.status_code == 200
assert read_back.json()["backend"] == "artnet"
assert read_back.json()["universe"] == 7
restore_response = client.put("/api/v1/dmx/config", json=original)
assert restore_response.status_code == 200
def test_dmx_artnet_discover_endpoint_returns_nodes(monkeypatch) -> None:
async def fake_discover(timeout_s: float) -> dict[str, object]:
assert timeout_s == 0.25
return {
"count": 1,
"items": [
{
"ip": "192.168.2.60",
"short_name": "WLED",
"long_name": "WLED Node",
"label": "WLED Node (192.168.2.60)",
"net": 0,
"sub_switch": 0,
"port_count": 1,
"raw_port_address": 0,
}
],
}
monkeypatch.setattr(app_state.dmx, "discover_artnet", fake_discover)
with TestClient(app) as client:
response = client.post("/api/v1/dmx/artnet/discover?timeout_s=0.25")
assert response.status_code == 200
payload = response.json()
assert payload["count"] == 1
assert payload["items"][0]["ip"] == "192.168.2.60"
def test_home_assistant_config_can_be_saved_and_mapping_created(monkeypatch) -> None:
with TestClient(app) as client:
config_response = client.put(
"/api/v1/integrations/home-assistant/config",
json={
"enabled": True,
"base_url": "http://ha.local:8123",
"token": "secret-token",
"default_universe": 10,
},
)
assert config_response.status_code == 200
assert config_response.json()["enabled"] is True
assert config_response.json()["base_url"] == "http://ha.local:8123"
assert config_response.json()["has_token"] is True
assert "token" not in config_response.json()
assert config_response.json()["token_mask"] == "********"
create_response = client.post(
"/api/v1/integrations/home-assistant/mappings",
json={
"name": "HA RGB",
"universe": 10,
"start_address": 1,
"fixture_type": "rgb",
"entity_id": "light.bar_rgb",
"rate_limit_hz": 5,
"deadband": 2,
"fade_ms": 150,
"invert_channel": False,
"min_value": 0,
"max_value": 255,
"enabled": True,
"master_dimmer": True,
},
)
assert create_response.status_code == 200
mapping = create_response.json()
assert mapping["universe"] == 10
assert mapping["channel_span"] == 4
async def fake_test_mapping(mapping_id: int) -> dict[str, object]:
return {"status": "sent", "mapping_id": mapping_id}
monkeypatch.setattr(app_state.home_assistant, "test_mapping", fake_test_mapping)
test_response = client.post(f"/api/v1/integrations/home-assistant/mappings/{mapping['id']}/test")
assert test_response.status_code == 200
assert test_response.json()["status"] == "sent"
delete_response = client.delete(f"/api/v1/integrations/home-assistant/mappings/{mapping['id']}")
assert delete_response.status_code == 200
def test_home_assistant_connection_endpoint_returns_status(monkeypatch) -> None:
async def fake_test_connection() -> dict[str, object]:
return {
"reachable": True,
"auth_ok": True,
"ha_version": "2026.7.0",
"last_error": None,
"last_successful_call_at": "2026-07-23T12:45:00+02:00",
"last_connection_success_at": "2026-07-23T12:45:00+02:00",
}
monkeypatch.setattr(app_state.home_assistant, "test_connection", fake_test_connection)
with TestClient(app) as client:
response = client.post("/api/v1/integrations/home-assistant/test-connection")
assert response.status_code == 200
payload = response.json()
assert payload["reachable"] is True
assert payload["auth_ok"] is True
assert payload["ha_version"] == "2026.7.0"
def test_bpm_config_can_be_saved_and_read_back() -> None:
with TestClient(app) as client:
original = client.get("/api/v1/bpm/config").json()["preferred_device"]
response = client.put(
"/api/v1/bpm/config",
json={"preferred_device": "alsa:plughw:CARD=SB,DEV=0"},
)
assert response.status_code == 200
assert response.json()["preferred_device"] == "alsa:plughw:CARD=SB,DEV=0"
status_response = client.get("/api/v1/bpm/status")
assert status_response.status_code == 200
assert status_response.json()["selected_device"] == "alsa:plughw:CARD=SB,DEV=0"
restore_response = client.put(
"/api/v1/bpm/config",
json={"preferred_device": original},
)
assert restore_response.status_code == 200
def test_restart_service_endpoint_returns_scheduler_result(monkeypatch) -> None:
async def fake_restart_service() -> dict[str, object]:
return {
"accepted": True,
"action": "restart-service",
"status": "scheduled",
"detail": "Servicegenstart planlagt.",
}
monkeypatch.setattr(app_state.system, "restart_service", fake_restart_service)
with TestClient(app) as client:
response = client.post("/api/v1/system/restart-service")
assert response.status_code == 200
assert response.json()["accepted"] is True
assert response.json()["action"] == "restart-service"
def test_reboot_host_endpoint_returns_scheduler_result(monkeypatch) -> None:
async def fake_reboot_host() -> dict[str, object]:
return {
"accepted": True,
"action": "reboot-host",
"status": "scheduled",
"detail": "Hostgenstart planlagt.",
}
monkeypatch.setattr(app_state.system, "reboot_host", fake_reboot_host)
with TestClient(app) as client:
response = client.post("/api/v1/system/reboot-host")
assert response.status_code == 200
assert response.json()["accepted"] is True
assert response.json()["action"] == "reboot-host"
def test_delete_scene_releases_active_layer() -> None:
with TestClient(app) as client:
app_state.scenes.active_slug = None
app_state.engine.layers.clear()
existing_scenes = client.get("/api/v1/scenes").json()["items"]
for scene in existing_scenes:
if scene["slug"] == "brr3-test":
client.delete(f"/api/v1/scenes/{scene['id']}")
create_response = client.post(
"/api/v1/scenes",
json={
"name": "BRR-3 test",
"slug": "brr3-test",
"priority": 10,
"values": [{"channel": 10, "value": 255, "precedence": "htp", "source": "scene"}],
},
)
assert create_response.status_code == 200
activate_response = client.post("/api/v1/scenes/brr3-test/activate")
assert activate_response.status_code == 200
assert "scene:brr3-test" in app_state.engine.layers
scene_id = create_response.json()["id"]
delete_response = client.delete(f"/api/v1/scenes/{scene_id}")
assert delete_response.status_code == 200
assert delete_response.json() == {"deleted": "brr3-test"}
assert "scene:brr3-test" not in app_state.engine.layers
assert app_state.scenes.active_slug is None
def test_imported_fixture_persists_and_can_be_patched() -> None:
universe = 101
unique_key = f"brr3-{uuid4().hex[:8]}"
payload = {
"manufacturer": "Eurolite",
"model": "BRR-3 Test",
"categories": ["Color Changer"],
"source": {"manufacturer_key": "eurolite", "fixture_key": unique_key},
"modes": [
{
"key": "6ch",
"channels": [
{"key": "Red", "display_name": "Red", "precedence": "ltp", "resolution": 8},
{"key": "Green", "display_name": "Green", "precedence": "ltp", "resolution": 8},
{"key": "Blue", "display_name": "Blue", "precedence": "ltp", "resolution": 8},
{"key": "White", "display_name": "White", "precedence": "ltp", "resolution": 8},
{"key": "Amber", "display_name": "Amber", "precedence": "ltp", "resolution": 8},
{"key": "UV", "display_name": "UV", "precedence": "ltp", "resolution": 8},
],
}
],
}
with TestClient(app) as client:
import_response = client.post("/api/v1/fixtures/import-file", json=payload)
assert import_response.status_code == 200
fixture = import_response.json()
fixture_id = fixture["id"]
assert fixture["manufacturer"] == "Eurolite"
assert fixture["modes"][0]["channel_count"] == 6
list_response = client.get("/api/v1/fixtures")
assert list_response.status_code == 200
assert any(item["id"] == fixture_id for item in list_response.json()["items"])
validate_response = client.post(
"/api/v1/patch/validate",
json={
"universe": universe,
"definition_id": fixture_id,
"mode_key": "6ch",
"start_address": 10,
},
)
assert validate_response.status_code == 200
assert validate_response.json()["valid"] is True
assert validate_response.json()["range"] == "10-15"
create_patch_response = client.post(
"/api/v1/patch",
json={
"universe": universe,
"name": "Eurolite venstre",
"definition_id": fixture_id,
"mode_key": "6ch",
"start_address": 10,
"enabled": True,
"group_names": ["front", "wash"],
"position": {"x": 20, "y": 35, "z": 8, "rotation": 15},
},
)
assert create_patch_response.status_code == 201
patch = create_patch_response.json()
assert patch["start_address"] == 10
assert patch["end_address"] == 15
assert patch["group_names"] == ["front", "wash"]
assert patch["position"]["x"] == 20
conflict_response = client.post(
"/api/v1/patch",
json={
"universe": universe,
"name": "Eurolite højre",
"definition_id": fixture_id,
"mode_key": "6ch",
"start_address": 12,
"enabled": True,
"group_names": [],
"position": {"x": 70, "y": 35, "z": 8, "rotation": 345},
},
)
assert conflict_response.status_code == 409
delete_patch_response = client.delete(f"/api/v1/patch/{patch['id']}")
assert delete_patch_response.status_code == 200
delete_fixture_response = client.delete(f"/api/v1/fixtures/{fixture_id}")
assert delete_fixture_response.status_code == 204
def test_import_ofl_style_fixture_file_supports_mode_channels() -> None:
payload = {
"$schema": "https://raw.githubusercontent.com/OpenLightingProject/open-fixture-library/master/schemas/fixture.json",
"manufacturer": "Eurolite",
"name": "LED Bar-3 HCL Bar",
"categories": ["Color Changer"],
"source": {
"manufacturer_key": "eurolite",
"fixture_key": f"led-bar-3-hcl-bar-{uuid4().hex[:8]}",
},
"availableChannels": {
"Dimmer": {"capability": {"type": "Intensity"}},
"Strobe": {
"capabilities": [
{"dmxRange": [0, 9], "type": "NoFunction"},
{"dmxRange": [10, 255], "type": "ShutterStrobe"},
]
},
"Red": {"capability": {"type": "ColorIntensity", "color": "Red"}},
"Green": {"capability": {"type": "ColorIntensity", "color": "Green"}},
"Blue": {"capability": {"type": "ColorIntensity", "color": "Blue"}},
"White": {"capability": {"type": "ColorIntensity", "color": "White"}},
"Amber": {"capability": {"type": "ColorIntensity", "color": "Amber"}},
"UV": {"capability": {"type": "ColorIntensity", "color": "UV"}},
},
"modes": [
{"name": "6ch", "channels": ["Red", "Green", "Blue", "White", "Amber", "UV"]},
{"name": "10ch", "channels": ["Dimmer", "Strobe", "Red", "Green", "Blue", "White", "Amber", "UV"]},
],
}
with TestClient(app) as client:
response = client.post("/api/v1/fixtures/import-file", json=payload)
assert response.status_code == 200
fixture = response.json()
assert fixture["manufacturer"] == "Eurolite"
assert fixture["model"] == "LED Bar-3 HCL Bar"
assert fixture["source"]["manufacturer_key"] == "eurolite"
assert fixture["modes"][0]["channels"][0]["key"] == "Red"
assert fixture["modes"][1]["channels"][0]["precedence"] == "htp"
assert fixture["modes"][1]["channels"][1]["capabilities"][1]["type"] == "ShutterStrobe"
delete_fixture_response = client.delete(f"/api/v1/fixtures/{fixture['id']}")
assert delete_fixture_response.status_code == 204
def test_live_mixer_returns_and_updates_patched_fixture() -> None:
universe = 102
unique_key = f"live-mixer-{uuid4().hex[:8]}"
payload = {
"manufacturer": "Eurolite",
"model": "LED Bar-3 HCL Bar",
"categories": ["Color Changer"],
"source": {"manufacturer_key": "eurolite", "fixture_key": unique_key},
"modes": [
{
"key": "10-channel",
"channels": [
{"key": "Dimmer", "display_name": "Dimmer", "precedence": "htp", "resolution": 8},
{"key": "Strobe", "display_name": "Strobe", "precedence": "ltp", "resolution": 8},
{"key": "Red", "display_name": "Red", "precedence": "ltp", "resolution": 8},
],
}
],
}
with TestClient(app) as client:
fixture = client.post("/api/v1/fixtures/import-file", json=payload).json()
patch = client.post(
"/api/v1/patch",
json={
"universe": universe,
"name": "Eurolite live",
"definition_id": fixture["id"],
"mode_key": "10-channel",
"start_address": 10,
"enabled": True,
"group_names": [],
"position": {"x": 40, "y": 50, "z": 5, "rotation": 0},
},
).json()
mixer_response = client.get("/api/v1/live/mixer")
assert mixer_response.status_code == 200
assert any(item["patch_id"] == patch["id"] for item in mixer_response.json()["items"])
update_response = client.put(
f"/api/v1/live/mixer/{patch['id']}",
json={"values": {"1": 200, "3": 128}},
)
assert update_response.status_code == 200
mixer_item = next(item for item in update_response.json()["items"] if item["patch_id"] == patch["id"])
dimmer = next(channel for channel in mixer_item["channels"] if channel["index"] == 1)
red = next(channel for channel in mixer_item["channels"] if channel["index"] == 3)
assert dimmer["value"] == 200
assert red["absolute_channel"] == 12
clear_response = client.delete(f"/api/v1/live/mixer/{patch['id']}")
assert clear_response.status_code == 200
assert client.delete(f"/api/v1/patch/{patch['id']}").status_code == 200
assert client.delete(f"/api/v1/fixtures/{fixture['id']}").status_code == 204
def test_live_mixer_returns_and_updates_home_assistant_mapping() -> None:
with TestClient(app) as client:
create_response = client.post(
"/api/v1/integrations/home-assistant/mappings",
json={
"name": "Loft bunker",
"universe": 10,
"start_address": 1,
"fixture_type": "switch",
"entity_id": "light.loft_bunker",
"rate_limit_hz": 5,
"deadband": 2,
"fade_ms": 0,
"invert_channel": False,
"min_value": 0,
"max_value": 255,
"enabled": True,
"master_dimmer": False,
},
)
assert create_response.status_code == 200
mapping = create_response.json()
mixer_response = client.get("/api/v1/live/mixer")
assert mixer_response.status_code == 200
mixer_item = next(
item
for item in mixer_response.json()["items"]
if item["source_type"] == "home_assistant" and item["source_id"] == mapping["id"]
)
assert mixer_item["entity_id"] == "light.loft_bunker"
assert mixer_item["universe"] == 10
assert mixer_item["channel_count"] == 1
update_response = client.put(
f"/api/v1/live/mixer/home-assistant/{mapping['id']}",
json={"values": {"1": 255}},
)
assert update_response.status_code == 200
updated_item = next(
item
for item in update_response.json()["items"]
if item["source_type"] == "home_assistant" and item["source_id"] == mapping["id"]
)
assert updated_item["channels"][0]["value"] == 255
assert updated_item["channels"][0]["absolute_channel"] == 1
clear_response = client.delete(f"/api/v1/live/mixer/home-assistant/{mapping['id']}")
assert clear_response.status_code == 200
delete_response = client.delete(f"/api/v1/integrations/home-assistant/mappings/{mapping['id']}")
assert delete_response.status_code == 200
def test_scene_group_target_resolves_fixture_channels() -> None:
universe = 103
unique_key = f"scene-group-{uuid4().hex[:8]}"
payload = {
"manufacturer": "Eurolite",
"model": "LED Bar-3 HCL Bar",
"categories": ["Color Changer"],
"source": {"manufacturer_key": "eurolite", "fixture_key": unique_key},
"modes": [
{
"key": "10-channel",
"channels": [
{"key": "Dimmer", "display_name": "Dimmer", "precedence": "htp", "resolution": 8},
{"key": "Strobe", "display_name": "Strobe", "precedence": "ltp", "resolution": 8},
{"key": "Blue", "display_name": "Blue", "precedence": "ltp", "resolution": 8},
],
}
],
}
with TestClient(app) as client:
fixture = client.post("/api/v1/fixtures/import-file", json=payload).json()
patch_left = client.post(
"/api/v1/patch",
json={
"universe": universe,
"name": "Bar venstre",
"definition_id": fixture["id"],
"mode_key": "10-channel",
"start_address": 1,
"enabled": True,
"group_names": ["synk-a", "front"],
"position": {"x": 20, "y": 30, "z": 5, "rotation": 10},
},
).json()
patch_right = client.post(
"/api/v1/patch",
json={
"universe": universe,
"name": "Bar højre",
"definition_id": fixture["id"],
"mode_key": "10-channel",
"start_address": 11,
"enabled": True,
"group_names": ["synk-a", "front"],
"position": {"x": 80, "y": 30, "z": 5, "rotation": 350},
},
).json()
scene_response = client.post(
"/api/v1/scenes",
json={
"name": "Front blue",
"slug": "front-blue",
"priority": 20,
"values": [],
"targets": [
{
"target_type": "group",
"group_name": "synk-a",
"values": [
{"attribute": "Dimmer", "value": 200},
{"attribute": "Blue", "value": 128},
],
}
],
},
)
assert scene_response.status_code == 200
activate_response = client.post("/api/v1/scenes/front-blue/activate")
assert activate_response.status_code == 200
layer = app_state.engine.layers["scene:front-blue"]
assert layer.values_by_universe[universe][1] == 200
assert layer.values_by_universe[universe][3] == 128
assert layer.values_by_universe[universe][11] == 200
assert layer.values_by_universe[universe][13] == 128
scene_id = scene_response.json()["id"]
assert client.delete(f"/api/v1/scenes/{scene_id}").status_code == 200
assert client.delete(f"/api/v1/patch/{patch_left['id']}").status_code == 200
assert client.delete(f"/api/v1/patch/{patch_right['id']}").status_code == 200
assert client.delete(f"/api/v1/fixtures/{fixture['id']}").status_code == 204
+118
View File
@@ -0,0 +1,118 @@
import struct
import pytest
from app.dmx.backends import (
ARTNET_HEADER,
ARTNET_OPCODE_DMX,
ARTNET_OPCODE_POLL_REPLY,
ARTNET_PORT,
ArtNetDmxBackend,
build_artnet_dmx_packet,
parse_artnet_poll_reply,
)
from app.dmx.frame import DmxFrame
def test_build_artnet_packet_sends_full_512_frame_to_expected_universe() -> None:
values = [0] * 512
values[0] = 255
values[9] = 64
packet = build_artnet_dmx_packet(2, values, sequence=9)
assert packet.startswith(ARTNET_HEADER)
assert struct.unpack_from("<H", packet, 8)[0] == ARTNET_OPCODE_DMX
assert packet[12] == 9
assert packet[14] == 1
assert packet[15] == 0
assert struct.unpack_from(">H", packet, 16)[0] == 512
assert len(packet) == 530
assert packet[18] == 255
assert packet[27] == 64
def test_parse_artnet_poll_reply_extracts_node_identity() -> None:
packet = bytearray(239)
packet[0:8] = ARTNET_HEADER
struct.pack_into("<H", packet, 8, ARTNET_OPCODE_POLL_REPLY)
packet[10:14] = bytes([192, 168, 2, 55])
packet[18] = 0
packet[19] = 1
packet[26 : 26 + len(b"WLED Node\x00")] = b"WLED Node\x00"
packet[44 : 44 + len(b"Paravega Test Node\x00")] = b"Paravega Test Node\x00"
struct.pack_into(">H", packet, 172, 4)
packet[190] = 3
node = parse_artnet_poll_reply(bytes(packet))
assert node is not None
assert node.ip == "192.168.2.55"
assert node.short_name == "WLED Node"
assert node.long_name == "Paravega Test Node"
assert node.port_count == 4
assert node.raw_port_address == 3
assert node.label == "Paravega Test Node (192.168.2.55)"
@pytest.mark.asyncio
async def test_artnet_backend_sends_udp_packet_and_updates_status(monkeypatch) -> None:
sent_packets: list[tuple[bytes, tuple[str, int]]] = []
class FakeSocket:
def setsockopt(self, *_args: object) -> None:
return
def sendto(self, packet: bytes, address: tuple[str, int]) -> None:
sent_packets.append((packet, address))
def close(self) -> None:
return
backend = ArtNetDmxBackend(
universe=4,
target_host="192.168.2.77",
output_port="Paravega stue",
)
monkeypatch.setattr(backend, "_socket", FakeSocket())
frame = DmxFrame(universe=4)
frame.set_channel(1, 200, "scene")
frame.set_channel(4, 99, "scene")
await backend.send_frame(frame)
status = backend.get_status()
assert status.connected is True
assert status.degraded is False
assert status.frames_sent == 1
assert status.send_errors == 0
assert status.selected_universe == 4
assert status.selected_output_port == "Paravega stue"
assert sent_packets[0][1] == ("192.168.2.77", ARTNET_PORT)
assert sent_packets[0][0][18] == 200
assert sent_packets[0][0][21] == 99
@pytest.mark.asyncio
async def test_artnet_backend_marks_degraded_on_socket_error(monkeypatch) -> None:
class FakeSocket:
def setsockopt(self, *_args: object) -> None:
return
def sendto(self, _packet: bytes, _address: tuple[str, int]) -> None:
raise OSError("Network unreachable")
def close(self) -> None:
return
backend = ArtNetDmxBackend(universe=1, target_host="192.168.2.90")
monkeypatch.setattr(backend, "_socket", FakeSocket())
frame = DmxFrame()
frame.set_channel(1, 255, "scene")
with pytest.raises(RuntimeError, match="Network unreachable"):
await backend.send_frame(frame)
status = backend.get_status()
assert status.connected is False
assert status.degraded is True
assert status.send_errors == 1
assert status.last_error == "Network unreachable"
+35
View File
@@ -0,0 +1,35 @@
from pathlib import Path
from app.backup.service import BackupService
def test_backup_and_restore_roundtrip(tmp_path: Path) -> None:
data_dir = tmp_path / "data"
backup_dir = data_dir / "backups"
data_dir.mkdir(parents=True)
sample_file = data_dir / "settings.json"
sample_file.write_text('{"scene":"base"}', encoding="utf-8")
service = BackupService(data_dir=data_dir, backup_dir=backup_dir)
created = service.create_backup("audit-backup")
sample_file.write_text('{"scene":"modified"}', encoding="utf-8")
service.restore_backup(created.id)
assert sample_file.read_text(encoding="utf-8") == '{"scene":"base"}'
assert any(item.label == "audit-backup" for item in service.list_backups())
def test_restore_empty_backup_clears_runtime_files(tmp_path: Path) -> None:
data_dir = tmp_path / "data"
backup_dir = data_dir / "backups"
data_dir.mkdir(parents=True)
service = BackupService(data_dir=data_dir, backup_dir=backup_dir)
empty_backup = service.create_backup("empty-runtime")
sample_file = data_dir / "settings.json"
sample_file.write_text('{"scene":"modified"}', encoding="utf-8")
service.restore_backup(empty_backup.id)
assert not sample_file.exists()
+61
View File
@@ -0,0 +1,61 @@
from __future__ import annotations
import asyncio
from app.bpm.service import BeatAnalyzer, BpmService
def test_beat_analyzer_estimates_click_track_bpm() -> None:
analyzer = BeatAnalyzer(sample_rate=44_100, window_size=1024)
click = (b"\x00\x40" * 2048) + (b"\x00\x00" * (44_100 // 2 - 2048))
result = None
for _ in range(6):
result = analyzer.feed_pcm16(click)
assert result is not None
bpm, confidence = result
assert 117 <= bpm <= 124
assert confidence > 0.5
def test_bpm_service_synthetic_audio_updates_snapshot() -> None:
async def run_test() -> None:
service = BpmService()
await service.start_audio("synthetic-click-track")
await asyncio.sleep(1.2)
snapshot = service.snapshot()
await service.stop_audio()
assert snapshot["mode"] == "audio:synthetic-click-track"
assert snapshot["audio_connected"] is True
assert 117 <= float(snapshot["bpm"]) <= 123
assert float(snapshot["confidence"]) > 0.3
assert float(snapshot["input_level"]) > 0.0
assert float(snapshot["peak_level"]) >= float(snapshot["input_level"])
assert snapshot["clipping"] is False
asyncio.run(run_test())
def test_parse_alsa_card_line_supports_named_aliases() -> None:
service = BpmService()
card = service._parse_card_line("card 1: SB [HDA ATI SB], device 0: ALC883 Analog [ALC883 Analog]")
device = service._parse_device_from_card_line(
"card 1: SB [HDA ATI SB], device 0: ALC883 Analog [ALC883 Analog]"
)
assert card == ("1", "SB", "HDA ATI SB")
assert device == ("0", "ALC883 Analog", "ALC883 Analog")
def test_busy_alsa_error_is_humanized() -> None:
service = BpmService()
message = service._humanize_alsa_error(
"alsa:plughw:CARD=SB,DEV=0",
"arecord: main:831: audio open error: Device or resource busy",
)
assert "Mikrofonen er optaget" in message
+39
View File
@@ -0,0 +1,39 @@
from app.dmx.frame import DmxFrame, FrameLayer, merge_layers
def test_frame_has_512_channels() -> None:
frame = DmxFrame()
assert len(frame.values) == 512
assert all(value == 0 for value in frame.values)
def test_htp_chooses_highest_value() -> None:
base = FrameLayer.from_channel_values("base", priority=10, values={1: 80}, precedence_map={1: "htp"})
overlay = FrameLayer.from_channel_values("overlay", priority=20, values={1: 200}, precedence_map={1: "htp"})
frames = merge_layers([base, overlay])
assert frames[1].values[0] == 200
assert frames[1].source_map[0] == "overlay"
def test_ltp_chooses_latest_priority_layer() -> None:
base = FrameLayer.from_channel_values("base", priority=10, values={2: 200}, precedence_map={2: "ltp"})
overlay = FrameLayer.from_channel_values("overlay", priority=20, values={2: 15}, precedence_map={2: "ltp"})
frames = merge_layers([base, overlay])
assert frames[1].values[1] == 15
assert frames[1].source_map[1] == "overlay"
def test_blackout_overrides_layers() -> None:
layer = FrameLayer.from_channel_values("scene", priority=10, values={1: 255, 2: 128}, precedence_map={1: "htp"})
frames = merge_layers([layer], blackout=True)
assert frames[1].values[0] == 0
assert frames[1].values[1] == 0
def test_layers_are_merged_per_universe() -> None:
front = FrameLayer.from_channel_values("front", priority=10, values={1: 255}, universe=1)
home_assistant = FrameLayer.from_channel_values("ha", priority=10, values={1: 180, 2: 90}, universe=10)
frames = merge_layers([front, home_assistant])
assert frames[1].values[0] == 255
assert frames[10].values[0] == 180
assert frames[10].values[1] == 90
+55
View File
@@ -0,0 +1,55 @@
from __future__ import annotations
import asyncio
from app.effects.service import EffectService
from app.models.schemas import EffectPayload
class FakeEngine:
def __init__(self) -> None:
self.layers: dict[str, object] = {}
def set_layer(self, layer) -> None: # type: ignore[no-untyped-def]
self.layers[layer.name] = layer
def remove_layer(self, name: str) -> None:
self.layers.pop(name, None)
class FakeBpm:
def __init__(self) -> None:
self.current_bpm = 180.0
self.audio_connected = False
self.beat_counter = 0
def test_beat_flash_effect_pulses_layer_from_bpm_clock() -> None:
async def run_test() -> None:
engine = FakeEngine()
bpm = FakeBpm()
service = EffectService(engine, bpm) # type: ignore[arg-type]
payload = EffectPayload(
name="Beat dimmer",
slug="beat-dimmer",
effect_type="beat-flash",
priority=60,
parameters={
"duration_ms": 90,
"channels": {"1": 255},
"precedence": {"1": "htp"},
},
)
service.save(payload)
service.trigger(payload.slug)
await asyncio.sleep(0.03)
assert "effect:beat-dimmer" in engine.layers
await asyncio.sleep(0.16)
assert "effect:beat-dimmer" not in engine.layers
service.stop(payload.slug)
await service.shutdown()
asyncio.run(run_test())
@@ -0,0 +1,682 @@
from __future__ import annotations
import asyncio
import httpx
import pytest
from app.dmx.engine import DmxEngine
from app.dmx.frame import DmxFrame
from app.homeassistant.service import HomeAssistantService
from app.telemetry.service import TelemetryService
class FakeHomeAssistantAdapter:
def __init__(self) -> None:
self.calls: list[tuple[str, str, str, dict[str, object]]] = []
self.list_payload: list[dict[str, object]] = [
{
"entity_id": "light.bar_rgb",
"state": "on",
"attributes": {
"friendly_name": "Bar RGB",
"supported_color_modes": ["rgb", "brightness"],
},
},
{
"entity_id": "light.bar_rgbw",
"state": "on",
"attributes": {
"friendly_name": "Bar RGBW",
"supported_color_modes": ["rgbw", "brightness"],
},
},
{
"entity_id": "light.bar_cct",
"state": "on",
"attributes": {
"friendly_name": "Bar CCT",
"supported_color_modes": ["color_temp", "brightness"],
},
},
{
"entity_id": "light.test",
"state": "off",
"attributes": {
"friendly_name": "Test light",
"supported_color_modes": ["brightness"],
},
},
{
"entity_id": "switch.test",
"state": "off",
"attributes": {
"friendly_name": "Test switch",
},
},
{
"entity_id": "input_boolean.test",
"state": "off",
"attributes": {
"friendly_name": "Test bool",
},
},
{
"entity_id": "scene.test",
"state": "scening",
"attributes": {
"friendly_name": "Test scene",
},
},
{
"entity_id": "automation.test",
"state": "on",
"attributes": {
"friendly_name": "Test automation",
},
},
]
self.connection_error: Exception | None = None
self.dispatch_error: Exception | None = None
async def get_api_root(self, base_url: str, token: str) -> dict[str, object]:
if self.connection_error is not None:
raise self.connection_error
return {"message": "API running."}
async def get_config(self, base_url: str, token: str) -> dict[str, object]:
if self.connection_error is not None:
raise self.connection_error
return {"version": "2026.7.0"}
async def list_entities(self, base_url: str, token: str) -> list[dict[str, object]]:
if self.connection_error is not None:
raise self.connection_error
return self.list_payload
async def call_service(
self,
base_url: str,
token: str,
domain: str,
service: str,
data: dict[str, object],
) -> dict[str, object]:
if self.dispatch_error is not None:
raise self.dispatch_error
self.calls.append((base_url, token, f"{domain}.{service}", data))
return {"ok": True}
def build_service(adapter: FakeHomeAssistantAdapter) -> HomeAssistantService:
engine = DmxEngine(TelemetryService())
service = HomeAssistantService(engine, adapter=adapter)
service._config = {
"enabled": True,
"base_url": "http://ha.local:8123",
"default_universe": 10,
}
service._token = "test-token"
return service
@pytest.mark.asyncio
async def test_home_assistant_connection_reports_version_and_auth() -> None:
adapter = FakeHomeAssistantAdapter()
service = build_service(adapter)
result = await service.test_connection()
assert result["reachable"] is True
assert result["auth_ok"] is True
assert result["ha_version"] == "2026.7.0"
config = await service.get_config()
assert config["has_token"] is True
assert config["token_mask"] == "********"
@pytest.mark.asyncio
async def test_home_assistant_rgb_mapping_reads_virtual_universe_10() -> None:
adapter = FakeHomeAssistantAdapter()
service = build_service(adapter)
frame = DmxFrame(universe=10)
frame.set_channel(1, 255, "scene")
frame.set_channel(2, 200, "scene")
frame.set_channel(3, 100, "scene")
frame.set_channel(4, 50, "scene")
service.engine.current_frames = {1: DmxFrame(universe=1), 10: frame}
await service.list_entities()
service._mappings = [
service._normalize_mapping(
{
"id": 1,
"name": "Bar RGB",
"universe": 10,
"start_address": 1,
"fixture_type": "rgb",
"entity_id": "light.bar_rgb",
"rate_limit_hz": 5,
"deadband": 1,
"fade_ms": 150,
"enabled": True,
"master_dimmer": True,
}
)
]
await service.dispatch_once()
assert len(adapter.calls) == 1
_base_url, _token, service_name, data = adapter.calls[0]
assert service_name == "light.turn_on"
assert data["entity_id"] == "light.bar_rgb"
assert data["brightness"] == 255
assert data["rgb_color"] == [200, 100, 50]
@pytest.mark.asyncio
async def test_home_assistant_rgbw_mapping_at_channel_512_uses_last_channel() -> None:
adapter = FakeHomeAssistantAdapter()
service = build_service(adapter)
frame = DmxFrame(universe=10)
frame.set_channel(508, 255, "scene")
frame.set_channel(509, 10, "scene")
frame.set_channel(510, 20, "scene")
frame.set_channel(511, 30, "scene")
frame.set_channel(512, 40, "scene")
service.engine.current_frames = {10: frame}
await service.list_entities()
service._mappings = [
service._normalize_mapping(
{
"id": 2,
"name": "Bar RGBW",
"universe": 10,
"start_address": 508,
"fixture_type": "rgbw",
"entity_id": "light.bar_rgbw",
"rate_limit_hz": 5,
"deadband": 1,
"fade_ms": 100,
"enabled": True,
"master_dimmer": True,
}
)
]
await service.dispatch_once()
assert adapter.calls[0][2] == "light.turn_on"
assert adapter.calls[0][3]["rgbw_color"] == [10, 20, 30, 40]
@pytest.mark.asyncio
async def test_home_assistant_rate_limit_prevents_duplicate_spam() -> None:
adapter = FakeHomeAssistantAdapter()
service = build_service(adapter)
frame = DmxFrame(universe=10)
frame.set_channel(1, 180, "scene")
service.engine.current_frames = {10: frame}
await service.list_entities()
service._mappings = [
service._normalize_mapping(
{
"id": 3,
"name": "Dimmer",
"universe": 10,
"start_address": 1,
"fixture_type": "dimmer",
"entity_id": "light.bar_rgb",
"rate_limit_hz": 5,
"deadband": 2,
"fade_ms": 0,
"enabled": True,
"master_dimmer": False,
}
)
]
await service.dispatch_once()
await service.dispatch_once()
assert len(adapter.calls) == 1
@pytest.mark.asyncio
async def test_scene_mapping_uses_rising_edge_only() -> None:
adapter = FakeHomeAssistantAdapter()
service = build_service(adapter)
frame = DmxFrame(universe=10)
service.engine.current_frames = {10: frame}
service._mappings = [
service._normalize_mapping(
{
"id": 4,
"name": "Party scene",
"universe": 10,
"start_address": 6,
"fixture_type": "scene",
"entity_id": "scene.party_mode",
"enabled": True,
"master_dimmer": False,
}
)
]
await service.dispatch_once()
frame.set_channel(6, 140, "scene")
await service.dispatch_once()
await service.dispatch_once()
assert len(adapter.calls) == 1
assert adapter.calls[0][2] == "scene.turn_on"
@pytest.mark.asyncio
async def test_switch_mapping_uses_hysteresis() -> None:
adapter = FakeHomeAssistantAdapter()
service = build_service(adapter)
frame = DmxFrame(universe=10)
service.engine.current_frames = {10: frame}
service._mappings = [
service._normalize_mapping(
{
"id": 5,
"name": "Switch",
"universe": 10,
"start_address": 50,
"fixture_type": "switch",
"entity_id": "switch.party_relay",
"enabled": True,
"master_dimmer": False,
}
)
]
frame.set_channel(50, 150, "scene")
await service.dispatch_once()
frame.set_channel(50, 130, "scene")
await service.dispatch_once()
frame.set_channel(50, 110, "scene")
await service.dispatch_once()
assert [call[2] for call in adapter.calls] == ["switch.turn_on", "switch.turn_off"]
@pytest.mark.asyncio
async def test_switch_fixture_routes_light_entity_by_entity_domain() -> None:
adapter = FakeHomeAssistantAdapter()
service = build_service(adapter)
frame = DmxFrame(universe=10)
service.engine.current_frames = {10: frame}
service._mappings = [
service._normalize_mapping(
{
"id": 10,
"name": "Light as switch",
"universe": 10,
"start_address": 20,
"fixture_type": "switch",
"entity_id": "light.test",
"enabled": True,
"master_dimmer": False,
}
)
]
frame.set_channel(20, 150, "scene")
await service.dispatch_once()
frame.set_channel(20, 110, "scene")
await service.dispatch_once()
mapping = (await service.list_mappings())["items"][0]
assert [call[2] for call in adapter.calls] == ["light.turn_on", "light.turn_off"]
assert mapping["last_sent_summary"] == "light.turn_off light.test"
@pytest.mark.asyncio
async def test_switch_fixture_routes_switch_entity_by_entity_domain() -> None:
adapter = FakeHomeAssistantAdapter()
service = build_service(adapter)
frame = DmxFrame(universe=10)
service.engine.current_frames = {10: frame}
service._mappings = [
service._normalize_mapping(
{
"id": 11,
"name": "Switch domain",
"universe": 10,
"start_address": 21,
"fixture_type": "switch",
"entity_id": "switch.test",
"enabled": True,
"master_dimmer": False,
}
)
]
frame.set_channel(21, 150, "scene")
await service.dispatch_once()
frame.set_channel(21, 110, "scene")
await service.dispatch_once()
assert [call[2] for call in adapter.calls] == ["switch.turn_on", "switch.turn_off"]
@pytest.mark.asyncio
async def test_dimmer_fixture_routes_light_entity_with_brightness() -> None:
adapter = FakeHomeAssistantAdapter()
service = build_service(adapter)
frame = DmxFrame(universe=10)
frame.set_channel(22, 180, "scene")
service.engine.current_frames = {10: frame}
await service.list_entities()
service._mappings = [
service._normalize_mapping(
{
"id": 12,
"name": "Dimmer light",
"universe": 10,
"start_address": 22,
"fixture_type": "dimmer",
"entity_id": "light.test",
"enabled": True,
"master_dimmer": False,
}
)
]
await service.dispatch_once()
assert adapter.calls[0][2] == "light.turn_on"
assert adapter.calls[0][3]["brightness"] == 180
@pytest.mark.asyncio
async def test_scene_fixture_routes_scene_entity() -> None:
adapter = FakeHomeAssistantAdapter()
service = build_service(adapter)
frame = DmxFrame(universe=10)
service.engine.current_frames = {10: frame}
service._mappings = [
service._normalize_mapping(
{
"id": 13,
"name": "Scene test",
"universe": 10,
"start_address": 23,
"fixture_type": "scene",
"entity_id": "scene.test",
"enabled": True,
"master_dimmer": False,
}
)
]
frame.set_channel(23, 200, "scene")
await service.dispatch_once()
assert adapter.calls[0][2] == "scene.turn_on"
@pytest.mark.asyncio
async def test_automation_fixture_routes_automation_entity() -> None:
adapter = FakeHomeAssistantAdapter()
service = build_service(adapter)
frame = DmxFrame(universe=10)
service.engine.current_frames = {10: frame}
service._mappings = [
service._normalize_mapping(
{
"id": 14,
"name": "Automation test",
"universe": 10,
"start_address": 24,
"fixture_type": "automation",
"entity_id": "automation.test",
"enabled": True,
"master_dimmer": False,
}
)
]
frame.set_channel(24, 200, "scene")
await service.dispatch_once()
assert adapter.calls[0][2] == "automation.trigger"
@pytest.mark.asyncio
async def test_test_mapping_uses_same_entity_domain_routing() -> None:
adapter = FakeHomeAssistantAdapter()
service = build_service(adapter)
await service.list_entities()
service._mappings = [
service._normalize_mapping(
{
"id": 15,
"name": "Test mapping light switch",
"universe": 10,
"start_address": 25,
"fixture_type": "switch",
"entity_id": "light.test",
"enabled": True,
"master_dimmer": False,
}
)
]
await service.test_mapping(15)
assert adapter.calls[0][2] == "light.turn_on"
@pytest.mark.asyncio
async def test_test_mapping_resyncs_back_to_live_dmx() -> None:
adapter = FakeHomeAssistantAdapter()
service = build_service(adapter)
frame = DmxFrame(universe=10)
frame.set_channel(1, 120, "scene")
service.engine.current_frames = {10: frame}
await service.list_entities()
service._mappings = [
service._normalize_mapping(
{
"id": 6,
"name": "Dimmer",
"universe": 10,
"start_address": 1,
"fixture_type": "dimmer",
"entity_id": "light.bar_rgb",
"enabled": True,
"master_dimmer": False,
}
)
]
await service.test_mapping(6)
await service.dispatch_once()
assert adapter.calls[0][3]["brightness"] == 255
assert adapter.calls[-1][3]["brightness"] == 120
@pytest.mark.asyncio
async def test_timeout_error_is_tracked_without_blocking() -> None:
adapter = FakeHomeAssistantAdapter()
adapter.dispatch_error = httpx.ReadTimeout("timeout")
service = build_service(adapter)
frame = DmxFrame(universe=10)
frame.set_channel(1, 180, "scene")
service.engine.current_frames = {10: frame}
await service.list_entities()
service._mappings = [
service._normalize_mapping(
{
"id": 7,
"name": "Dimmer",
"universe": 10,
"start_address": 1,
"fixture_type": "dimmer",
"entity_id": "light.bar_rgb",
"enabled": True,
"master_dimmer": False,
}
)
]
await service.dispatch_once()
mapping = (await service.list_mappings())["items"][0]
assert mapping["last_error"] == "Timeout ved kald til Home Assistant"
@pytest.mark.asyncio
async def test_401_is_reported_from_connection_test() -> None:
adapter = FakeHomeAssistantAdapter()
request = httpx.Request("GET", "http://ha.local:8123/api/")
response = httpx.Response(401, request=request)
adapter.connection_error = httpx.HTTPStatusError("401", request=request, response=response)
service = build_service(adapter)
result = await service.test_connection()
assert result["reachable"] is True
assert result["auth_ok"] is False
assert result["last_error"] == "HTTP 401 fra Home Assistant"
@pytest.mark.asyncio
async def test_404_entity_not_found_is_tracked() -> None:
adapter = FakeHomeAssistantAdapter()
request = httpx.Request("POST", "http://ha.local:8123/api/services/light/turn_on")
response = httpx.Response(404, request=request)
adapter.dispatch_error = httpx.HTTPStatusError("404", request=request, response=response)
service = build_service(adapter)
frame = DmxFrame(universe=10)
frame.set_channel(1, 180, "scene")
service.engine.current_frames = {10: frame}
await service.list_entities()
service._mappings = [
service._normalize_mapping(
{
"id": 8,
"name": "Dimmer",
"universe": 10,
"start_address": 1,
"fixture_type": "dimmer",
"entity_id": "light.bar_rgb",
"enabled": True,
"master_dimmer": False,
}
)
]
await service.dispatch_once()
mapping = (await service.list_mappings())["items"][0]
assert mapping["last_error"] == "HTTP 404 fra Home Assistant"
@pytest.mark.asyncio
async def test_home_assistant_unavailable_is_reported() -> None:
adapter = FakeHomeAssistantAdapter()
adapter.connection_error = httpx.ConnectError("offline")
service = build_service(adapter)
result = await service.test_connection()
assert result["reachable"] is False
assert result["auth_ok"] is False
assert result["last_error"] == "Home Assistant kunne ikke kontaktes"
@pytest.mark.asyncio
async def test_persistence_survives_service_reload() -> None:
adapter = FakeHomeAssistantAdapter()
service = build_service(adapter)
await service.save_config(
{
"enabled": True,
"base_url": "http://ha.local:8123",
"token": "persistent-token",
"default_universe": 10,
}
)
await service.create_mapping(
{
"name": "Persisted RGB",
"universe": 10,
"start_address": 1,
"fixture_type": "rgb",
"entity_id": "light.bar_rgb",
"enabled": True,
"master_dimmer": True,
}
)
reloaded = HomeAssistantService(service.engine, adapter=adapter)
await reloaded._load()
config = await reloaded.get_config()
mappings = await reloaded.list_mappings()
assert config["base_url"] == "http://ha.local:8123"
assert config["has_token"] is True
assert mappings["items"][0]["entity_id"] == "light.bar_rgb"
@pytest.mark.asyncio
async def test_slow_dispatch_drops_intermediate_frames_and_keeps_latest() -> None:
adapter = FakeHomeAssistantAdapter()
service = build_service(adapter)
frame = DmxFrame(universe=10)
frame.set_channel(1, 50, "scene")
service.engine.current_frames = {10: frame}
await service.list_entities()
service._mappings = [
service._normalize_mapping(
{
"id": 9,
"name": "Dimmer",
"universe": 10,
"start_address": 1,
"fixture_type": "dimmer",
"entity_id": "light.bar_rgb",
"enabled": True,
"master_dimmer": False,
"deadband": 1,
"rate_limit_hz": 30,
}
)
]
first_call = asyncio.Event()
release_call = asyncio.Event()
async def slow_call(
base_url: str,
token: str,
domain: str,
service_name: str,
data: dict[str, object],
) -> dict[str, object]:
adapter.calls.append((base_url, token, f"{domain}.{service_name}", data))
first_call.set()
await release_call.wait()
return {"ok": True}
adapter.call_service = slow_call # type: ignore[method-assign]
dispatch_task = asyncio.create_task(service._dispatch_enabled_mappings())
await first_call.wait()
frame.set_channel(1, 120, "scene")
await service._dispatch_enabled_mappings()
frame.set_channel(1, 220, "scene")
await service._dispatch_enabled_mappings()
release_call.set()
await dispatch_task
await service._drain_mapping_tasks()
assert adapter.calls[0][3]["brightness"] == 50
assert adapter.calls[-1][3]["brightness"] == 220
assert len(adapter.calls) == 2
+156
View File
@@ -0,0 +1,156 @@
from __future__ import annotations
import json
import shutil
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
SCRIPTS_DIR = ROOT / "scripts"
def read_script(name: str) -> str:
return (SCRIPTS_DIR / name).read_text(encoding="utf-8")
def test_install_linux_script_exists_and_supports_required_flags() -> None:
script = read_script("install-linux.sh")
assert 'parse_common_args "$@"' in script
assert "verify_debian_13_amd64" in script
assert 'run_install_flow "Debian 13 amd64"' in script
def test_update_linux_script_exists_and_uses_common_arg_parser() -> None:
script = read_script("update-linux.sh")
assert 'parse_common_args "$@"' in script
assert "verify_debian_13_amd64" in script
assert 'run_update_flow "Debian 13 amd64"' in script
def test_common_install_library_contains_required_linux_behaviour() -> None:
script = read_script("lib/install-common.sh")
assert "python3-venv" in script
assert "sqlite3" in script
assert "sudo" in script
assert "nodejs" in script
assert "npm" in script
assert "python3-ola" in script
assert "ola-python" in script
assert "olad" in script
assert 'PNPM_REQUIRED_VERSION="10.28.2"' in script
assert "--system-site-packages" in script
assert "/opt/tuxdmx.new" in script
assert "/opt/tuxdmx" in script
assert "/var/lib/tuxdmx" in script
assert "/var/log/tuxdmx" in script
assert "/etc/tuxdmx" in script
assert "dialout,plugdev,audio" in script
assert "frontend/dist/index.html" in script
assert "pip\" check" in script
assert "apt-listchanges" in script
assert "debconf" in script
assert "Ignorerer kendt Debian pip check-advarsel" in script
assert "import greenlet" in script
assert "import sqlalchemy" in script
assert "import ola" in script
assert "backend/app/main.py" in script
assert "systemctl status \"$SYSTEMD_UNIT_NAME\" --no-pager --full" in script
assert "journalctl -u \"$SYSTEMD_UNIT_NAME\" -n 200 --no-pager" in script
assert "update-rc.d" in script
assert "systemctl is-enabled \"$OLA_SYSTEMD_UNIT\"" in script
assert "service \"$OLA_SERVICE_NAME\" restart" in script
assert "--warning=no-file-changed" in script
assert "--exclude=\"./tuxdmx.json.log\"" in script
assert "Kunne ikke oprette backup af datamappe." in script
assert "http://127.0.0.1:8000/api/v1/health" in script
assert "/etc/sudoers.d/tuxdmx-control" in script
assert "control-system.sh restart-service" in script
assert "control-system.sh reboot-host" in script
assert "restore_rollbacks" in script
def test_pi_and_linux_installers_share_common_library() -> None:
for name in (
"install-pi.sh",
"install-linux.sh",
"update-pi.sh",
"update-linux.sh",
):
script = read_script(name)
assert 'source "$SCRIPT_DIR/lib/install-common.sh"' in script
def test_systemd_unit_contains_required_production_fields() -> None:
unit = (ROOT / "systemd" / "tuxdmx.service").read_text(encoding="utf-8")
assert "WorkingDirectory=/opt/tuxdmx" in unit
assert "After=network-online.target olad.service" in unit
assert "Wants=network-online.target olad.service" in unit
assert "Restart=on-failure" in unit
assert "UMask=0027" in unit
def test_alembic_ini_uses_path_relative_to_config_file() -> None:
alembic_ini = (ROOT / "backend" / "alembic.ini").read_text(encoding="utf-8")
assert "script_location = %(here)s/alembic" in alembic_ini
def test_package_manager_and_requirements_are_locked() -> None:
package_json = json.loads((ROOT / "package.json").read_text(encoding="utf-8"))
assert package_json["packageManager"] == "pnpm@10.28.2"
assert package_json["engines"]["node"] == "20.x"
assert package_json["engines"]["pnpm"] == "10.28.2"
requirements = (ROOT / "requirements.lock").read_text(encoding="utf-8")
assert "greenlet==" in requirements
assert "sqlalchemy==" in requirements
def test_shell_scripts_parse_with_bash_when_available() -> None:
bash = shutil.which("bash")
if bash is None:
return
if "system32\\bash.exe" in bash.lower():
return
for script_name in (
"scripts/install-pi.sh",
"scripts/update-pi.sh",
"scripts/install-linux.sh",
"scripts/update-linux.sh",
"scripts/uninstall-linux.sh",
"scripts/lib/install-common.sh",
):
subprocess.run(
[bash, "-n", str(ROOT / script_name)],
check=True,
cwd=ROOT,
)
def test_shellcheck_when_available() -> None:
shellcheck = shutil.which("shellcheck")
if shellcheck is None:
return
subprocess.run(
[
shellcheck,
str(ROOT / "scripts/install-pi.sh"),
str(ROOT / "scripts/update-pi.sh"),
str(ROOT / "scripts/install-linux.sh"),
str(ROOT / "scripts/update-linux.sh"),
str(ROOT / "scripts/lib/install-common.sh"),
],
check=True,
cwd=ROOT,
)
def test_pip_check_and_greenlet_import() -> None:
subprocess.run([sys.executable, "-m", "pip", "check"], check=True, cwd=ROOT)
subprocess.run(
[sys.executable, "-c", "import greenlet; print(greenlet.__version__)"],
check=True,
cwd=ROOT,
)
+322
View File
@@ -0,0 +1,322 @@
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
+45
View File
@@ -0,0 +1,45 @@
import os
import sqlite3
import subprocess
import sys
from pathlib import Path
def test_alembic_upgrade_from_empty_database(tmp_path: Path) -> None:
database_path = tmp_path / "audit-migrations.db"
env = os.environ.copy()
env["TUXDMX_DATABASE_URL"] = f"sqlite:///{database_path.as_posix()}"
result = subprocess.run(
[
sys.executable,
"-m",
"alembic",
"-c",
"backend/alembic.ini",
"upgrade",
"head",
],
cwd=Path(__file__).resolve().parents[2],
env=env,
capture_output=True,
text=True,
check=False,
)
assert result.returncode == 0, result.stderr
with sqlite3.connect(database_path) as connection:
tables = {
row[0]
for row in connection.execute(
"SELECT name FROM sqlite_master WHERE type='table'"
).fetchall()
}
assert "alembic_version" in tables
assert "users" in tables
assert "fixture_sources" in tables
assert "fixture_definitions" in tables
assert "fixture_instances" in tables
assert "midi_bridges" in tables
assert "midi_bridge_tokens" in tables
assert "midi_mappings" in tables
+37
View File
@@ -0,0 +1,37 @@
import asyncio
import httpx
from app.fixtures.ofl import OflClient
def test_fetch_fixture_uses_json_endpoint(monkeypatch) -> None:
requested_urls: list[str] = []
class FakeResponse:
def raise_for_status(self) -> None:
return None
def json(self) -> dict[str, object]:
return {"name": "LED Bar-3 HCL Bar", "modes": []}
class FakeAsyncClient:
def __init__(self, *args, **kwargs) -> None:
pass
async def __aenter__(self) -> "FakeAsyncClient":
return self
async def __aexit__(self, exc_type, exc, tb) -> None:
return None
async def get(self, url: str) -> FakeResponse:
requested_urls.append(url)
return FakeResponse()
monkeypatch.setattr(httpx, "AsyncClient", FakeAsyncClient)
payload = asyncio.run(OflClient().fetch_fixture("eurolite", "led-bar-3-hcl-bar"))
assert payload["name"] == "LED Bar-3 HCL Bar"
assert requested_urls == ["https://open-fixture-library.org/eurolite/led-bar-3-hcl-bar.json"]
+141
View File
@@ -0,0 +1,141 @@
import pytest
from app.dmx.backends import FakeOlaClientAdapter, OlaDmxBackend, PythonOlaClientAdapter
from app.dmx.frame import DmxFrame
@pytest.mark.asyncio
async def test_ola_backend_sends_full_frame_and_updates_status() -> None:
adapter = FakeOlaClientAdapter()
backend = OlaDmxBackend(
universe=7,
output_port="usb-dmx-1",
adapter_factory=lambda: adapter,
)
frame = DmxFrame(universe=7)
frame.set_channel(1, 255, "scene")
frame.set_channel(512, 64, "scene")
await backend.send_frame(frame)
status = backend.get_status()
assert status.connected is True
assert status.degraded is False
assert status.frames_sent == 1
assert status.send_errors == 0
assert status.last_successful_frame is not None
assert status.selected_universe == 7
assert status.selected_output_port == "usb-dmx-1"
assert adapter.open_calls == 1
assert len(adapter.sent_frames) == 1
sent_universe, values = adapter.sent_frames[0]
assert sent_universe == 7
assert len(values) == 512
assert values[0] == 255
assert values[511] == 64
@pytest.mark.asyncio
async def test_ola_backend_reconnects_after_send_failure() -> None:
created_adapters: list[FakeOlaClientAdapter] = []
def factory() -> FakeOlaClientAdapter:
adapter = FakeOlaClientAdapter(
send_results=[RuntimeError("olad unavailable")] if not created_adapters else [],
)
created_adapters.append(adapter)
return adapter
backend = OlaDmxBackend(
universe=1,
output_port="usb-dmx-1",
adapter_factory=factory,
)
frame = DmxFrame()
frame.set_channel(1, 128, "scene")
with pytest.raises(RuntimeError, match="olad unavailable"):
await backend.send_frame(frame)
failed_status = backend.get_status()
assert failed_status.connected is False
assert failed_status.degraded is True
assert failed_status.send_errors == 1
assert failed_status.frames_sent == 0
await backend.send_frame(frame)
recovered_status = backend.get_status()
assert recovered_status.connected is True
assert recovered_status.degraded is False
assert recovered_status.reconnect_count == 1
assert recovered_status.frames_sent == 1
assert recovered_status.send_errors == 1
assert len(created_adapters) == 2
assert created_adapters[0].close_calls == 1
assert created_adapters[1].sent_frames[0][1][0] == 128
@pytest.mark.asyncio
async def test_ola_backend_does_not_send_random_black_frame_on_reconnect() -> None:
created_adapters: list[FakeOlaClientAdapter] = []
def factory() -> FakeOlaClientAdapter:
adapter = FakeOlaClientAdapter(
send_results=[RuntimeError("send failed")] if not created_adapters else [],
)
created_adapters.append(adapter)
return adapter
backend = OlaDmxBackend(
universe=3,
output_port="usb-dmx-2",
adapter_factory=factory,
)
failed_frame = DmxFrame(universe=3)
failed_frame.set_channel(1, 10, "effect")
recovered_frame = DmxFrame(universe=3)
recovered_frame.set_channel(1, 200, "effect")
recovered_frame.set_channel(2, 150, "effect")
with pytest.raises(RuntimeError, match="send failed"):
await backend.send_frame(failed_frame)
await backend.send_frame(recovered_frame)
assert created_adapters[0].sent_frames[0][1][0] == 10
assert created_adapters[1].sent_frames[0][0] == 3
assert created_adapters[1].sent_frames[0][1] == recovered_frame.values
assert any(value != 0 for value in created_adapters[1].sent_frames[0][1])
def test_python_ola_adapter_uses_buffer_with_tobytes() -> None:
captured: dict[str, object] = {}
class FakeClient:
def SendDmx(self, universe: int, payload: object, callback: object) -> None:
captured["universe"] = universe
captured["payload_type"] = type(payload).__name__
captured["has_tobytes"] = hasattr(payload, "tobytes")
callback(True)
class FakeWrapper:
def __init__(self) -> None:
self._client = FakeClient()
def Client(self) -> FakeClient:
return self._client
def Run(self) -> None:
return
def Stop(self) -> None:
return
adapter = PythonOlaClientAdapter(timeout_s=0.1)
adapter._wrapper_class = FakeWrapper
adapter.send_frame(1, [0, 255] + [0] * 510)
assert captured["universe"] == 1
assert captured["payload_type"] == "array"
assert captured["has_tobytes"] is True
+66
View File
@@ -0,0 +1,66 @@
from __future__ import annotations
import os
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
def test_app_imports_with_production_like_absolute_paths(tmp_path: Path) -> None:
frontend_dist = tmp_path / "frontend-dist"
data_dir = tmp_path / "data"
backup_dir = data_dir / "backups"
fixture_dir = data_dir / "fixtures"
diagnostics_dir = data_dir / "diagnostics"
uploads_dir = data_dir / "uploads"
log_dir = tmp_path / "logs"
frontend_dist.mkdir(parents=True)
data_dir.mkdir(parents=True)
backup_dir.mkdir()
fixture_dir.mkdir()
diagnostics_dir.mkdir()
uploads_dir.mkdir()
log_dir.mkdir()
(frontend_dist / "index.html").write_text(
"<!doctype html><title>TuxDMX</title>",
encoding="utf-8",
)
env = os.environ.copy()
env.update(
{
"PYTHONPATH": str(ROOT / "backend"),
"TUXDMX_FRONTEND_DIST": str(frontend_dist),
"TUXDMX_DATA_DIR": str(data_dir),
"TUXDMX_BACKUP_DIR": str(backup_dir),
"TUXDMX_FIXTURE_CACHE_DIR": str(fixture_dir),
"TUXDMX_DIAGNOSTICS_DIR": str(diagnostics_dir),
"TUXDMX_UPLOADS_DIR": str(uploads_dir),
"TUXDMX_LOG_DIR": str(log_dir),
"TUXDMX_DATABASE_URL": f"sqlite+aiosqlite:///{(data_dir / 'tuxdmx.db').as_posix()}",
}
)
subprocess.run(
[
sys.executable,
"-c",
(
"from app.core.config import get_settings; "
"get_settings.cache_clear(); "
"import app.main; "
"settings = get_settings(); "
"assert settings.frontend_dist.is_absolute(); "
"assert settings.data_dir.is_absolute(); "
"assert settings.backup_dir.is_absolute(); "
"assert settings.diagnostics_dir.is_absolute(); "
"assert settings.uploads_dir.is_absolute(); "
"print('ok')"
),
],
check=True,
cwd=ROOT,
env=env,
)