236 lines
13 KiB
Python
236 lines
13 KiB
Python
"""Late roster Mapping reaches a physical-socket double through real HTTP/SQL.
|
|
|
|
Only the Agent WebSocket is replaced. The imported fixture installs the real
|
|
audit wrappers; controller guards, profile publication, source catalog, SQL,
|
|
mapping resolver and positional ACK/cache handling remain production code.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from datetime import datetime
|
|
import json
|
|
from types import SimpleNamespace
|
|
|
|
import httpx
|
|
import pytest
|
|
from sqlalchemy import select
|
|
|
|
from hockey_data.mapping_context import MappingDataService
|
|
from hockey_data.models import (
|
|
Game, GameRoster, MappingSqlDataSource, OperatorSession, Player,
|
|
VmixDevice, VmixMappingField, VmixMappingProfile,
|
|
)
|
|
from tests.test_build195_mapping_config_apply import audited_env, env
|
|
|
|
|
|
ROSTER_SQL = """SELECT
|
|
COALESCE((SELECT p.full_name_ru FROM hockey_game_rosters r
|
|
JOIN hockey_players p ON p.external_id = r.player_external_id
|
|
WHERE r.game_external_id = :game_id AND r.side = 'home'
|
|
ORDER BY r.number LIMIT 1), '') AS home,
|
|
COALESCE((SELECT p.full_name_ru FROM hockey_game_rosters r
|
|
JOIN hockey_players p ON p.external_id = r.player_external_id
|
|
WHERE r.game_external_id = :game_id AND r.side = 'away'
|
|
ORDER BY r.number LIMIT 1), '') AS away,
|
|
'20:00' AS clock"""
|
|
|
|
|
|
class RosterAckSocket:
|
|
def __init__(self, hub, device_id):
|
|
self.hub, self.device_id = hub, device_id
|
|
self.client = SimpleNamespace(host="127.0.0.1")
|
|
self.sent, self.executed, self.acknowledged = [], [], []
|
|
self.values, self.running = {}, set()
|
|
|
|
async def send_json(self, packet):
|
|
self.sent.append(packet)
|
|
if packet.get("type") not in {"vmix.command", "vmix.batch"}:
|
|
return
|
|
commands = packet["commands"] if packet["type"] == "vmix.batch" else [packet["command"]]
|
|
for command in commands:
|
|
self.executed.append(dict(command))
|
|
target = (command.get("Input"), command.get("SelectedName"))
|
|
if command.get("Function") in {"SetText", "SetCountdown"}:
|
|
self.values[target] = command.get("Value", "")
|
|
if command.get("Function") == "StartCountdown":
|
|
self.running.add(target)
|
|
if not packet.get("request_id"):
|
|
return
|
|
ack = {
|
|
"type": "command.batch.ack" if packet["type"] == "vmix.batch" else "command.ack",
|
|
"request_id": packet["request_id"], "assignment_id": packet["assignment_id"],
|
|
"match_id": packet["match_id"], "ok": True,
|
|
}
|
|
if packet["type"] == "vmix.batch":
|
|
ack["results"] = [{"ok": True, "reason": ""} for _ in commands]
|
|
await self.hub.receive_command_ack(self.device_id, ack)
|
|
self.acknowledged.append(ack)
|
|
|
|
async def close(self, **_kwargs):
|
|
pass
|
|
|
|
|
|
async def seed_agent(env, client, *, device_id, game_id="g", capable=True, clock=False):
|
|
hub = env.hub
|
|
assert hub._program_audit_installed is True
|
|
assert hub.mapping_data.data_catalog.__func__ is MappingDataService.data_catalog
|
|
socket = RosterAckSocket(hub, device_id)
|
|
fields = [("roster197.home", "Home.Text"), ("roster197.away", "Away.Text")]
|
|
if clock:
|
|
fields.append(("roster197.clock", "Clock.Text"))
|
|
inventory = {"fingerprint": "b" * 64, "inputs": [{
|
|
"key": "roster-guid", "number": "1", "title": "Roster",
|
|
"fields": [{"name": name, "type": "text"} for _, name in fields],
|
|
}]}
|
|
await hub.register(socket, {"device_id": device_id, "device_secret": "r" * 40,
|
|
"agent_version": "1.5.5" if capable else "1.5.3",
|
|
"capabilities": {"mapping_title_batch_v1": capable}, "vmix": {"connected": True}})
|
|
await hub.pair_device(device_id, env.users["admin"])
|
|
await hub.receive_inventory(device_id, {"inventory": inventory}, websocket=socket)
|
|
token = f"local-roster-197-{device_id}"
|
|
with env.db.session() as session:
|
|
if session.scalar(select(MappingSqlDataSource).where(MappingSqlDataSource.code == "roster197")) is None:
|
|
session.add(MappingSqlDataSource(code="roster197", name="Local roster source",
|
|
sql_text=ROSTER_SQL, enabled=True, auto_refresh_enabled=False))
|
|
device = session.scalar(select(VmixDevice).where(VmixDevice.device_uuid == device_id))
|
|
profile = VmixMappingProfile(name=f"Roster197-{device_id}", active=False, version=1,
|
|
project_fingerprint=device.project_fingerprint, inventory_json=device.project_inventory_json)
|
|
session.add(profile)
|
|
session.flush()
|
|
profile_id = profile.id
|
|
for order, (key, field) in enumerate(fields):
|
|
session.add(VmixMappingField(profile_id=profile_id, graphic="roster", data_key=key,
|
|
vmix_input_title="Roster", vmix_field=field, field_type="text", enabled=True,
|
|
sort_order=order))
|
|
session.add(OperatorSession(session_token=token, wfl_user_id="admin", login_snapshot="admin",
|
|
tournament_external_id="t", game_external_id=game_id, status="active",
|
|
browser_participant_id=env.participants["admin"], vmix_device_uuid=device_id,
|
|
last_activity_at=datetime.utcnow()))
|
|
response = await client.post(f"/api/hockey/games/{game_id}/access/join",
|
|
headers=env.headers("admin", game=game_id), json={"device_id": device_id})
|
|
assert response.status_code == 200, response.text
|
|
response = await client.post(f"/api/hockey/games/{game_id}/access/claim",
|
|
headers=env.headers("admin", game=game_id), json={})
|
|
assert response.status_code == 200, response.text
|
|
assigned = await hub.assign_match(wfl_user_id="admin", game_external_id=game_id,
|
|
tournament_external_id="t", device_id=device_id, operator_session_token=token,
|
|
wait_for_mapping=False)
|
|
assert assigned and assigned["delivered"] is True
|
|
headers = lambda: {**env.headers("admin", game=game_id), "X-Hockey-Session": token,
|
|
"X-Hockey-Device": device_id}
|
|
response = await client.post(f"/api/hockey/admin/vmix-mapping/profiles/{profile_id}/use",
|
|
headers=headers(), json={"device_id": device_id, "game_id": game_id, "session_token": token})
|
|
assert response.status_code == 200, response.text
|
|
publication = response.json()
|
|
first = publication["applied"]
|
|
assert first["ok"] is True and first["complete"] is True, first
|
|
assert socket.values[("Roster", "Home.Text")] == ""
|
|
assert socket.values[("Roster", "Away.Text")] == ""
|
|
assert first["mapping_commands_confirmed"] == len(fields)
|
|
with env.db.session() as session:
|
|
device = session.scalar(select(VmixDevice).where(VmixDevice.device_uuid == device_id))
|
|
assert device.mapping_profile_id == publication["runtime_profile_id"] != profile_id
|
|
assert device.mapping_source_profile_id == profile_id
|
|
return SimpleNamespace(socket=socket, device=device_id, game=game_id, token=token,
|
|
headers=headers, first=first, publication=publication)
|
|
|
|
|
|
def insert_roster(env, game_id, prefix):
|
|
with env.db.session() as session:
|
|
for side, number in (("home", "17"), ("away", "30")):
|
|
player_id = f"{game_id}-{side}-197"
|
|
session.add(Player(external_id=player_id, full_name_ru=f"{prefix} {side}",
|
|
full_name_en=f"{prefix} {side}", first_name_ru=prefix, last_name_ru=side))
|
|
session.add(GameRoster(game_external_id=game_id, team_external_id=side,
|
|
player_external_id=player_id, side=side, number=number, role="forward"))
|
|
session.scalar(select(Game).where(Game.external_id == game_id)).details_synced_at = datetime.utcnow()
|
|
|
|
|
|
async def apply_full(env, client, rig):
|
|
before = len(rig.socket.executed)
|
|
response = await client.post(f"/api/hockey/agents/devices/{rig.device}/apply-mapping",
|
|
headers=rig.headers())
|
|
assert response.status_code == 200, response.text
|
|
result = response.json()
|
|
assert result["ok"] is True and result["complete"] is True, result
|
|
assert result["match_id"] == rig.game and result["device_id"] == rig.device
|
|
assert not env.hub._pending_commands
|
|
return result, rig.socket.executed[before:]
|
|
|
|
|
|
@pytest.mark.parametrize("capable", [True, False])
|
|
def test_second_full_http_apply_reads_late_roster_sql_after_initial_empty_publication(audited_env, capable):
|
|
env, store = audited_env
|
|
|
|
async def scenario():
|
|
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=env.app), base_url="http://test") as client:
|
|
rig = await seed_agent(env, client, device_id="GFX-ROSTER-197-A", capable=capable)
|
|
insert_roster(env, "g", "Late first roster")
|
|
web = await client.get("/api/hockey/games/g/details", headers=rig.headers())
|
|
assert web.status_code == 200, web.text
|
|
assert web.json()["game"]["home"]["players"][0]["name"] == "Late first roster home"
|
|
result, commands = await apply_full(env, client, rig)
|
|
assert result["mapping_commands_confirmed"] == result["applied"] == 2
|
|
assert result["transport"] == ("title_batch_ack" if capable else "bounded_batch_ack")
|
|
assert {row["SelectedName"]: row["Value"] for row in commands} == {
|
|
"Home.Text": "Late first roster home", "Away.Text": "Late first roster away"}
|
|
assert rig.socket.values[("Roster", "Home.Text")] == "Late first roster home"
|
|
assert all(packet["match_id"] == "g" for packet in rig.socket.sent if packet.get("type") in {"vmix.batch", "vmix.command"})
|
|
assert store.flush()
|
|
events = store.query(preset="all", device_id=rig.device)["items"]
|
|
assert sum(row["event"] == "vmix.batch.sent" for row in events) == 2
|
|
assert rig.token not in json.dumps(events)
|
|
asyncio.run(scenario())
|
|
|
|
|
|
def test_late_full_roster_apply_preserves_running_native_countdown_field(audited_env):
|
|
env, _store = audited_env
|
|
|
|
async def scenario():
|
|
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=env.app), base_url="http://test") as client:
|
|
rig = await seed_agent(env, client, device_id="GFX-ROSTER-197-CLOCK", clock=True)
|
|
response = await client.post("/api/hockey/vmix/sequence", headers=rig.headers(), json={
|
|
"device_id": rig.device, "session_token": rig.token, "commands": [
|
|
{"Function": "SetCountdown", "Input": "Roster", "SelectedName": "Clock.Text", "Value": "00:37"},
|
|
{"Function": "StartCountdown", "Input": "Roster", "SelectedName": "Clock.Text"},
|
|
]})
|
|
assert response.status_code == 200, response.text
|
|
assert response.json()["ok"] is True
|
|
assert ("Roster", "Clock.Text") in rig.socket.running
|
|
clock_cache = {key: value for key, value in env.hub._mapping_value_cache.items() if key[4] == "Clock.Text"}
|
|
insert_roster(env, "g", "Native safe roster")
|
|
result, commands = await apply_full(env, client, rig)
|
|
assert result["mapping_commands_confirmed"] == result["applied"] == 2
|
|
assert {row["SelectedName"] for row in commands} == {"Home.Text", "Away.Text"}
|
|
assert not any(row["Function"].endswith("Countdown") for row in commands)
|
|
assert rig.socket.values[("Roster", "Clock.Text")] == "00:37"
|
|
assert ("Roster", "Clock.Text") in rig.socket.running
|
|
assert {key: value for key, value in env.hub._mapping_value_cache.items() if key[4] == "Clock.Text"} == clock_cache
|
|
asyncio.run(scenario())
|
|
|
|
|
|
def test_late_roster_full_apply_isolated_by_explicit_agent_game_and_session(audited_env):
|
|
env, _store = audited_env
|
|
|
|
async def scenario():
|
|
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=env.app), base_url="http://test") as client:
|
|
first = await seed_agent(env, client, device_id="GFX-ROSTER-197-ONE", game_id="g")
|
|
second = await seed_agent(env, client, device_id="GFX-ROSTER-197-TWO", game_id="g2")
|
|
insert_roster(env, "g", "Game one")
|
|
insert_roster(env, "g2", "Game two")
|
|
second_before = len(second.socket.executed)
|
|
_, commands = await apply_full(env, client, first)
|
|
assert {row["Value"] for row in commands} == {"Game one home", "Game one away"}
|
|
assert len(second.socket.executed) == second_before
|
|
first_before = len(first.socket.executed)
|
|
_, commands = await apply_full(env, client, second)
|
|
assert {row["Value"] for row in commands} == {"Game two home", "Game two away"}
|
|
assert len(first.socket.executed) == first_before
|
|
denied = await client.post(f"/api/hockey/agents/devices/{first.device}/apply-mapping",
|
|
headers=second.headers())
|
|
assert denied.status_code in {403, 409}
|
|
assert len(first.socket.executed) == first_before
|
|
assert all(row["match_id"] == "g" for row in first.socket.sent if row.get("type") in {"vmix.batch", "vmix.command"})
|
|
assert all(row["match_id"] == "g2" for row in second.socket.sent if row.get("type") in {"vmix.batch", "vmix.command"})
|
|
asyncio.run(scenario())
|