193 lines
7.1 KiB
Python
193 lines
7.1 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
|
|
from sqlalchemy import and_, select
|
|
|
|
from hockey_data.agent_bridge import VmixAgentHub, _stable_vmix_inventory_fingerprint
|
|
from hockey_data.auth_bridge import HockeyUser
|
|
from hockey_data.models import OperatorSession, VmixAssignment, VmixDevice, VmixMappingField, VmixMappingProfile
|
|
from tests.support import LocalTestDatabase
|
|
|
|
|
|
class FakeWebSocket:
|
|
def __init__(self) -> None:
|
|
self.client = SimpleNamespace(host="127.0.0.1")
|
|
self.sent: list[dict] = []
|
|
self.closed = False
|
|
|
|
async def send_json(self, payload: dict) -> None:
|
|
self.sent.append(payload)
|
|
|
|
async def close(self, **_kwargs) -> None:
|
|
self.closed = True
|
|
|
|
|
|
def _hello(device_id: str, secret: str) -> dict:
|
|
return {
|
|
"device_id": device_id,
|
|
"device_secret": secret,
|
|
"device_name": device_id,
|
|
"hostname": device_id,
|
|
"agent_version": "1.4.0",
|
|
"vmix": {"connected": True, "url": "http://127.0.0.1:8088/api/"},
|
|
}
|
|
|
|
|
|
def test_same_account_can_keep_multiple_agents_enabled_without_broadcast_assignment(tmp_path: Path) -> None:
|
|
database = LocalTestDatabase(tmp_path / "multi-agent.sqlite3")
|
|
database.create_all()
|
|
hub = VmixAgentHub(database) # type: ignore[arg-type]
|
|
user = HockeyUser(id="multi-1", login="operator", display_name="Operator")
|
|
ws_a = FakeWebSocket()
|
|
ws_b = FakeWebSocket()
|
|
|
|
async def scenario() -> None:
|
|
await hub.register(ws_a, _hello("GFX-MULTI-A", "a" * 40)) # type: ignore[arg-type]
|
|
await hub.register(ws_b, _hello("GFX-MULTI-B", "b" * 40)) # type: ignore[arg-type]
|
|
await hub.pair_device("GFX-MULTI-A", user)
|
|
await hub.pair_device("GFX-MULTI-B", user)
|
|
|
|
listed = await hub.list_for_user(user)
|
|
assert set(listed["active_device_ids"]) == {"GFX-MULTI-A", "GFX-MULTI-B"}
|
|
|
|
with database.session() as session:
|
|
session.add(OperatorSession(
|
|
session_token="multi-session",
|
|
wfl_user_id=user.id,
|
|
login_snapshot=user.login,
|
|
tournament_external_id="1437",
|
|
game_external_id="902918",
|
|
status="active",
|
|
))
|
|
|
|
# With two enabled Agents there is deliberately no implicit target.
|
|
assert await hub.assign_current_match(user) is None
|
|
assert not any(item.get("type") == "match.assign" for item in ws_a.sent)
|
|
assert not any(item.get("type") == "match.assign" for item in ws_b.sent)
|
|
|
|
result = await hub.assign_match(
|
|
wfl_user_id=user.id,
|
|
tournament_external_id="1437",
|
|
game_external_id="902918",
|
|
device_id="GFX-MULTI-B",
|
|
operator_session_token="multi-session",
|
|
)
|
|
assert result is not None
|
|
assert result["device_id"] == "GFX-MULTI-B"
|
|
assert any(item.get("type") == "match.assign" for item in ws_b.sent)
|
|
assert not any(item.get("type") == "match.assign" for item in ws_a.sent)
|
|
|
|
with database.session() as session:
|
|
a = session.scalar(select(VmixDevice).where(VmixDevice.device_uuid == "GFX-MULTI-A"))
|
|
b = session.scalar(select(VmixDevice).where(VmixDevice.device_uuid == "GFX-MULTI-B"))
|
|
assert a is not None and b is not None
|
|
assert a.is_active_for_account is True
|
|
assert b.is_active_for_account is True
|
|
assert a.current_match_external_id == ""
|
|
assert b.current_match_external_id == "902918"
|
|
|
|
await hub.deactivate_device("GFX-MULTI-A", user)
|
|
listed = await hub.list_for_user(user)
|
|
assert listed["active_device_ids"] == ["GFX-MULTI-B"]
|
|
|
|
asyncio.run(scenario())
|
|
|
|
def test_mapping_recovers_when_only_unrelated_vmix_inputs_change(tmp_path: Path) -> None:
|
|
database = LocalTestDatabase(tmp_path / "mapping-compatible.sqlite3")
|
|
database.create_all()
|
|
hub = VmixAgentHub(database) # type: ignore[arg-type]
|
|
ws = FakeWebSocket()
|
|
|
|
old_inputs = [
|
|
{
|
|
"key": "score-key",
|
|
"number": "5",
|
|
"title": "Score",
|
|
"type": "GT",
|
|
"fields": [{"name": "Clock.Text", "type": "text", "index": "0"}],
|
|
},
|
|
{
|
|
"key": "old-extra-key",
|
|
"number": "6",
|
|
"title": "Old Extra",
|
|
"type": "Video",
|
|
"fields": [],
|
|
},
|
|
]
|
|
new_inputs = [
|
|
{
|
|
"key": "score-key",
|
|
"number": "17",
|
|
"title": "Score Renamed",
|
|
"type": "GT",
|
|
"fields": [{"name": "Clock.Text", "type": "text", "index": "4"}],
|
|
},
|
|
{
|
|
"key": "new-extra-key",
|
|
"number": "1",
|
|
"title": "New Extra",
|
|
"type": "Video",
|
|
"fields": [],
|
|
},
|
|
]
|
|
old_stable = _stable_vmix_inventory_fingerprint(old_inputs)
|
|
new_stable = _stable_vmix_inventory_fingerprint(new_inputs)
|
|
assert old_stable != new_stable
|
|
|
|
async def scenario() -> None:
|
|
await hub.register(ws, _hello("GFX-MAP-RECOVER", "c" * 40)) # type: ignore[arg-type]
|
|
with database.session() as session:
|
|
profile = VmixMappingProfile(
|
|
name="RECOVER-MAP",
|
|
description="",
|
|
project_fingerprint=old_stable,
|
|
inventory_json='{"inputs":[]}',
|
|
version=1,
|
|
active=True,
|
|
created_by="admin",
|
|
updated_by="admin",
|
|
)
|
|
session.add(profile)
|
|
session.flush()
|
|
session.add(VmixMappingField(
|
|
profile_id=profile.id,
|
|
graphic="score",
|
|
data_key="game.clock",
|
|
vmix_input_key="score-key",
|
|
vmix_input_number="5",
|
|
vmix_input_title="Score",
|
|
vmix_field="Clock.Text",
|
|
field_type="text",
|
|
enabled=True,
|
|
sort_order=0,
|
|
))
|
|
|
|
await hub.receive_inventory("GFX-MAP-RECOVER", {
|
|
"type": "vmix.inventory",
|
|
"inventory": {
|
|
"fingerprint": "d" * 64,
|
|
"inputs": new_inputs,
|
|
"vmix_version": "29.0.0.48",
|
|
},
|
|
})
|
|
assigned = [item for item in ws.sent if item.get("type") == "mapping.assigned"]
|
|
missing = [item for item in ws.sent if item.get("type") == "mapping.missing"]
|
|
assert assigned
|
|
assert assigned[-1]["name"] == "RECOVER-MAP"
|
|
assert not missing
|
|
|
|
with database.session() as session:
|
|
device = session.scalar(select(VmixDevice).where(VmixDevice.device_uuid == "GFX-MAP-RECOVER"))
|
|
assert device is not None
|
|
resolved = hub._active_mapping_profile_for_device(session, device)
|
|
assert resolved is not None
|
|
assert resolved.name == "RECOVER-MAP"
|
|
# Compatibility recovery must not rewrite the profile's primary
|
|
# fingerprint just because this Agent has an unrelated extra Input.
|
|
assert resolved.project_fingerprint == old_stable
|
|
|
|
asyncio.run(scenario())
|