Files
ledgrab/server/tests/core/test_cs2_adapter.py
T
alexei.dolgolyov 492bdb95e3 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
2026-03-31 13:17:52 +03:00

280 lines
11 KiB
Python

"""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