155 lines
6.5 KiB
Python
155 lines
6.5 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from pathlib import Path
|
|
|
|
from fastapi import HTTPException
|
|
|
|
from hockey_data.agent_bridge import MAPPING_BATCH_MAX_COMMANDS, VmixAgentHub
|
|
from tests.support import LocalTestDatabase
|
|
|
|
|
|
def _entry(index: int, input_ref: str, *, value_size: int = 4) -> dict:
|
|
return {
|
|
"data_key": f"source.row.{index}",
|
|
"command": {
|
|
"Function": "SetText",
|
|
"Input": input_ref,
|
|
"SelectedName": f"Field{index}.Text",
|
|
"Value": "X" * value_size,
|
|
},
|
|
}
|
|
|
|
|
|
def test_mapping_chunk_planner_groups_by_input_and_caps_packets(tmp_path: Path) -> None:
|
|
database = LocalTestDatabase(tmp_path / "mapping-chunks.sqlite3")
|
|
database.create_all()
|
|
hub = VmixAgentHub(database) # type: ignore[arg-type]
|
|
|
|
entries = [*[_entry(i, "INPUT-A") for i in range(95)], *[_entry(100 + i, "INPUT-B") for i in range(17)]]
|
|
chunks = hub._mapping_batch_chunks(entries)
|
|
|
|
assert len(chunks) == 4 # A: 40 + 40 + 15, B: 17
|
|
assert all(len(chunk) <= MAPPING_BATCH_MAX_COMMANDS for chunk in chunks)
|
|
assert all(len({item[1]["command"]["Input"] for item in chunk}) == 1 for chunk in chunks)
|
|
# ACK positions can still be restored to original Mapping order.
|
|
assert [index for chunk in chunks for index, _entry_item in chunk] == list(range(len(entries)))
|
|
|
|
|
|
def test_mapping_chunk_transport_sends_300_plus_links_in_small_batches(tmp_path: Path) -> None:
|
|
database = LocalTestDatabase(tmp_path / "mapping-300.sqlite3")
|
|
database.create_all()
|
|
hub = VmixAgentHub(database) # type: ignore[arg-type]
|
|
packets: list[list[dict]] = []
|
|
|
|
async def fake_batch(device_id: str, *, assignment_id: str, match_id: str, commands: list[dict], timeout: float = 8.0) -> dict:
|
|
assert device_id == "GFX-MAP-300"
|
|
packets.append(commands)
|
|
return {"ok": True, "results": [{"ok": True} for _ in commands]}
|
|
|
|
async def scenario() -> None:
|
|
hub.send_vmix_batch = fake_batch # type: ignore[method-assign]
|
|
entries: list[dict] = []
|
|
# 12 Inputs x 31 commands = 372 Mapping commands.
|
|
for input_index in range(12):
|
|
entries.extend(_entry(input_index * 1000 + row, f"INPUT-{input_index:02d}") for row in range(31))
|
|
result = await hub._send_mapping_entries_resilient(
|
|
"GFX-MAP-300",
|
|
assignment_id="assignment-300",
|
|
match_id="game-300",
|
|
entries=entries,
|
|
use_batch=True,
|
|
)
|
|
assert all(item["ok"] for item in result["results"])
|
|
assert result["input_groups"] == 12
|
|
assert result["chunks_total"] == 12
|
|
assert result["chunks_ok"] == 12
|
|
assert result["chunks_failed"] == 0
|
|
assert result["fallback_commands"] == 0
|
|
assert sum(len(packet) for packet in packets) == 372
|
|
assert max(len(packet) for packet in packets) <= MAPPING_BATCH_MAX_COMMANDS
|
|
assert all(len({command["Input"] for command in packet}) == 1 for packet in packets)
|
|
|
|
asyncio.run(scenario())
|
|
|
|
|
|
def test_mapping_transport_splits_timeout_batch_and_keeps_going(tmp_path: Path) -> None:
|
|
database = LocalTestDatabase(tmp_path / "mapping-retry.sqlite3")
|
|
database.create_all()
|
|
hub = VmixAgentHub(database) # type: ignore[arg-type]
|
|
packet_sizes: list[int] = []
|
|
|
|
async def flaky_batch(device_id: str, *, assignment_id: str, match_id: str, commands: list[dict], timeout: float = 8.0) -> dict:
|
|
packet_sizes.append(len(commands))
|
|
if len(commands) > 10:
|
|
raise HTTPException(status_code=504, detail="simulated batch timeout")
|
|
return {"ok": True, "results": [{"ok": True} for _ in commands]}
|
|
|
|
async def scenario() -> None:
|
|
hub.send_vmix_batch = flaky_batch # type: ignore[method-assign]
|
|
entries = [_entry(i, "ONE-BIG-INPUT") for i in range(35)]
|
|
result = await hub._send_mapping_entries_resilient(
|
|
"GFX-MAP-RETRY",
|
|
assignment_id="assignment-retry",
|
|
match_id="game-retry",
|
|
entries=entries,
|
|
use_batch=True,
|
|
)
|
|
assert all(item["ok"] for item in result["results"])
|
|
assert result["chunks_total"] == 1
|
|
assert result["chunks_ok"] == 1
|
|
assert result["chunks_failed"] == 0
|
|
assert result["retries"] > 0
|
|
assert packet_sizes[0] == 35
|
|
assert any(size <= 10 for size in packet_sizes[1:])
|
|
|
|
asyncio.run(scenario())
|
|
|
|
|
|
def test_mapping_transport_retries_isolated_failed_command(tmp_path: Path) -> None:
|
|
database = LocalTestDatabase(tmp_path / "mapping-single-fallback.sqlite3")
|
|
database.create_all()
|
|
hub = VmixAgentHub(database) # type: ignore[arg-type]
|
|
singles: list[dict] = []
|
|
|
|
async def partial_batch(device_id: str, *, assignment_id: str, match_id: str, commands: list[dict], timeout: float = 8.0) -> dict:
|
|
results = [{"ok": True} for _ in commands]
|
|
results[3] = {"ok": False, "reason": "temporary failure"}
|
|
return {"ok": False, "results": results}
|
|
|
|
async def single_ok(device_id: str, *, assignment_id: str, match_id: str, command: dict, timeout: float = 5.0) -> dict:
|
|
singles.append(command)
|
|
return {"ok": True}
|
|
|
|
async def scenario() -> None:
|
|
hub.send_vmix_batch = partial_batch # type: ignore[method-assign]
|
|
hub.send_vmix_command = single_ok # type: ignore[method-assign]
|
|
entries = [_entry(i, "INPUT-FALLBACK") for i in range(8)]
|
|
result = await hub._send_mapping_entries_resilient(
|
|
"GFX-MAP-FALLBACK",
|
|
assignment_id="assignment-fallback",
|
|
match_id="game-fallback",
|
|
entries=entries,
|
|
use_batch=True,
|
|
)
|
|
assert all(item["ok"] for item in result["results"])
|
|
assert result["fallback_commands"] == 1
|
|
assert result["retries"] == 1
|
|
assert len(singles) == 1
|
|
assert singles[0]["SelectedName"] == "Field3.Text"
|
|
|
|
asyncio.run(scenario())
|
|
|
|
|
|
def test_build97_runtime_version_and_mapping_diagnostics() -> None:
|
|
root = Path(__file__).resolve().parents[1]
|
|
app = (root / "app.py").read_text(encoding="utf-8")
|
|
bridge = (root / "hockey_data" / "agent_bridge.py").read_text(encoding="utf-8")
|
|
admin = (root / "hockey_data" / "static" / "admin-directories.js").read_text(encoding="utf-8")
|
|
|
|
assert 'BUILD_VERSION = "2026.08.21.1"' in app
|
|
assert 'result["transport"] = "chunked_batch" if use_batch else "legacy"' in bridge
|
|
assert 'MAPPING_BATCH_MAX_COMMANDS = 40' in bridge
|
|
assert 'mappingApplyTransportSummary' in admin
|
|
assert 'пакетов ${chunksApplied}/${chunksTotal}' in admin
|