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
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:
@@ -0,0 +1,4 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
Reference in New Issue
Block a user