"""Retain BUILD97 delivery guarantees with bounded acknowledged BUILD192 batches. 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_BYTES, MAPPING_BATCH_MAX_COMMANDS, VmixAgentHub from tests.support import LocalTestDatabase from tests.test_hotfix_mapping_transport import frame_commands, sent_commands, 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) # This reusable planner retains its count/byte bound. Actual Mapping wire # delivery below has the stricter six independent targets per packet. 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))) # The byte bound remains effective even when the six-command cap permits # more entries. A single giant initial packet must not return accidentally. large = [_entry(i, "INPUT-A", value_size=20000) for i in range(7)] large_chunks = hub._mapping_batch_chunks(large) assert [index for chunk in large_chunks for index, _ in chunk] == list(range(7)) assert all(len(chunk) <= 2 for chunk in large_chunks) import json assert all(len(json.dumps([entry["command"] for _, entry in chunk], ensure_ascii=False).encode()) <= MAPPING_BATCH_MAX_BYTES for chunk in large_chunks) 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"] == "bounded_batch_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.batch" and 1 <= len(frame["commands"]) <= 6 for frame in commands) assert all(len({(command["Input"], command["SelectedName"]) for command in frame["commands"]}) == len(frame["commands"]) for frame in commands) assert sent_commands(own) == [row["command"] for row in rows] assert len(commands) == 62 # independent targets fill packets across Input boundaries assert len({frame["request_id"] for frame in commands}) == len(commands) 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_batch_send = hub.send_vmix_batch lost = [] async def lose_one_ack(message): if any(command.get("SelectedName") == "Field10.Text" for command in frame_commands(message)) 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, commands, timeout=4.0): return await original_batch_send(device_id, assignment_id=assignment_id, match_id=match_id, commands=commands, timeout=0.01) own.send_json = lose_one_ack hub.send_vmix_batch = 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"]) lost_names = [command["SelectedName"] for command in frame_commands(lost[0])] assert len(lost) == 1 and len(lost_names) == 6 assert result["retries"] == len(lost_names) names = [command["SelectedName"] for command in sent_commands(own)] assert names == [f"Field{i}.Text" for i in range(35)] + lost_names 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): commands = frame_commands(message) for command in commands: name = command["SelectedName"] attempts[name] = attempts.get(name, 0) + 1 if any(command["SelectedName"] == "Field3.Text" and attempts["Field3.Text"] == 1 for command in commands): own.frames.append(message) await own.acknowledge(message, results=[{"ok": command["SelectedName"] != "Field3.Text", "reason": "HTTP 500" if command["SelectedName"] == "Field3.Text" else ""} for command in commands]) 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 [command["SelectedName"] for command in sent_commands(own)] == [ "Field0.Text", "Field1.Text", "Field2.Text", "Field3.Text", "Field4.Text", "Field5.Text", "Field6.Text", "Field7.Text", "Field3.Text"] assert not hub._pending_commands and other.frames == [] def test_three_bad_prefix_fields_do_not_starve_healthy_suffix_or_flood_retries(tmp_path: Path): hub, own, other = transport(tmp_path) original = own.send_json async def reject_values(message): commands = frame_commands(message) if not commands: return await original(message) own.frames.append(message) await own.acknowledge(message, results=[{"ok": command["SelectedName"] not in {"Field0.Text", "Field1.Text", "Field2.Text"}, "reason": "HTTP 500" if command["SelectedName"] in {"Field0.Text", "Field1.Text", "Field2.Text"} else ""} for command in commands]) 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 assert [item["ok"] for item in result["results"]] == [False] * 3 + [True] * 197 assert result["retries"] == 3 names = [command["SelectedName"] for command in sent_commands(own)] assert names == [f"Field{i}.Text" for i in range(200)] + ["Field0.Text", "Field1.Text", "Field2.Text"] assert all(len(frame_commands(frame)) <= 6 for frame in own.frames) assert 0 <= result["retries"] <= 12 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('пакетов')); ''') batched = 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=True)) assert batched["transport"] == "bounded_batch_ack" and batched["ack_waited"] is True assert batched["chunks_total"] == batched["chunks_ok"] == 1 and batched["chunks_failed"] == 0 _run_functions(admin, ["mappingApplyTransportSummary"], r''' const summary = mappingApplyTransportSummary({transport:'bounded_batch_ack', input_groups:1, mapping_commands_sent:2, batch_retries:0, batch_chunks_total:1, batch_chunks_applied:1}); assert.ok(summary.includes('Input: 1')); assert.ok(summary.includes('пакетов 1/1')); assert.ok(!summary.includes('без ожидания ACK')); ''')