Files
hockey_new/tests/test_multi_agent_mapping_recovery.py

222 lines
8.8 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",
"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()
source_id = profile.id
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]["id"] != source_id
assert assigned[-1]["name"].startswith("__AUTO_MAPPING__")
assert assigned[-1]["fields"][0]["vmix_input_title"] == "Score"
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.id == device.mapping_profile_id == assigned[-1]["id"]
assert device.mapping_source_profile_id == source_id
assert device.mapping_profile_version == resolved.version == 1
assert resolved.project_fingerprint == new_stable
# Recovery publishes this Agent's independent snapshot without
# rewriting the shared source template used by another project.
source = session.get(VmixMappingProfile, source_id)
assert source.name == "RECOVER-MAP" and source.project_fingerprint == old_stable
assert source.active is True
asyncio.run(scenario())
def test_mapping_recovery_does_not_guess_renamed_mapped_input_by_guid(tmp_path: Path):
import json
database = LocalTestDatabase(tmp_path / "renamed-target.sqlite3")
database.create_all()
hub = VmixAgentHub(database)
with database.session() as session:
profile = VmixMappingProfile(name="Existing mapping", project_fingerprint="a" * 64,
inventory_json="{}", active=True, created_by="admin", updated_by="admin")
session.add(profile)
session.flush()
session.add(VmixMappingField(profile_id=profile.id, data_key="game.clock", graphic="clock",
vmix_input_key="same-guid", vmix_input_number="5", vmix_input_title="Score",
vmix_field="Clock.Text", field_type="text", enabled=True, sort_order=0))
device = VmixDevice(device_uuid="GFX-RENAMED", device_secret_hash="x", project_fingerprint="b" * 64,
project_inventory_json=json.dumps({"inputs": [{"key": "same-guid", "number": "5",
"title": "Score Renamed", "fields": [{"name": "Clock.Text", "type": "text"}]}]}))
session.add(device)
session.flush()
assert hub._compatible_mapping_profile_for_device(session, device) is None