Files
hockey_new/tests/test_build195_mapping_config_apply.py

301 lines
16 KiB
Python

"""The browser's config Use endpoint must execute the complete Mapping path.
Only the physical Agent socket is replaced. Authentication, MatchAccess,
registration, publication, builtin DB score resolution and positional ACK/cache
handling are production code on a temporary SQLite database.
"""
from __future__ import annotations
import asyncio
import json
import logging
from datetime import datetime
from types import SimpleNamespace
import httpx
import pytest
from fastapi import HTTPException
from sqlalchemy import select
from hockey_data import program_log
from hockey_data.agent_bridge import LiveAgent
from hockey_data.mapping_context import MappingDataService
from hockey_data.models import Game, OperatorSession, VmixAssignment, VmixDevice, VmixMappingField, VmixMappingProfile
from hockey_data.program_log import ProgramLogHandler, ProgramLogStore, configure_program_logging
from hockey_data.web_observability import WebAuditMiddleware, install_runtime_audit
from tests.test_stage1_match_access import env
DEVICE = "GFX-CONFIG-195"
TOKEN = "build195-local-admin-session"
@pytest.fixture
def audited_env(env, tmp_path):
"""Install the production app's real logging hooks with a local journal."""
previous_store = program_log._DEFAULT_STORE
loggers = [logging.getLogger(name) for name in ("", "uvicorn", "uvicorn.error", "uvicorn.access")]
previous = [(logger, logger.level, [h for h in logger.handlers if isinstance(h, ProgramLogHandler)])
for logger in loggers]
store = ProgramLogStore(tmp_path / "runtime-journal")
store.start()
assert store._ready.wait(3)
handler = configure_program_logging(store)
previous_app = env.app
try:
# The shared fixture has already served participant joins. Wrapping its
# ASGI application installs the exact middleware without mutating an
# already built Starlette middleware stack.
env.app = WebAuditMiddleware(previous_app)
install_runtime_audit(env.service, env.hub)
yield env, store
finally:
env.app = previous_app
for logger, level, old_handlers in previous:
logger.removeHandler(handler)
for old in old_handlers:
if old not in logger.handlers:
logger.addHandler(old)
logger.setLevel(level)
program_log._DEFAULT_STORE = previous_store
assert store.close()
class ImmediateAckSocket:
"""A physical-socket double; it does not replace a hub transport method."""
def __init__(self, hub):
self.hub = hub
self.client = SimpleNamespace(host="127.0.0.1")
self.sent = []
self.acknowledged = []
async def send_json(self, packet):
self.sent.append(packet)
if packet.get("type") not in {"vmix.command", "vmix.batch"}:
return
rows = packet["commands"] if packet["type"] == "vmix.batch" else [packet["command"]]
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 rows]
await self.hub.receive_command_ack(DEVICE, ack)
self.acknowledged.append(ack)
async def close(self, **_kwargs):
pass
def _exercise_config_use(env, *, capable=True, include_apply=False, field_count=2):
async def scenario():
hub = env.hub
socket = ImmediateAckSocket(hub)
definitions = [("score.home" if index % 2 == 0 else "score.away",
("Home.Text", "Away.Text")[index] if field_count == 2 else f"Score{index}.Text")
for index in range(field_count)]
expected_values = {field: "5" if key == "score.home" else "4" for key, field in definitions}
inventory = {"fingerprint": "a" * 64, "inputs": [{
"key": "score-guid", "number": "1", "title": "Score",
"fields": [{"name": field, "type": "text"} for _, field in definitions],
}]}
try:
await hub.register(socket, {"device_id": DEVICE, "device_secret": "x" * 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, env.users["admin"])
await hub.receive_inventory(DEVICE, {"inventory": inventory}, websocket=socket)
assert hub._live[DEVICE].mapping_title_batch_v1 is capable
assert DEVICE in hub._mapping_inventory_ready
# The real catalog must remain installed, including builtin score
# context. Neither SQL selection nor score persistence is stubbed.
assert hub.mapping_data.data_catalog.__func__ is MappingDataService.data_catalog
with env.db.session() as session:
game = session.scalar(select(Game).where(Game.external_id == "g"))
game.home_score, game.away_score, game.score_raw = 5, 4, "5:4"
device = session.scalar(select(VmixDevice).where(VmixDevice.device_uuid == DEVICE))
# An explicit Use must publish this template; it is not picked
# implicitly as a legacy active template during assignment.
source = VmixMappingProfile(name="HTTP config baseline", active=False, version=1,
project_fingerprint=device.project_fingerprint,
inventory_json=device.project_inventory_json)
session.add(source)
session.flush()
profile_id = source.id
for index, (key, field) in enumerate(definitions):
session.add(VmixMappingField(profile_id=profile_id, graphic="score", data_key=key,
vmix_input_title="Score", vmix_field=field, field_type="text", enabled=True,
sort_order=index))
session.add(OperatorSession(session_token=TOKEN, wfl_user_id="admin", login_snapshot="admin",
tournament_external_id="t", game_external_id="g", status="active",
browser_participant_id=env.participants["admin"], vmix_device_uuid=DEVICE,
last_activity_at=datetime.utcnow()))
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=env.app), base_url="http://test") as client:
joined = await client.post("/api/hockey/games/g/access/join", headers=env.headers("admin"),
json={"device_id": DEVICE})
assert joined.status_code == 200, joined.text
claimed = await client.post("/api/hockey/games/g/access/claim", headers=env.headers("admin"), json={})
assert claimed.status_code == 200, claimed.text
assigned = await hub.assign_match(wfl_user_id="admin", game_external_id="g",
tournament_external_id="t", device_id=DEVICE, operator_session_token=TOKEN,
wait_for_mapping=False)
assert assigned and assigned["delivered"] is True
before = len(socket.sent)
response = await client.post(f"/api/hockey/admin/vmix-mapping/profiles/{profile_id}/use",
headers={**env.headers("admin"), "X-Hockey-Session": TOKEN},
json={"device_id": DEVICE, "game_id": "g", "session_token": TOKEN})
assert response.status_code == 200, response.text
result = response.json()
assert result["ok"] is True and result["device_id"] == DEVICE
applied = result["applied"]
assert applied["ok"] is True and applied["complete"] is True, applied
assert applied["applied"] == applied["mapping_commands_confirmed"] == field_count
assert applied["transport"] == ("title_batch_ack" if capable else "bounded_batch_ack")
packets = socket.sent[before:]
publication = next(i for i, packet in enumerate(packets) if packet.get("type") == "mapping.assigned")
batches = [(i, packet) for i, packet in enumerate(packets) if packet.get("type") == "vmix.batch"]
assert len(batches) == 1 and publication < batches[0][0]
packet = batches[0][1]
assert (packet.get("mapping_bulk") is True) is capable and packet["match_id"] == "g"
assert {command["SelectedName"]: command["Value"] for command in packet["commands"]} == expected_values
if field_count == 372:
assert packet["mapping_deadline_ms"] == 59000
assert len(json.dumps(packet, ensure_ascii=False, separators=(",", ":")).encode()) <= 48 * 1024
assert socket.acknowledged[-1]["request_id"] == packet["request_id"]
assert len(hub._mapping_value_cache) == field_count and not hub._pending_commands
with env.db.session() as session:
device = session.scalar(select(VmixDevice).where(VmixDevice.device_uuid == DEVICE))
assignment = session.scalar(select(VmixAssignment).where(
VmixAssignment.assignment_key == packet["assignment_id"]))
assert device.mapping_profile_id == result["runtime_profile_id"] != profile_id
assert device.mapping_source_profile_id == profile_id and device.mapping_profile_version == 1
assert assignment.active is True and assignment.wfl_user_id == "admin"
assert assignment.game_external_id == "g" and assignment.operator_session_token == TOKEN
control = await client.get("/api/hockey/games/g/control", headers=env.headers("admin"))
assert control.status_code == 200, control.text
assert (control.json()["score"]["home"], control.json()["score"]["away"]) == (5, 4)
if include_apply:
before_apply = len(socket.sent)
response = await client.post(f"/api/hockey/agents/devices/{DEVICE}/apply-mapping",
headers={**env.headers("admin"), "X-Hockey-Session": TOKEN})
assert response.status_code == 200, response.text
reapplied = response.json()
assert reapplied["ok"] is True and reapplied["complete"] is True, reapplied
assert reapplied["applied"] == reapplied["mapping_commands_confirmed"] == field_count
assert reapplied["transport"] == ("title_batch_ack" if capable else "bounded_batch_ack")
fresh_batches = [row for row in socket.sent[before_apply:] if row.get("type") == "vmix.batch"]
assert len(fresh_batches) == 1
assert {row["SelectedName"]: row["Value"] for row in fresh_batches[0]["commands"]} == expected_values
assert (fresh_batches[0].get("mapping_bulk") is True) is capable
assert not hub._pending_commands
finally:
await hub.stop_auto_refresh()
await env.service.stop_live_refreshes()
await env.service.client.aclose()
asyncio.run(scenario())
def test_http_config_use_with_real_controller_and_capable_agent_delivers_builtin_db_score(env):
_exercise_config_use(env)
def test_http_config_use_with_real_installed_runtime_audit_delivers_score(audited_env):
env, store = audited_env
# Execute the identical HTTP/registration/ACK assertions through the hooks
# installed by app.py, rather than replacing any hub boundary with a fake.
test_http_config_use_with_real_controller_and_capable_agent_delivers_builtin_db_score(env)
assert store.flush()
records = store.query(preset="all", device_id=DEVICE)["items"]
assert any(row["event"] == "vmix.batch.sent" for row in records)
assert any(row["event"] == "command.ack" and row["level"] == "INFO" for row in records)
assert TOKEN not in json.dumps(records)
def test_http_apply_mapping_with_real_installed_runtime_audit_delivers_score(audited_env):
env, store = audited_env
_exercise_config_use(env, include_apply=True)
assert store.flush()
assert sum(row["event"] == "vmix.batch.sent" for row in store.query(preset="all", device_id=DEVICE)["items"]) == 2
def test_instrumented_legacy_agent_keeps_acknowledged_nonbulk_fallback(audited_env):
env, store = audited_env
_exercise_config_use(env, capable=False, include_apply=True)
assert store.flush()
batches = [row for row in store.query(preset="all", device_id=DEVICE)["items"] if row["event"] == "vmix.batch.sent"]
assert len(batches) == 2 and all(row["details"].get("mapping_bulk") is not True for row in batches)
def test_instrumented_http_config_use_logs_and_confirms_a_complete_372_field_title(audited_env):
env, store = audited_env
_exercise_config_use(env, field_count=372)
assert store.flush()
rows = store.query(preset="all", device_id=DEVICE)["items"]
assert sum(row["event"] == "vmix.batch.sent" for row in rows) == 1
assert TOKEN not in json.dumps(rows)
@pytest.mark.parametrize("change", ["current", "connection", "generation", "capability_string"])
def test_instrumented_bulk_rechecks_exact_connection_generation_and_capability_after_wire_wait(audited_env, change):
env, store = audited_env
async def scenario():
hub = env.hub
first, replacement = ImmediateAckSocket(hub), ImmediateAckSocket(hub)
task = None
try:
await hub.register(first, {"device_id": DEVICE, "device_secret": "x" * 40,
"agent_version": "1.5.5", "capabilities": {"mapping_title_batch_v1": True},
"vmix": {"connected": True}})
await hub.pair_device(DEVICE, env.users["admin"])
assignment = await hub.assign_match(wfl_user_id="admin", game_external_id="g",
tournament_external_id="t", device_id=DEVICE, wait_for_mapping=False)
assert assignment and assignment["delivered"]
live, generation = hub._live[DEVICE], hub._mapping_generations[DEVICE]
wire = hub._wire_send_locks[DEVICE]
before = len(first.sent)
async with wire:
task = asyncio.create_task(hub.send_vmix_batch(DEVICE,
assignment_id=assignment["assignment_id"], match_id="g",
commands=[{"Function": "SetText", "Input": "Score", "SelectedName": "Home.Text", "Value": "5"}],
mapping_bulk=True, mapping_deadline_ms=4000, delivery_generation=generation, timeout=2))
await asyncio.sleep(0)
assert not task.done() and len(hub._pending_commands) == 1
assert len(first.sent) == before and not replacement.sent
if change == "connection":
# Replace only LiveAgent identity, keeping generation equal:
# dropping expected_connection must be independently caught.
hub._live[DEVICE] = LiveAgent(replacement, datetime.utcnow(), True)
assert hub._mapping_generations[DEVICE] == generation
elif change == "generation":
hub._invalidate_mapping_delivery(DEVICE)
assert hub._live[DEVICE] is live
elif change == "capability_string":
# A truthy string must not pass the final strict bool check.
live.mapping_title_batch_v1 = "true"
if change == "current":
answer = await task
assert answer["ok"] is True
assert len(first.sent) == before + 1 and not replacement.sent
else:
with pytest.raises(HTTPException) as rejected:
await task
assert rejected.value.status_code == 409
assert len(first.sent) == before and not replacement.sent
assert not hub._pending_commands and not hub._mapping_value_cache
finally:
if task is not None and not task.done():
task.cancel()
await asyncio.gather(task, return_exceptions=True)
await hub.stop_auto_refresh()
await env.service.stop_live_refreshes()
await env.service.client.aclose()
asyncio.run(scenario())
assert store.flush()
serialized = json.dumps(store.query(preset="all", device_id=DEVICE)["items"])
assert "expected_connection" not in serialized and "expected_generation" not in serialized
assert "LiveAgent(" not in serialized