Files
ledgrab/server/tests/test_lol_poll_manager.py
T
alexei.dolgolyov 6745e25b20 feat: roadmap batch (2026-06-19) — solar/linear-light/dither/nanoleaf + integrations
Eight roadmap features from the 2026-06-19 review, each a full vertical
(backend + tests + frontend + i18n en/ru/zh); ~67 new unit tests:

- automations: SolarRule sunrise/sunset trigger (new utils/solar.py, shared
  with the daylight cycle; window logic mirrors TimeOfDayRule)
- ci: best-effort arm64 multi-arch Docker manifest via QEMU + docker manifest
  (release.yml; amd64 path untouched, continue-on-error)
- game-integration: wire the orphaned LoLPoller via a LoLPollManager + a shared
  runtime_state module (poll lifecycle on enable/CRUD/startup/shutdown)
- ui: color-harmony gradient generator (complementary/analogous/triadic/...)
- effects: audio-reactive palette modulation (new audio_energy_tap; brightness/
  saturation modulation across all 12 procedural effects)
- capture: linear-light blending + spatio-temporal dithering, opt-in per
  calibration (new utils/linear_light.py, utils/dither.py)
- devices: Nanoleaf extControl v2 per-panel UDP streaming (per_panel mode)

Also bundles the pending 2026-06-18 production-review fixes and other
in-progress work already in the working tree (manual-trigger rule, etc.),
since they share files and could not be cleanly separated.

Gate: ruff + tsc clean; pytest 2654 passed / 2 skipped. The single failing
test (automation manual_trigger handler coverage) is a separate in-progress
item owned elsewhere, intentionally left as-is.
2026-06-22 23:21:24 +03:00

179 lines
5.9 KiB
Python

"""Tests for the LoL poll manager + shared game-integration payload processing.
Covers the runtime wiring that was previously missing: the orphaned
``LoLPoller`` is now driven by ``LoLPollManager``, and polled payloads flow
through the same ``process_payload`` core the HTTP ingest route uses.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
import pytest
from ledgrab.core.game_integration import runtime_state
from ledgrab.core.game_integration.adapters.lol_adapter import LoLAdapter
from ledgrab.core.game_integration.lol_poll_manager import LoLPollManager
# ── Fakes ───────────────────────────────────────────────────────────────
class _FakeBus:
def __init__(self) -> None:
self.published: list[Any] = []
def publish(self, event: Any) -> None:
self.published.append(event)
@dataclass
class _FakeConfig:
id: str
enabled: bool
adapter_type: str
adapter_config: dict = field(default_factory=dict)
class _FakePoller:
"""Stand-in for LoLPoller so the manager test never spawns real threads."""
instances: list["_FakePoller"] = []
def __init__(self, adapter_config: dict, callback: Any) -> None:
self.adapter_config = adapter_config
self.callback = callback
self.started = False
self.stopped = False
_FakePoller.instances.append(self)
def start(self) -> None:
self.started = True
def stop(self) -> None:
self.stopped = True
@pytest.fixture
def fake_poller(monkeypatch):
_FakePoller.instances = []
monkeypatch.setattr("ledgrab.core.game_integration.lol_poll_manager.LoLPoller", _FakePoller)
return _FakePoller
def _lol(id: str = "lol1", enabled: bool = True, **cfg) -> _FakeConfig:
return _FakeConfig(id=id, enabled=enabled, adapter_type="lol", adapter_config=cfg)
# ── process_payload ─────────────────────────────────────────────────────
class TestProcessPayload:
def setup_method(self):
runtime_state.cleanup_state("pp_test")
def test_publishes_parsed_events_and_records_stats(self):
bus = _FakeBus()
payload = {
"activePlayer": {
"championStats": {"currentHealth": 50, "maxHealth": 100},
"summonerName": "Me",
"level": 9,
},
"allPlayers": [],
}
events = runtime_state.process_payload("pp_test", LoLAdapter, {}, payload, bus)
assert len(events) >= 1
assert any(e.event_type == "health" and abs(e.value - 0.5) < 1e-6 for e in events)
assert len(bus.published) == len(events)
assert runtime_state.get_stats("pp_test")["event_count"] == len(events)
def test_swallows_parse_errors(self):
bus = _FakeBus()
class _Boom:
ADAPTER_TYPE = "boom"
@classmethod
def parse_payload(cls, *a):
raise ValueError("bad frame")
events = runtime_state.process_payload("pp_test", _Boom, {}, {}, bus)
assert events == []
assert bus.published == []
# ── LoLPollManager lifecycle ────────────────────────────────────────────
class TestLoLPollManager:
def test_sync_starts_poller_for_enabled_lol(self, fake_poller):
mgr = LoLPollManager(_FakeBus())
mgr.sync([_lol()])
assert mgr.active_count == 1
assert fake_poller.instances[-1].started is True
def test_sync_ignores_non_lol_and_disabled(self, fake_poller):
mgr = LoLPollManager(_FakeBus())
mgr.sync(
[
_FakeConfig("cs", True, "cs2", {}),
_lol("off", enabled=False),
]
)
assert mgr.active_count == 0
assert fake_poller.instances == []
def test_sync_is_idempotent_for_unchanged_config(self, fake_poller):
mgr = LoLPollManager(_FakeBus())
mgr.sync([_lol(poll_interval_ms=500)])
first = fake_poller.instances[-1]
mgr.sync([_lol(poll_interval_ms=500)])
assert mgr.active_count == 1
assert len(fake_poller.instances) == 1 # not restarted
assert first.stopped is False
def test_sync_restarts_on_config_change(self, fake_poller):
mgr = LoLPollManager(_FakeBus())
mgr.sync([_lol(poll_interval_ms=500)])
first = fake_poller.instances[-1]
mgr.sync([_lol(poll_interval_ms=1000)])
assert first.stopped is True
assert len(fake_poller.instances) == 2
assert mgr.active_count == 1
def test_sync_stops_when_removed_or_disabled(self, fake_poller):
mgr = LoLPollManager(_FakeBus())
mgr.sync([_lol()])
poller = fake_poller.instances[-1]
mgr.sync([]) # integration gone
assert poller.stopped is True
assert mgr.active_count == 0
def test_stop_all(self, fake_poller):
mgr = LoLPollManager(_FakeBus())
mgr.sync([_lol("a"), _lol("b")])
assert mgr.active_count == 2
mgr.stop_all()
assert mgr.active_count == 0
assert all(p.stopped for p in fake_poller.instances)
def test_callback_routes_polled_data_through_process_payload(self, fake_poller):
bus = _FakeBus()
mgr = LoLPollManager(bus)
mgr.sync([_lol("cbtest")])
callback = fake_poller.instances[-1].callback
callback(
{
"activePlayer": {
"championStats": {"currentHealth": 80, "maxHealth": 100},
"summonerName": "Me",
"level": 1,
},
"allPlayers": [],
}
)
assert any(e.event_type == "health" for e in bus.published)