Files
hockey_new/tests/test_build197_roster_mapping_backend.py

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())