отправка данных для mapping с агентом

This commit is contained in:
2026-08-24 10:49:00 +03:00
parent c4f71e122a
commit 17c3fcfbac
4 changed files with 412 additions and 90 deletions

View File

@@ -0,0 +1,154 @@
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