bulid 63
This commit is contained in:
749
tests/test_agent_bridge.py
Normal file
749
tests/test_agent_bridge.py
Normal file
@@ -0,0 +1,749 @@
|
||||
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())
|
||||
Reference in New Issue
Block a user