6745e25b20
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.
179 lines
5.9 KiB
Python
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)
|