Files
hockey_new/tests/test_build97_mapping_chunked_transport.py

162 lines
7.8 KiB
Python

"""Retain BUILD97's delivery guarantees using the acknowledged BUILD190 protocol.
The old giant-batch/repartition implementation is obsolete. The requirements
remain: ordered large mappings, bounded packets and retries, isolated failures,
independent devices, and truthful operator diagnostics.
"""
from __future__ import annotations
import asyncio
from pathlib import Path
from hockey_data.agent_bridge import MAPPING_BATCH_MAX_COMMANDS, VmixAgentHub
from tests.support import LocalTestDatabase
from tests.test_hotfix_mapping_transport import transport
from tests.test_stable_vmix_input_identity import _run_functions
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_372_mapping_fields_are_ordered_acknowledged_and_bounded(tmp_path: Path):
hub, own, other = transport(tmp_path)
rows = [_entry(input_index * 1000 + row, f"INPUT-{input_index:02d}")
for input_index in range(12) for row in range(31)]
result = asyncio.run(hub._send_mapping_entries_resilient("GFX-OWNER",
assignment_id="assignment", match_id="g", entries=rows, use_batch=True))
assert len(result["results"]) == 372 and all(item["ok"] for item in result["results"])
assert result["transport"] == "sequential_ack" and result["ack_waited"] is True
assert result["commands_sent"] == 372 and result["input_groups"] == 12
assert own.max_pending == 1 and other.frames == []
commands = own.frames[1:]
assert own.frames[0]["type"] == "match.assign"
assert all(frame["type"] == "vmix.command" for frame in commands)
assert [frame["command"] for frame in commands] == [row["command"] for row in rows]
assert len({frame["request_id"] for frame in commands}) == 372
assert all(frame["assignment_id"] == "assignment" and frame["match_id"] == "g" for frame in commands)
assert not hub._pending_commands
def test_mapping_transport_retries_one_timeout_then_keeps_order(tmp_path: Path):
hub, own, other = transport(tmp_path)
original_socket_send = own.send_json
original_command_send = hub.send_vmix_command
lost = []
async def lose_one_ack(message):
if message.get("command", {}).get("SelectedName") == "Field10.Text" and not lost:
own.frames.append(message)
lost.append(message)
return # Agent read the frame; its ACK was lost.
await original_socket_send(message)
async def short_timeout(device_id, *, assignment_id, match_id, command, timeout=4.0):
return await original_command_send(device_id, assignment_id=assignment_id,
match_id=match_id, command=command, timeout=0.01)
own.send_json = lose_one_ack
hub.send_vmix_command = short_timeout
rows = [_entry(i, "One Input") for i in range(35)]
async def scenario():
result = await hub._send_mapping_entries_resilient("GFX-OWNER", assignment_id="assignment",
match_id="g", entries=rows, use_batch=True)
assert all(item["ok"] for item in result["results"])
assert result["retries"] == 1 and len(lost) == 1
names = [frame["command"]["SelectedName"] for frame in own.frames[1:]]
assert names == [f"Field{i}.Text" for i in range(11)] + [f"Field{i}.Text" for i in range(10, 35)]
assert not hub._pending_commands and other.frames == []
before = list(result["results"])
await own.acknowledge(lost[0])
assert result["results"] == before and not hub._pending_commands
asyncio.run(scenario())
def test_mapping_transport_retries_only_the_isolated_failed_command(tmp_path: Path):
hub, own, other = transport(tmp_path)
original = own.send_json
attempts = {}
async def fail_one_once(message):
if message.get("type") == "vmix.command":
name = message["command"]["SelectedName"]
attempts[name] = attempts.get(name, 0) + 1
if name == "Field3.Text" and attempts[name] == 1:
own.frames.append(message)
await hub.receive_command_ack("GFX-OWNER", {"type": "command.ack",
"request_id": message["request_id"], "ok": False, "reason": "HTTP 500"})
return
await original(message)
own.send_json = fail_one_once
result = asyncio.run(hub._send_mapping_entries_resilient("GFX-OWNER", assignment_id="assignment",
match_id="g", entries=[_entry(i, "Score") for i in range(8)], use_batch=True))
assert all(item["ok"] for item in result["results"])
assert result["retries"] == 1 and attempts["Field3.Text"] == 2
assert all(count == 1 for name, count in attempts.items() if name != "Field3.Text")
assert [frame["command"]["SelectedName"] for frame in own.frames[1:]] == [
"Field0.Text", "Field1.Text", "Field2.Text", "Field3.Text", "Field3.Text",
"Field4.Text", "Field5.Text", "Field6.Text", "Field7.Text"]
assert not hub._pending_commands and other.frames == []
def test_repeated_mapping_failure_stops_before_flooding_the_agent(tmp_path: Path):
hub, own, other = transport(tmp_path)
original = own.send_json
async def reject_values(message):
if message.get("type") != "vmix.command":
return await original(message)
own.frames.append(message)
await hub.receive_command_ack("GFX-OWNER", {"type": "command.ack",
"request_id": message["request_id"], "ok": False, "reason": "HTTP 500"})
own.send_json = reject_values
result = asyncio.run(hub._send_mapping_entries_resilient("GFX-OWNER", assignment_id="assignment",
match_id="g", entries=[_entry(i, "Score") for i in range(200)], use_batch=True))
assert len(result["results"]) == 200 and all(not item["ok"] for item in result["results"])
assert result["retries"] == 3
assert len(own.frames) == 7 # assignment + at most two attempts for three failed fields
assert other.frames == [] and not hub._pending_commands
def test_mapping_diagnostics_report_actual_retry_without_claiming_unconfirmed_delivery(tmp_path: Path):
hub, own, _ = transport(tmp_path)
result = asyncio.run(hub._send_mapping_entries_resilient("GFX-OWNER", assignment_id="assignment",
match_id="g", entries=[_entry(i, "Score") for i in range(2)], use_batch=False))
assert result["ack_waited"] is True and result["transport"] == "sequential_ack"
root = Path(__file__).resolve().parents[1]
admin = (root / "hockey_data/static/admin-directories.js").read_text(encoding="utf-8")
_run_functions(admin, ["mappingApplyTransportSummary"], r'''
const summary = mappingApplyTransportSummary({transport:'sequential_ack', input_groups:1,
mapping_commands_sent:2, batch_retries:1, batch_chunks_total:0, batch_chunks_applied:0});
assert.ok(summary.includes('Input: 1'));
assert.ok(summary.includes('повторов 1'));
assert.ok(!summary.includes('без ожидания ACK'));
assert.ok(!summary.includes('пакетов'));
''')