"""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('пакетов')); ''')