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
|
||||
@@ -187,8 +187,8 @@ def make_automation():
|
||||
id=f"auto_test_{_counter:04d}",
|
||||
name=name or f"Automation {_counter}",
|
||||
enabled=True,
|
||||
condition_logic="or",
|
||||
conditions=[],
|
||||
rule_logic="or",
|
||||
rules=[],
|
||||
scene_preset_id=None,
|
||||
deactivation_mode="none",
|
||||
deactivation_scene_preset_id=None,
|
||||
@@ -213,6 +213,7 @@ def authenticated_client(test_config, monkeypatch):
|
||||
Patches global config so the app uses temp storage paths.
|
||||
"""
|
||||
import wled_controller.config as config_mod
|
||||
|
||||
monkeypatch.setattr(config_mod, "config", test_config)
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
@@ -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() == {}
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Tests for AutomationEngine — condition evaluation in isolation."""
|
||||
"""Tests for AutomationEngine — rule evaluation in isolation."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import MagicMock, patch
|
||||
@@ -7,14 +7,13 @@ import pytest
|
||||
|
||||
from wled_controller.core.automations.automation_engine import AutomationEngine
|
||||
from wled_controller.storage.automation import (
|
||||
AlwaysCondition,
|
||||
ApplicationCondition,
|
||||
ApplicationRule,
|
||||
Automation,
|
||||
DisplayStateCondition,
|
||||
StartupCondition,
|
||||
SystemIdleCondition,
|
||||
TimeOfDayCondition,
|
||||
WebhookCondition,
|
||||
DisplayStateRule,
|
||||
StartupRule,
|
||||
SystemIdleRule,
|
||||
TimeOfDayRule,
|
||||
WebhookRule,
|
||||
)
|
||||
from wled_controller.storage.automation_store import AutomationStore
|
||||
|
||||
@@ -44,9 +43,7 @@ def engine(mock_store, mock_manager) -> AutomationEngine:
|
||||
causes access violations in the test environment, so we replace it
|
||||
with a simple MagicMock.
|
||||
"""
|
||||
with patch(
|
||||
"wled_controller.core.automations.automation_engine.PlatformDetector"
|
||||
):
|
||||
with patch("wled_controller.core.automations.automation_engine.PlatformDetector"):
|
||||
eng = AutomationEngine(
|
||||
automation_store=mock_store,
|
||||
processor_manager=mock_manager,
|
||||
@@ -56,21 +53,21 @@ def engine(mock_store, mock_manager) -> AutomationEngine:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Condition evaluation (unit-level)
|
||||
# Rule evaluation (unit-level)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestConditionEvaluation:
|
||||
"""Test _evaluate_condition for each condition type individually."""
|
||||
class TestRuleEvaluation:
|
||||
"""Test _evaluate_rule for each rule type individually."""
|
||||
|
||||
def _make_automation(self, conditions):
|
||||
def _make_automation(self, rules):
|
||||
now = datetime.now(timezone.utc)
|
||||
return Automation(
|
||||
id="test_auto",
|
||||
name="Test",
|
||||
enabled=True,
|
||||
condition_logic="or",
|
||||
conditions=conditions,
|
||||
rule_logic="or",
|
||||
rules=rules,
|
||||
scene_preset_id=None,
|
||||
deactivation_mode="none",
|
||||
deactivation_scene_preset_id=None,
|
||||
@@ -78,8 +75,8 @@ class TestConditionEvaluation:
|
||||
updated_at=now,
|
||||
)
|
||||
|
||||
def _eval(self, engine, condition, **kwargs):
|
||||
"""Invoke the private _evaluate_condition method."""
|
||||
def _eval(self, engine, rule, **kwargs):
|
||||
"""Invoke the private _evaluate_rule method."""
|
||||
defaults = dict(
|
||||
running_procs=set(),
|
||||
topmost_proc=None,
|
||||
@@ -89,8 +86,8 @@ class TestConditionEvaluation:
|
||||
display_state=None,
|
||||
)
|
||||
defaults.update(kwargs)
|
||||
return engine._evaluate_condition(
|
||||
condition,
|
||||
return engine._evaluate_rule(
|
||||
rule,
|
||||
defaults["running_procs"],
|
||||
defaults["topmost_proc"],
|
||||
defaults["topmost_fullscreen"],
|
||||
@@ -99,102 +96,103 @@ class TestConditionEvaluation:
|
||||
defaults["display_state"],
|
||||
)
|
||||
|
||||
def test_always_true(self, engine):
|
||||
assert self._eval(engine, AlwaysCondition()) is True
|
||||
|
||||
def test_startup_true(self, engine):
|
||||
assert self._eval(engine, StartupCondition()) is True
|
||||
assert self._eval(engine, StartupRule()) is True
|
||||
|
||||
def test_application_running_match(self, engine):
|
||||
cond = ApplicationCondition(apps=["chrome.exe"], match_type="running")
|
||||
rule = ApplicationRule(apps=["chrome.exe"], match_type="running")
|
||||
result = self._eval(
|
||||
engine, cond,
|
||||
engine,
|
||||
rule,
|
||||
running_procs={"chrome.exe", "explorer.exe"},
|
||||
)
|
||||
assert result is True
|
||||
|
||||
def test_application_running_no_match(self, engine):
|
||||
cond = ApplicationCondition(apps=["chrome.exe"], match_type="running")
|
||||
rule = ApplicationRule(apps=["chrome.exe"], match_type="running")
|
||||
result = self._eval(
|
||||
engine, cond,
|
||||
engine,
|
||||
rule,
|
||||
running_procs={"explorer.exe"},
|
||||
)
|
||||
assert result is False
|
||||
|
||||
def test_application_topmost_match(self, engine):
|
||||
cond = ApplicationCondition(apps=["game.exe"], match_type="topmost")
|
||||
rule = ApplicationRule(apps=["game.exe"], match_type="topmost")
|
||||
result = self._eval(
|
||||
engine, cond,
|
||||
engine,
|
||||
rule,
|
||||
topmost_proc="game.exe",
|
||||
)
|
||||
assert result is True
|
||||
|
||||
def test_application_topmost_no_match(self, engine):
|
||||
cond = ApplicationCondition(apps=["game.exe"], match_type="topmost")
|
||||
rule = ApplicationRule(apps=["game.exe"], match_type="topmost")
|
||||
result = self._eval(
|
||||
engine, cond,
|
||||
engine,
|
||||
rule,
|
||||
topmost_proc="chrome.exe",
|
||||
)
|
||||
assert result is False
|
||||
|
||||
def test_time_of_day_within_range(self, engine):
|
||||
cond = TimeOfDayCondition(start_time="00:00", end_time="23:59")
|
||||
result = self._eval(engine, cond)
|
||||
rule = TimeOfDayRule(start_time="00:00", end_time="23:59")
|
||||
result = self._eval(engine, rule)
|
||||
assert result is True
|
||||
|
||||
def test_system_idle_when_idle(self, engine):
|
||||
cond = SystemIdleCondition(idle_minutes=5, when_idle=True)
|
||||
result = self._eval(engine, cond, idle_seconds=600.0) # 10 minutes idle
|
||||
rule = SystemIdleRule(idle_minutes=5, when_idle=True)
|
||||
result = self._eval(engine, rule, idle_seconds=600.0) # 10 minutes idle
|
||||
assert result is True
|
||||
|
||||
def test_system_idle_not_idle(self, engine):
|
||||
cond = SystemIdleCondition(idle_minutes=5, when_idle=True)
|
||||
result = self._eval(engine, cond, idle_seconds=60.0) # 1 minute idle
|
||||
rule = SystemIdleRule(idle_minutes=5, when_idle=True)
|
||||
result = self._eval(engine, rule, idle_seconds=60.0) # 1 minute idle
|
||||
assert result is False
|
||||
|
||||
def test_system_idle_when_not_idle(self, engine):
|
||||
"""when_idle=False means active when user is NOT idle."""
|
||||
cond = SystemIdleCondition(idle_minutes=5, when_idle=False)
|
||||
result = self._eval(engine, cond, idle_seconds=60.0) # 1 min idle (not yet 5)
|
||||
rule = SystemIdleRule(idle_minutes=5, when_idle=False)
|
||||
result = self._eval(engine, rule, idle_seconds=60.0) # 1 min idle (not yet 5)
|
||||
assert result is True
|
||||
|
||||
def test_display_state_match(self, engine):
|
||||
cond = DisplayStateCondition(state="on")
|
||||
result = self._eval(engine, cond, display_state="on")
|
||||
rule = DisplayStateRule(state="on")
|
||||
result = self._eval(engine, rule, display_state="on")
|
||||
assert result is True
|
||||
|
||||
def test_display_state_no_match(self, engine):
|
||||
cond = DisplayStateCondition(state="off")
|
||||
result = self._eval(engine, cond, display_state="on")
|
||||
rule = DisplayStateRule(state="off")
|
||||
result = self._eval(engine, rule, display_state="on")
|
||||
assert result is False
|
||||
|
||||
def test_webhook_active(self, engine):
|
||||
cond = WebhookCondition(token="tok123")
|
||||
rule = WebhookRule(token="tok123")
|
||||
engine._webhook_states["tok123"] = True
|
||||
result = self._eval(engine, cond)
|
||||
result = self._eval(engine, rule)
|
||||
assert result is True
|
||||
|
||||
def test_webhook_inactive(self, engine):
|
||||
cond = WebhookCondition(token="tok123")
|
||||
rule = WebhookRule(token="tok123")
|
||||
# Not in _webhook_states → False
|
||||
result = self._eval(engine, cond)
|
||||
result = self._eval(engine, rule)
|
||||
assert result is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Condition logic (AND / OR)
|
||||
# Rule logic (AND / OR)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestConditionLogic:
|
||||
def _make_automation(self, conditions, logic="or"):
|
||||
class TestRuleLogic:
|
||||
def _make_automation(self, rules, logic="or"):
|
||||
now = datetime.now(timezone.utc)
|
||||
return Automation(
|
||||
id="logic_auto",
|
||||
name="Logic",
|
||||
enabled=True,
|
||||
condition_logic=logic,
|
||||
conditions=conditions,
|
||||
rule_logic=logic,
|
||||
rules=rules,
|
||||
scene_preset_id=None,
|
||||
deactivation_mode="none",
|
||||
deactivation_scene_preset_id=None,
|
||||
@@ -205,12 +203,12 @@ class TestConditionLogic:
|
||||
def test_or_any_true(self, engine):
|
||||
auto = self._make_automation(
|
||||
[
|
||||
ApplicationCondition(apps=["missing.exe"], match_type="running"),
|
||||
AlwaysCondition(),
|
||||
ApplicationRule(apps=["missing.exe"], match_type="running"),
|
||||
StartupRule(),
|
||||
],
|
||||
logic="or",
|
||||
)
|
||||
result = engine._evaluate_conditions(
|
||||
result = engine._evaluate_rules(
|
||||
auto,
|
||||
running_procs=set(),
|
||||
topmost_proc=None,
|
||||
@@ -224,12 +222,12 @@ class TestConditionLogic:
|
||||
def test_and_all_must_be_true(self, engine):
|
||||
auto = self._make_automation(
|
||||
[
|
||||
AlwaysCondition(),
|
||||
ApplicationCondition(apps=["missing.exe"], match_type="running"),
|
||||
StartupRule(),
|
||||
ApplicationRule(apps=["missing.exe"], match_type="running"),
|
||||
],
|
||||
logic="and",
|
||||
)
|
||||
result = engine._evaluate_conditions(
|
||||
result = engine._evaluate_rules(
|
||||
auto,
|
||||
running_procs=set(),
|
||||
topmost_proc=None,
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
"""Tests for community adapter YAML loader."""
|
||||
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from wled_controller.core.game_integration.community_loader import (
|
||||
clear_community_adapters,
|
||||
get_community_adapter,
|
||||
get_community_adapter_info,
|
||||
get_community_adapters,
|
||||
load_community_adapters,
|
||||
register_community_adapters,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_registry() -> None:
|
||||
"""Clear community adapters before and after each test."""
|
||||
clear_community_adapters()
|
||||
yield # type: ignore[misc]
|
||||
clear_community_adapters()
|
||||
|
||||
|
||||
def _write_yaml(directory: Path, name: str, content: str) -> Path:
|
||||
"""Write a YAML file to a directory."""
|
||||
path = directory / name
|
||||
path.write_text(textwrap.dedent(content))
|
||||
return path
|
||||
|
||||
|
||||
class TestLoadCommunityAdapters:
|
||||
def test_load_from_directory(self, tmp_path: Path) -> None:
|
||||
_write_yaml(
|
||||
tmp_path,
|
||||
"test_game.yaml",
|
||||
"""\
|
||||
name: test_game
|
||||
game: Test Game
|
||||
protocol: webhook
|
||||
mappings:
|
||||
- source_path: player.health
|
||||
event: health
|
||||
min: 0
|
||||
max: 100
|
||||
""",
|
||||
)
|
||||
|
||||
adapters = load_community_adapters(tmp_path)
|
||||
assert "community_test_game" in adapters
|
||||
assert adapters["community_test_game"].game == "Test Game"
|
||||
|
||||
def test_load_multiple_files(self, tmp_path: Path) -> None:
|
||||
_write_yaml(
|
||||
tmp_path,
|
||||
"game_a.yaml",
|
||||
"""\
|
||||
name: game_a
|
||||
game: Game A
|
||||
protocol: webhook
|
||||
mappings:
|
||||
- source_path: hp
|
||||
event: health
|
||||
""",
|
||||
)
|
||||
_write_yaml(
|
||||
tmp_path,
|
||||
"game_b.yaml",
|
||||
"""\
|
||||
name: game_b
|
||||
game: Game B
|
||||
protocol: webhook
|
||||
mappings:
|
||||
- source_path: mp
|
||||
event: mana
|
||||
""",
|
||||
)
|
||||
|
||||
adapters = load_community_adapters(tmp_path)
|
||||
assert len(adapters) == 2
|
||||
assert "community_game_a" in adapters
|
||||
assert "community_game_b" in adapters
|
||||
|
||||
def test_load_nonexistent_directory(self, tmp_path: Path) -> None:
|
||||
nonexistent = tmp_path / "does_not_exist"
|
||||
adapters = load_community_adapters(nonexistent)
|
||||
assert adapters == {}
|
||||
|
||||
def test_skip_invalid_yaml(self, tmp_path: Path) -> None:
|
||||
_write_yaml(
|
||||
tmp_path,
|
||||
"valid.yaml",
|
||||
"""\
|
||||
name: valid
|
||||
game: Valid Game
|
||||
protocol: webhook
|
||||
mappings:
|
||||
- source_path: hp
|
||||
event: health
|
||||
""",
|
||||
)
|
||||
_write_yaml(
|
||||
tmp_path,
|
||||
"invalid.yaml",
|
||||
"""\
|
||||
name: invalid
|
||||
protocol: webhook
|
||||
mappings: []
|
||||
""",
|
||||
)
|
||||
|
||||
adapters = load_community_adapters(tmp_path)
|
||||
assert len(adapters) == 1
|
||||
assert "community_valid" in adapters
|
||||
|
||||
def test_load_yml_extension(self, tmp_path: Path) -> None:
|
||||
_write_yaml(
|
||||
tmp_path,
|
||||
"game.yml",
|
||||
"""\
|
||||
name: yml_game
|
||||
game: YML Game
|
||||
protocol: webhook
|
||||
mappings:
|
||||
- source_path: hp
|
||||
event: health
|
||||
""",
|
||||
)
|
||||
|
||||
adapters = load_community_adapters(tmp_path)
|
||||
assert "community_game" in adapters
|
||||
|
||||
def test_empty_directory(self, tmp_path: Path) -> None:
|
||||
adapters = load_community_adapters(tmp_path)
|
||||
assert adapters == {}
|
||||
|
||||
|
||||
class TestRegisterCommunityAdapters:
|
||||
def test_register_and_retrieve(self, tmp_path: Path) -> None:
|
||||
_write_yaml(
|
||||
tmp_path,
|
||||
"test.yaml",
|
||||
"""\
|
||||
name: test
|
||||
game: Test
|
||||
protocol: webhook
|
||||
mappings:
|
||||
- source_path: hp
|
||||
event: health
|
||||
""",
|
||||
)
|
||||
|
||||
count = register_community_adapters(tmp_path)
|
||||
assert count == 1
|
||||
|
||||
adapters = get_community_adapters()
|
||||
assert "community_test" in adapters
|
||||
|
||||
def test_get_single_adapter(self, tmp_path: Path) -> None:
|
||||
_write_yaml(
|
||||
tmp_path,
|
||||
"test.yaml",
|
||||
"""\
|
||||
name: test
|
||||
game: Test
|
||||
protocol: webhook
|
||||
mappings:
|
||||
- source_path: hp
|
||||
event: health
|
||||
""",
|
||||
)
|
||||
|
||||
register_community_adapters(tmp_path)
|
||||
adapter = get_community_adapter("community_test")
|
||||
assert adapter is not None
|
||||
assert adapter.name == "test"
|
||||
|
||||
def test_get_nonexistent_adapter(self) -> None:
|
||||
adapter = get_community_adapter("community_nonexistent")
|
||||
assert adapter is None
|
||||
|
||||
def test_get_adapter_info(self, tmp_path: Path) -> None:
|
||||
_write_yaml(
|
||||
tmp_path,
|
||||
"my_game.yaml",
|
||||
"""\
|
||||
name: My Game Adapter
|
||||
game: My Game
|
||||
protocol: webhook
|
||||
mappings:
|
||||
- source_path: hp
|
||||
event: health
|
||||
- source_path: kills
|
||||
event: kill
|
||||
""",
|
||||
)
|
||||
|
||||
register_community_adapters(tmp_path)
|
||||
info = get_community_adapter_info()
|
||||
assert len(info) == 1
|
||||
assert info[0]["adapter_type"] == "community_my_game"
|
||||
assert info[0]["display_name"] == "My Game Adapter"
|
||||
assert info[0]["game_name"] == "My Game"
|
||||
assert info[0]["source"] == "community"
|
||||
assert "health" in info[0]["supported_events"]
|
||||
assert "kill" in info[0]["supported_events"]
|
||||
|
||||
|
||||
class TestBuiltInYamlFiles:
|
||||
"""Test that the shipped YAML adapter files load correctly."""
|
||||
|
||||
def test_load_bundled_adapters(self) -> None:
|
||||
"""Load the built-in game_adapters directory."""
|
||||
bundled_dir = (
|
||||
Path(__file__).parent.parent.parent
|
||||
/ "src"
|
||||
/ "wled_controller"
|
||||
/ "data"
|
||||
/ "game_adapters"
|
||||
)
|
||||
if not bundled_dir.exists():
|
||||
pytest.skip("Bundled adapter directory not found")
|
||||
|
||||
adapters = load_community_adapters(bundled_dir)
|
||||
# We ship minecraft, valorant, rocket_league
|
||||
assert len(adapters) >= 3
|
||||
assert "community_minecraft" in adapters
|
||||
assert "community_valorant" in adapters
|
||||
assert "community_rocket_league" in adapters
|
||||
|
||||
def test_minecraft_adapter_parses(self) -> None:
|
||||
"""Test that the Minecraft community adapter can parse a payload."""
|
||||
bundled_dir = (
|
||||
Path(__file__).parent.parent.parent
|
||||
/ "src"
|
||||
/ "wled_controller"
|
||||
/ "data"
|
||||
/ "game_adapters"
|
||||
)
|
||||
adapters = load_community_adapters(bundled_dir)
|
||||
mc = adapters.get("community_minecraft")
|
||||
if mc is None:
|
||||
pytest.skip("Minecraft adapter not found")
|
||||
|
||||
payload = {
|
||||
"player": {
|
||||
"health": 15,
|
||||
"armor": 10,
|
||||
"food_level": 18,
|
||||
"experience_level": 30,
|
||||
},
|
||||
"stats": {"kills": 5},
|
||||
}
|
||||
events, _ = mc.parse_payload(payload, {"adapter_id": "mc_test"}, {})
|
||||
types = {e.event_type for e in events}
|
||||
assert "health" in types
|
||||
assert "armor" in types
|
||||
|
||||
def test_rocket_league_adapter_parses(self) -> None:
|
||||
"""Test that the Rocket League community adapter can parse a payload."""
|
||||
bundled_dir = (
|
||||
Path(__file__).parent.parent.parent
|
||||
/ "src"
|
||||
/ "wled_controller"
|
||||
/ "data"
|
||||
/ "game_adapters"
|
||||
)
|
||||
adapters = load_community_adapters(bundled_dir)
|
||||
rl = adapters.get("community_rocket_league")
|
||||
if rl is None:
|
||||
pytest.skip("Rocket League adapter not found")
|
||||
|
||||
payload = {
|
||||
"player": {"boost": 50, "speed": 1150},
|
||||
"match": {"goals_scored": 2, "goals_conceded": 1, "time_remaining": 150},
|
||||
"team": {"score_blue": 2, "score_orange": 1},
|
||||
}
|
||||
events, _ = rl.parse_payload(payload, {"adapter_id": "rl_test"}, {})
|
||||
types = {e.event_type for e in events}
|
||||
assert "energy" in types
|
||||
assert "speed" in types
|
||||
@@ -0,0 +1,279 @@
|
||||
"""Tests for CS2 Game State Integration adapter."""
|
||||
|
||||
import pytest
|
||||
|
||||
from wled_controller.core.game_integration.adapters.cs2_adapter import CS2Adapter
|
||||
|
||||
|
||||
# ── Realistic CS2 GSI payload samples ────────────────────────────────────
|
||||
|
||||
|
||||
def _make_cs2_payload(
|
||||
*,
|
||||
health: int = 100,
|
||||
armor: int = 100,
|
||||
money: int = 800,
|
||||
kills: int = 0,
|
||||
deaths: int = 0,
|
||||
round_phase: str | None = None,
|
||||
bomb: str | None = None,
|
||||
flashed: int = 0,
|
||||
team: str = "CT",
|
||||
ammo_clip: int | None = None,
|
||||
ammo_clip_max: int | None = None,
|
||||
auth_token: str | None = None,
|
||||
) -> dict:
|
||||
"""Build a realistic CS2 GSI payload."""
|
||||
payload: dict = {
|
||||
"player": {
|
||||
"steamid": "76561198012345678",
|
||||
"name": "TestPlayer",
|
||||
"team": team,
|
||||
"state": {
|
||||
"health": health,
|
||||
"armor": armor,
|
||||
"helmet": True,
|
||||
"flashed": flashed,
|
||||
"smoked": 0,
|
||||
"burning": 0,
|
||||
"money": money,
|
||||
"round_kills": 0,
|
||||
"round_killhs": 0,
|
||||
"equip_value": 4400,
|
||||
},
|
||||
"match_stats": {
|
||||
"kills": kills,
|
||||
"assists": 0,
|
||||
"deaths": deaths,
|
||||
"mvps": 0,
|
||||
"score": kills * 2,
|
||||
},
|
||||
"weapons": {},
|
||||
},
|
||||
}
|
||||
|
||||
if ammo_clip is not None and ammo_clip_max is not None:
|
||||
payload["player"]["weapons"]["weapon_0"] = {
|
||||
"name": "weapon_ak47",
|
||||
"paintkit": "default",
|
||||
"type": "Rifle",
|
||||
"ammo_clip": ammo_clip,
|
||||
"ammo_clip_max": ammo_clip_max,
|
||||
"ammo_reserve": 90,
|
||||
"state": "active",
|
||||
}
|
||||
|
||||
if round_phase is not None:
|
||||
payload["round"] = {"phase": round_phase}
|
||||
|
||||
if bomb is not None:
|
||||
payload.setdefault("round", {})["bomb"] = bomb
|
||||
|
||||
if auth_token is not None:
|
||||
payload["auth"] = {"token": auth_token}
|
||||
|
||||
return payload
|
||||
|
||||
|
||||
# ── Health/Armor/Money tests ─────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestCS2ContinuousEvents:
|
||||
def test_health_normalized(self) -> None:
|
||||
payload = _make_cs2_payload(health=75)
|
||||
events, _ = CS2Adapter.parse_payload(payload, {"adapter_id": "cs2_test"}, {})
|
||||
health_events = [e for e in events if e.event_type == "health"]
|
||||
assert len(health_events) == 1
|
||||
assert health_events[0].value == pytest.approx(0.75)
|
||||
|
||||
def test_health_zero(self) -> None:
|
||||
payload = _make_cs2_payload(health=0)
|
||||
events, _ = CS2Adapter.parse_payload(payload, {"adapter_id": "cs2_test"}, {})
|
||||
health_events = [e for e in events if e.event_type == "health"]
|
||||
assert health_events[0].value == 0.0
|
||||
|
||||
def test_armor_normalized(self) -> None:
|
||||
payload = _make_cs2_payload(armor=50)
|
||||
events, _ = CS2Adapter.parse_payload(payload, {"adapter_id": "cs2_test"}, {})
|
||||
armor_events = [e for e in events if e.event_type == "armor"]
|
||||
assert len(armor_events) == 1
|
||||
assert armor_events[0].value == pytest.approx(0.5)
|
||||
|
||||
def test_money_normalized(self) -> None:
|
||||
payload = _make_cs2_payload(money=8000)
|
||||
events, _ = CS2Adapter.parse_payload(payload, {"adapter_id": "cs2_test"}, {})
|
||||
gold_events = [e for e in events if e.event_type == "gold"]
|
||||
assert len(gold_events) == 1
|
||||
assert gold_events[0].value == pytest.approx(8000.0 / 16000.0)
|
||||
|
||||
def test_ammo_normalized(self) -> None:
|
||||
payload = _make_cs2_payload(ammo_clip=15, ammo_clip_max=30)
|
||||
events, _ = CS2Adapter.parse_payload(payload, {"adapter_id": "cs2_test"}, {})
|
||||
ammo_events = [e for e in events if e.event_type == "ammo"]
|
||||
assert len(ammo_events) == 1
|
||||
assert ammo_events[0].value == pytest.approx(0.5)
|
||||
|
||||
def test_adapter_id_passed_through(self) -> None:
|
||||
payload = _make_cs2_payload(health=100)
|
||||
events, _ = CS2Adapter.parse_payload(payload, {"adapter_id": "my_cs2"}, {})
|
||||
assert all(e.adapter_id == "my_cs2" for e in events)
|
||||
|
||||
|
||||
# ── Kill/Death diff detection tests ──────────────────────────────────────
|
||||
|
||||
|
||||
class TestCS2DiffDetection:
|
||||
def test_kill_detected_on_increase(self) -> None:
|
||||
payload1 = _make_cs2_payload(kills=3)
|
||||
_, state1 = CS2Adapter.parse_payload(payload1, {"adapter_id": "t"}, {})
|
||||
|
||||
payload2 = _make_cs2_payload(kills=4)
|
||||
events, _ = CS2Adapter.parse_payload(payload2, {"adapter_id": "t"}, state1)
|
||||
kill_events = [e for e in events if e.event_type == "kill"]
|
||||
assert len(kill_events) == 1
|
||||
|
||||
def test_no_kill_on_first_payload(self) -> None:
|
||||
payload = _make_cs2_payload(kills=5)
|
||||
events, _ = CS2Adapter.parse_payload(payload, {"adapter_id": "t"}, {})
|
||||
kill_events = [e for e in events if e.event_type == "kill"]
|
||||
assert len(kill_events) == 0
|
||||
|
||||
def test_multiple_kills_detected(self) -> None:
|
||||
payload1 = _make_cs2_payload(kills=2)
|
||||
_, state1 = CS2Adapter.parse_payload(payload1, {"adapter_id": "t"}, {})
|
||||
|
||||
payload2 = _make_cs2_payload(kills=5)
|
||||
events, _ = CS2Adapter.parse_payload(payload2, {"adapter_id": "t"}, state1)
|
||||
kill_events = [e for e in events if e.event_type == "kill"]
|
||||
assert len(kill_events) == 3
|
||||
|
||||
def test_death_detected(self) -> None:
|
||||
payload1 = _make_cs2_payload(deaths=0)
|
||||
_, state1 = CS2Adapter.parse_payload(payload1, {"adapter_id": "t"}, {})
|
||||
|
||||
payload2 = _make_cs2_payload(deaths=1)
|
||||
events, _ = CS2Adapter.parse_payload(payload2, {"adapter_id": "t"}, state1)
|
||||
death_events = [e for e in events if e.event_type == "death"]
|
||||
assert len(death_events) == 1
|
||||
|
||||
def test_no_death_on_same_count(self) -> None:
|
||||
payload1 = _make_cs2_payload(deaths=2)
|
||||
_, state1 = CS2Adapter.parse_payload(payload1, {"adapter_id": "t"}, {})
|
||||
|
||||
payload2 = _make_cs2_payload(deaths=2)
|
||||
events, _ = CS2Adapter.parse_payload(payload2, {"adapter_id": "t"}, state1)
|
||||
death_events = [e for e in events if e.event_type == "death"]
|
||||
assert len(death_events) == 0
|
||||
|
||||
|
||||
# ── Round/Bomb trigger tests ────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestCS2Triggers:
|
||||
def test_round_start(self) -> None:
|
||||
payload = _make_cs2_payload(round_phase="live")
|
||||
events, state = CS2Adapter.parse_payload(payload, {"adapter_id": "t"}, {})
|
||||
rs = [e for e in events if e.event_type == "round_start"]
|
||||
assert len(rs) == 1
|
||||
assert state["round_phase"] == "live"
|
||||
|
||||
def test_round_end(self) -> None:
|
||||
prev = {"round_phase": "live"}
|
||||
payload = _make_cs2_payload(round_phase="over")
|
||||
events, _ = CS2Adapter.parse_payload(payload, {"adapter_id": "t"}, prev)
|
||||
re = [e for e in events if e.event_type == "round_end"]
|
||||
assert len(re) == 1
|
||||
|
||||
def test_no_round_event_on_same_phase(self) -> None:
|
||||
prev = {"round_phase": "live"}
|
||||
payload = _make_cs2_payload(round_phase="live")
|
||||
events, _ = CS2Adapter.parse_payload(payload, {"adapter_id": "t"}, prev)
|
||||
round_events = [e for e in events if e.event_type in ("round_start", "round_end")]
|
||||
assert len(round_events) == 0
|
||||
|
||||
def test_bomb_planted(self) -> None:
|
||||
payload = _make_cs2_payload(round_phase="live", bomb="planted")
|
||||
events, _ = CS2Adapter.parse_payload(payload, {"adapter_id": "t"}, {})
|
||||
bomb = [e for e in events if e.event_type == "objective_captured"]
|
||||
assert len(bomb) == 1
|
||||
|
||||
def test_bomb_defused(self) -> None:
|
||||
prev = {"bomb": "planted"}
|
||||
payload = _make_cs2_payload(round_phase="live", bomb="defused")
|
||||
events, _ = CS2Adapter.parse_payload(payload, {"adapter_id": "t"}, prev)
|
||||
defuse = [e for e in events if e.event_type == "objective_lost"]
|
||||
assert len(defuse) == 1
|
||||
|
||||
def test_flashbang_detected(self) -> None:
|
||||
payload = _make_cs2_payload(flashed=200)
|
||||
events, _ = CS2Adapter.parse_payload(payload, {"adapter_id": "t"}, {"flashed": 0})
|
||||
flash = [e for e in events if e.event_type == "blinded"]
|
||||
assert len(flash) == 1
|
||||
assert flash[0].value == pytest.approx(200.0 / 255.0)
|
||||
|
||||
def test_no_flashbang_when_already_flashed(self) -> None:
|
||||
prev = {"flashed": 200}
|
||||
payload = _make_cs2_payload(flashed=150)
|
||||
events, _ = CS2Adapter.parse_payload(payload, {"adapter_id": "t"}, prev)
|
||||
flash = [e for e in events if e.event_type == "blinded"]
|
||||
# Already flashed (prev > 0), so no new flash event
|
||||
assert len(flash) == 0
|
||||
|
||||
def test_team_ct(self) -> None:
|
||||
payload = _make_cs2_payload(team="CT")
|
||||
events, _ = CS2Adapter.parse_payload(payload, {"adapter_id": "t"}, {})
|
||||
team = [e for e in events if e.event_type == "team_a"]
|
||||
assert len(team) == 1
|
||||
|
||||
def test_team_t(self) -> None:
|
||||
payload = _make_cs2_payload(team="T")
|
||||
events, _ = CS2Adapter.parse_payload(payload, {"adapter_id": "t"}, {})
|
||||
team = [e for e in events if e.event_type == "team_b"]
|
||||
assert len(team) == 1
|
||||
|
||||
|
||||
# ── Auth validation tests ───────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestCS2Auth:
|
||||
def test_auth_valid(self) -> None:
|
||||
payload = _make_cs2_payload(auth_token="mysecret")
|
||||
result = CS2Adapter.validate_auth({}, payload, {"auth_token": "mysecret"})
|
||||
assert result is True
|
||||
|
||||
def test_auth_invalid(self) -> None:
|
||||
payload = _make_cs2_payload(auth_token="wrong")
|
||||
result = CS2Adapter.validate_auth({}, payload, {"auth_token": "mysecret"})
|
||||
assert result is False
|
||||
|
||||
def test_auth_no_token_configured_accepts_all(self) -> None:
|
||||
payload = _make_cs2_payload()
|
||||
result = CS2Adapter.validate_auth({}, payload, {})
|
||||
assert result is True
|
||||
|
||||
def test_auth_missing_payload_token(self) -> None:
|
||||
payload = _make_cs2_payload() # no auth in payload
|
||||
result = CS2Adapter.validate_auth({}, payload, {"auth_token": "mysecret"})
|
||||
assert result is False
|
||||
|
||||
|
||||
# ── Metadata tests ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestCS2Metadata:
|
||||
def test_adapter_type(self) -> None:
|
||||
assert CS2Adapter.ADAPTER_TYPE == "cs2"
|
||||
|
||||
def test_supported_events(self) -> None:
|
||||
assert "health" in CS2Adapter.SUPPORTED_EVENTS
|
||||
assert "kill" in CS2Adapter.SUPPORTED_EVENTS
|
||||
assert "death" in CS2Adapter.SUPPORTED_EVENTS
|
||||
|
||||
def test_config_schema(self) -> None:
|
||||
schema = CS2Adapter.get_config_schema()
|
||||
assert "auth_token" in schema["properties"]
|
||||
|
||||
def test_setup_instructions(self) -> None:
|
||||
instructions = CS2Adapter.get_setup_instructions()
|
||||
assert "gamestate_integration" in instructions
|
||||
assert "CS2" in instructions
|
||||
@@ -0,0 +1,204 @@
|
||||
"""Tests for Dota 2 Game State Integration adapter."""
|
||||
|
||||
import pytest
|
||||
|
||||
from wled_controller.core.game_integration.adapters.dota2_adapter import Dota2Adapter
|
||||
|
||||
|
||||
def _make_dota2_payload(
|
||||
*,
|
||||
health: int = 1000,
|
||||
max_health: int = 1000,
|
||||
mana: float = 500.0,
|
||||
max_mana: float = 500.0,
|
||||
gold: int = 625,
|
||||
kills: int = 0,
|
||||
deaths: int = 0,
|
||||
game_state: str | None = None,
|
||||
auth_token: str | None = None,
|
||||
) -> dict:
|
||||
"""Build a realistic Dota 2 GSI payload."""
|
||||
payload: dict = {
|
||||
"hero": {
|
||||
"xpos": -6624,
|
||||
"ypos": -6592,
|
||||
"id": 1,
|
||||
"name": "npc_dota_hero_antimage",
|
||||
"level": 12,
|
||||
"alive": True,
|
||||
"respawn_seconds": 0,
|
||||
"buyback_cost": 874,
|
||||
"buyback_cooldown": 0,
|
||||
"health": health,
|
||||
"max_health": max_health,
|
||||
"health_percent": int(100.0 * health / max_health) if max_health else 0,
|
||||
"mana": mana,
|
||||
"max_mana": max_mana,
|
||||
"mana_percent": int(100.0 * mana / max_mana) if max_mana else 0,
|
||||
},
|
||||
"player": {
|
||||
"steamid": "76561198012345678",
|
||||
"name": "TestPlayer",
|
||||
"activity": "playing",
|
||||
"kills": kills,
|
||||
"deaths": deaths,
|
||||
"assists": 0,
|
||||
"last_hits": 120,
|
||||
"denies": 10,
|
||||
"kill_streak": 0,
|
||||
"gold": gold,
|
||||
"gold_reliable": 200,
|
||||
"gold_unreliable": gold - 200,
|
||||
"gpm": 450,
|
||||
"xpm": 520,
|
||||
},
|
||||
}
|
||||
|
||||
if game_state is not None:
|
||||
payload["map"] = {
|
||||
"name": "start",
|
||||
"matchid": "12345",
|
||||
"game_time": 1200,
|
||||
"clock_time": 1200,
|
||||
"daytime": True,
|
||||
"game_state": game_state,
|
||||
"win_team": "none",
|
||||
}
|
||||
|
||||
if auth_token is not None:
|
||||
payload["auth"] = {"token": auth_token}
|
||||
|
||||
return payload
|
||||
|
||||
|
||||
class TestDota2ContinuousEvents:
|
||||
def test_health_normalized(self) -> None:
|
||||
payload = _make_dota2_payload(health=750, max_health=1500)
|
||||
events, _ = Dota2Adapter.parse_payload(payload, {"adapter_id": "d2"}, {})
|
||||
hp = [e for e in events if e.event_type == "health"]
|
||||
assert len(hp) == 1
|
||||
assert hp[0].value == pytest.approx(0.5)
|
||||
|
||||
def test_mana_normalized(self) -> None:
|
||||
payload = _make_dota2_payload(mana=300.0, max_mana=600.0)
|
||||
events, _ = Dota2Adapter.parse_payload(payload, {"adapter_id": "d2"}, {})
|
||||
mp = [e for e in events if e.event_type == "mana"]
|
||||
assert len(mp) == 1
|
||||
assert mp[0].value == pytest.approx(0.5)
|
||||
|
||||
def test_gold_normalized(self) -> None:
|
||||
payload = _make_dota2_payload(gold=5000)
|
||||
events, _ = Dota2Adapter.parse_payload(payload, {"adapter_id": "d2"}, {})
|
||||
g = [e for e in events if e.event_type == "gold"]
|
||||
assert len(g) == 1
|
||||
assert g[0].value == pytest.approx(5000.0 / 99999.0)
|
||||
|
||||
def test_gold_custom_max(self) -> None:
|
||||
payload = _make_dota2_payload(gold=5000)
|
||||
events, _ = Dota2Adapter.parse_payload(
|
||||
payload,
|
||||
{"adapter_id": "d2", "max_gold": 10000},
|
||||
{},
|
||||
)
|
||||
g = [e for e in events if e.event_type == "gold"]
|
||||
assert g[0].value == pytest.approx(0.5)
|
||||
|
||||
|
||||
class TestDota2DiffDetection:
|
||||
def test_kill_detected(self) -> None:
|
||||
p1 = _make_dota2_payload(kills=3)
|
||||
_, s1 = Dota2Adapter.parse_payload(p1, {"adapter_id": "d2"}, {})
|
||||
|
||||
p2 = _make_dota2_payload(kills=4)
|
||||
events, _ = Dota2Adapter.parse_payload(p2, {"adapter_id": "d2"}, s1)
|
||||
kills = [e for e in events if e.event_type == "kill"]
|
||||
assert len(kills) == 1
|
||||
|
||||
def test_no_kill_on_first_payload(self) -> None:
|
||||
p = _make_dota2_payload(kills=5)
|
||||
events, _ = Dota2Adapter.parse_payload(p, {"adapter_id": "d2"}, {})
|
||||
kills = [e for e in events if e.event_type == "kill"]
|
||||
assert len(kills) == 0
|
||||
|
||||
def test_death_detected(self) -> None:
|
||||
p1 = _make_dota2_payload(deaths=1)
|
||||
_, s1 = Dota2Adapter.parse_payload(p1, {"adapter_id": "d2"}, {})
|
||||
|
||||
p2 = _make_dota2_payload(deaths=2)
|
||||
events, _ = Dota2Adapter.parse_payload(p2, {"adapter_id": "d2"}, s1)
|
||||
deaths = [e for e in events if e.event_type == "death"]
|
||||
assert len(deaths) == 1
|
||||
|
||||
def test_multiple_kills(self) -> None:
|
||||
p1 = _make_dota2_payload(kills=0)
|
||||
_, s1 = Dota2Adapter.parse_payload(p1, {"adapter_id": "d2"}, {})
|
||||
|
||||
p2 = _make_dota2_payload(kills=3)
|
||||
events, _ = Dota2Adapter.parse_payload(p2, {"adapter_id": "d2"}, s1)
|
||||
kills = [e for e in events if e.event_type == "kill"]
|
||||
assert len(kills) == 3
|
||||
|
||||
|
||||
class TestDota2MatchFlow:
|
||||
def test_match_start_pre_game(self) -> None:
|
||||
p = _make_dota2_payload(game_state="DOTA_GAMERULES_STATE_PRE_GAME")
|
||||
events, _ = Dota2Adapter.parse_payload(p, {"adapter_id": "d2"}, {})
|
||||
ms = [e for e in events if e.event_type == "match_start"]
|
||||
assert len(ms) == 1
|
||||
|
||||
def test_match_start_in_progress(self) -> None:
|
||||
prev = {"game_state": "DOTA_GAMERULES_STATE_PRE_GAME"}
|
||||
p = _make_dota2_payload(game_state="DOTA_GAMERULES_STATE_GAME_IN_PROGRESS")
|
||||
events, _ = Dota2Adapter.parse_payload(p, {"adapter_id": "d2"}, prev)
|
||||
ms = [e for e in events if e.event_type == "match_start"]
|
||||
assert len(ms) == 1
|
||||
|
||||
def test_match_end(self) -> None:
|
||||
prev = {"game_state": "DOTA_GAMERULES_STATE_GAME_IN_PROGRESS"}
|
||||
p = _make_dota2_payload(game_state="DOTA_GAMERULES_STATE_POST_GAME")
|
||||
events, _ = Dota2Adapter.parse_payload(p, {"adapter_id": "d2"}, prev)
|
||||
me = [e for e in events if e.event_type == "match_end"]
|
||||
assert len(me) == 1
|
||||
|
||||
def test_no_event_on_same_state(self) -> None:
|
||||
prev = {"game_state": "DOTA_GAMERULES_STATE_GAME_IN_PROGRESS"}
|
||||
p = _make_dota2_payload(game_state="DOTA_GAMERULES_STATE_GAME_IN_PROGRESS")
|
||||
events, _ = Dota2Adapter.parse_payload(p, {"adapter_id": "d2"}, prev)
|
||||
flow = [e for e in events if e.event_type in ("match_start", "match_end")]
|
||||
assert len(flow) == 0
|
||||
|
||||
|
||||
class TestDota2Auth:
|
||||
def test_auth_valid(self) -> None:
|
||||
p = _make_dota2_payload(auth_token="secret")
|
||||
result = Dota2Adapter.validate_auth({}, p, {"auth_token": "secret"})
|
||||
assert result is True
|
||||
|
||||
def test_auth_invalid(self) -> None:
|
||||
p = _make_dota2_payload(auth_token="wrong")
|
||||
result = Dota2Adapter.validate_auth({}, p, {"auth_token": "secret"})
|
||||
assert result is False
|
||||
|
||||
def test_auth_no_config_accepts_all(self) -> None:
|
||||
p = _make_dota2_payload()
|
||||
result = Dota2Adapter.validate_auth({}, p, {})
|
||||
assert result is True
|
||||
|
||||
|
||||
class TestDota2Metadata:
|
||||
def test_adapter_type(self) -> None:
|
||||
assert Dota2Adapter.ADAPTER_TYPE == "dota2"
|
||||
|
||||
def test_supported_events(self) -> None:
|
||||
assert "health" in Dota2Adapter.SUPPORTED_EVENTS
|
||||
assert "mana" in Dota2Adapter.SUPPORTED_EVENTS
|
||||
assert "gold" in Dota2Adapter.SUPPORTED_EVENTS
|
||||
|
||||
def test_config_schema(self) -> None:
|
||||
schema = Dota2Adapter.get_config_schema()
|
||||
assert "auth_token" in schema["properties"]
|
||||
assert "max_gold" in schema["properties"]
|
||||
|
||||
def test_setup_instructions(self) -> None:
|
||||
instructions = Dota2Adapter.get_setup_instructions()
|
||||
assert "Dota 2" in instructions
|
||||
@@ -0,0 +1,239 @@
|
||||
"""Tests for GameEventBus — publish, subscribe, unsubscribe, thread safety."""
|
||||
|
||||
import threading
|
||||
|
||||
|
||||
from wled_controller.core.game_integration.event_bus import GameEventBus
|
||||
from wled_controller.core.game_integration.events import GameEvent
|
||||
|
||||
|
||||
def _make_event(event_type: str = "health", value: float = 0.5) -> GameEvent:
|
||||
return GameEvent(
|
||||
adapter_id="test_adapter",
|
||||
event_type=event_type,
|
||||
value=value,
|
||||
raw_data={"test": True},
|
||||
)
|
||||
|
||||
|
||||
class TestPublishSubscribe:
|
||||
"""Basic publish/subscribe behavior."""
|
||||
|
||||
def test_subscribe_receives_matching_events(self) -> None:
|
||||
bus = GameEventBus()
|
||||
received: list[GameEvent] = []
|
||||
bus.subscribe("health", received.append)
|
||||
|
||||
event = _make_event("health", 0.8)
|
||||
bus.publish(event)
|
||||
|
||||
assert len(received) == 1
|
||||
assert received[0] is event
|
||||
|
||||
def test_subscribe_ignores_non_matching_events(self) -> None:
|
||||
bus = GameEventBus()
|
||||
received: list[GameEvent] = []
|
||||
bus.subscribe("health", received.append)
|
||||
|
||||
bus.publish(_make_event("kill"))
|
||||
|
||||
assert len(received) == 0
|
||||
|
||||
def test_subscribe_all_receives_every_event(self) -> None:
|
||||
bus = GameEventBus()
|
||||
received: list[GameEvent] = []
|
||||
bus.subscribe_all(received.append)
|
||||
|
||||
bus.publish(_make_event("health"))
|
||||
bus.publish(_make_event("kill"))
|
||||
bus.publish(_make_event("death"))
|
||||
|
||||
assert len(received) == 3
|
||||
|
||||
def test_multiple_subscribers_same_type(self) -> None:
|
||||
bus = GameEventBus()
|
||||
received_a: list[GameEvent] = []
|
||||
received_b: list[GameEvent] = []
|
||||
bus.subscribe("kill", received_a.append)
|
||||
bus.subscribe("kill", received_b.append)
|
||||
|
||||
bus.publish(_make_event("kill"))
|
||||
|
||||
assert len(received_a) == 1
|
||||
assert len(received_b) == 1
|
||||
|
||||
def test_type_and_wildcard_both_receive(self) -> None:
|
||||
bus = GameEventBus()
|
||||
type_received: list[GameEvent] = []
|
||||
wild_received: list[GameEvent] = []
|
||||
bus.subscribe("health", type_received.append)
|
||||
bus.subscribe_all(wild_received.append)
|
||||
|
||||
bus.publish(_make_event("health"))
|
||||
|
||||
assert len(type_received) == 1
|
||||
assert len(wild_received) == 1
|
||||
|
||||
|
||||
class TestUnsubscribe:
|
||||
"""Unsubscribe behavior."""
|
||||
|
||||
def test_unsubscribe_stops_delivery(self) -> None:
|
||||
bus = GameEventBus()
|
||||
received: list[GameEvent] = []
|
||||
sub_id = bus.subscribe("health", received.append)
|
||||
|
||||
bus.publish(_make_event("health"))
|
||||
assert len(received) == 1
|
||||
|
||||
result = bus.unsubscribe(sub_id)
|
||||
assert result is True
|
||||
|
||||
bus.publish(_make_event("health"))
|
||||
assert len(received) == 1 # No new events
|
||||
|
||||
def test_unsubscribe_wildcard(self) -> None:
|
||||
bus = GameEventBus()
|
||||
received: list[GameEvent] = []
|
||||
sub_id = bus.subscribe_all(received.append)
|
||||
|
||||
bus.publish(_make_event("health"))
|
||||
assert len(received) == 1
|
||||
|
||||
bus.unsubscribe(sub_id)
|
||||
bus.publish(_make_event("health"))
|
||||
assert len(received) == 1
|
||||
|
||||
def test_unsubscribe_unknown_id_returns_false(self) -> None:
|
||||
bus = GameEventBus()
|
||||
assert bus.unsubscribe("nonexistent_id") is False
|
||||
|
||||
|
||||
class TestRecentEvents:
|
||||
"""Recent events deque."""
|
||||
|
||||
def test_get_recent_events_returns_published(self) -> None:
|
||||
bus = GameEventBus()
|
||||
bus.publish(_make_event("health", 0.5))
|
||||
bus.publish(_make_event("kill", 1.0))
|
||||
|
||||
recent = bus.get_recent_events()
|
||||
assert len(recent) == 2
|
||||
assert recent[0].event_type == "health"
|
||||
assert recent[1].event_type == "kill"
|
||||
|
||||
def test_get_recent_events_respects_limit(self) -> None:
|
||||
bus = GameEventBus()
|
||||
for i in range(10):
|
||||
bus.publish(_make_event("health", i / 10.0))
|
||||
|
||||
recent = bus.get_recent_events(limit=3)
|
||||
assert len(recent) == 3
|
||||
|
||||
def test_recent_events_bounded_by_maxlen(self) -> None:
|
||||
bus = GameEventBus(recent_maxlen=5)
|
||||
for i in range(10):
|
||||
bus.publish(_make_event("health", i / 10.0))
|
||||
|
||||
recent = bus.get_recent_events(limit=100)
|
||||
assert len(recent) == 5
|
||||
|
||||
|
||||
class TestStats:
|
||||
"""Event statistics."""
|
||||
|
||||
def test_stats_counts_per_type(self) -> None:
|
||||
bus = GameEventBus()
|
||||
bus.publish(_make_event("health"))
|
||||
bus.publish(_make_event("health"))
|
||||
bus.publish(_make_event("kill"))
|
||||
|
||||
stats = bus.get_stats()
|
||||
assert stats["event_counts"]["health"] == 2
|
||||
assert stats["event_counts"]["kill"] == 1
|
||||
|
||||
def test_stats_last_timestamp(self) -> None:
|
||||
bus = GameEventBus()
|
||||
assert bus.get_stats()["last_event_timestamp"] is None
|
||||
|
||||
event = _make_event("health")
|
||||
bus.publish(event)
|
||||
|
||||
stats = bus.get_stats()
|
||||
assert stats["last_event_timestamp"] == event.timestamp
|
||||
|
||||
|
||||
class TestThreadSafety:
|
||||
"""Concurrent publish/subscribe must not crash."""
|
||||
|
||||
def test_concurrent_publish(self) -> None:
|
||||
bus = GameEventBus()
|
||||
received: list[GameEvent] = []
|
||||
lock = threading.Lock()
|
||||
|
||||
def safe_append(event: GameEvent) -> None:
|
||||
with lock:
|
||||
received.append(event)
|
||||
|
||||
bus.subscribe_all(safe_append)
|
||||
|
||||
num_threads = 10
|
||||
events_per_thread = 50
|
||||
threads = []
|
||||
|
||||
def publisher(thread_id: int) -> None:
|
||||
for i in range(events_per_thread):
|
||||
bus.publish(_make_event("health", i / events_per_thread))
|
||||
|
||||
for t_id in range(num_threads):
|
||||
t = threading.Thread(target=publisher, args=(t_id,))
|
||||
threads.append(t)
|
||||
t.start()
|
||||
|
||||
for t in threads:
|
||||
t.join(timeout=10)
|
||||
|
||||
assert len(received) == num_threads * events_per_thread
|
||||
|
||||
def test_concurrent_subscribe_unsubscribe(self) -> None:
|
||||
"""Subscribe and unsubscribe from multiple threads simultaneously."""
|
||||
bus = GameEventBus()
|
||||
sub_ids: list[str] = []
|
||||
lock = threading.Lock()
|
||||
|
||||
def subscriber() -> None:
|
||||
sid = bus.subscribe("health", lambda e: None)
|
||||
with lock:
|
||||
sub_ids.append(sid)
|
||||
|
||||
threads = [threading.Thread(target=subscriber) for _ in range(20)]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join(timeout=10)
|
||||
|
||||
assert len(sub_ids) == 20
|
||||
|
||||
# Unsubscribe all
|
||||
for sid in sub_ids:
|
||||
assert bus.unsubscribe(sid) is True
|
||||
|
||||
|
||||
class TestCallbackError:
|
||||
"""Callback errors must not crash the bus."""
|
||||
|
||||
def test_error_in_callback_does_not_block_others(self) -> None:
|
||||
bus = GameEventBus()
|
||||
received: list[GameEvent] = []
|
||||
|
||||
def bad_callback(event: GameEvent) -> None:
|
||||
raise RuntimeError("boom")
|
||||
|
||||
bus.subscribe("health", bad_callback)
|
||||
bus.subscribe("health", received.append)
|
||||
|
||||
# Should not raise
|
||||
bus.publish(_make_event("health"))
|
||||
|
||||
# Second subscriber still receives the event
|
||||
assert len(received) == 1
|
||||
@@ -0,0 +1,492 @@
|
||||
"""Tests for GameEventColorStripSource and GameEventColorStripStream."""
|
||||
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import numpy as np
|
||||
|
||||
from wled_controller.core.game_integration.event_bus import GameEventBus
|
||||
from wled_controller.core.game_integration.events import GameEvent
|
||||
from wled_controller.core.processing.game_event_stream import GameEventColorStripStream
|
||||
from wled_controller.storage.bindable import BindableColor
|
||||
from wled_controller.storage.color_strip_source import (
|
||||
ColorStripSource,
|
||||
GameEventColorStripSource,
|
||||
)
|
||||
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _make_source(**overrides) -> GameEventColorStripSource:
|
||||
"""Create a GameEventColorStripSource with sensible defaults."""
|
||||
defaults = dict(
|
||||
id="css_test01",
|
||||
name="Test Game Event Source",
|
||||
source_type="game_event",
|
||||
created_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
|
||||
updated_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
|
||||
game_integration_id="gi_abc12345",
|
||||
idle_color=BindableColor([0, 0, 0]),
|
||||
event_mappings=[
|
||||
{
|
||||
"event_type": "kill",
|
||||
"effect": "flash",
|
||||
"color": [255, 0, 0],
|
||||
"duration_ms": 500,
|
||||
"intensity": 1.0,
|
||||
"priority": 1,
|
||||
},
|
||||
{
|
||||
"event_type": "death",
|
||||
"effect": "pulse",
|
||||
"color": [0, 0, 255],
|
||||
"duration_ms": 800,
|
||||
"intensity": 0.8,
|
||||
"priority": 2,
|
||||
},
|
||||
],
|
||||
led_count=10,
|
||||
)
|
||||
defaults.update(overrides)
|
||||
return GameEventColorStripSource(**defaults)
|
||||
|
||||
|
||||
def _make_event(event_type: str = "kill", value: float = 1.0) -> GameEvent:
|
||||
return GameEvent(
|
||||
adapter_id="test_adapter",
|
||||
event_type=event_type,
|
||||
value=value,
|
||||
)
|
||||
|
||||
|
||||
# ── GameEventColorStripSource serialization tests ────────────────────
|
||||
|
||||
|
||||
class TestGameEventColorStripSourceSerialization:
|
||||
def test_to_dict_roundtrip(self):
|
||||
source = _make_source()
|
||||
d = source.to_dict()
|
||||
|
||||
assert d["source_type"] == "game_event"
|
||||
assert d["game_integration_id"] == "gi_abc12345"
|
||||
assert d["idle_color"] == [0, 0, 0]
|
||||
assert len(d["event_mappings"]) == 2
|
||||
assert d["led_count"] == 10
|
||||
|
||||
restored = GameEventColorStripSource.from_dict(d)
|
||||
assert restored.id == source.id
|
||||
assert restored.name == source.name
|
||||
assert restored.source_type == "game_event"
|
||||
assert restored.game_integration_id == "gi_abc12345"
|
||||
assert restored.idle_color.color == [0, 0, 0]
|
||||
assert len(restored.event_mappings) == 2
|
||||
assert restored.led_count == 10
|
||||
|
||||
def test_from_dict_defaults(self):
|
||||
data = {
|
||||
"id": "css_min01",
|
||||
"name": "Minimal",
|
||||
"source_type": "game_event",
|
||||
"created_at": "2026-01-01T00:00:00+00:00",
|
||||
"updated_at": "2026-01-01T00:00:00+00:00",
|
||||
}
|
||||
source = GameEventColorStripSource.from_dict(data)
|
||||
assert source.game_integration_id == ""
|
||||
assert source.idle_color.color == [0, 0, 0]
|
||||
assert source.event_mappings == []
|
||||
assert source.led_count == 0
|
||||
|
||||
def test_factory_dispatch(self):
|
||||
"""ColorStripSource.from_dict dispatches to GameEventColorStripSource."""
|
||||
data = {
|
||||
"id": "css_fac01",
|
||||
"name": "Factory Test",
|
||||
"source_type": "game_event",
|
||||
"created_at": "2026-01-01T00:00:00+00:00",
|
||||
"updated_at": "2026-01-01T00:00:00+00:00",
|
||||
"game_integration_id": "gi_xyz",
|
||||
}
|
||||
source = ColorStripSource.from_dict(data)
|
||||
assert isinstance(source, GameEventColorStripSource)
|
||||
assert source.game_integration_id == "gi_xyz"
|
||||
|
||||
def test_create_from_kwargs(self):
|
||||
source = GameEventColorStripSource.create_from_kwargs(
|
||||
id="css_kw01",
|
||||
name="KW Test",
|
||||
source_type="game_event",
|
||||
created_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
|
||||
updated_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
|
||||
game_integration_id="gi_123",
|
||||
idle_color=[10, 20, 30],
|
||||
event_mappings=[{"event_type": "kill", "effect": "flash"}],
|
||||
led_count=20,
|
||||
)
|
||||
assert source.game_integration_id == "gi_123"
|
||||
assert source.idle_color.color == [10, 20, 30]
|
||||
assert len(source.event_mappings) == 1
|
||||
assert source.led_count == 20
|
||||
|
||||
def test_create_instance_factory(self):
|
||||
source = ColorStripSource.create_instance(
|
||||
source_type="game_event",
|
||||
id="css_ci01",
|
||||
name="CI Test",
|
||||
created_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
|
||||
updated_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
|
||||
game_integration_id="gi_ci",
|
||||
)
|
||||
assert isinstance(source, GameEventColorStripSource)
|
||||
|
||||
def test_sharable_is_false(self):
|
||||
source = _make_source()
|
||||
assert source.sharable is False
|
||||
|
||||
|
||||
class TestGameEventColorStripSourceUpdate:
|
||||
def test_apply_update_game_integration_id(self):
|
||||
source = _make_source()
|
||||
source.apply_update(game_integration_id="gi_new")
|
||||
assert source.game_integration_id == "gi_new"
|
||||
|
||||
def test_apply_update_idle_color(self):
|
||||
source = _make_source()
|
||||
source.apply_update(idle_color=[100, 100, 100])
|
||||
assert source.idle_color.color == [100, 100, 100]
|
||||
|
||||
def test_apply_update_event_mappings(self):
|
||||
source = _make_source()
|
||||
new_mappings = [{"event_type": "health", "effect": "breathing"}]
|
||||
source.apply_update(event_mappings=new_mappings)
|
||||
assert len(source.event_mappings) == 1
|
||||
assert source.event_mappings[0]["event_type"] == "health"
|
||||
|
||||
def test_apply_update_led_count(self):
|
||||
source = _make_source()
|
||||
source.apply_update(led_count=50)
|
||||
assert source.led_count == 50
|
||||
|
||||
def test_apply_update_ignores_none(self):
|
||||
source = _make_source()
|
||||
original_id = source.game_integration_id
|
||||
source.apply_update(game_integration_id=None)
|
||||
assert source.game_integration_id == original_id
|
||||
|
||||
|
||||
# ── GameEventColorStripStream tests ──────────────────────────────────
|
||||
|
||||
|
||||
class TestGameEventColorStripStreamLifecycle:
|
||||
def test_start_stop(self):
|
||||
source = _make_source()
|
||||
bus = GameEventBus()
|
||||
stream = GameEventColorStripStream(source, event_bus=bus)
|
||||
|
||||
stream.start()
|
||||
assert stream._running is True
|
||||
assert stream._thread is not None
|
||||
|
||||
stream.stop()
|
||||
assert stream._running is False
|
||||
assert stream._thread is None
|
||||
# Subscriptions should be cleaned up
|
||||
assert len(stream._subscription_ids) == 0
|
||||
|
||||
def test_double_start_is_safe(self):
|
||||
source = _make_source()
|
||||
bus = GameEventBus()
|
||||
stream = GameEventColorStripStream(source, event_bus=bus)
|
||||
|
||||
stream.start()
|
||||
thread1 = stream._thread
|
||||
stream.start() # Should not create a second thread
|
||||
assert stream._thread is thread1
|
||||
|
||||
stream.stop()
|
||||
|
||||
def test_subscriptions_registered(self):
|
||||
source = _make_source()
|
||||
bus = GameEventBus()
|
||||
stream = GameEventColorStripStream(source, event_bus=bus)
|
||||
|
||||
stream.start()
|
||||
# Should have subscribed to "kill" and "death"
|
||||
assert len(stream._subscription_ids) == 2
|
||||
|
||||
stream.stop()
|
||||
|
||||
def test_no_event_bus_still_starts(self):
|
||||
"""Stream should start even without an EventBus (just no events)."""
|
||||
source = _make_source()
|
||||
stream = GameEventColorStripStream(source, event_bus=None)
|
||||
stream.start()
|
||||
assert stream._running is True
|
||||
stream.stop()
|
||||
|
||||
|
||||
class TestGameEventColorStripStreamRendering:
|
||||
def test_idle_outputs_idle_color(self):
|
||||
source = _make_source(idle_color=BindableColor([50, 100, 150]))
|
||||
bus = GameEventBus()
|
||||
stream = GameEventColorStripStream(source, event_bus=bus)
|
||||
stream.start()
|
||||
|
||||
# Wait for at least one render frame
|
||||
time.sleep(0.1)
|
||||
|
||||
colors = stream.get_latest_colors()
|
||||
assert colors is not None
|
||||
assert colors.shape == (10, 3)
|
||||
# Should be idle color
|
||||
assert colors[0, 0] == 50
|
||||
assert colors[0, 1] == 100
|
||||
assert colors[0, 2] == 150
|
||||
|
||||
stream.stop()
|
||||
|
||||
def test_event_triggers_effect(self):
|
||||
source = _make_source(
|
||||
idle_color=BindableColor([0, 0, 0]),
|
||||
event_mappings=[
|
||||
{
|
||||
"event_type": "kill",
|
||||
"effect": "flash",
|
||||
"color": [255, 0, 0],
|
||||
"duration_ms": 2000,
|
||||
"intensity": 1.0,
|
||||
"priority": 1,
|
||||
}
|
||||
],
|
||||
)
|
||||
bus = GameEventBus()
|
||||
stream = GameEventColorStripStream(source, event_bus=bus)
|
||||
stream.start()
|
||||
time.sleep(0.05)
|
||||
|
||||
# Fire a kill event
|
||||
bus.publish(_make_event("kill"))
|
||||
time.sleep(0.1)
|
||||
|
||||
colors = stream.get_latest_colors()
|
||||
assert colors is not None
|
||||
# Should have non-zero red (flash effect in progress)
|
||||
assert colors[0, 0] > 0
|
||||
|
||||
stream.stop()
|
||||
|
||||
def test_unmatched_event_ignored(self):
|
||||
source = _make_source(
|
||||
idle_color=BindableColor([10, 10, 10]),
|
||||
event_mappings=[
|
||||
{
|
||||
"event_type": "kill",
|
||||
"effect": "flash",
|
||||
"color": [255, 0, 0],
|
||||
"duration_ms": 500,
|
||||
"intensity": 1.0,
|
||||
"priority": 1,
|
||||
}
|
||||
],
|
||||
)
|
||||
bus = GameEventBus()
|
||||
stream = GameEventColorStripStream(source, event_bus=bus)
|
||||
stream.start()
|
||||
time.sleep(0.05)
|
||||
|
||||
# Fire an event type not in mappings
|
||||
bus.publish(_make_event("health", value=0.5))
|
||||
time.sleep(0.1)
|
||||
|
||||
colors = stream.get_latest_colors()
|
||||
# Should still be idle color (no matching mapping)
|
||||
assert colors[0, 0] == 10
|
||||
assert colors[0, 1] == 10
|
||||
assert colors[0, 2] == 10
|
||||
|
||||
stream.stop()
|
||||
|
||||
def test_priority_based_layering(self):
|
||||
"""Higher priority effects override lower ones."""
|
||||
source = _make_source(
|
||||
idle_color=BindableColor([0, 0, 0]),
|
||||
event_mappings=[
|
||||
{
|
||||
"event_type": "kill",
|
||||
"effect": "flash",
|
||||
"color": [255, 0, 0],
|
||||
"duration_ms": 3000,
|
||||
"intensity": 1.0,
|
||||
"priority": 1,
|
||||
},
|
||||
{
|
||||
"event_type": "death",
|
||||
"effect": "flash",
|
||||
"color": [0, 0, 255],
|
||||
"duration_ms": 3000,
|
||||
"intensity": 1.0,
|
||||
"priority": 5,
|
||||
},
|
||||
],
|
||||
)
|
||||
bus = GameEventBus()
|
||||
stream = GameEventColorStripStream(source, event_bus=bus)
|
||||
stream.start()
|
||||
time.sleep(0.05)
|
||||
|
||||
# Fire low-priority kill event
|
||||
bus.publish(_make_event("kill"))
|
||||
time.sleep(0.05)
|
||||
# Fire high-priority death event — should override
|
||||
bus.publish(_make_event("death"))
|
||||
time.sleep(0.1)
|
||||
|
||||
colors = stream.get_latest_colors()
|
||||
# Should be blue (death effect), not red (kill effect)
|
||||
assert colors[0, 2] > colors[0, 0]
|
||||
|
||||
stream.stop()
|
||||
|
||||
def test_effect_returns_to_idle(self):
|
||||
"""After effect completes, output returns to idle_color."""
|
||||
source = _make_source(
|
||||
idle_color=BindableColor([42, 42, 42]),
|
||||
event_mappings=[
|
||||
{
|
||||
"event_type": "kill",
|
||||
"effect": "flash",
|
||||
"color": [255, 0, 0],
|
||||
"duration_ms": 50, # Very short effect
|
||||
"intensity": 1.0,
|
||||
"priority": 1,
|
||||
}
|
||||
],
|
||||
)
|
||||
bus = GameEventBus()
|
||||
stream = GameEventColorStripStream(source, event_bus=bus)
|
||||
stream.start()
|
||||
time.sleep(0.05)
|
||||
|
||||
bus.publish(_make_event("kill"))
|
||||
# Wait for effect to complete
|
||||
time.sleep(0.2)
|
||||
|
||||
colors = stream.get_latest_colors()
|
||||
# Should be back to idle color
|
||||
assert colors[0, 0] == 42
|
||||
assert colors[0, 1] == 42
|
||||
assert colors[0, 2] == 42
|
||||
|
||||
stream.stop()
|
||||
|
||||
|
||||
class TestGameEventColorStripStreamEffects:
|
||||
"""Test individual effect types produce non-zero output at mid-progress."""
|
||||
|
||||
def _fire_and_sample(self, effect: str, color: list) -> np.ndarray:
|
||||
source = _make_source(
|
||||
idle_color=BindableColor([0, 0, 0]),
|
||||
event_mappings=[
|
||||
{
|
||||
"event_type": "kill",
|
||||
"effect": effect,
|
||||
"color": color,
|
||||
"duration_ms": 2000,
|
||||
"intensity": 1.0,
|
||||
"priority": 1,
|
||||
}
|
||||
],
|
||||
)
|
||||
bus = GameEventBus()
|
||||
stream = GameEventColorStripStream(source, event_bus=bus)
|
||||
stream.start()
|
||||
time.sleep(0.05)
|
||||
|
||||
bus.publish(_make_event("kill"))
|
||||
# Sample in the first quarter of the effect
|
||||
time.sleep(0.15)
|
||||
|
||||
colors = stream.get_latest_colors()
|
||||
stream.stop()
|
||||
return colors
|
||||
|
||||
def test_flash_produces_output(self):
|
||||
colors = self._fire_and_sample("flash", [255, 0, 0])
|
||||
assert colors[0, 0] > 0
|
||||
|
||||
def test_pulse_produces_output(self):
|
||||
colors = self._fire_and_sample("pulse", [0, 255, 0])
|
||||
assert colors[0, 1] > 0
|
||||
|
||||
def test_sweep_produces_output(self):
|
||||
colors = self._fire_and_sample("sweep", [0, 0, 255])
|
||||
# At least some LEDs should have non-zero blue
|
||||
assert np.any(colors[:, 2] > 0)
|
||||
|
||||
def test_color_shift_produces_output(self):
|
||||
colors = self._fire_and_sample("color_shift", [255, 128, 0])
|
||||
# Should have some non-zero pixels
|
||||
assert np.any(colors > 0)
|
||||
|
||||
def test_breathing_produces_output(self):
|
||||
colors = self._fire_and_sample("breathing", [128, 0, 255])
|
||||
# Breathing may start dim, but should have some output by 0.15s into a 2s effect
|
||||
assert np.any(colors > 0)
|
||||
|
||||
|
||||
class TestGameEventColorStripStreamConfigure:
|
||||
def test_auto_size(self):
|
||||
source = _make_source(led_count=0) # auto-size
|
||||
stream = GameEventColorStripStream(source)
|
||||
assert stream.led_count == 1 # minimum
|
||||
|
||||
stream.configure(60)
|
||||
assert stream.led_count == 60
|
||||
|
||||
def test_fixed_size_not_overridden(self):
|
||||
source = _make_source(led_count=30)
|
||||
stream = GameEventColorStripStream(source)
|
||||
assert stream.led_count == 30
|
||||
|
||||
stream.configure(60)
|
||||
# Should NOT change because auto_size is False
|
||||
assert stream.led_count == 30
|
||||
|
||||
|
||||
class TestGameEventColorStripStreamUpdate:
|
||||
def test_update_source_hot_reloads(self):
|
||||
source = _make_source(
|
||||
event_mappings=[
|
||||
{
|
||||
"event_type": "kill",
|
||||
"effect": "flash",
|
||||
"color": [255, 0, 0],
|
||||
"duration_ms": 500,
|
||||
"intensity": 1.0,
|
||||
"priority": 1,
|
||||
}
|
||||
],
|
||||
)
|
||||
bus = GameEventBus()
|
||||
stream = GameEventColorStripStream(source, event_bus=bus)
|
||||
stream.start()
|
||||
|
||||
# Update with new mappings
|
||||
updated_source = _make_source(
|
||||
event_mappings=[
|
||||
{
|
||||
"event_type": "health",
|
||||
"effect": "breathing",
|
||||
"color": [0, 255, 0],
|
||||
"duration_ms": 1000,
|
||||
"intensity": 0.5,
|
||||
"priority": 0,
|
||||
}
|
||||
],
|
||||
)
|
||||
stream.update_source(updated_source)
|
||||
|
||||
assert "health" in stream._mapping_lookup
|
||||
assert "kill" not in stream._mapping_lookup
|
||||
|
||||
stream.stop()
|
||||
@@ -0,0 +1,500 @@
|
||||
"""Tests for GameEventValueSource (storage model) and GameEventValueStream (runtime)."""
|
||||
|
||||
import time
|
||||
import threading
|
||||
|
||||
import pytest
|
||||
|
||||
from wled_controller.core.game_integration.event_bus import GameEventBus
|
||||
from wled_controller.core.game_integration.events import GameEvent
|
||||
from wled_controller.core.value_sources.game_event_value_source import GameEventValueStream
|
||||
from wled_controller.storage.value_source import (
|
||||
GameEventValueSource,
|
||||
ValueSource,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GameEventValueSource model tests (Task 5)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGameEventValueSourceModel:
|
||||
"""Serialization round-trips and defaults for the storage dataclass."""
|
||||
|
||||
def test_round_trip(self):
|
||||
data = {
|
||||
"id": "vs_game1",
|
||||
"name": "Health Monitor",
|
||||
"source_type": "game_event",
|
||||
"game_integration_id": "gi_abc123",
|
||||
"event_type": "health",
|
||||
"min_game_value": 0.0,
|
||||
"max_game_value": 100.0,
|
||||
"smoothing": 0.3,
|
||||
"default_value": 0.5,
|
||||
"timeout": 10.0,
|
||||
"created_at": "2025-01-01T00:00:00+00:00",
|
||||
"updated_at": "2025-01-01T00:00:00+00:00",
|
||||
}
|
||||
src = ValueSource.from_dict(data)
|
||||
assert isinstance(src, GameEventValueSource)
|
||||
assert src.game_integration_id == "gi_abc123"
|
||||
assert src.event_type == "health"
|
||||
assert src.min_game_value == 0.0
|
||||
assert src.max_game_value == 100.0
|
||||
assert src.smoothing == 0.3
|
||||
assert src.default_value == 0.5
|
||||
assert src.timeout == 10.0
|
||||
|
||||
# Round-trip through to_dict -> from_dict
|
||||
restored = ValueSource.from_dict(src.to_dict())
|
||||
assert isinstance(restored, GameEventValueSource)
|
||||
assert restored.game_integration_id == "gi_abc123"
|
||||
assert restored.event_type == "health"
|
||||
assert restored.smoothing == 0.3
|
||||
assert restored.default_value == 0.5
|
||||
assert restored.timeout == 10.0
|
||||
|
||||
def test_defaults(self):
|
||||
data = {
|
||||
"id": "vs_game2",
|
||||
"name": "Default Test",
|
||||
"source_type": "game_event",
|
||||
"created_at": "2025-01-01T00:00:00+00:00",
|
||||
"updated_at": "2025-01-01T00:00:00+00:00",
|
||||
}
|
||||
src = ValueSource.from_dict(data)
|
||||
assert isinstance(src, GameEventValueSource)
|
||||
assert src.game_integration_id == ""
|
||||
assert src.event_type == "health"
|
||||
assert src.min_game_value == 0.0
|
||||
assert src.max_game_value == 100.0
|
||||
assert src.smoothing == 0.0
|
||||
assert src.default_value == 0.5
|
||||
assert src.timeout == 5.0
|
||||
|
||||
def test_to_dict_includes_all_fields(self):
|
||||
data = {
|
||||
"id": "vs_game3",
|
||||
"name": "Full",
|
||||
"source_type": "game_event",
|
||||
"game_integration_id": "gi_xyz",
|
||||
"event_type": "ammo",
|
||||
"min_game_value": 0.0,
|
||||
"max_game_value": 30.0,
|
||||
"smoothing": 0.5,
|
||||
"default_value": 0.0,
|
||||
"timeout": 3.0,
|
||||
"created_at": "2025-01-01T00:00:00+00:00",
|
||||
"updated_at": "2025-01-01T00:00:00+00:00",
|
||||
}
|
||||
src = ValueSource.from_dict(data)
|
||||
d = src.to_dict()
|
||||
assert d["source_type"] == "game_event"
|
||||
assert d["game_integration_id"] == "gi_xyz"
|
||||
assert d["event_type"] == "ammo"
|
||||
assert d["max_game_value"] == 30.0
|
||||
assert d["smoothing"] == 0.5
|
||||
assert d["default_value"] == 0.0
|
||||
assert d["timeout"] == 3.0
|
||||
|
||||
def test_from_dict_with_zero_values(self):
|
||||
"""Ensure 0.0 values are preserved, not replaced by defaults."""
|
||||
data = {
|
||||
"id": "vs_game4",
|
||||
"name": "Zeros",
|
||||
"source_type": "game_event",
|
||||
"min_game_value": 0.0,
|
||||
"max_game_value": 0.0,
|
||||
"smoothing": 0.0,
|
||||
"default_value": 0.0,
|
||||
"timeout": 0.0,
|
||||
"created_at": "2025-01-01T00:00:00+00:00",
|
||||
"updated_at": "2025-01-01T00:00:00+00:00",
|
||||
}
|
||||
src = ValueSource.from_dict(data)
|
||||
assert isinstance(src, GameEventValueSource)
|
||||
assert src.max_game_value == 0.0
|
||||
assert src.default_value == 0.0
|
||||
assert src.timeout == 0.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GameEventValueStream runtime tests (Task 6)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_event(event_type: str, value: float) -> GameEvent:
|
||||
return GameEvent(
|
||||
adapter_id="test_adapter",
|
||||
event_type=event_type,
|
||||
value=value,
|
||||
)
|
||||
|
||||
|
||||
class TestGameEventValueStreamNormalization:
|
||||
"""Tests for value normalization (min/max mapping)."""
|
||||
|
||||
def test_mid_value(self):
|
||||
bus = GameEventBus()
|
||||
stream = GameEventValueStream(
|
||||
event_type="health",
|
||||
min_game_value=0.0,
|
||||
max_game_value=100.0,
|
||||
event_bus=bus,
|
||||
)
|
||||
stream.start()
|
||||
bus.publish(_make_event("health", 50.0))
|
||||
assert stream.get_value() == pytest.approx(0.5)
|
||||
stream.stop()
|
||||
|
||||
def test_min_value(self):
|
||||
bus = GameEventBus()
|
||||
stream = GameEventValueStream(
|
||||
event_type="health",
|
||||
min_game_value=0.0,
|
||||
max_game_value=100.0,
|
||||
event_bus=bus,
|
||||
)
|
||||
stream.start()
|
||||
bus.publish(_make_event("health", 0.0))
|
||||
assert stream.get_value() == pytest.approx(0.0)
|
||||
stream.stop()
|
||||
|
||||
def test_max_value(self):
|
||||
bus = GameEventBus()
|
||||
stream = GameEventValueStream(
|
||||
event_type="health",
|
||||
min_game_value=0.0,
|
||||
max_game_value=100.0,
|
||||
event_bus=bus,
|
||||
)
|
||||
stream.start()
|
||||
bus.publish(_make_event("health", 100.0))
|
||||
assert stream.get_value() == pytest.approx(1.0)
|
||||
stream.stop()
|
||||
|
||||
def test_clamp_above_max(self):
|
||||
bus = GameEventBus()
|
||||
stream = GameEventValueStream(
|
||||
event_type="health",
|
||||
min_game_value=0.0,
|
||||
max_game_value=100.0,
|
||||
event_bus=bus,
|
||||
)
|
||||
stream.start()
|
||||
bus.publish(_make_event("health", 150.0))
|
||||
assert stream.get_value() == pytest.approx(1.0)
|
||||
stream.stop()
|
||||
|
||||
def test_clamp_below_min(self):
|
||||
bus = GameEventBus()
|
||||
stream = GameEventValueStream(
|
||||
event_type="health",
|
||||
min_game_value=0.0,
|
||||
max_game_value=100.0,
|
||||
event_bus=bus,
|
||||
)
|
||||
stream.start()
|
||||
bus.publish(_make_event("health", -10.0))
|
||||
assert stream.get_value() == pytest.approx(0.0)
|
||||
stream.stop()
|
||||
|
||||
def test_custom_range(self):
|
||||
bus = GameEventBus()
|
||||
stream = GameEventValueStream(
|
||||
event_type="ammo",
|
||||
min_game_value=10.0,
|
||||
max_game_value=60.0,
|
||||
event_bus=bus,
|
||||
)
|
||||
stream.start()
|
||||
bus.publish(_make_event("ammo", 35.0))
|
||||
assert stream.get_value() == pytest.approx(0.5)
|
||||
stream.stop()
|
||||
|
||||
def test_zero_range_returns_midpoint(self):
|
||||
bus = GameEventBus()
|
||||
stream = GameEventValueStream(
|
||||
event_type="health",
|
||||
min_game_value=50.0,
|
||||
max_game_value=50.0,
|
||||
event_bus=bus,
|
||||
)
|
||||
stream.start()
|
||||
bus.publish(_make_event("health", 50.0))
|
||||
assert stream.get_value() == pytest.approx(0.5)
|
||||
stream.stop()
|
||||
|
||||
|
||||
class TestGameEventValueStreamSmoothing:
|
||||
"""Tests for EMA smoothing behavior."""
|
||||
|
||||
def test_no_smoothing(self):
|
||||
bus = GameEventBus()
|
||||
stream = GameEventValueStream(
|
||||
event_type="health",
|
||||
min_game_value=0.0,
|
||||
max_game_value=100.0,
|
||||
smoothing=0.0,
|
||||
event_bus=bus,
|
||||
)
|
||||
stream.start()
|
||||
bus.publish(_make_event("health", 100.0))
|
||||
assert stream.get_value() == pytest.approx(1.0)
|
||||
bus.publish(_make_event("health", 0.0))
|
||||
assert stream.get_value() == pytest.approx(0.0)
|
||||
stream.stop()
|
||||
|
||||
def test_smoothing_dampens_change(self):
|
||||
bus = GameEventBus()
|
||||
stream = GameEventValueStream(
|
||||
event_type="health",
|
||||
min_game_value=0.0,
|
||||
max_game_value=100.0,
|
||||
smoothing=0.8,
|
||||
event_bus=bus,
|
||||
)
|
||||
stream.start()
|
||||
# First event: no smoothing applied (no previous value)
|
||||
bus.publish(_make_event("health", 100.0))
|
||||
assert stream.get_value() == pytest.approx(1.0)
|
||||
|
||||
# Second event: smoothing kicks in
|
||||
# alpha = 1 - 0.8 = 0.2
|
||||
# smoothed = 0.2 * 0.0 + 0.8 * 1.0 = 0.8
|
||||
bus.publish(_make_event("health", 0.0))
|
||||
assert stream.get_value() == pytest.approx(0.8)
|
||||
|
||||
# Third event: continues smoothing
|
||||
# smoothed = 0.2 * 0.0 + 0.8 * 0.8 = 0.64
|
||||
bus.publish(_make_event("health", 0.0))
|
||||
assert stream.get_value() == pytest.approx(0.64)
|
||||
stream.stop()
|
||||
|
||||
def test_smoothing_converges(self):
|
||||
"""With many events at the same value, output should approach that value."""
|
||||
bus = GameEventBus()
|
||||
stream = GameEventValueStream(
|
||||
event_type="health",
|
||||
min_game_value=0.0,
|
||||
max_game_value=100.0,
|
||||
smoothing=0.5,
|
||||
event_bus=bus,
|
||||
)
|
||||
stream.start()
|
||||
bus.publish(_make_event("health", 100.0))
|
||||
for _ in range(20):
|
||||
bus.publish(_make_event("health", 50.0))
|
||||
assert stream.get_value() == pytest.approx(0.5, abs=0.01)
|
||||
stream.stop()
|
||||
|
||||
|
||||
class TestGameEventValueStreamTimeout:
|
||||
"""Tests for timeout / revert to default."""
|
||||
|
||||
def test_default_before_any_event(self):
|
||||
stream = GameEventValueStream(
|
||||
event_type="health",
|
||||
default_value=0.5,
|
||||
event_bus=GameEventBus(),
|
||||
)
|
||||
stream.start()
|
||||
assert stream.get_value() == pytest.approx(0.5)
|
||||
stream.stop()
|
||||
|
||||
def test_timeout_reverts_to_default(self):
|
||||
bus = GameEventBus()
|
||||
stream = GameEventValueStream(
|
||||
event_type="health",
|
||||
min_game_value=0.0,
|
||||
max_game_value=100.0,
|
||||
default_value=0.5,
|
||||
timeout=0.05, # 50ms timeout for fast test
|
||||
event_bus=bus,
|
||||
)
|
||||
stream.start()
|
||||
bus.publish(_make_event("health", 80.0))
|
||||
assert stream.get_value() == pytest.approx(0.8)
|
||||
|
||||
# Wait for timeout
|
||||
time.sleep(0.1)
|
||||
assert stream.get_value() == pytest.approx(0.5)
|
||||
stream.stop()
|
||||
|
||||
def test_no_timeout_when_zero(self):
|
||||
bus = GameEventBus()
|
||||
stream = GameEventValueStream(
|
||||
event_type="health",
|
||||
min_game_value=0.0,
|
||||
max_game_value=100.0,
|
||||
default_value=0.5,
|
||||
timeout=0.0,
|
||||
event_bus=bus,
|
||||
)
|
||||
stream.start()
|
||||
bus.publish(_make_event("health", 80.0))
|
||||
time.sleep(0.05)
|
||||
# With timeout=0.0, should never revert
|
||||
assert stream.get_value() == pytest.approx(0.8)
|
||||
stream.stop()
|
||||
|
||||
def test_event_resets_timeout(self):
|
||||
bus = GameEventBus()
|
||||
stream = GameEventValueStream(
|
||||
event_type="health",
|
||||
min_game_value=0.0,
|
||||
max_game_value=100.0,
|
||||
default_value=0.5,
|
||||
timeout=0.1,
|
||||
event_bus=bus,
|
||||
)
|
||||
stream.start()
|
||||
bus.publish(_make_event("health", 80.0))
|
||||
time.sleep(0.06)
|
||||
# Send another event before timeout
|
||||
bus.publish(_make_event("health", 80.0))
|
||||
time.sleep(0.06)
|
||||
# Should still be live (timeout reset)
|
||||
assert stream.get_value() == pytest.approx(0.8)
|
||||
stream.stop()
|
||||
|
||||
|
||||
class TestGameEventValueStreamLifecycle:
|
||||
"""Tests for start/stop and EventBus subscription management."""
|
||||
|
||||
def test_stop_unsubscribes(self):
|
||||
bus = GameEventBus()
|
||||
stream = GameEventValueStream(
|
||||
event_type="health",
|
||||
min_game_value=0.0,
|
||||
max_game_value=100.0,
|
||||
default_value=0.5,
|
||||
event_bus=bus,
|
||||
)
|
||||
stream.start()
|
||||
bus.publish(_make_event("health", 100.0))
|
||||
assert stream.get_value() == pytest.approx(1.0)
|
||||
|
||||
stream.stop()
|
||||
# After stop, events should not affect the stream
|
||||
assert stream.get_value() == pytest.approx(0.5)
|
||||
|
||||
def test_ignores_other_event_types(self):
|
||||
bus = GameEventBus()
|
||||
stream = GameEventValueStream(
|
||||
event_type="health",
|
||||
min_game_value=0.0,
|
||||
max_game_value=100.0,
|
||||
default_value=0.5,
|
||||
event_bus=bus,
|
||||
)
|
||||
stream.start()
|
||||
bus.publish(_make_event("ammo", 100.0))
|
||||
# Should still be at default since event_type doesn't match
|
||||
assert stream.get_value() == pytest.approx(0.5)
|
||||
stream.stop()
|
||||
|
||||
def test_no_event_bus(self):
|
||||
"""Stream without event bus should return default value."""
|
||||
stream = GameEventValueStream(
|
||||
event_type="health",
|
||||
default_value=0.7,
|
||||
event_bus=None,
|
||||
)
|
||||
stream.start()
|
||||
assert stream.get_value() == pytest.approx(0.7)
|
||||
stream.stop()
|
||||
|
||||
def test_get_color_raises(self):
|
||||
stream = GameEventValueStream(
|
||||
event_type="health",
|
||||
event_bus=None,
|
||||
)
|
||||
with pytest.raises(NotImplementedError):
|
||||
stream.get_color()
|
||||
|
||||
|
||||
class TestGameEventValueStreamThreadSafety:
|
||||
"""Tests for concurrent access from multiple threads."""
|
||||
|
||||
def test_concurrent_publish_and_read(self):
|
||||
bus = GameEventBus()
|
||||
stream = GameEventValueStream(
|
||||
event_type="health",
|
||||
min_game_value=0.0,
|
||||
max_game_value=100.0,
|
||||
default_value=0.5,
|
||||
smoothing=0.0,
|
||||
timeout=5.0,
|
||||
event_bus=bus,
|
||||
)
|
||||
stream.start()
|
||||
|
||||
errors = []
|
||||
|
||||
def publisher():
|
||||
try:
|
||||
for i in range(100):
|
||||
bus.publish(_make_event("health", float(i)))
|
||||
except Exception as e:
|
||||
errors.append(e)
|
||||
|
||||
def reader():
|
||||
try:
|
||||
for _ in range(100):
|
||||
val = stream.get_value()
|
||||
assert 0.0 <= val <= 1.0
|
||||
except Exception as e:
|
||||
errors.append(e)
|
||||
|
||||
threads = [
|
||||
threading.Thread(target=publisher),
|
||||
threading.Thread(target=reader),
|
||||
threading.Thread(target=reader),
|
||||
]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join(timeout=5.0)
|
||||
|
||||
stream.stop()
|
||||
assert len(errors) == 0, f"Thread errors: {errors}"
|
||||
|
||||
|
||||
class TestGameEventValueStreamHotUpdate:
|
||||
"""Tests for update_source hot-update functionality."""
|
||||
|
||||
def test_update_source_changes_params(self):
|
||||
bus = GameEventBus()
|
||||
stream = GameEventValueStream(
|
||||
event_type="health",
|
||||
min_game_value=0.0,
|
||||
max_game_value=100.0,
|
||||
default_value=0.5,
|
||||
timeout=5.0,
|
||||
event_bus=bus,
|
||||
)
|
||||
stream.start()
|
||||
|
||||
# Create an updated source config
|
||||
source = GameEventValueSource(
|
||||
id="vs_test",
|
||||
name="Test",
|
||||
source_type="game_event",
|
||||
min_game_value=0.0,
|
||||
max_game_value=200.0,
|
||||
smoothing=0.5,
|
||||
default_value=0.3,
|
||||
timeout=10.0,
|
||||
created_at="2025-01-01T00:00:00+00:00",
|
||||
updated_at="2025-01-01T00:00:00+00:00",
|
||||
)
|
||||
stream.update_source(source)
|
||||
|
||||
# Verify hot-updated params take effect
|
||||
bus.publish(_make_event("health", 100.0))
|
||||
# With max_game_value=200, 100 normalizes to 0.5
|
||||
assert stream.get_value() == pytest.approx(0.5)
|
||||
stream.stop()
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Tests for built-in effect presets."""
|
||||
|
||||
import pytest
|
||||
|
||||
from wled_controller.core.game_integration.presets import (
|
||||
EffectPreset,
|
||||
get_all_presets,
|
||||
get_preset,
|
||||
)
|
||||
from wled_controller.storage.game_integration import EventMapping
|
||||
|
||||
|
||||
class TestPresetData:
|
||||
"""Verify preset data structure and contents."""
|
||||
|
||||
def test_get_all_presets_returns_list(self):
|
||||
presets = get_all_presets()
|
||||
assert isinstance(presets, list)
|
||||
assert len(presets) >= 4
|
||||
|
||||
def test_each_preset_has_required_fields(self):
|
||||
for preset in get_all_presets():
|
||||
assert isinstance(preset, EffectPreset)
|
||||
assert preset.key
|
||||
assert preset.name
|
||||
assert preset.description
|
||||
assert len(preset.target_game_types) > 0
|
||||
assert len(preset.event_mappings) > 0
|
||||
|
||||
def test_each_mapping_is_valid(self):
|
||||
for preset in get_all_presets():
|
||||
for mapping in preset.event_mappings:
|
||||
assert isinstance(mapping, EventMapping)
|
||||
assert mapping.event_type
|
||||
assert mapping.effect
|
||||
assert len(mapping.color) == 3
|
||||
assert all(0 <= c <= 255 for c in mapping.color)
|
||||
assert mapping.duration_ms > 0
|
||||
assert 0.0 <= mapping.intensity <= 1.0
|
||||
assert mapping.priority >= 0
|
||||
|
||||
def test_get_preset_by_key(self):
|
||||
assert get_preset("fps_combat") is not None
|
||||
assert get_preset("moba_health") is not None
|
||||
assert get_preset("racing") is not None
|
||||
assert get_preset("generic_alert") is not None
|
||||
|
||||
def test_get_preset_unknown_returns_none(self):
|
||||
assert get_preset("nonexistent") is None
|
||||
|
||||
def test_fps_combat_has_expected_events(self):
|
||||
preset = get_preset("fps_combat")
|
||||
assert preset is not None
|
||||
event_types = {m.event_type for m in preset.event_mappings}
|
||||
assert "health" in event_types
|
||||
assert "kill" in event_types
|
||||
assert "death" in event_types
|
||||
|
||||
def test_moba_health_has_expected_events(self):
|
||||
preset = get_preset("moba_health")
|
||||
assert preset is not None
|
||||
event_types = {m.event_type for m in preset.event_mappings}
|
||||
assert "health" in event_types
|
||||
assert "mana" in event_types
|
||||
|
||||
def test_presets_are_frozen(self):
|
||||
preset = get_preset("fps_combat")
|
||||
assert preset is not None
|
||||
with pytest.raises(AttributeError):
|
||||
preset.name = "Changed" # type: ignore[misc]
|
||||
|
||||
def test_preset_keys_unique(self):
|
||||
presets = get_all_presets()
|
||||
keys = [p.key for p in presets]
|
||||
assert len(keys) == len(set(keys))
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Tests verifying game integration wiring in ProcessorDependencies.
|
||||
|
||||
Ensures that GameEventBus is properly threaded through to
|
||||
ColorStripStreamManager and ValueStreamManager.
|
||||
"""
|
||||
|
||||
from wled_controller.core.game_integration.event_bus import GameEventBus
|
||||
from wled_controller.core.processing.processor_manager import (
|
||||
ProcessorDependencies,
|
||||
ProcessorManager,
|
||||
)
|
||||
|
||||
|
||||
class TestGameEventBusWiring:
|
||||
"""Verify that game_event_bus propagates through ProcessorManager."""
|
||||
|
||||
def test_processor_dependencies_accepts_game_event_bus(self):
|
||||
bus = GameEventBus()
|
||||
deps = ProcessorDependencies(game_event_bus=bus)
|
||||
assert deps.game_event_bus is bus
|
||||
|
||||
def test_css_stream_manager_receives_bus(self):
|
||||
bus = GameEventBus()
|
||||
deps = ProcessorDependencies(game_event_bus=bus)
|
||||
pm = ProcessorManager(deps)
|
||||
css_mgr = pm._color_strip_stream_manager
|
||||
assert css_mgr._game_event_bus is bus
|
||||
|
||||
def test_value_stream_manager_receives_bus(self):
|
||||
"""ValueStreamManager is only created when value_source_store is provided."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
bus = GameEventBus()
|
||||
mock_vs_store = MagicMock()
|
||||
deps = ProcessorDependencies(
|
||||
game_event_bus=bus,
|
||||
value_source_store=mock_vs_store,
|
||||
)
|
||||
pm = ProcessorManager(deps)
|
||||
vs_mgr = pm._value_stream_manager
|
||||
assert vs_mgr is not None
|
||||
assert vs_mgr._event_bus is bus
|
||||
|
||||
def test_without_bus_defaults_to_none(self):
|
||||
deps = ProcessorDependencies()
|
||||
pm = ProcessorManager(deps)
|
||||
assert pm._color_strip_stream_manager._game_event_bus is None
|
||||
@@ -0,0 +1,162 @@
|
||||
"""Tests for Generic Webhook adapter."""
|
||||
|
||||
import pytest
|
||||
|
||||
from wled_controller.core.game_integration.adapters.generic_webhook_adapter import (
|
||||
GenericWebhookAdapter,
|
||||
)
|
||||
|
||||
|
||||
class TestGenericWebhookParsing:
|
||||
def test_basic_mapping(self) -> None:
|
||||
config = {
|
||||
"adapter_id": "webhook_test",
|
||||
"mappings": [
|
||||
{
|
||||
"source_path": "player.health",
|
||||
"event": "health",
|
||||
"min": 0,
|
||||
"max": 100,
|
||||
"trigger": "on_value",
|
||||
},
|
||||
],
|
||||
}
|
||||
payload = {"player": {"health": 75}}
|
||||
events, _ = GenericWebhookAdapter.parse_payload(payload, config, {})
|
||||
assert len(events) == 1
|
||||
assert events[0].event_type == "health"
|
||||
assert events[0].value == pytest.approx(0.75)
|
||||
|
||||
def test_multiple_mappings(self) -> None:
|
||||
config = {
|
||||
"adapter_id": "webhook_test",
|
||||
"mappings": [
|
||||
{
|
||||
"source_path": "hp",
|
||||
"event": "health",
|
||||
"min": 0,
|
||||
"max": 100,
|
||||
"trigger": "on_value",
|
||||
},
|
||||
{"source_path": "mp", "event": "mana", "min": 0, "max": 200, "trigger": "on_value"},
|
||||
],
|
||||
}
|
||||
payload = {"hp": 50, "mp": 100}
|
||||
events, _ = GenericWebhookAdapter.parse_payload(payload, config, {})
|
||||
assert len(events) == 2
|
||||
types = {e.event_type for e in events}
|
||||
assert types == {"health", "mana"}
|
||||
|
||||
def test_empty_mappings(self) -> None:
|
||||
config = {"adapter_id": "test", "mappings": []}
|
||||
events, state = GenericWebhookAdapter.parse_payload({"hp": 50}, config, {})
|
||||
assert len(events) == 0
|
||||
|
||||
def test_no_mappings_key(self) -> None:
|
||||
config = {"adapter_id": "test"}
|
||||
events, _ = GenericWebhookAdapter.parse_payload({"hp": 50}, config, {})
|
||||
assert len(events) == 0
|
||||
|
||||
def test_diff_based_trigger(self) -> None:
|
||||
config = {
|
||||
"adapter_id": "test",
|
||||
"mappings": [
|
||||
{
|
||||
"source_path": "kills",
|
||||
"event": "kill",
|
||||
"min": 0,
|
||||
"max": 50,
|
||||
"trigger": "on_increase",
|
||||
},
|
||||
],
|
||||
}
|
||||
# First call — establish baseline
|
||||
_, state1 = GenericWebhookAdapter.parse_payload({"kills": 5}, config, {})
|
||||
|
||||
# Increase — should emit
|
||||
events2, state2 = GenericWebhookAdapter.parse_payload({"kills": 6}, config, state1)
|
||||
assert len(events2) == 1
|
||||
assert events2[0].event_type == "kill"
|
||||
|
||||
# Same — should not emit
|
||||
events3, _ = GenericWebhookAdapter.parse_payload({"kills": 6}, config, state2)
|
||||
assert len(events3) == 0
|
||||
|
||||
def test_nested_json_path(self) -> None:
|
||||
config = {
|
||||
"adapter_id": "test",
|
||||
"mappings": [
|
||||
{
|
||||
"source_path": "a.b.c",
|
||||
"event": "health",
|
||||
"min": 0,
|
||||
"max": 10,
|
||||
"trigger": "on_value",
|
||||
},
|
||||
],
|
||||
}
|
||||
payload = {"a": {"b": {"c": 5}}}
|
||||
events, _ = GenericWebhookAdapter.parse_payload(payload, config, {})
|
||||
assert len(events) == 1
|
||||
assert events[0].value == pytest.approx(0.5)
|
||||
|
||||
|
||||
class TestGenericWebhookAuth:
|
||||
def test_no_auth_configured(self) -> None:
|
||||
result = GenericWebhookAdapter.validate_auth({}, {}, {})
|
||||
assert result is True
|
||||
|
||||
def test_bearer_auth_valid(self) -> None:
|
||||
result = GenericWebhookAdapter.validate_auth(
|
||||
{"Authorization": "Bearer secret123"},
|
||||
{},
|
||||
{"auth_token": "secret123"},
|
||||
)
|
||||
assert result is True
|
||||
|
||||
def test_bearer_auth_invalid(self) -> None:
|
||||
result = GenericWebhookAdapter.validate_auth(
|
||||
{"Authorization": "Bearer wrong"},
|
||||
{},
|
||||
{"auth_token": "secret123"},
|
||||
)
|
||||
assert result is False
|
||||
|
||||
def test_raw_token_auth(self) -> None:
|
||||
result = GenericWebhookAdapter.validate_auth(
|
||||
{"Authorization": "secret123"},
|
||||
{},
|
||||
{"auth_token": "secret123"},
|
||||
)
|
||||
assert result is True
|
||||
|
||||
def test_custom_header(self) -> None:
|
||||
result = GenericWebhookAdapter.validate_auth(
|
||||
{"X-Custom": "mytoken"},
|
||||
{},
|
||||
{"auth_token": "mytoken", "auth_header": "X-Custom"},
|
||||
)
|
||||
assert result is True
|
||||
|
||||
def test_missing_header(self) -> None:
|
||||
result = GenericWebhookAdapter.validate_auth(
|
||||
{},
|
||||
{},
|
||||
{"auth_token": "secret"},
|
||||
)
|
||||
assert result is False
|
||||
|
||||
|
||||
class TestGenericWebhookMetadata:
|
||||
def test_adapter_type(self) -> None:
|
||||
assert GenericWebhookAdapter.ADAPTER_TYPE == "generic_webhook"
|
||||
|
||||
def test_config_schema(self) -> None:
|
||||
schema = GenericWebhookAdapter.get_config_schema()
|
||||
assert "auth_token" in schema["properties"]
|
||||
assert "mappings" in schema["properties"]
|
||||
assert "auth_header" in schema["properties"]
|
||||
|
||||
def test_setup_instructions(self) -> None:
|
||||
instructions = GenericWebhookAdapter.get_setup_instructions()
|
||||
assert "Webhook" in instructions
|
||||
@@ -0,0 +1,183 @@
|
||||
"""Tests for League of Legends Live Client Data API adapter."""
|
||||
|
||||
import threading
|
||||
|
||||
import pytest
|
||||
|
||||
from wled_controller.core.game_integration.adapters.lol_adapter import (
|
||||
LoLAdapter,
|
||||
LoLPoller,
|
||||
)
|
||||
|
||||
|
||||
def _make_lol_payload(
|
||||
*,
|
||||
current_health: float = 1000.0,
|
||||
max_health: float = 1000.0,
|
||||
resource_value: float = 500.0,
|
||||
resource_max: float = 500.0,
|
||||
level: int = 10,
|
||||
summoner_name: str = "TestPlayer",
|
||||
current_gold: float | None = None,
|
||||
) -> dict:
|
||||
"""Build a realistic LoL Live Client Data payload."""
|
||||
payload: dict = {
|
||||
"activePlayer": {
|
||||
"summonerName": summoner_name,
|
||||
"level": level,
|
||||
"currentHealth": current_health,
|
||||
"championStats": {
|
||||
"currentHealth": current_health,
|
||||
"maxHealth": max_health,
|
||||
"resourceValue": resource_value,
|
||||
"resourceMax": resource_max,
|
||||
"attackDamage": 80.0,
|
||||
"abilityPower": 0.0,
|
||||
"armor": 60.0,
|
||||
"magicResist": 40.0,
|
||||
"moveSpeed": 345.0,
|
||||
},
|
||||
},
|
||||
"allPlayers": [
|
||||
{
|
||||
"summonerName": summoner_name,
|
||||
"championName": "Jinx",
|
||||
"team": "ORDER",
|
||||
"scores": {"kills": 5, "deaths": 2, "assists": 7},
|
||||
},
|
||||
],
|
||||
"gameData": {
|
||||
"gameMode": "CLASSIC",
|
||||
"gameTime": 1200.5,
|
||||
"mapName": "Map11",
|
||||
"mapNumber": 11,
|
||||
"mapTerrain": "Default",
|
||||
},
|
||||
}
|
||||
|
||||
if current_gold is not None:
|
||||
payload["allPlayers"][0]["currentGold"] = current_gold
|
||||
|
||||
return payload
|
||||
|
||||
|
||||
class TestLoLContinuousEvents:
|
||||
def test_health_normalized(self) -> None:
|
||||
payload = _make_lol_payload(current_health=500.0, max_health=1000.0)
|
||||
events, _ = LoLAdapter.parse_payload(payload, {"adapter_id": "lol"}, {})
|
||||
hp = [e for e in events if e.event_type == "health"]
|
||||
assert len(hp) == 1
|
||||
assert hp[0].value == pytest.approx(0.5)
|
||||
|
||||
def test_mana_normalized(self) -> None:
|
||||
payload = _make_lol_payload(resource_value=300.0, resource_max=600.0)
|
||||
events, _ = LoLAdapter.parse_payload(payload, {"adapter_id": "lol"}, {})
|
||||
mp = [e for e in events if e.event_type == "mana"]
|
||||
assert len(mp) == 1
|
||||
assert mp[0].value == pytest.approx(0.5)
|
||||
|
||||
def test_level_normalized(self) -> None:
|
||||
payload = _make_lol_payload(level=9)
|
||||
events, _ = LoLAdapter.parse_payload(payload, {"adapter_id": "lol"}, {})
|
||||
lvl = [e for e in events if e.event_type == "speed"]
|
||||
assert len(lvl) == 1
|
||||
assert lvl[0].value == pytest.approx(9.0 / 18.0)
|
||||
|
||||
def test_level_max(self) -> None:
|
||||
payload = _make_lol_payload(level=18)
|
||||
events, _ = LoLAdapter.parse_payload(payload, {"adapter_id": "lol"}, {})
|
||||
lvl = [e for e in events if e.event_type == "speed"]
|
||||
assert lvl[0].value == pytest.approx(1.0)
|
||||
|
||||
def test_gold(self) -> None:
|
||||
payload = _make_lol_payload(current_gold=15000.0)
|
||||
events, _ = LoLAdapter.parse_payload(payload, {"adapter_id": "lol"}, {})
|
||||
gold = [e for e in events if e.event_type == "gold"]
|
||||
assert len(gold) == 1
|
||||
assert gold[0].value == pytest.approx(15000.0 / 30000.0)
|
||||
|
||||
|
||||
class TestLoLDeathRespawn:
|
||||
def test_death_detected_when_health_drops_to_zero(self) -> None:
|
||||
prev = {"alive": True}
|
||||
payload = _make_lol_payload(current_health=0.0, max_health=1000.0)
|
||||
events, state = LoLAdapter.parse_payload(payload, {"adapter_id": "lol"}, prev)
|
||||
deaths = [e for e in events if e.event_type == "death"]
|
||||
assert len(deaths) == 1
|
||||
assert state["alive"] is False
|
||||
|
||||
def test_no_death_when_already_dead(self) -> None:
|
||||
prev = {"alive": False}
|
||||
payload = _make_lol_payload(current_health=0.0, max_health=1000.0)
|
||||
events, _ = LoLAdapter.parse_payload(payload, {"adapter_id": "lol"}, prev)
|
||||
deaths = [e for e in events if e.event_type == "death"]
|
||||
assert len(deaths) == 0
|
||||
|
||||
def test_respawn_detected(self) -> None:
|
||||
prev = {"alive": False}
|
||||
payload = _make_lol_payload(current_health=800.0, max_health=1000.0)
|
||||
events, state = LoLAdapter.parse_payload(payload, {"adapter_id": "lol"}, prev)
|
||||
respawns = [e for e in events if e.event_type == "objective_progress"]
|
||||
assert len(respawns) == 1
|
||||
assert state["alive"] is True
|
||||
|
||||
def test_no_respawn_when_already_alive(self) -> None:
|
||||
prev = {"alive": True}
|
||||
payload = _make_lol_payload(current_health=800.0, max_health=1000.0)
|
||||
events, _ = LoLAdapter.parse_payload(payload, {"adapter_id": "lol"}, prev)
|
||||
respawns = [e for e in events if e.event_type == "objective_progress"]
|
||||
assert len(respawns) == 0
|
||||
|
||||
|
||||
class TestLoLAuth:
|
||||
def test_always_accepts(self) -> None:
|
||||
assert LoLAdapter.validate_auth({}, {}, {}) is True
|
||||
assert LoLAdapter.validate_auth({"X-Custom": "val"}, {}, {"auth_token": "x"}) is True
|
||||
|
||||
|
||||
class TestLoLMetadata:
|
||||
def test_adapter_type(self) -> None:
|
||||
assert LoLAdapter.ADAPTER_TYPE == "lol"
|
||||
|
||||
def test_supported_events(self) -> None:
|
||||
assert "health" in LoLAdapter.SUPPORTED_EVENTS
|
||||
assert "mana" in LoLAdapter.SUPPORTED_EVENTS
|
||||
assert "death" in LoLAdapter.SUPPORTED_EVENTS
|
||||
|
||||
def test_config_schema(self) -> None:
|
||||
schema = LoLAdapter.get_config_schema()
|
||||
assert "poll_interval_ms" in schema["properties"]
|
||||
|
||||
def test_setup_instructions(self) -> None:
|
||||
instructions = LoLAdapter.get_setup_instructions()
|
||||
assert "League of Legends" in instructions
|
||||
|
||||
|
||||
class TestLoLPoller:
|
||||
def test_start_stop(self) -> None:
|
||||
"""Test that the poller starts and stops cleanly."""
|
||||
called = threading.Event()
|
||||
|
||||
def callback(data: dict) -> None:
|
||||
called.set()
|
||||
|
||||
poller = LoLPoller({"poll_interval_ms": 100}, callback)
|
||||
assert not poller.is_running
|
||||
|
||||
poller.start()
|
||||
assert poller.is_running
|
||||
|
||||
poller.stop()
|
||||
assert not poller.is_running
|
||||
|
||||
def test_double_start_no_crash(self) -> None:
|
||||
"""Starting twice should not create duplicate threads."""
|
||||
poller = LoLPoller({"poll_interval_ms": 1000}, lambda d: None)
|
||||
poller.start()
|
||||
poller.start() # should warn but not crash
|
||||
poller.stop()
|
||||
|
||||
def test_stop_without_start(self) -> None:
|
||||
"""Stopping without starting should not crash."""
|
||||
poller = LoLPoller({"poll_interval_ms": 1000}, lambda d: None)
|
||||
poller.stop() # no-op
|
||||
@@ -0,0 +1,469 @@
|
||||
"""Tests for MappingAdapter — YAML parsing, payload translation, validation."""
|
||||
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from wled_controller.core.game_integration.mapping_adapter import (
|
||||
MappingAdapter,
|
||||
load_adapter_from_yaml,
|
||||
validate_adapter_yaml,
|
||||
)
|
||||
|
||||
|
||||
# ── YAML validation tests ───────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestValidateAdapterYaml:
|
||||
def test_valid_minimal(self) -> None:
|
||||
data = {
|
||||
"name": "test_adapter",
|
||||
"game": "TestGame",
|
||||
"protocol": "webhook",
|
||||
"mappings": [
|
||||
{"source_path": "player.health", "event": "health"},
|
||||
],
|
||||
}
|
||||
errors = validate_adapter_yaml(data)
|
||||
assert errors == []
|
||||
|
||||
def test_missing_name(self) -> None:
|
||||
data = {
|
||||
"game": "TestGame",
|
||||
"protocol": "webhook",
|
||||
"mappings": [{"source_path": "x", "event": "health"}],
|
||||
}
|
||||
errors = validate_adapter_yaml(data)
|
||||
assert any("name" in e for e in errors)
|
||||
|
||||
def test_missing_game(self) -> None:
|
||||
data = {
|
||||
"name": "test",
|
||||
"protocol": "webhook",
|
||||
"mappings": [{"source_path": "x", "event": "health"}],
|
||||
}
|
||||
errors = validate_adapter_yaml(data)
|
||||
assert any("game" in e for e in errors)
|
||||
|
||||
def test_invalid_protocol(self) -> None:
|
||||
data = {
|
||||
"name": "test",
|
||||
"game": "TestGame",
|
||||
"protocol": "invalid",
|
||||
"mappings": [{"source_path": "x", "event": "health"}],
|
||||
}
|
||||
errors = validate_adapter_yaml(data)
|
||||
assert any("protocol" in e for e in errors)
|
||||
|
||||
def test_empty_mappings(self) -> None:
|
||||
data = {
|
||||
"name": "test",
|
||||
"game": "TestGame",
|
||||
"protocol": "webhook",
|
||||
"mappings": [],
|
||||
}
|
||||
errors = validate_adapter_yaml(data)
|
||||
assert any("mappings" in e for e in errors)
|
||||
|
||||
def test_unknown_event_type(self) -> None:
|
||||
data = {
|
||||
"name": "test",
|
||||
"game": "TestGame",
|
||||
"protocol": "webhook",
|
||||
"mappings": [{"source_path": "x", "event": "nonexistent_event"}],
|
||||
}
|
||||
errors = validate_adapter_yaml(data)
|
||||
assert any("unknown event type" in e for e in errors)
|
||||
|
||||
def test_invalid_trigger_mode(self) -> None:
|
||||
data = {
|
||||
"name": "test",
|
||||
"game": "TestGame",
|
||||
"protocol": "webhook",
|
||||
"mappings": [
|
||||
{"source_path": "x", "event": "health", "trigger": "bad_mode"},
|
||||
],
|
||||
}
|
||||
errors = validate_adapter_yaml(data)
|
||||
assert any("trigger mode" in e for e in errors)
|
||||
|
||||
def test_non_numeric_min_max(self) -> None:
|
||||
data = {
|
||||
"name": "test",
|
||||
"game": "TestGame",
|
||||
"protocol": "webhook",
|
||||
"mappings": [
|
||||
{"source_path": "x", "event": "health", "min": "not_a_number"},
|
||||
],
|
||||
}
|
||||
errors = validate_adapter_yaml(data)
|
||||
assert any("'min' must be numeric" in e for e in errors)
|
||||
|
||||
|
||||
# ── MappingAdapter payload parsing tests ─────────────────────────────────
|
||||
|
||||
|
||||
class TestMappingAdapterParsePayload:
|
||||
def _make_adapter(self, mappings: list[dict]) -> MappingAdapter:
|
||||
return MappingAdapter(
|
||||
{
|
||||
"name": "test_adapter",
|
||||
"game": "TestGame",
|
||||
"protocol": "webhook",
|
||||
"mappings": mappings,
|
||||
}
|
||||
)
|
||||
|
||||
def test_continuous_value_normalization(self) -> None:
|
||||
adapter = self._make_adapter(
|
||||
[
|
||||
{
|
||||
"source_path": "player.health",
|
||||
"event": "health",
|
||||
"min": 0,
|
||||
"max": 100,
|
||||
"trigger": "on_value",
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
payload = {"player": {"health": 75}}
|
||||
events, state = adapter.parse_payload(payload, {"adapter_id": "test"}, {})
|
||||
|
||||
assert len(events) == 1
|
||||
assert events[0].event_type == "health"
|
||||
assert events[0].value == pytest.approx(0.75)
|
||||
assert events[0].adapter_id == "test"
|
||||
|
||||
def test_value_clamped_to_0_1(self) -> None:
|
||||
adapter = self._make_adapter(
|
||||
[
|
||||
{
|
||||
"source_path": "val",
|
||||
"event": "health",
|
||||
"min": 0,
|
||||
"max": 100,
|
||||
"trigger": "on_value",
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
events_over, _ = adapter.parse_payload({"val": 150}, {"adapter_id": "t"}, {})
|
||||
assert events_over[0].value == 1.0
|
||||
|
||||
events_under, _ = adapter.parse_payload({"val": -50}, {"adapter_id": "t"}, {})
|
||||
assert events_under[0].value == 0.0
|
||||
|
||||
def test_on_change_trigger(self) -> None:
|
||||
adapter = self._make_adapter(
|
||||
[
|
||||
{
|
||||
"source_path": "kills",
|
||||
"event": "kill",
|
||||
"min": 0,
|
||||
"max": 50,
|
||||
"trigger": "on_change",
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
# First call: no prev_state, should emit
|
||||
events1, state1 = adapter.parse_payload({"kills": 5}, {"adapter_id": "t"}, {})
|
||||
assert len(events1) == 1
|
||||
|
||||
# Same value: should NOT emit
|
||||
events2, state2 = adapter.parse_payload({"kills": 5}, {"adapter_id": "t"}, state1)
|
||||
assert len(events2) == 0
|
||||
|
||||
# Changed value: should emit
|
||||
events3, _ = adapter.parse_payload({"kills": 6}, {"adapter_id": "t"}, state2)
|
||||
assert len(events3) == 1
|
||||
|
||||
def test_on_increase_trigger(self) -> None:
|
||||
adapter = self._make_adapter(
|
||||
[
|
||||
{
|
||||
"source_path": "kills",
|
||||
"event": "kill",
|
||||
"min": 0,
|
||||
"max": 50,
|
||||
"trigger": "on_increase",
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
# First call: prev_state empty, should NOT emit (no baseline)
|
||||
events1, state1 = adapter.parse_payload({"kills": 5}, {"adapter_id": "t"}, {})
|
||||
assert len(events1) == 0
|
||||
|
||||
# Decrease: should NOT emit
|
||||
events2, state2 = adapter.parse_payload({"kills": 3}, {"adapter_id": "t"}, state1)
|
||||
assert len(events2) == 0
|
||||
|
||||
# Increase: should emit
|
||||
events3, _ = adapter.parse_payload({"kills": 7}, {"adapter_id": "t"}, state2)
|
||||
assert len(events3) == 1
|
||||
|
||||
def test_on_decrease_trigger(self) -> None:
|
||||
adapter = self._make_adapter(
|
||||
[
|
||||
{
|
||||
"source_path": "hp",
|
||||
"event": "damage_taken",
|
||||
"min": 0,
|
||||
"max": 100,
|
||||
"trigger": "on_decrease",
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
_, state1 = adapter.parse_payload({"hp": 100}, {"adapter_id": "t"}, {})
|
||||
events, _ = adapter.parse_payload({"hp": 80}, {"adapter_id": "t"}, state1)
|
||||
assert len(events) == 1
|
||||
assert events[0].event_type == "damage_taken"
|
||||
|
||||
def test_missing_path_skipped(self) -> None:
|
||||
adapter = self._make_adapter(
|
||||
[
|
||||
{"source_path": "player.health", "event": "health", "trigger": "on_value"},
|
||||
]
|
||||
)
|
||||
|
||||
events, _ = adapter.parse_payload({"player": {}}, {"adapter_id": "t"}, {})
|
||||
assert len(events) == 0
|
||||
|
||||
def test_non_numeric_value_emits_trigger(self) -> None:
|
||||
adapter = self._make_adapter(
|
||||
[
|
||||
{"source_path": "status", "event": "buffed", "trigger": "on_value"},
|
||||
]
|
||||
)
|
||||
|
||||
events, _ = adapter.parse_payload({"status": "active"}, {"adapter_id": "t"}, {})
|
||||
assert len(events) == 1
|
||||
assert events[0].value == 1.0
|
||||
|
||||
def test_nested_json_path(self) -> None:
|
||||
adapter = self._make_adapter(
|
||||
[
|
||||
{
|
||||
"source_path": "a.b.c.d",
|
||||
"event": "health",
|
||||
"min": 0,
|
||||
"max": 10,
|
||||
"trigger": "on_value",
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
payload = {"a": {"b": {"c": {"d": 5}}}}
|
||||
events, _ = adapter.parse_payload(payload, {"adapter_id": "t"}, {})
|
||||
assert len(events) == 1
|
||||
assert events[0].value == pytest.approx(0.5)
|
||||
|
||||
def test_multiple_mappings(self) -> None:
|
||||
adapter = self._make_adapter(
|
||||
[
|
||||
{
|
||||
"source_path": "hp",
|
||||
"event": "health",
|
||||
"min": 0,
|
||||
"max": 100,
|
||||
"trigger": "on_value",
|
||||
},
|
||||
{"source_path": "mp", "event": "mana", "min": 0, "max": 200, "trigger": "on_value"},
|
||||
]
|
||||
)
|
||||
|
||||
events, _ = adapter.parse_payload(
|
||||
{"hp": 50, "mp": 100},
|
||||
{"adapter_id": "t"},
|
||||
{},
|
||||
)
|
||||
assert len(events) == 2
|
||||
types = {e.event_type for e in events}
|
||||
assert types == {"health", "mana"}
|
||||
|
||||
|
||||
# ── Auth validation tests ────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestMappingAdapterAuth:
|
||||
def test_no_auth_accepts_all(self) -> None:
|
||||
adapter = MappingAdapter(
|
||||
{
|
||||
"name": "test",
|
||||
"game": "TestGame",
|
||||
"protocol": "webhook",
|
||||
"mappings": [{"source_path": "x", "event": "health"}],
|
||||
}
|
||||
)
|
||||
assert adapter.validate_auth({}, {}, {}) is True
|
||||
|
||||
def test_header_auth_valid(self) -> None:
|
||||
adapter = MappingAdapter(
|
||||
{
|
||||
"name": "test",
|
||||
"game": "TestGame",
|
||||
"protocol": "webhook",
|
||||
"mappings": [{"source_path": "x", "event": "health"}],
|
||||
"auth": {"type": "header", "header": "X-Auth-Token"},
|
||||
}
|
||||
)
|
||||
result = adapter.validate_auth(
|
||||
{"X-Auth-Token": "secret123"},
|
||||
{},
|
||||
{"auth_token": "secret123"},
|
||||
)
|
||||
assert result is True
|
||||
|
||||
def test_header_auth_invalid(self) -> None:
|
||||
adapter = MappingAdapter(
|
||||
{
|
||||
"name": "test",
|
||||
"game": "TestGame",
|
||||
"protocol": "webhook",
|
||||
"mappings": [{"source_path": "x", "event": "health"}],
|
||||
"auth": {"type": "header", "header": "X-Auth-Token"},
|
||||
}
|
||||
)
|
||||
result = adapter.validate_auth(
|
||||
{"X-Auth-Token": "wrong"},
|
||||
{},
|
||||
{"auth_token": "secret123"},
|
||||
)
|
||||
assert result is False
|
||||
|
||||
|
||||
# ── YAML file loading tests ──────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestLoadAdapterFromYaml:
|
||||
def test_load_valid_yaml(self, tmp_path: Path) -> None:
|
||||
yaml_content = textwrap.dedent(
|
||||
"""\
|
||||
name: cs2_gsi
|
||||
game: "Counter-Strike 2"
|
||||
protocol: webhook
|
||||
mappings:
|
||||
- source_path: player.state.health
|
||||
event: health
|
||||
min: 0
|
||||
max: 100
|
||||
- source_path: player.state.armor
|
||||
event: armor
|
||||
min: 0
|
||||
max: 100
|
||||
auth:
|
||||
type: header
|
||||
header: X-GSI-Auth
|
||||
"""
|
||||
)
|
||||
yaml_file = tmp_path / "cs2.yaml"
|
||||
yaml_file.write_text(yaml_content)
|
||||
|
||||
adapter = load_adapter_from_yaml(yaml_file)
|
||||
assert adapter.name == "cs2_gsi"
|
||||
assert adapter.game == "Counter-Strike 2"
|
||||
assert adapter.protocol == "webhook"
|
||||
assert "health" in adapter.supported_events
|
||||
assert "armor" in adapter.supported_events
|
||||
|
||||
def test_load_nonexistent_file_raises(self) -> None:
|
||||
with pytest.raises(FileNotFoundError):
|
||||
load_adapter_from_yaml("/nonexistent/path.yaml")
|
||||
|
||||
def test_load_invalid_yaml_raises(self, tmp_path: Path) -> None:
|
||||
yaml_file = tmp_path / "bad.yaml"
|
||||
yaml_file.write_text("name: test\n") # Missing required fields
|
||||
|
||||
with pytest.raises(ValueError, match="Invalid adapter YAML"):
|
||||
load_adapter_from_yaml(yaml_file)
|
||||
|
||||
def test_load_non_dict_yaml_raises(self, tmp_path: Path) -> None:
|
||||
yaml_file = tmp_path / "list.yaml"
|
||||
yaml_file.write_text("- item1\n- item2\n")
|
||||
|
||||
with pytest.raises(ValueError, match="must be a dict"):
|
||||
load_adapter_from_yaml(yaml_file)
|
||||
|
||||
def test_loaded_adapter_parses_payload(self, tmp_path: Path) -> None:
|
||||
yaml_content = textwrap.dedent(
|
||||
"""\
|
||||
name: test_game
|
||||
game: TestGame
|
||||
protocol: webhook
|
||||
mappings:
|
||||
- source_path: hp
|
||||
event: health
|
||||
min: 0
|
||||
max: 100
|
||||
trigger: on_value
|
||||
"""
|
||||
)
|
||||
yaml_file = tmp_path / "test.yaml"
|
||||
yaml_file.write_text(yaml_content)
|
||||
|
||||
adapter = load_adapter_from_yaml(yaml_file)
|
||||
events, _ = adapter.parse_payload(
|
||||
{"hp": 60},
|
||||
{"adapter_id": "loaded_test"},
|
||||
{},
|
||||
)
|
||||
assert len(events) == 1
|
||||
assert events[0].value == pytest.approx(0.6)
|
||||
|
||||
|
||||
# ── Properties and metadata tests ────────────────────────────────────────
|
||||
|
||||
|
||||
class TestMappingAdapterMetadata:
|
||||
def test_config_schema_with_auth(self) -> None:
|
||||
adapter = MappingAdapter(
|
||||
{
|
||||
"name": "test",
|
||||
"game": "TestGame",
|
||||
"protocol": "webhook",
|
||||
"mappings": [{"source_path": "x", "event": "health"}],
|
||||
"auth": {"type": "header", "header": "X-Token"},
|
||||
}
|
||||
)
|
||||
schema = adapter.get_config_schema()
|
||||
assert "auth_token" in schema["properties"]
|
||||
|
||||
def test_config_schema_without_auth(self) -> None:
|
||||
adapter = MappingAdapter(
|
||||
{
|
||||
"name": "test",
|
||||
"game": "TestGame",
|
||||
"protocol": "webhook",
|
||||
"mappings": [{"source_path": "x", "event": "health"}],
|
||||
}
|
||||
)
|
||||
schema = adapter.get_config_schema()
|
||||
assert schema["properties"] == {}
|
||||
|
||||
def test_setup_instructions_from_yaml(self) -> None:
|
||||
adapter = MappingAdapter(
|
||||
{
|
||||
"name": "test",
|
||||
"game": "TestGame",
|
||||
"protocol": "webhook",
|
||||
"mappings": [{"source_path": "x", "event": "health"}],
|
||||
"setup_instructions": "# Step 1\nDo this.",
|
||||
}
|
||||
)
|
||||
assert "Step 1" in adapter.get_setup_instructions()
|
||||
|
||||
def test_setup_instructions_default(self) -> None:
|
||||
adapter = MappingAdapter(
|
||||
{
|
||||
"name": "test",
|
||||
"game": "TestGame",
|
||||
"protocol": "webhook",
|
||||
"mappings": [{"source_path": "x", "event": "health"}],
|
||||
}
|
||||
)
|
||||
instructions = adapter.get_setup_instructions()
|
||||
assert "TestGame" in instructions
|
||||
@@ -1,18 +1,17 @@
|
||||
"""Tests for AutomationStore — CRUD, conditions, name uniqueness."""
|
||||
"""Tests for AutomationStore — CRUD, rules, name uniqueness."""
|
||||
|
||||
import pytest
|
||||
|
||||
from wled_controller.storage.automation import (
|
||||
AlwaysCondition,
|
||||
ApplicationCondition,
|
||||
ApplicationRule,
|
||||
Automation,
|
||||
Condition,
|
||||
DisplayStateCondition,
|
||||
MQTTCondition,
|
||||
StartupCondition,
|
||||
SystemIdleCondition,
|
||||
TimeOfDayCondition,
|
||||
WebhookCondition,
|
||||
DisplayStateRule,
|
||||
MQTTRule,
|
||||
Rule,
|
||||
StartupRule,
|
||||
SystemIdleRule,
|
||||
TimeOfDayRule,
|
||||
WebhookRule,
|
||||
)
|
||||
from wled_controller.storage.automation_store import AutomationStore
|
||||
|
||||
@@ -23,72 +22,78 @@ def store(tmp_db) -> AutomationStore:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Condition models
|
||||
# Rule models
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestConditionModels:
|
||||
def test_always_round_trip(self):
|
||||
c = AlwaysCondition()
|
||||
data = c.to_dict()
|
||||
restored = Condition.from_dict(data)
|
||||
assert isinstance(restored, AlwaysCondition)
|
||||
|
||||
class TestRuleModels:
|
||||
def test_application_round_trip(self):
|
||||
c = ApplicationCondition(apps=["chrome.exe", "firefox.exe"], match_type="topmost")
|
||||
data = c.to_dict()
|
||||
restored = Condition.from_dict(data)
|
||||
assert isinstance(restored, ApplicationCondition)
|
||||
r = ApplicationRule(apps=["chrome.exe", "firefox.exe"], match_type="topmost")
|
||||
data = r.to_dict()
|
||||
restored = Rule.from_dict(data)
|
||||
assert isinstance(restored, ApplicationRule)
|
||||
assert restored.apps == ["chrome.exe", "firefox.exe"]
|
||||
assert restored.match_type == "topmost"
|
||||
|
||||
def test_time_of_day_round_trip(self):
|
||||
c = TimeOfDayCondition(start_time="22:00", end_time="06:00")
|
||||
data = c.to_dict()
|
||||
restored = Condition.from_dict(data)
|
||||
assert isinstance(restored, TimeOfDayCondition)
|
||||
r = TimeOfDayRule(start_time="22:00", end_time="06:00")
|
||||
data = r.to_dict()
|
||||
restored = Rule.from_dict(data)
|
||||
assert isinstance(restored, TimeOfDayRule)
|
||||
assert restored.start_time == "22:00"
|
||||
assert restored.end_time == "06:00"
|
||||
|
||||
def test_system_idle_round_trip(self):
|
||||
c = SystemIdleCondition(idle_minutes=10, when_idle=False)
|
||||
data = c.to_dict()
|
||||
restored = Condition.from_dict(data)
|
||||
assert isinstance(restored, SystemIdleCondition)
|
||||
r = SystemIdleRule(idle_minutes=10, when_idle=False)
|
||||
data = r.to_dict()
|
||||
restored = Rule.from_dict(data)
|
||||
assert isinstance(restored, SystemIdleRule)
|
||||
assert restored.idle_minutes == 10
|
||||
assert restored.when_idle is False
|
||||
|
||||
def test_display_state_round_trip(self):
|
||||
c = DisplayStateCondition(state="off")
|
||||
data = c.to_dict()
|
||||
restored = Condition.from_dict(data)
|
||||
assert isinstance(restored, DisplayStateCondition)
|
||||
r = DisplayStateRule(state="off")
|
||||
data = r.to_dict()
|
||||
restored = Rule.from_dict(data)
|
||||
assert isinstance(restored, DisplayStateRule)
|
||||
assert restored.state == "off"
|
||||
|
||||
def test_mqtt_round_trip(self):
|
||||
c = MQTTCondition(topic="home/tv", payload="on", match_mode="contains")
|
||||
data = c.to_dict()
|
||||
restored = Condition.from_dict(data)
|
||||
assert isinstance(restored, MQTTCondition)
|
||||
r = MQTTRule(topic="home/tv", payload="on", match_mode="contains")
|
||||
data = r.to_dict()
|
||||
restored = Rule.from_dict(data)
|
||||
assert isinstance(restored, MQTTRule)
|
||||
assert restored.topic == "home/tv"
|
||||
assert restored.match_mode == "contains"
|
||||
|
||||
def test_webhook_round_trip(self):
|
||||
c = WebhookCondition(token="abc123")
|
||||
data = c.to_dict()
|
||||
restored = Condition.from_dict(data)
|
||||
assert isinstance(restored, WebhookCondition)
|
||||
r = WebhookRule(token="abc123")
|
||||
data = r.to_dict()
|
||||
restored = Rule.from_dict(data)
|
||||
assert isinstance(restored, WebhookRule)
|
||||
assert restored.token == "abc123"
|
||||
|
||||
def test_startup_round_trip(self):
|
||||
c = StartupCondition()
|
||||
data = c.to_dict()
|
||||
restored = Condition.from_dict(data)
|
||||
assert isinstance(restored, StartupCondition)
|
||||
r = StartupRule()
|
||||
data = r.to_dict()
|
||||
restored = Rule.from_dict(data)
|
||||
assert isinstance(restored, StartupRule)
|
||||
|
||||
def test_unknown_condition_type_raises(self):
|
||||
with pytest.raises(ValueError, match="Unknown condition type"):
|
||||
Condition.from_dict({"condition_type": "nonexistent"})
|
||||
def test_unknown_rule_type_raises(self):
|
||||
with pytest.raises(ValueError, match="Unknown rule type"):
|
||||
Rule.from_dict({"rule_type": "nonexistent"})
|
||||
|
||||
def test_legacy_condition_type_migration(self):
|
||||
"""Legacy data with condition_type should still deserialize."""
|
||||
data = {"condition_type": "startup"}
|
||||
restored = Rule.from_dict(data)
|
||||
assert isinstance(restored, StartupRule)
|
||||
|
||||
def test_legacy_always_maps_to_startup(self):
|
||||
"""Legacy 'always' condition_type should map to StartupRule."""
|
||||
data = {"condition_type": "always"}
|
||||
restored = Rule.from_dict(data)
|
||||
assert isinstance(restored, StartupRule)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -100,7 +105,7 @@ class TestAutomationModel:
|
||||
def test_round_trip(self, make_automation):
|
||||
auto = make_automation(
|
||||
name="Test Auto",
|
||||
conditions=[AlwaysCondition(), WebhookCondition(token="tok1")],
|
||||
rules=[StartupRule(), WebhookRule(token="tok1")],
|
||||
scene_preset_id="sp_123",
|
||||
deactivation_mode="revert",
|
||||
)
|
||||
@@ -109,21 +114,21 @@ class TestAutomationModel:
|
||||
|
||||
assert restored.id == auto.id
|
||||
assert restored.name == "Test Auto"
|
||||
assert len(restored.conditions) == 2
|
||||
assert isinstance(restored.conditions[0], AlwaysCondition)
|
||||
assert isinstance(restored.conditions[1], WebhookCondition)
|
||||
assert len(restored.rules) == 2
|
||||
assert isinstance(restored.rules[0], StartupRule)
|
||||
assert isinstance(restored.rules[1], WebhookRule)
|
||||
assert restored.scene_preset_id == "sp_123"
|
||||
assert restored.deactivation_mode == "revert"
|
||||
|
||||
def test_from_dict_skips_unknown_conditions(self):
|
||||
def test_from_dict_skips_unknown_rules(self):
|
||||
data = {
|
||||
"id": "a1",
|
||||
"name": "Skip",
|
||||
"enabled": True,
|
||||
"condition_logic": "or",
|
||||
"conditions": [
|
||||
{"condition_type": "always"},
|
||||
{"condition_type": "future_unknown"},
|
||||
"rule_logic": "or",
|
||||
"rules": [
|
||||
{"rule_type": "startup"},
|
||||
{"rule_type": "future_unknown"},
|
||||
],
|
||||
"scene_preset_id": None,
|
||||
"deactivation_mode": "none",
|
||||
@@ -132,7 +137,30 @@ class TestAutomationModel:
|
||||
"updated_at": "2025-01-01T00:00:00+00:00",
|
||||
}
|
||||
auto = Automation.from_dict(data)
|
||||
assert len(auto.conditions) == 1 # unknown was skipped
|
||||
assert len(auto.rules) == 1 # unknown was skipped
|
||||
|
||||
def test_legacy_conditions_field_migration(self):
|
||||
"""Legacy data with 'conditions' and 'condition_logic' should migrate."""
|
||||
data = {
|
||||
"id": "a2",
|
||||
"name": "Legacy",
|
||||
"enabled": True,
|
||||
"condition_logic": "and",
|
||||
"conditions": [
|
||||
{"condition_type": "startup"},
|
||||
{"condition_type": "application", "apps": ["test.exe"], "match_type": "running"},
|
||||
],
|
||||
"scene_preset_id": None,
|
||||
"deactivation_mode": "none",
|
||||
"deactivation_scene_preset_id": None,
|
||||
"created_at": "2025-01-01T00:00:00+00:00",
|
||||
"updated_at": "2025-01-01T00:00:00+00:00",
|
||||
}
|
||||
auto = Automation.from_dict(data)
|
||||
assert auto.rule_logic == "and"
|
||||
assert len(auto.rules) == 2
|
||||
assert isinstance(auto.rules[0], StartupRule)
|
||||
assert isinstance(auto.rules[1], ApplicationRule)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -146,27 +174,27 @@ class TestAutomationStoreCRUD:
|
||||
assert a.id.startswith("auto_")
|
||||
assert a.name == "Auto A"
|
||||
assert a.enabled is True
|
||||
assert a.condition_logic == "or"
|
||||
assert a.rule_logic == "or"
|
||||
assert store.count() == 1
|
||||
|
||||
def test_create_with_conditions(self, store):
|
||||
conditions = [
|
||||
AlwaysCondition(),
|
||||
WebhookCondition(token="secret123"),
|
||||
def test_create_with_rules(self, store):
|
||||
rules = [
|
||||
StartupRule(),
|
||||
WebhookRule(token="secret123"),
|
||||
]
|
||||
a = store.create_automation(
|
||||
name="Full",
|
||||
enabled=False,
|
||||
condition_logic="and",
|
||||
conditions=conditions,
|
||||
rule_logic="and",
|
||||
rules=rules,
|
||||
scene_preset_id="sp_001",
|
||||
deactivation_mode="fallback_scene",
|
||||
deactivation_scene_preset_id="sp_002",
|
||||
tags=["test"],
|
||||
)
|
||||
assert a.enabled is False
|
||||
assert a.condition_logic == "and"
|
||||
assert len(a.conditions) == 2
|
||||
assert a.rule_logic == "and"
|
||||
assert len(a.rules) == 2
|
||||
assert a.scene_preset_id == "sp_001"
|
||||
assert a.tags == ["test"]
|
||||
|
||||
@@ -195,12 +223,12 @@ class TestAutomationStoreCRUD:
|
||||
assert updated.name == "New"
|
||||
assert updated.enabled is False
|
||||
|
||||
def test_update_conditions(self, store):
|
||||
a = store.create_automation(name="Conds")
|
||||
new_conds = [ApplicationCondition(apps=["notepad.exe"])]
|
||||
updated = store.update_automation(a.id, conditions=new_conds)
|
||||
assert len(updated.conditions) == 1
|
||||
assert isinstance(updated.conditions[0], ApplicationCondition)
|
||||
def test_update_rules(self, store):
|
||||
a = store.create_automation(name="Rules")
|
||||
new_rules = [ApplicationRule(apps=["notepad.exe"])]
|
||||
updated = store.update_automation(a.id, rules=new_rules)
|
||||
assert len(updated.rules) == 1
|
||||
assert isinstance(updated.rules[0], ApplicationRule)
|
||||
|
||||
def test_update_scene_preset_id_clear(self, store):
|
||||
a = store.create_automation(name="SP", scene_preset_id="sp_1")
|
||||
@@ -241,17 +269,18 @@ class TestAutomationNameUniqueness:
|
||||
class TestAutomationPersistence:
|
||||
def test_persist_and_reload(self, tmp_path):
|
||||
from wled_controller.storage.database import Database
|
||||
|
||||
db = Database(tmp_path / "auto_persist.db")
|
||||
s1 = AutomationStore(db)
|
||||
a = s1.create_automation(
|
||||
name="Persist",
|
||||
conditions=[WebhookCondition(token="t1")],
|
||||
rules=[WebhookRule(token="t1")],
|
||||
)
|
||||
aid = a.id
|
||||
|
||||
s2 = AutomationStore(db)
|
||||
loaded = s2.get_automation(aid)
|
||||
assert loaded.name == "Persist"
|
||||
assert len(loaded.conditions) == 1
|
||||
assert isinstance(loaded.conditions[0], WebhookCondition)
|
||||
assert len(loaded.rules) == 1
|
||||
assert isinstance(loaded.rules[0], WebhookRule)
|
||||
db.close()
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
"""Tests for GameIntegrationStore — CRUD, validation, uniqueness."""
|
||||
|
||||
import pytest
|
||||
|
||||
from wled_controller.storage.base_store import EntityNotFoundError
|
||||
from wled_controller.storage.game_integration import EventMapping, GameIntegrationConfig
|
||||
from wled_controller.storage.game_integration_store import GameIntegrationStore
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def store(tmp_db) -> GameIntegrationStore:
|
||||
return GameIntegrationStore(tmp_db)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dataclass model tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestEventMapping:
|
||||
def test_round_trip(self):
|
||||
m = EventMapping(
|
||||
event_type="health",
|
||||
effect="pulse",
|
||||
color=[0, 255, 0],
|
||||
duration_ms=1000,
|
||||
intensity=0.8,
|
||||
priority=5,
|
||||
)
|
||||
data = m.to_dict()
|
||||
restored = EventMapping.from_dict(data)
|
||||
assert restored.event_type == "health"
|
||||
assert restored.effect == "pulse"
|
||||
assert restored.color == [0, 255, 0]
|
||||
assert restored.duration_ms == 1000
|
||||
assert restored.intensity == 0.8
|
||||
assert restored.priority == 5
|
||||
|
||||
def test_defaults(self):
|
||||
m = EventMapping(event_type="kill")
|
||||
assert m.effect == "flash"
|
||||
assert m.color == [255, 0, 0]
|
||||
assert m.duration_ms == 500
|
||||
assert m.intensity == 1.0
|
||||
assert m.priority == 0
|
||||
|
||||
def test_from_dict_defaults(self):
|
||||
m = EventMapping.from_dict({"event_type": "death"})
|
||||
assert m.effect == "flash"
|
||||
assert m.color == [255, 0, 0]
|
||||
|
||||
|
||||
class TestGameIntegrationConfig:
|
||||
def test_round_trip(self):
|
||||
config = GameIntegrationConfig.create_from_kwargs(
|
||||
name="CS2 Integration",
|
||||
adapter_type="cs2_gsi",
|
||||
enabled=True,
|
||||
adapter_config={"token": "secret123"},
|
||||
event_mappings=[
|
||||
EventMapping(event_type="health", effect="gradient", color=[255, 0, 0]),
|
||||
EventMapping(event_type="kill", effect="flash", color=[0, 255, 0]),
|
||||
],
|
||||
description="Counter-Strike 2 game state integration",
|
||||
tags=["fps", "cs2"],
|
||||
)
|
||||
|
||||
data = config.to_dict()
|
||||
restored = GameIntegrationConfig.from_dict(data)
|
||||
|
||||
assert restored.id == config.id
|
||||
assert restored.name == "CS2 Integration"
|
||||
assert restored.adapter_type == "cs2_gsi"
|
||||
assert restored.enabled is True
|
||||
assert restored.adapter_config == {"token": "secret123"}
|
||||
assert len(restored.event_mappings) == 2
|
||||
assert restored.event_mappings[0].event_type == "health"
|
||||
assert restored.event_mappings[1].event_type == "kill"
|
||||
assert restored.description == "Counter-Strike 2 game state integration"
|
||||
assert restored.tags == ["fps", "cs2"]
|
||||
|
||||
def test_create_from_kwargs_generates_id(self):
|
||||
config = GameIntegrationConfig.create_from_kwargs(name="Test", adapter_type="test")
|
||||
assert config.id.startswith("gi_")
|
||||
assert len(config.id) == 11 # gi_ + 8 hex chars
|
||||
|
||||
def test_apply_update_immutable(self):
|
||||
original = GameIntegrationConfig.create_from_kwargs(
|
||||
name="Original", adapter_type="test", enabled=True
|
||||
)
|
||||
updated = original.apply_update(name="Updated", enabled=False)
|
||||
|
||||
# Original unchanged
|
||||
assert original.name == "Original"
|
||||
assert original.enabled is True
|
||||
|
||||
# Updated has new values
|
||||
assert updated.name == "Updated"
|
||||
assert updated.enabled is False
|
||||
assert updated.id == original.id
|
||||
assert updated.created_at == original.created_at
|
||||
assert updated.updated_at >= original.updated_at
|
||||
|
||||
def test_apply_update_partial(self):
|
||||
original = GameIntegrationConfig.create_from_kwargs(
|
||||
name="Test", adapter_type="test", description="original desc"
|
||||
)
|
||||
updated = original.apply_update(description="new desc")
|
||||
|
||||
assert updated.name == "Test"
|
||||
assert updated.adapter_type == "test"
|
||||
assert updated.description == "new desc"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Store CRUD tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGameIntegrationStoreCRUD:
|
||||
def test_create_and_get(self, store):
|
||||
config = store.create_integration(
|
||||
name="My Integration",
|
||||
adapter_type="webhook",
|
||||
description="Test integration",
|
||||
)
|
||||
|
||||
assert config.id.startswith("gi_")
|
||||
assert config.name == "My Integration"
|
||||
assert config.adapter_type == "webhook"
|
||||
assert config.enabled is True
|
||||
|
||||
fetched = store.get_integration(config.id)
|
||||
assert fetched.name == "My Integration"
|
||||
|
||||
def test_list_all(self, store):
|
||||
store.create_integration(name="Int 1", adapter_type="webhook")
|
||||
store.create_integration(name="Int 2", adapter_type="cs2_gsi")
|
||||
|
||||
all_configs = store.get_all_integrations()
|
||||
assert len(all_configs) == 2
|
||||
names = {c.name for c in all_configs}
|
||||
assert names == {"Int 1", "Int 2"}
|
||||
|
||||
def test_update(self, store):
|
||||
config = store.create_integration(
|
||||
name="Old Name",
|
||||
adapter_type="webhook",
|
||||
)
|
||||
|
||||
updated = store.update_integration(
|
||||
config.id,
|
||||
name="New Name",
|
||||
enabled=False,
|
||||
description="Updated description",
|
||||
)
|
||||
|
||||
assert updated.name == "New Name"
|
||||
assert updated.enabled is False
|
||||
assert updated.description == "Updated description"
|
||||
assert updated.updated_at > config.updated_at
|
||||
|
||||
def test_update_event_mappings(self, store):
|
||||
config = store.create_integration(
|
||||
name="Test",
|
||||
adapter_type="webhook",
|
||||
event_mappings=[EventMapping(event_type="health")],
|
||||
)
|
||||
|
||||
new_mappings = [
|
||||
EventMapping(event_type="kill", effect="flash", color=[0, 255, 0]),
|
||||
EventMapping(event_type="death", effect="pulse", color=[255, 0, 0]),
|
||||
]
|
||||
updated = store.update_integration(config.id, event_mappings=new_mappings)
|
||||
|
||||
assert len(updated.event_mappings) == 2
|
||||
assert updated.event_mappings[0].event_type == "kill"
|
||||
assert updated.event_mappings[1].event_type == "death"
|
||||
|
||||
def test_delete(self, store):
|
||||
config = store.create_integration(name="ToDelete", adapter_type="webhook")
|
||||
store.delete_integration(config.id)
|
||||
|
||||
with pytest.raises(EntityNotFoundError):
|
||||
store.get_integration(config.id)
|
||||
|
||||
def test_delete_nonexistent(self, store):
|
||||
with pytest.raises(EntityNotFoundError):
|
||||
store.delete_integration("gi_nonexist")
|
||||
|
||||
def test_get_nonexistent(self, store):
|
||||
with pytest.raises(EntityNotFoundError):
|
||||
store.get_integration("gi_nonexist")
|
||||
|
||||
def test_create_with_adapter_config(self, store):
|
||||
config = store.create_integration(
|
||||
name="CS2",
|
||||
adapter_type="cs2_gsi",
|
||||
adapter_config={"auth_token": "secret", "port": 3000},
|
||||
)
|
||||
|
||||
fetched = store.get_integration(config.id)
|
||||
assert fetched.adapter_config == {"auth_token": "secret", "port": 3000}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Name uniqueness tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestNameUniqueness:
|
||||
def test_duplicate_name_rejected(self, store):
|
||||
store.create_integration(name="Unique Name", adapter_type="webhook")
|
||||
|
||||
with pytest.raises(ValueError, match="already exists"):
|
||||
store.create_integration(name="Unique Name", adapter_type="webhook")
|
||||
|
||||
def test_empty_name_rejected(self, store):
|
||||
with pytest.raises(ValueError, match="required"):
|
||||
store.create_integration(name="", adapter_type="webhook")
|
||||
|
||||
def test_whitespace_name_rejected(self, store):
|
||||
with pytest.raises(ValueError, match="required"):
|
||||
store.create_integration(name=" ", adapter_type="webhook")
|
||||
|
||||
def test_update_same_name_allowed(self, store):
|
||||
config = store.create_integration(name="Same", adapter_type="webhook")
|
||||
updated = store.update_integration(config.id, name="Same")
|
||||
assert updated.name == "Same"
|
||||
|
||||
def test_update_to_existing_name_rejected(self, store):
|
||||
store.create_integration(name="Name A", adapter_type="webhook")
|
||||
config_b = store.create_integration(name="Name B", adapter_type="webhook")
|
||||
|
||||
with pytest.raises(ValueError, match="already exists"):
|
||||
store.update_integration(config_b.id, name="Name A")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Persistence tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPersistence:
|
||||
def test_survives_reload(self, tmp_db):
|
||||
store1 = GameIntegrationStore(tmp_db)
|
||||
config = store1.create_integration(
|
||||
name="Persistent",
|
||||
adapter_type="webhook",
|
||||
event_mappings=[EventMapping(event_type="health", effect="gradient")],
|
||||
tags=["test"],
|
||||
)
|
||||
|
||||
# Create a new store instance (simulates restart)
|
||||
store2 = GameIntegrationStore(tmp_db)
|
||||
fetched = store2.get_integration(config.id)
|
||||
|
||||
assert fetched.name == "Persistent"
|
||||
assert fetched.adapter_type == "webhook"
|
||||
assert len(fetched.event_mappings) == 1
|
||||
assert fetched.event_mappings[0].event_type == "health"
|
||||
assert fetched.tags == ["test"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_references tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGetReferences:
|
||||
def test_returns_empty(self, store):
|
||||
config = store.create_integration(name="Test", adapter_type="webhook")
|
||||
refs = store.get_references(config.id)
|
||||
assert refs == []
|
||||
Reference in New Issue
Block a user