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