feat: game integration system
Receive real-time events from games (CS2, Dota 2, LoL, etc.) and drive LED effects through the existing color strip and value source pipelines. Core: - GameEventBus (thread-safe pub/sub) with standardized 23-type event vocabulary - GameAdapter ABC + AdapterRegistry + MappingAdapter (YAML-driven) - Built-in adapters: CS2 GSI, Dota 2 GSI, LoL Live Client, Generic Webhook - Community YAML adapters: Minecraft, Valorant, Rocket League - GameEventColorStripStream with 5 effects (flash/pulse/sweep/color_shift/breathing) - GameEventValueSource with EMA smoothing and timeout - 4 built-in effect presets (FPS Combat, MOBA Health, Racing, Generic Alert) - Auto-setup for Valve GSI games (Steam path detection, cfg file writing) - Demo capture engine exposed to non-demo mode Frontend: - Game tab in Streams tree navigation with integration cards - Game integration editor modal with adapter picker, config fields, event mappings - game_event source type in CSS and ValueSource editors - Setup instructions overlay (markdown rendered) - Live event monitor and connection test API: - Full CRUD for game integrations - Event ingestion endpoint (adapter-level auth) - Adapter metadata, presets, auto-setup, status/diagnostics endpoints
This commit is contained in:
@@ -0,0 +1,495 @@
|
||||
"""Tests for game integration API routes.
|
||||
|
||||
Uses FastAPI TestClient with dependency overrides to test route handlers
|
||||
in isolation from the real application.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from wled_controller.api.routes.game_integration import router
|
||||
from wled_controller.core.game_integration.adapter_registry import AdapterRegistry
|
||||
from wled_controller.core.game_integration.base_adapter import GameAdapter
|
||||
from wled_controller.core.game_integration.event_bus import GameEventBus
|
||||
from wled_controller.core.game_integration.events import GameEvent
|
||||
from wled_controller.storage.game_integration_store import GameIntegrationStore
|
||||
from wled_controller.api import dependencies as deps
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test adapter for ingestion tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _TestAdapter(GameAdapter):
|
||||
ADAPTER_TYPE = "test_adapter"
|
||||
DISPLAY_NAME = "Test Adapter"
|
||||
GAME_NAME = "Test Game"
|
||||
SUPPORTED_EVENTS = ["health", "kill"]
|
||||
|
||||
@classmethod
|
||||
def parse_payload(cls, payload, adapter_config, prev_state):
|
||||
events = []
|
||||
if "health" in payload:
|
||||
events.append(
|
||||
GameEvent(
|
||||
adapter_id=adapter_config.get("integration_id", "test"),
|
||||
event_type="health",
|
||||
value=payload["health"] / 100.0,
|
||||
)
|
||||
)
|
||||
return events, prev_state
|
||||
|
||||
@classmethod
|
||||
def validate_auth(cls, headers, payload, adapter_config):
|
||||
token = adapter_config.get("auth_token")
|
||||
if not token:
|
||||
return True
|
||||
return headers.get("x-auth-token") == token
|
||||
|
||||
@classmethod
|
||||
def get_config_schema(cls):
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"auth_token": {"type": "string", "description": "Auth token"},
|
||||
},
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def get_setup_instructions(cls):
|
||||
return "Configure the test adapter with an auth token."
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_app():
|
||||
app = FastAPI()
|
||||
app.include_router(router)
|
||||
return app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def _route_db(tmp_path):
|
||||
from wled_controller.storage.database import Database
|
||||
|
||||
db = Database(tmp_path / "test.db")
|
||||
yield db
|
||||
db.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def game_store(_route_db):
|
||||
return GameIntegrationStore(_route_db)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def event_bus():
|
||||
return GameEventBus()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _register_test_adapter():
|
||||
"""Register and clean up the test adapter for each test."""
|
||||
AdapterRegistry.register(_TestAdapter)
|
||||
yield
|
||||
AdapterRegistry.clear_registry()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(game_store, event_bus):
|
||||
app = _make_app()
|
||||
|
||||
# Override auth to always pass
|
||||
from wled_controller.api.auth import verify_api_key
|
||||
|
||||
app.dependency_overrides[verify_api_key] = lambda: "test-user"
|
||||
app.dependency_overrides[deps.get_game_integration_store] = lambda: game_store
|
||||
app.dependency_overrides[deps.get_game_event_bus] = lambda: event_bus
|
||||
|
||||
return TestClient(app, raise_server_exceptions=False)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _create_integration(client, name="Test Integration", adapter_type="test_adapter", **kwargs):
|
||||
"""Helper to create an integration via API."""
|
||||
body = {
|
||||
"name": name,
|
||||
"adapter_type": adapter_type,
|
||||
**kwargs,
|
||||
}
|
||||
resp = client.post("/api/v1/game-integrations", json=body)
|
||||
assert resp.status_code == 201, resp.text
|
||||
return resp.json()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CRUD tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCreateIntegration:
|
||||
def test_create_basic(self, client):
|
||||
data = _create_integration(client)
|
||||
assert data["id"].startswith("gi_")
|
||||
assert data["name"] == "Test Integration"
|
||||
assert data["adapter_type"] == "test_adapter"
|
||||
assert data["enabled"] is True
|
||||
|
||||
def test_create_with_mappings(self, client):
|
||||
data = _create_integration(
|
||||
client,
|
||||
event_mappings=[
|
||||
{"event_type": "health", "effect": "gradient", "color": [255, 0, 0]},
|
||||
{"event_type": "kill", "effect": "flash", "color": [0, 255, 0]},
|
||||
],
|
||||
)
|
||||
assert len(data["event_mappings"]) == 2
|
||||
assert data["event_mappings"][0]["event_type"] == "health"
|
||||
|
||||
def test_create_with_config(self, client):
|
||||
data = _create_integration(
|
||||
client,
|
||||
adapter_config={"auth_token": "secret123"},
|
||||
description="My game",
|
||||
tags=["fps"],
|
||||
)
|
||||
assert data["adapter_config"] == {"auth_token": "secret123"}
|
||||
assert data["description"] == "My game"
|
||||
assert data["tags"] == ["fps"]
|
||||
|
||||
def test_create_duplicate_name(self, client):
|
||||
_create_integration(client, name="Unique")
|
||||
resp = client.post(
|
||||
"/api/v1/game-integrations",
|
||||
json={"name": "Unique", "adapter_type": "test_adapter"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "already exists" in resp.json()["detail"]
|
||||
|
||||
def test_create_empty_name(self, client):
|
||||
resp = client.post(
|
||||
"/api/v1/game-integrations",
|
||||
json={"name": "", "adapter_type": "test_adapter"},
|
||||
)
|
||||
assert resp.status_code == 422 # Pydantic validation
|
||||
|
||||
|
||||
class TestListIntegrations:
|
||||
def test_list_empty(self, client):
|
||||
resp = client.get("/api/v1/game-integrations")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["count"] == 0
|
||||
assert data["integrations"] == []
|
||||
|
||||
def test_list_multiple(self, client):
|
||||
_create_integration(client, name="Int 1")
|
||||
_create_integration(client, name="Int 2")
|
||||
|
||||
resp = client.get("/api/v1/game-integrations")
|
||||
data = resp.json()
|
||||
assert data["count"] == 2
|
||||
names = {i["name"] for i in data["integrations"]}
|
||||
assert names == {"Int 1", "Int 2"}
|
||||
|
||||
|
||||
class TestGetIntegration:
|
||||
def test_get_existing(self, client):
|
||||
created = _create_integration(client)
|
||||
resp = client.get(f"/api/v1/game-integrations/{created['id']}")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["name"] == "Test Integration"
|
||||
|
||||
def test_get_nonexistent(self, client):
|
||||
resp = client.get("/api/v1/game-integrations/gi_nonexist")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
class TestUpdateIntegration:
|
||||
def test_update_name(self, client):
|
||||
created = _create_integration(client)
|
||||
resp = client.put(
|
||||
f"/api/v1/game-integrations/{created['id']}",
|
||||
json={"name": "Updated Name"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["name"] == "Updated Name"
|
||||
|
||||
def test_update_enabled(self, client):
|
||||
created = _create_integration(client)
|
||||
resp = client.put(
|
||||
f"/api/v1/game-integrations/{created['id']}",
|
||||
json={"enabled": False},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["enabled"] is False
|
||||
|
||||
def test_update_nonexistent(self, client):
|
||||
resp = client.put(
|
||||
"/api/v1/game-integrations/gi_nonexist",
|
||||
json={"name": "x"},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_update_to_duplicate_name(self, client):
|
||||
_create_integration(client, name="Name A")
|
||||
b = _create_integration(client, name="Name B")
|
||||
resp = client.put(
|
||||
f"/api/v1/game-integrations/{b['id']}",
|
||||
json={"name": "Name A"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
class TestDeleteIntegration:
|
||||
def test_delete_existing(self, client):
|
||||
created = _create_integration(client)
|
||||
resp = client.delete(f"/api/v1/game-integrations/{created['id']}")
|
||||
assert resp.status_code == 204
|
||||
|
||||
# Verify it's gone
|
||||
resp = client.get(f"/api/v1/game-integrations/{created['id']}")
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_delete_nonexistent(self, client):
|
||||
resp = client.delete("/api/v1/game-integrations/gi_nonexist")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Event ingestion tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestEventIngestion:
|
||||
def test_ingest_basic(self, client, event_bus):
|
||||
created = _create_integration(client)
|
||||
integration_id = created["id"]
|
||||
|
||||
resp = client.post(
|
||||
f"/api/v1/game-integrations/{integration_id}/event",
|
||||
json={"data": {"health": 75}},
|
||||
)
|
||||
assert resp.status_code == 204
|
||||
|
||||
# Verify event was published to bus
|
||||
recent = event_bus.get_recent_events()
|
||||
assert len(recent) == 1
|
||||
assert recent[0].event_type == "health"
|
||||
assert recent[0].value == 0.75
|
||||
|
||||
def test_ingest_disabled_integration(self, client):
|
||||
created = _create_integration(client)
|
||||
integration_id = created["id"]
|
||||
|
||||
# Disable the integration
|
||||
client.put(
|
||||
f"/api/v1/game-integrations/{integration_id}",
|
||||
json={"enabled": False},
|
||||
)
|
||||
|
||||
resp = client.post(
|
||||
f"/api/v1/game-integrations/{integration_id}/event",
|
||||
json={"data": {"health": 50}},
|
||||
)
|
||||
assert resp.status_code == 409
|
||||
|
||||
def test_ingest_nonexistent_integration(self, client):
|
||||
resp = client.post(
|
||||
"/api/v1/game-integrations/gi_nonexist/event",
|
||||
json={"data": {"health": 50}},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_ingest_auth_failure(self, client):
|
||||
created = _create_integration(
|
||||
client,
|
||||
adapter_config={"auth_token": "correct_token"},
|
||||
)
|
||||
integration_id = created["id"]
|
||||
|
||||
resp = client.post(
|
||||
f"/api/v1/game-integrations/{integration_id}/event",
|
||||
json={"data": {"health": 50}},
|
||||
headers={"x-auth-token": "wrong_token"},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
def test_ingest_auth_success(self, client, event_bus):
|
||||
created = _create_integration(
|
||||
client,
|
||||
adapter_config={"auth_token": "correct_token"},
|
||||
)
|
||||
integration_id = created["id"]
|
||||
|
||||
resp = client.post(
|
||||
f"/api/v1/game-integrations/{integration_id}/event",
|
||||
json={"data": {"health": 50}},
|
||||
headers={"x-auth-token": "correct_token"},
|
||||
)
|
||||
assert resp.status_code == 204
|
||||
|
||||
recent = event_bus.get_recent_events()
|
||||
assert len(recent) == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Status / diagnostics tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestStatus:
|
||||
def test_status_no_events(self, client):
|
||||
created = _create_integration(client)
|
||||
resp = client.get(f"/api/v1/game-integrations/{created['id']}/status")
|
||||
assert resp.status_code == 200
|
||||
|
||||
data = resp.json()
|
||||
assert data["integration_id"] == created["id"]
|
||||
assert data["enabled"] is True
|
||||
assert data["connected"] is False
|
||||
assert data["event_count"] == 0
|
||||
|
||||
def test_status_after_events(self, client):
|
||||
created = _create_integration(client)
|
||||
integration_id = created["id"]
|
||||
|
||||
# Send an event
|
||||
client.post(
|
||||
f"/api/v1/game-integrations/{integration_id}/event",
|
||||
json={"data": {"health": 80}},
|
||||
)
|
||||
|
||||
resp = client.get(f"/api/v1/game-integrations/{integration_id}/status")
|
||||
data = resp.json()
|
||||
assert data["event_count"] == 1
|
||||
assert data["connected"] is True
|
||||
assert "health" in data["event_counts_by_type"]
|
||||
|
||||
def test_status_nonexistent(self, client):
|
||||
resp = client.get("/api/v1/game-integrations/gi_nonexist/status")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
class TestRecentEvents:
|
||||
def test_recent_events_empty(self, client):
|
||||
created = _create_integration(client)
|
||||
resp = client.get(f"/api/v1/game-integrations/{created['id']}/events")
|
||||
assert resp.status_code == 200
|
||||
|
||||
data = resp.json()
|
||||
assert data["count"] == 0
|
||||
assert data["events"] == []
|
||||
|
||||
def test_recent_events_nonexistent(self, client):
|
||||
resp = client.get("/api/v1/game-integrations/gi_nonexist/events")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Adapter metadata tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAdapterMetadata:
|
||||
def test_list_adapters(self, client):
|
||||
resp = client.get("/api/v1/game-adapters")
|
||||
assert resp.status_code == 200
|
||||
|
||||
data = resp.json()
|
||||
assert data["count"] >= 1
|
||||
|
||||
adapter = data["adapters"][0]
|
||||
assert adapter["adapter_type"] == "test_adapter"
|
||||
assert adapter["display_name"] == "Test Adapter"
|
||||
assert adapter["game_name"] == "Test Game"
|
||||
assert "health" in adapter["supported_events"]
|
||||
assert "config_schema" in adapter
|
||||
assert "setup_instructions" in adapter
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Preset tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPresets:
|
||||
def test_list_presets(self, client):
|
||||
resp = client.get("/api/v1/game-integrations/presets")
|
||||
assert resp.status_code == 200
|
||||
|
||||
data = resp.json()
|
||||
assert data["count"] >= 4
|
||||
keys = {p["key"] for p in data["presets"]}
|
||||
assert "fps_combat" in keys
|
||||
assert "moba_health" in keys
|
||||
assert "racing" in keys
|
||||
assert "generic_alert" in keys
|
||||
|
||||
def test_preset_has_mappings(self, client):
|
||||
resp = client.get("/api/v1/game-integrations/presets")
|
||||
data = resp.json()
|
||||
for preset in data["presets"]:
|
||||
assert len(preset["event_mappings"]) > 0
|
||||
for m in preset["event_mappings"]:
|
||||
assert "event_type" in m
|
||||
assert "effect" in m
|
||||
assert "color" in m
|
||||
|
||||
def test_apply_preset_replace(self, client):
|
||||
created = _create_integration(
|
||||
client,
|
||||
event_mappings=[{"event_type": "kill", "effect": "flash", "color": [255, 0, 0]}],
|
||||
)
|
||||
integration_id = created["id"]
|
||||
assert len(created["event_mappings"]) == 1
|
||||
|
||||
resp = client.post(
|
||||
f"/api/v1/game-integrations/{integration_id}/apply-preset",
|
||||
json={"preset_key": "fps_combat", "replace": True},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
# Should have replaced the single mapping with preset mappings
|
||||
assert len(data["event_mappings"]) >= 3
|
||||
|
||||
def test_apply_preset_append(self, client):
|
||||
created = _create_integration(
|
||||
client,
|
||||
event_mappings=[{"event_type": "kill", "effect": "flash", "color": [255, 0, 0]}],
|
||||
)
|
||||
integration_id = created["id"]
|
||||
|
||||
resp = client.post(
|
||||
f"/api/v1/game-integrations/{integration_id}/apply-preset",
|
||||
json={"preset_key": "generic_alert", "replace": False},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
# Should have original + preset mappings
|
||||
assert len(data["event_mappings"]) >= 5
|
||||
|
||||
def test_apply_preset_unknown_key(self, client):
|
||||
created = _create_integration(client)
|
||||
resp = client.post(
|
||||
f"/api/v1/game-integrations/{created['id']}/apply-preset",
|
||||
json={"preset_key": "nonexistent"},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
assert "not found" in resp.json()["detail"]
|
||||
|
||||
def test_apply_preset_unknown_integration(self, client):
|
||||
resp = client.post(
|
||||
"/api/v1/game-integrations/gi_nonexist/apply-preset",
|
||||
json={"preset_key": "fps_combat"},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
Reference in New Issue
Block a user