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,135 @@
|
||||
"""Tests for AdapterRegistry — register, get, duplicates, clear."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
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.events import GameEvent
|
||||
|
||||
|
||||
class FakeAdapter(GameAdapter):
|
||||
ADAPTER_TYPE = "fake_game"
|
||||
DISPLAY_NAME = "Fake Game"
|
||||
GAME_NAME = "FakeGame"
|
||||
SUPPORTED_EVENTS = ["health", "kill"]
|
||||
|
||||
@classmethod
|
||||
def parse_payload(
|
||||
cls,
|
||||
payload: dict[str, Any],
|
||||
adapter_config: dict[str, Any],
|
||||
prev_state: dict[str, Any],
|
||||
) -> tuple[list[GameEvent], dict[str, Any]]:
|
||||
return [], prev_state
|
||||
|
||||
@classmethod
|
||||
def validate_auth(
|
||||
cls,
|
||||
headers: dict[str, str],
|
||||
payload: dict[str, Any],
|
||||
adapter_config: dict[str, Any],
|
||||
) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
class AnotherAdapter(GameAdapter):
|
||||
ADAPTER_TYPE = "another_game"
|
||||
DISPLAY_NAME = "Another Game"
|
||||
GAME_NAME = "AnotherGame"
|
||||
SUPPORTED_EVENTS = ["mana"]
|
||||
|
||||
@classmethod
|
||||
def parse_payload(
|
||||
cls,
|
||||
payload: dict[str, Any],
|
||||
adapter_config: dict[str, Any],
|
||||
prev_state: dict[str, Any],
|
||||
) -> tuple[list[GameEvent], dict[str, Any]]:
|
||||
return [], prev_state
|
||||
|
||||
@classmethod
|
||||
def validate_auth(
|
||||
cls,
|
||||
headers: dict[str, str],
|
||||
payload: dict[str, Any],
|
||||
adapter_config: dict[str, Any],
|
||||
) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_registry():
|
||||
"""Ensure a clean registry for each test."""
|
||||
AdapterRegistry.clear_registry()
|
||||
yield
|
||||
AdapterRegistry.clear_registry()
|
||||
|
||||
|
||||
class TestRegister:
|
||||
def test_register_and_get(self) -> None:
|
||||
AdapterRegistry.register(FakeAdapter)
|
||||
assert AdapterRegistry.get_adapter("fake_game") is FakeAdapter
|
||||
|
||||
def test_register_multiple(self) -> None:
|
||||
AdapterRegistry.register(FakeAdapter)
|
||||
AdapterRegistry.register(AnotherAdapter)
|
||||
assert AdapterRegistry.get_adapter("fake_game") is FakeAdapter
|
||||
assert AdapterRegistry.get_adapter("another_game") is AnotherAdapter
|
||||
|
||||
def test_register_duplicate_overwrites(self) -> None:
|
||||
AdapterRegistry.register(FakeAdapter)
|
||||
# Re-registering the same type should overwrite without error
|
||||
AdapterRegistry.register(FakeAdapter)
|
||||
assert AdapterRegistry.get_adapter("fake_game") is FakeAdapter
|
||||
|
||||
def test_register_non_subclass_raises(self) -> None:
|
||||
with pytest.raises(ValueError, match="must be a subclass"):
|
||||
AdapterRegistry.register(str) # type: ignore[arg-type]
|
||||
|
||||
def test_register_base_type_raises(self) -> None:
|
||||
with pytest.raises(ValueError, match="reserved type"):
|
||||
AdapterRegistry.register(GameAdapter) # type: ignore[arg-type]
|
||||
|
||||
|
||||
class TestGetAdapter:
|
||||
def test_get_unknown_raises(self) -> None:
|
||||
with pytest.raises(ValueError, match="Unknown adapter type"):
|
||||
AdapterRegistry.get_adapter("nonexistent")
|
||||
|
||||
def test_get_unknown_shows_available(self) -> None:
|
||||
AdapterRegistry.register(FakeAdapter)
|
||||
with pytest.raises(ValueError, match="fake_game"):
|
||||
AdapterRegistry.get_adapter("nonexistent")
|
||||
|
||||
|
||||
class TestGetAll:
|
||||
def test_get_all_returns_copy(self) -> None:
|
||||
AdapterRegistry.register(FakeAdapter)
|
||||
all_adapters = AdapterRegistry.get_all_adapters()
|
||||
assert "fake_game" in all_adapters
|
||||
|
||||
# Mutating the copy should not affect the registry
|
||||
all_adapters.pop("fake_game")
|
||||
assert AdapterRegistry.get_adapter("fake_game") is FakeAdapter
|
||||
|
||||
def test_get_available_adapters_metadata(self) -> None:
|
||||
AdapterRegistry.register(FakeAdapter)
|
||||
available = AdapterRegistry.get_available_adapters()
|
||||
|
||||
assert len(available) == 1
|
||||
meta = available[0]
|
||||
assert meta["adapter_type"] == "fake_game"
|
||||
assert meta["display_name"] == "Fake Game"
|
||||
assert meta["game_name"] == "FakeGame"
|
||||
assert meta["supported_events"] == ["health", "kill"]
|
||||
|
||||
|
||||
class TestClear:
|
||||
def test_clear_removes_all(self) -> None:
|
||||
AdapterRegistry.register(FakeAdapter)
|
||||
AdapterRegistry.register(AnotherAdapter)
|
||||
AdapterRegistry.clear_registry()
|
||||
|
||||
assert AdapterRegistry.get_all_adapters() == {}
|
||||
Reference in New Issue
Block a user