750 lines
28 KiB
Python
750 lines
28 KiB
Python
from __future__ import annotations
|
||
|
||
import asyncio
|
||
from pathlib import Path
|
||
from types import SimpleNamespace
|
||
|
||
from sqlalchemy import select
|
||
|
||
from hockey_data.agent_bridge import VmixAgentHub
|
||
from hockey_data.auth_bridge import HockeyUser
|
||
from hockey_data.models import OperatorSession, VmixAssignment, VmixDevice
|
||
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 test_device_pairing_and_match_switch(tmp_path: Path) -> None:
|
||
database = LocalTestDatabase(tmp_path / "agent.sqlite3")
|
||
database.create_all()
|
||
hub = VmixAgentHub(database) # type: ignore[arg-type]
|
||
ws = FakeWebSocket()
|
||
user = HockeyUser(id="42", login="operator", display_name="operator")
|
||
|
||
async def scenario() -> None:
|
||
registered = await hub.register(
|
||
ws, # type: ignore[arg-type]
|
||
{
|
||
"device_id": "GFX-PC-TEST",
|
||
"device_secret": "x" * 40,
|
||
"device_name": "Test GFX",
|
||
"hostname": "TEST-PC",
|
||
"agent_version": "1.0.0",
|
||
"vmix": {"connected": True, "url": "http://127.0.0.1:8088/api/"},
|
||
},
|
||
)
|
||
assert registered["device_id"] == "GFX-PC-TEST"
|
||
|
||
before = await hub.list_for_user(user)
|
||
assert before["devices"][0]["pair_state"] == "free"
|
||
|
||
paired = await hub.pair_device("GFX-PC-TEST", user)
|
||
assert paired["paired_to_me"] is True
|
||
assert paired["active_for_account"] is True
|
||
|
||
with database.session() as session:
|
||
session.add(
|
||
OperatorSession(
|
||
session_token="channel-1",
|
||
wfl_user_id=user.id,
|
||
login_snapshot=user.login,
|
||
tournament_external_id="1437",
|
||
game_external_id="902918",
|
||
status="active",
|
||
)
|
||
)
|
||
|
||
first = await hub.assign_current_match(user)
|
||
assert first is not None
|
||
assert first["game_id"] == "902918"
|
||
first_assignment = first["assignment_id"]
|
||
|
||
second = await hub.assign_match(
|
||
wfl_user_id=user.id,
|
||
tournament_external_id="1437",
|
||
game_external_id="902950",
|
||
)
|
||
assert second is not None
|
||
assert second["game_id"] == "902950"
|
||
assert second["assignment_id"] != first_assignment
|
||
|
||
with database.session() as session:
|
||
device = session.scalar(select(VmixDevice).where(VmixDevice.device_uuid == "GFX-PC-TEST"))
|
||
assert device is not None
|
||
assert device.current_match_external_id == "902950"
|
||
assignments = list(session.scalars(select(VmixAssignment).order_by(VmixAssignment.id)))
|
||
assert len(assignments) == 2
|
||
assert assignments[0].active is False
|
||
assert assignments[1].active is True
|
||
|
||
assert any(item.get("type") == "pairing.confirmed" for item in ws.sent)
|
||
assert any(item.get("type") == "match.assign" and item.get("game_id") == "902950" for item in ws.sent)
|
||
|
||
asyncio.run(scenario())
|
||
|
||
|
||
def test_set_text_command_waits_for_agent_ack(tmp_path: Path) -> None:
|
||
database = LocalTestDatabase(tmp_path / "agent-command.sqlite3")
|
||
database.create_all()
|
||
hub = VmixAgentHub(database) # type: ignore[arg-type]
|
||
ws = FakeWebSocket()
|
||
user = HockeyUser(id="52", login="gfx", display_name="gfx")
|
||
|
||
async def scenario() -> None:
|
||
await hub.register(
|
||
ws, # type: ignore[arg-type]
|
||
{
|
||
"device_id": "GFX-PC-COMMAND",
|
||
"device_secret": "z" * 40,
|
||
"device_name": "Command GFX",
|
||
"hostname": "COMMAND-PC",
|
||
"agent_version": "1.1.0",
|
||
"vmix": {"connected": True, "url": "http://127.0.0.1:8088/api/"},
|
||
},
|
||
)
|
||
await hub.pair_device("GFX-PC-COMMAND", user)
|
||
assigned = await hub.assign_match(
|
||
wfl_user_id=user.id,
|
||
tournament_external_id="1437",
|
||
game_external_id="902918",
|
||
)
|
||
assert assigned is not None
|
||
|
||
task = asyncio.create_task(
|
||
hub.test_set_text(
|
||
"GFX-PC-COMMAND",
|
||
user,
|
||
input_ref="Scorebug",
|
||
selected_name="HomeTeam.Text",
|
||
value="СКА",
|
||
)
|
||
)
|
||
for _ in range(50):
|
||
await asyncio.sleep(0)
|
||
command = next((item for item in reversed(ws.sent) if item.get("type") == "vmix.command"), None)
|
||
if command is not None:
|
||
break
|
||
assert command is not None
|
||
assert command["assignment_id"] == assigned["assignment_id"]
|
||
assert command["match_id"] == "902918"
|
||
assert command["command"] == {
|
||
"Function": "SetText",
|
||
"Input": "Scorebug",
|
||
"SelectedName": "HomeTeam.Text",
|
||
"Value": "СКА",
|
||
}
|
||
await hub.receive_command_ack(
|
||
"GFX-PC-COMMAND",
|
||
{
|
||
"type": "command.ack",
|
||
"request_id": command["request_id"],
|
||
"ok": True,
|
||
"assignment_id": assigned["assignment_id"],
|
||
"match_id": "902918",
|
||
"vmix": {"connected": True, "response": "OK"},
|
||
},
|
||
)
|
||
result = await task
|
||
assert result["ok"] is True
|
||
assert result["function"] == "SetText"
|
||
assert result["match_id"] == "902918"
|
||
|
||
asyncio.run(scenario())
|
||
|
||
|
||
def test_vmix_inventory_creates_admin_mapping_profile(tmp_path: Path) -> None:
|
||
from types import SimpleNamespace
|
||
|
||
database = LocalTestDatabase(tmp_path / "agent-mapping.sqlite3")
|
||
database.create_all()
|
||
hub = VmixAgentHub(database) # type: ignore[arg-type]
|
||
ws = FakeWebSocket()
|
||
admin = HockeyUser(id="1", login="admin", display_name="admin", is_admin=True, role="admin")
|
||
|
||
async def scenario() -> None:
|
||
await hub.register(
|
||
ws, # type: ignore[arg-type]
|
||
{
|
||
"device_id": "GFX-MAPPING-TEST",
|
||
"device_secret": "m" * 40,
|
||
"device_name": "Mapping GFX",
|
||
"agent_version": "1.3.0",
|
||
"vmix": {"connected": True, "version": "29.0.0.48", "url": "http://127.0.0.1:8088/api/"},
|
||
},
|
||
)
|
||
fingerprint = "a" * 64
|
||
await hub.receive_inventory(
|
||
"GFX-MAPPING-TEST",
|
||
{
|
||
"type": "vmix.inventory",
|
||
"inventory": {
|
||
"fingerprint": fingerprint,
|
||
"vmix_version": "29.0.0.48",
|
||
"inputs": [
|
||
{
|
||
"key": "input-key-1",
|
||
"number": "12",
|
||
"title": "Scorebug",
|
||
"type": "GT",
|
||
"fields": [
|
||
{"name": "HomeTeam.Text", "type": "text", "index": "0"},
|
||
{"name": "HomeLogo.Source", "type": "image", "index": "0"},
|
||
],
|
||
}
|
||
],
|
||
},
|
||
},
|
||
)
|
||
devices = await hub.list_mapping_devices()
|
||
assert devices["devices"][0]["input_count"] == 1
|
||
assert devices["devices"][0]["field_count"] == 2
|
||
assert devices["devices"][0]["mapping"] is None
|
||
|
||
profile = await hub.create_mapping_profile(
|
||
SimpleNamespace(name="KHL_MAIN", device_id="GFX-MAPPING-TEST", description="Test"),
|
||
admin,
|
||
)
|
||
assert profile["project_fingerprint"] != fingerprint
|
||
assert len(profile["project_fingerprint"]) == 64
|
||
assert profile["version"] == 1
|
||
assert any(item.get("type") == "mapping.assigned" and item.get("name") == "KHL_MAIN" for item in ws.sent)
|
||
|
||
saved = await hub.replace_mapping_fields(
|
||
profile["id"],
|
||
SimpleNamespace(fields=[SimpleNamespace(
|
||
graphic="score",
|
||
data_key="game.home.name",
|
||
vmix_input_key="input-key-1",
|
||
vmix_input_number="12",
|
||
vmix_input_title="Scorebug",
|
||
vmix_field="HomeTeam.Text",
|
||
field_type="text",
|
||
enabled=True,
|
||
)]),
|
||
admin,
|
||
)
|
||
assert saved["version"] == 2
|
||
assert saved["fields"][0]["vmix_field"] == "HomeTeam.Text"
|
||
|
||
asyncio.run(scenario())
|
||
|
||
|
||
def test_admin_mapping_preview_uses_current_device_assignment(tmp_path: Path) -> None:
|
||
database = LocalTestDatabase(tmp_path / "agent-mapping-preview.sqlite3")
|
||
database.create_all()
|
||
hub = VmixAgentHub(database) # type: ignore[arg-type]
|
||
ws = FakeWebSocket()
|
||
operator = HockeyUser(id="72", login="operator72", display_name="operator72")
|
||
|
||
async def scenario() -> None:
|
||
await hub.register(
|
||
ws, # type: ignore[arg-type]
|
||
{
|
||
"device_id": "GFX-MAPPING-PREVIEW",
|
||
"device_secret": "p" * 40,
|
||
"device_name": "Preview GFX",
|
||
"agent_version": "1.3.0",
|
||
"vmix": {"connected": True, "url": "http://127.0.0.1:8088/api/"},
|
||
},
|
||
)
|
||
await hub.pair_device("GFX-MAPPING-PREVIEW", operator)
|
||
assigned = await hub.assign_match(
|
||
wfl_user_id=operator.id,
|
||
tournament_external_id="1437",
|
||
game_external_id="902918",
|
||
)
|
||
assert assigned is not None
|
||
|
||
task = asyncio.create_task(
|
||
hub.admin_test_mapping_value(
|
||
"GFX-MAPPING-PREVIEW",
|
||
input_ref="Scorebug",
|
||
selected_name="HomeTeam.Text",
|
||
value="СКА",
|
||
field_type="text",
|
||
)
|
||
)
|
||
command = None
|
||
for _ in range(50):
|
||
await asyncio.sleep(0)
|
||
command = next((item for item in reversed(ws.sent) if item.get("type") == "vmix.command"), None)
|
||
if command is not None:
|
||
break
|
||
assert command is not None
|
||
assert command["assignment_id"] == assigned["assignment_id"]
|
||
assert command["match_id"] == "902918"
|
||
assert command["command"]["Function"] == "SetText"
|
||
assert command["command"]["Value"] == "СКА"
|
||
await hub.receive_command_ack(
|
||
"GFX-MAPPING-PREVIEW",
|
||
{
|
||
"type": "command.ack",
|
||
"request_id": command["request_id"],
|
||
"ok": True,
|
||
"assignment_id": assigned["assignment_id"],
|
||
"match_id": "902918",
|
||
"vmix": {"connected": True, "response": "OK"},
|
||
},
|
||
)
|
||
result = await task
|
||
assert result["ok"] is True
|
||
assert result["function"] == "SetText"
|
||
|
||
asyncio.run(scenario())
|
||
|
||
|
||
def test_saved_mapping_is_resolved_and_pushed_to_vmix(tmp_path: Path) -> None:
|
||
from types import SimpleNamespace
|
||
|
||
database = LocalTestDatabase(tmp_path / "agent-mapping-apply.sqlite3")
|
||
database.create_all()
|
||
hub = VmixAgentHub(database) # type: ignore[arg-type]
|
||
ws = FakeWebSocket()
|
||
operator = HockeyUser(id="88", login="operator88", display_name="operator88")
|
||
admin = HockeyUser(id="1", login="admin", display_name="admin", is_admin=True, role="admin")
|
||
|
||
async def scenario() -> None:
|
||
await hub.register(
|
||
ws, # type: ignore[arg-type]
|
||
{
|
||
"device_id": "GFX-MAPPING-APPLY",
|
||
"device_secret": "a" * 40,
|
||
"device_name": "Apply GFX",
|
||
"agent_version": "1.3.0",
|
||
"vmix": {"connected": True, "url": "http://127.0.0.1:8088/api/"},
|
||
},
|
||
)
|
||
fingerprint = "b" * 64
|
||
await hub.receive_inventory(
|
||
"GFX-MAPPING-APPLY",
|
||
{
|
||
"type": "vmix.inventory",
|
||
"inventory": {
|
||
"fingerprint": fingerprint,
|
||
"inputs": [{
|
||
"key": "score-key", "number": "5", "title": "Scorebug", "type": "GT",
|
||
"fields": [{"name": "HomeTeam.Text", "type": "text", "index": "0"}],
|
||
}],
|
||
},
|
||
},
|
||
)
|
||
profile = await hub.create_mapping_profile(
|
||
SimpleNamespace(name="AUTO", device_id="GFX-MAPPING-APPLY", description=""), admin
|
||
)
|
||
await hub.pair_device("GFX-MAPPING-APPLY", operator)
|
||
assigned = await hub.assign_match(
|
||
wfl_user_id=operator.id,
|
||
tournament_external_id="1437",
|
||
game_external_id="902918",
|
||
)
|
||
assert assigned is not None
|
||
hub.mapping_data.data_catalog = lambda _user, _ctx=None: { # type: ignore[method-assign]
|
||
"items": [{"key": "game.home.name", "value": "СКА", "kind": "text"}]
|
||
}
|
||
|
||
task = asyncio.create_task(
|
||
hub.replace_mapping_fields(
|
||
profile["id"],
|
||
SimpleNamespace(fields=[SimpleNamespace(
|
||
graphic="score", data_key="game.home.name",
|
||
vmix_input_key="score-key", vmix_input_number="5", vmix_input_title="Scorebug",
|
||
vmix_field="HomeTeam.Text", field_type="text", enabled=True,
|
||
)]),
|
||
admin,
|
||
)
|
||
)
|
||
command = None
|
||
for _ in range(100):
|
||
await asyncio.sleep(0)
|
||
command = next((item for item in reversed(ws.sent) if item.get("type") == "vmix.command"), None)
|
||
if command is not None:
|
||
break
|
||
assert command is not None
|
||
assert command["command"] == {
|
||
"Function": "SetText",
|
||
"Input": "score-key",
|
||
"SelectedName": "HomeTeam.Text",
|
||
"Value": "СКА",
|
||
}
|
||
await hub.receive_command_ack(
|
||
"GFX-MAPPING-APPLY",
|
||
{
|
||
"type": "command.ack", "request_id": command["request_id"], "ok": True,
|
||
"assignment_id": assigned["assignment_id"], "match_id": "902918",
|
||
},
|
||
)
|
||
saved = await task
|
||
assert saved["version"] == 2
|
||
|
||
asyncio.run(scenario())
|
||
|
||
|
||
def test_global_language_refresh_targets_all_active_operator_devices(tmp_path: Path) -> None:
|
||
database = LocalTestDatabase(tmp_path / "agent-language-refresh.sqlite3")
|
||
database.create_all()
|
||
hub = VmixAgentHub(database) # type: ignore[arg-type]
|
||
user_a = HockeyUser(id="201", login="operator-a", display_name="A")
|
||
user_b = HockeyUser(id="202", login="operator-b", display_name="B")
|
||
ws_a = FakeWebSocket()
|
||
ws_b = FakeWebSocket()
|
||
|
||
async def scenario() -> None:
|
||
for device_id, secret, ws, user, game_id in (
|
||
("GFX-LANG-A", "a" * 40, ws_a, user_a, "902918"),
|
||
("GFX-LANG-B", "b" * 40, ws_b, user_b, "902950"),
|
||
):
|
||
await hub.register(
|
||
ws, # type: ignore[arg-type]
|
||
{
|
||
"device_id": device_id,
|
||
"device_secret": secret,
|
||
"device_name": device_id,
|
||
"agent_version": "1.3.0",
|
||
"vmix": {"connected": True, "url": "http://127.0.0.1:8088/api/"},
|
||
},
|
||
)
|
||
await hub.pair_device(device_id, user)
|
||
assigned = await hub.assign_match(
|
||
wfl_user_id=user.id,
|
||
tournament_external_id="1437",
|
||
game_external_id=game_id,
|
||
)
|
||
assert assigned is not None
|
||
|
||
calls: list[tuple[str, str]] = []
|
||
|
||
async def fake_apply(device_id: str, *, reason: str = "manual") -> dict:
|
||
calls.append((device_id, reason))
|
||
return {"ok": True, "device_id": device_id, "applied": 2, "total": 2}
|
||
|
||
hub.apply_mapping_to_device = fake_apply # type: ignore[method-assign]
|
||
result = await hub.apply_mapping_to_all_active_devices(reason="ui_language_changed")
|
||
assert result["ok"] is True
|
||
assert result["total_devices"] == 2
|
||
assert result["applied_devices"] == 2
|
||
assert {device_id for device_id, _ in calls} == {"GFX-LANG-A", "GFX-LANG-B"}
|
||
assert all(reason == "ui_language_changed" for _, reason in calls)
|
||
|
||
asyncio.run(scenario())
|
||
|
||
|
||
def test_mapping_apply_uses_operator_vmix_language_preference(tmp_path: Path) -> None:
|
||
from hockey_data.models import UserPreference, VmixMappingField, VmixMappingProfile
|
||
|
||
database = LocalTestDatabase(tmp_path / "agent-language-preference.sqlite3")
|
||
database.create_all()
|
||
hub = VmixAgentHub(database) # type: ignore[arg-type]
|
||
ws = FakeWebSocket()
|
||
operator = HockeyUser(id="303", login="operator-lang", display_name="Operator Lang")
|
||
|
||
async def scenario() -> None:
|
||
await hub.register(
|
||
ws, # type: ignore[arg-type]
|
||
{
|
||
"device_id": "GFX-LANG-PREF",
|
||
"device_secret": "p" * 40,
|
||
"device_name": "Language GFX",
|
||
"agent_version": "1.3.0",
|
||
"vmix": {"connected": True, "url": "http://127.0.0.1:8088/api/"},
|
||
},
|
||
)
|
||
fingerprint = "c" * 64
|
||
await hub.receive_inventory(
|
||
"GFX-LANG-PREF",
|
||
{
|
||
"type": "vmix.inventory",
|
||
"inventory": {
|
||
"fingerprint": fingerprint,
|
||
"inputs": [{
|
||
"key": "score-key", "number": "5", "title": "Scorebug", "type": "GT",
|
||
"fields": [{"name": "HomeTeam.Text", "type": "text", "index": "0"}],
|
||
}],
|
||
},
|
||
},
|
||
)
|
||
await hub.pair_device("GFX-LANG-PREF", operator)
|
||
assigned = await hub.assign_match(
|
||
wfl_user_id=operator.id,
|
||
tournament_external_id="1437",
|
||
game_external_id="902918",
|
||
)
|
||
assert assigned is not None
|
||
|
||
with database.session() as session:
|
||
session.add(UserPreference(
|
||
wfl_user_id=operator.id,
|
||
display_language="en",
|
||
vmix_language="en",
|
||
selected_tournament_external_id="1437",
|
||
))
|
||
profile = VmixMappingProfile(
|
||
name="LANG", project_fingerprint=fingerprint, active=True, version=1,
|
||
created_by="admin", updated_by="admin",
|
||
)
|
||
session.add(profile)
|
||
session.flush()
|
||
session.add(VmixMappingField(
|
||
profile_id=profile.id, graphic="score", data_key="game.home.name",
|
||
vmix_input_key="score-key", vmix_input_number="5", vmix_input_title="Scorebug",
|
||
vmix_field="HomeTeam.Text", field_type="text", enabled=True, sort_order=0,
|
||
))
|
||
|
||
captured: dict = {}
|
||
|
||
def fake_catalog(_user, ctx=None):
|
||
captured.update(ctx or {})
|
||
value = "Dynamo" if (ctx or {}).get("ui_language") == "en" else "Динамо"
|
||
return {"items": [{"key": "game.home.name", "value": value, "kind": "text"}]}
|
||
|
||
hub.mapping_data.data_catalog = fake_catalog # type: ignore[method-assign]
|
||
task = asyncio.create_task(hub.apply_mapping_to_device("GFX-LANG-PREF", reason="language_switch"))
|
||
command = None
|
||
for _ in range(100):
|
||
await asyncio.sleep(0)
|
||
command = next((item for item in reversed(ws.sent) if item.get("type") == "vmix.command"), None)
|
||
if command is not None:
|
||
break
|
||
assert command is not None
|
||
assert captured["ui_language"] == "en"
|
||
assert command["command"]["Value"] == "Dynamo"
|
||
await hub.receive_command_ack(
|
||
"GFX-LANG-PREF",
|
||
{
|
||
"type": "command.ack", "request_id": command["request_id"], "ok": True,
|
||
"assignment_id": assigned["assignment_id"], "match_id": "902918",
|
||
},
|
||
)
|
||
result = await task
|
||
assert result["ok"] is True
|
||
assert result["applied"] == 1
|
||
|
||
asyncio.run(scenario())
|
||
|
||
|
||
def test_auto_refresh_scheduler_targets_only_linked_due_sql_source(tmp_path: Path) -> None:
|
||
from hockey_data.models import MappingSqlDataSource, VmixMappingField, VmixMappingProfile
|
||
|
||
database = LocalTestDatabase(tmp_path / "agent-auto-refresh.sqlite3")
|
||
database.create_all()
|
||
hub = VmixAgentHub(database) # type: ignore[arg-type]
|
||
ws = FakeWebSocket()
|
||
operator = HockeyUser(id="404", login="clock-op", display_name="Clock")
|
||
|
||
async def scenario() -> None:
|
||
await hub.register(
|
||
ws, # type: ignore[arg-type]
|
||
{
|
||
"device_id": "GFX-AUTO-CLOCK",
|
||
"device_secret": "c" * 40,
|
||
"device_name": "Clock GFX",
|
||
"agent_version": "1.3.0",
|
||
"vmix": {"connected": True, "url": "http://127.0.0.1:8088/api/"},
|
||
},
|
||
)
|
||
fingerprint = "d" * 64
|
||
await hub.receive_inventory(
|
||
"GFX-AUTO-CLOCK",
|
||
{
|
||
"type": "vmix.inventory",
|
||
"inventory": {
|
||
"fingerprint": fingerprint,
|
||
"inputs": [{
|
||
"key": "clock-key", "number": "8", "title": "Clock", "type": "GT",
|
||
"fields": [{"name": "Time.Text", "type": "text", "index": "0"}],
|
||
}],
|
||
},
|
||
},
|
||
)
|
||
await hub.pair_device("GFX-AUTO-CLOCK", operator)
|
||
assigned = await hub.assign_match(
|
||
wfl_user_id=operator.id,
|
||
tournament_external_id="1437",
|
||
game_external_id="902733",
|
||
)
|
||
assert assigned is not None
|
||
|
||
with database.session() as session:
|
||
profile = VmixMappingProfile(
|
||
name="CLOCK", project_fingerprint=fingerprint, active=True, version=1,
|
||
created_by="admin", updated_by="admin",
|
||
)
|
||
session.add(profile)
|
||
session.flush()
|
||
session.add(VmixMappingField(
|
||
profile_id=profile.id, graphic="clock", data_key="clock.time",
|
||
vmix_input_key="clock-key", vmix_input_number="8", vmix_input_title="Clock",
|
||
vmix_field="Time.Text", field_type="text", enabled=True, sort_order=0,
|
||
))
|
||
session.add(MappingSqlDataSource(
|
||
code="clock", name="Часы", category="Система", description="",
|
||
sql_text="SELECT 1 AS time", field_metadata_json="{}", enabled=True,
|
||
auto_refresh_enabled=True, refresh_interval_ms=1000, sort_order=1,
|
||
created_by="admin", updated_by="admin",
|
||
))
|
||
# Another auto source exists but is not linked to this Mapping.
|
||
session.add(MappingSqlDataSource(
|
||
code="unused_auto", name="Unused", category="Система", description="",
|
||
sql_text="SELECT 1 AS value", field_metadata_json="{}", enabled=True,
|
||
auto_refresh_enabled=True, refresh_interval_ms=1000, sort_order=2,
|
||
created_by="admin", updated_by="admin",
|
||
))
|
||
|
||
calls: list[tuple[str, set[str] | None, bool]] = []
|
||
|
||
async def fake_apply(device_id: str, *, reason: str = "manual", source_codes=None, only_changed=False) -> dict:
|
||
calls.append((device_id, set(source_codes or []), bool(only_changed)))
|
||
return {"ok": True, "device_id": device_id, "applied": 1, "total": 1, "errors": []}
|
||
|
||
hub.apply_mapping_to_device = fake_apply # type: ignore[method-assign]
|
||
first = await hub.auto_refresh_once()
|
||
assert first["applied"] == 1
|
||
assert calls == [("GFX-AUTO-CLOCK", {"clock"}, True)]
|
||
|
||
# The 1 second source is not due again immediately.
|
||
second = await hub.auto_refresh_once()
|
||
assert second["applied"] == 0
|
||
assert len(calls) == 1
|
||
|
||
asyncio.run(scenario())
|
||
|
||
|
||
def test_agent_batch_transport_frame_and_ack(tmp_path: Path) -> None:
|
||
database = LocalTestDatabase(tmp_path / "agent-batch.sqlite3")
|
||
database.create_all()
|
||
hub = VmixAgentHub(database) # type: ignore[arg-type]
|
||
ws = FakeWebSocket()
|
||
|
||
async def scenario() -> None:
|
||
await hub.register(
|
||
ws, # type: ignore[arg-type]
|
||
{
|
||
"device_id": "GFX-BATCH-TEST",
|
||
"device_secret": "b" * 40,
|
||
"device_name": "Batch GFX",
|
||
"hostname": "BATCH-PC",
|
||
"agent_version": "1.4.0",
|
||
"vmix": {"connected": True, "url": "http://127.0.0.1:8088/api/"},
|
||
},
|
||
)
|
||
assert hub._agent_supports_batch("1.4.0") is True
|
||
assert hub._agent_supports_batch("1.3.0") is False
|
||
task = asyncio.create_task(hub.send_vmix_batch(
|
||
"GFX-BATCH-TEST",
|
||
assignment_id="assignment-1",
|
||
match_id="902733",
|
||
commands=[
|
||
{"Function": "SetText", "Input": "12", "SelectedName": "Name1.Text", "Value": "СКА"},
|
||
{"Function": "SetText", "Input": "12", "SelectedName": "Name2.Text", "Value": ""},
|
||
],
|
||
))
|
||
batch = None
|
||
for _ in range(50):
|
||
await asyncio.sleep(0)
|
||
batch = next((item for item in reversed(ws.sent) if item.get("type") == "vmix.batch"), None)
|
||
if batch is not None:
|
||
break
|
||
assert batch is not None
|
||
assert len(batch["commands"]) == 2
|
||
assert batch["commands"][1]["Value"] == ""
|
||
await hub.receive_command_ack(
|
||
"GFX-BATCH-TEST",
|
||
{
|
||
"type": "command.batch.ack",
|
||
"request_id": batch["request_id"],
|
||
"ok": True,
|
||
"results": [{"ok": True}, {"ok": True}],
|
||
},
|
||
)
|
||
result = await task
|
||
assert result["ok"] is True
|
||
assert len(result["results"]) == 2
|
||
|
||
asyncio.run(scenario())
|
||
|
||
|
||
def test_runtime_vmix_sequence_preserves_command_order(tmp_path: Path) -> None:
|
||
database = LocalTestDatabase(tmp_path / "agent-runtime-sequence.sqlite3")
|
||
database.create_all()
|
||
hub = VmixAgentHub(database) # type: ignore[arg-type]
|
||
ws = FakeWebSocket()
|
||
user = HockeyUser(id="91", login="operator91", display_name="operator91")
|
||
|
||
async def scenario() -> None:
|
||
await hub.register(
|
||
ws, # type: ignore[arg-type]
|
||
{
|
||
"device_id": "GFX-PC-SEQUENCE",
|
||
"device_secret": "s" * 40,
|
||
"device_name": "Sequence GFX",
|
||
"hostname": "SEQUENCE-PC",
|
||
"agent_version": "1.4.0",
|
||
"vmix": {"connected": True, "url": "http://127.0.0.1:8088/api/"},
|
||
},
|
||
)
|
||
await hub.pair_device("GFX-PC-SEQUENCE", user)
|
||
assigned = await hub.assign_match(
|
||
wfl_user_id=user.id,
|
||
tournament_external_id="1437",
|
||
game_external_id="902918",
|
||
)
|
||
assert assigned is not None
|
||
|
||
task = asyncio.create_task(
|
||
hub.run_vmix_sequence_for_user(
|
||
user,
|
||
[
|
||
{"Function": "SetCountdown", "Input": "53", "Value": "00:18:42"},
|
||
{"Function": "StartCountdown", "Input": "53"},
|
||
],
|
||
)
|
||
)
|
||
|
||
first = None
|
||
for _ in range(50):
|
||
await asyncio.sleep(0)
|
||
commands = [item for item in ws.sent if item.get("type") == "vmix.command"]
|
||
if commands:
|
||
first = commands[-1]
|
||
break
|
||
assert first is not None
|
||
assert first["command"] == {"Function": "SetCountdown", "Input": "53", "Value": "00:18:42"}
|
||
# The second command must not be emitted before the first ACK.
|
||
assert len([item for item in ws.sent if item.get("type") == "vmix.command"]) == 1
|
||
|
||
await hub.receive_command_ack(
|
||
"GFX-PC-SEQUENCE",
|
||
{"type": "command.ack", "request_id": first["request_id"], "ok": True},
|
||
)
|
||
|
||
second = None
|
||
for _ in range(50):
|
||
await asyncio.sleep(0)
|
||
commands = [item for item in ws.sent if item.get("type") == "vmix.command"]
|
||
if len(commands) >= 2:
|
||
second = commands[-1]
|
||
break
|
||
assert second is not None
|
||
assert second["command"] == {"Function": "StartCountdown", "Input": "53"}
|
||
|
||
await hub.receive_command_ack(
|
||
"GFX-PC-SEQUENCE",
|
||
{"type": "command.ack", "request_id": second["request_id"], "ok": True},
|
||
)
|
||
result = await task
|
||
assert result["ok"] is True
|
||
assert result["applied"] == 2
|
||
assert [item["function"] for item in result["results"]] == ["SetCountdown", "StartCountdown"]
|
||
|
||
asyncio.run(scenario())
|