тест 1 времени

This commit is contained in:
2026-08-24 16:14:29 +03:00
parent 79c59eeeed
commit c82ec2cbfb
4 changed files with 203 additions and 34 deletions

3
app.py
View File

@@ -29,7 +29,8 @@ from ui_builder import install_ui_builder
from khl_site.khl_data_center import APP as khl_site_app from khl_site.khl_data_center import APP as khl_site_app
BASE_DIR = Path(__file__).resolve().parent BASE_DIR = Path(__file__).resolve().parent
BUILD_VERSION = "2026.08.24.5" BUILD_VERSION = "2026.08.24.6"
# compatibility: BUILD_VERSION = "2026.08.24.5"
# compatibility: BUILD_VERSION = "2026.08.24.4" # compatibility: BUILD_VERSION = "2026.08.24.4"
# compatibility: BUILD_VERSION = "2026.08.24.2" # compatibility: BUILD_VERSION = "2026.08.24.2"
# compatibility: BUILD_VERSION = "2026.08.24.1" # compatibility: BUILD_VERSION = "2026.08.24.1"

View File

@@ -1403,6 +1403,7 @@ class VmixAgentHub:
sequence_id: str = "", sequence_id: str = "",
sequence_name: str = "", sequence_name: str = "",
button_id: str = "", button_id: str = "",
delivery_mode: str = "",
timeout: float = 4.0, timeout: float = 4.0,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Execute commands on exactly one Agent bound to this browser/match session.""" """Execute commands on exactly one Agent bound to this browser/match session."""
@@ -1522,49 +1523,92 @@ class VmixAgentHub:
# for this transport. Mapping and generic runtime sync keep their existing ACK # for this transport. Mapping and generic runtime sync keep their existing ACK
# semantics so diagnostics are still useful where exact field delivery matters. # semantics so diagnostics are still useful where exact field delivery matters.
interactive_shortcut = bool(str(sequence_id or "").strip() or str(button_id or "").strip()) interactive_shortcut = bool(str(sequence_id or "").strip() or str(button_id or "").strip())
use_batch = (not interactive_shortcut) and self._agent_supports_batch(agent_version) and len(prepared_commands) > 1 delivery_mode = str(delivery_mode or "").strip().lower()
transport = "shortcut-no-ack" if interactive_shortcut else ("batch" if use_batch else "legacy") fast_timer = delivery_mode == "timer-fast"
no_ack_delivery = interactive_shortcut or fast_timer
supports_batch = self._agent_supports_batch(agent_version) and len(prepared_commands) > 1
use_batch = (not no_ack_delivery) and supports_batch
if fast_timer:
transport = "timer-batch-no-ack" if supports_batch else "timer-no-ack"
else:
transport = "shortcut-no-ack" if interactive_shortcut else ("batch" if use_batch else "legacy")
if interactive_shortcut: if no_ack_delivery:
# Preserve order between operator shortcuts, but do not wait behind Mapping's # BUILD103: realtime operator traffic (shortcuts + timers) bypasses Mapping's
# command ACK lock. A shortcut returns as soon as its frames are on the Agent socket. # ACK queue. Timer pairs use ONE vmix.batch frame on Agent 1.4+ and return
# immediately after WebSocket delivery; the later Agent ACK is intentionally ignored.
# The shared realtime lock preserves exact frame order if Space and an F-key are
# pressed almost simultaneously without reintroducing the slow Mapping lock.
async with self._device_vmix_shortcut_send_lock(target_device_id): async with self._device_vmix_shortcut_send_lock(target_device_id):
for index, command in enumerate(prepared_commands): if fast_timer and supports_batch:
function = str(command.get("Function") or "")
request_id = secrets.token_urlsafe(12) request_id = secrets.token_urlsafe(12)
delivered = await self.send( delivered = await self.send(
target_device_id, target_device_id,
{ {
"type": "vmix.command", "type": "vmix.batch",
"protocol": AGENT_PROTOCOL_VERSION, "protocol": AGENT_PROTOCOL_VERSION,
"request_id": request_id, "request_id": request_id,
"device_id": target_device_id, "device_id": target_device_id,
"assignment_id": assignment_id, "assignment_id": assignment_id,
"match_id": match_id, "match_id": match_id,
"command": command, "commands": prepared_commands,
}, },
) )
item = { for index, command in enumerate(prepared_commands):
"index": index, function = str(command.get("Function") or "")
"function": function, results.append({
"ok": bool(delivered), "index": index,
"reason": "" if delivered else "Agent сейчас offline", "function": function,
"delivery": "agent-websocket", "ok": bool(delivered),
"ack_waited": False, "reason": "" if delivered else "Agent сейчас offline",
} "delivery": "agent-websocket-batch",
results.append(item) "ack_waited": False,
if delivered: })
# With no ACK wait the local ON AIR state is intentionally optimistic: if delivered:
# success means delivery to the live Agent WebSocket, not vMix confirmation. self._track_runtime_overlay_command(
self._track_runtime_overlay_command( target_device_id,
command,
sequence_id=sequence_id,
sequence_name=sequence_name,
button_id=button_id,
)
else:
for index, command in enumerate(prepared_commands):
function = str(command.get("Function") or "")
request_id = secrets.token_urlsafe(12)
delivered = await self.send(
target_device_id, target_device_id,
command, {
sequence_id=sequence_id, "type": "vmix.command",
sequence_name=sequence_name, "protocol": AGENT_PROTOCOL_VERSION,
button_id=button_id, "request_id": request_id,
"device_id": target_device_id,
"assignment_id": assignment_id,
"match_id": match_id,
"command": command,
},
) )
else: item = {
break "index": index,
"function": function,
"ok": bool(delivered),
"reason": "" if delivered else "Agent сейчас offline",
"delivery": "agent-websocket",
"ack_waited": False,
}
results.append(item)
if delivered:
# With no ACK wait the local ON AIR state is intentionally optimistic:
# success means delivery to the live Agent WebSocket, not vMix confirmation.
self._track_runtime_overlay_command(
target_device_id,
command,
sequence_id=sequence_id,
sequence_name=sequence_name,
button_id=button_id,
)
else:
break
else: else:
async with self._device_vmix_send_lock(target_device_id): async with self._device_vmix_send_lock(target_device_id):
if use_batch: if use_batch:
@@ -1637,7 +1681,8 @@ class VmixAgentHub:
"failed": len(failed), "failed": len(failed),
"results": results, "results": results,
"transport": transport, "transport": transport,
"confirmation": "not_waited" if interactive_shortcut else "ack", # BUILD102 compatibility marker: "confirmation": "not_waited" if interactive_shortcut else "ack"
"confirmation": "not_waited" if no_ack_delivery else "ack",
"overlay_state": self._runtime_overlay_payload(target_device_id), "overlay_state": self._runtime_overlay_payload(target_device_id),
} }
@@ -3914,6 +3959,7 @@ class RuntimeVmixSequencePayload(BaseModel):
sequence_id: str = Field(default="", max_length=160) sequence_id: str = Field(default="", max_length=160)
sequence_name: str = Field(default="", max_length=300) sequence_name: str = Field(default="", max_length=300)
button_id: str = Field(default="", max_length=160) button_id: str = Field(default="", max_length=160)
delivery_mode: str = Field(default="", max_length=32)
class SelectSessionDevicePayload(BaseModel): class SelectSessionDevicePayload(BaseModel):
@@ -4259,6 +4305,7 @@ def create_hockey_agent_router(
sequence_id=payload.sequence_id, sequence_id=payload.sequence_id,
sequence_name=payload.sequence_name, sequence_name=payload.sequence_name,
button_id=payload.button_id, button_id=payload.button_id,
delivery_mode=payload.delivery_mode,
) )
@router.get("/api/hockey/vmix/overlay-state") @router.get("/api/hockey/vmix/overlay-state")

View File

@@ -0,0 +1,120 @@
from __future__ import annotations
import asyncio
from pathlib import Path
from types import SimpleNamespace
from hockey_data.agent_bridge import VmixAgentHub
from hockey_data.auth_bridge import HockeyUser
from tests.support import LocalTestDatabase
ROOT = Path(__file__).resolve().parents[1]
APP_JS = (ROOT / "ui_builder" / "static" / "app.js").read_text(encoding="utf-8")
BRIDGE = (ROOT / "hockey_data" / "agent_bridge.py").read_text(encoding="utf-8")
APP = (ROOT / "app.py").read_text(encoding="utf-8")
class FakeWebSocket:
def __init__(self) -> None:
self.client = SimpleNamespace(host="127.0.0.1")
self.sent: list[dict] = []
async def send_json(self, payload: dict) -> None:
self.sent.append(payload)
async def close(self, **_kwargs) -> None:
return None
def test_space_edge_is_single_fire_and_local_timer_changes_before_transport() -> None:
# One physical Space press fires once; repeats are swallowed until keyup.
assert "state.pressedShortcutKeys.has(physicalKey)" in APP_JS
assert "if (event.repeat) return false;" in APP_JS
assert "state.pressedShortcutKeys.add(physicalKey)" in APP_JS
assert "state.pressedShortcutKeys.delete(physicalKey)" in APP_JS
assert 'combo: "Space"' in APP_JS
block = APP_JS.split('case "hockey_vmix_timers_start":', 1)[1].split('case "delay":', 1)[0]
assert block.index("if (step.start_web_game)") < block.index("if (commands.length) sendRuntimeVmixTimerSequence(commands);")
assert 'delivery_mode: "timer-fast"' in APP_JS
def test_timer_fast_backend_uses_one_no_ack_batch_and_bypasses_mapping_lock(tmp_path: Path) -> None:
database = LocalTestDatabase(tmp_path / "build103-timer-fast.sqlite3")
database.create_all()
hub = VmixAgentHub(database) # type: ignore[arg-type]
ws = FakeWebSocket()
user = HockeyUser(id="103", login="operator103", display_name="operator103")
async def scenario() -> None:
await hub.register(
ws, # type: ignore[arg-type]
{
"device_id": "GFX-TIMER-FAST",
"device_secret": "t" * 40,
"device_name": "Build103 Timer GFX",
"hostname": "BUILD103-PC",
"agent_version": "1.4.0",
"vmix": {"connected": True, "url": "http://127.0.0.1:8088/api/"},
},
)
await hub.pair_device("GFX-TIMER-FAST", user)
assigned = await hub.assign_match(
wfl_user_id=user.id,
tournament_external_id="1437",
game_external_id="103103",
)
assert assigned is not None
# Mapping may be stuck waiting for ACK. Space timer transport must not wait for it.
mapping_lock = hub._device_vmix_send_lock("GFX-TIMER-FAST")
await mapping_lock.acquire()
try:
result = await asyncio.wait_for(
hub.run_vmix_sequence_for_user(
user,
[
{"Function": "SetCountdown", "Input": "SCORE", "SelectedName": "TIME.Text", "Value": "00:18:42"},
{"Function": "StartCountdown", "Input": "SCORE", "SelectedName": "TIME.Text"},
],
delivery_mode="timer-fast",
),
timeout=0.5,
)
finally:
mapping_lock.release()
stop_result = await asyncio.wait_for(
hub.run_vmix_sequence_for_user(
user,
[
{"Function": "StopCountdown", "Input": "SCORE", "SelectedName": "TIME.Text"},
{"Function": "SetCountdown", "Input": "SCORE", "SelectedName": "TIME.Text", "Value": "00:18:41"},
],
delivery_mode="timer-fast",
),
timeout=0.5,
)
batches = [item for item in ws.sent if item.get("type") == "vmix.batch"]
assert len(batches) == 2
assert [item["Function"] for item in batches[0]["commands"]] == ["SetCountdown", "StartCountdown"]
assert [item["Function"] for item in batches[1]["commands"]] == ["StopCountdown", "SetCountdown"]
assert not [item for item in ws.sent if item.get("type") == "vmix.command"]
for payload in (result, stop_result):
assert payload["ok"] is True
assert payload["transport"] == "timer-batch-no-ack"
assert payload["confirmation"] == "not_waited"
assert payload["applied"] == 2
assert all(row["ack_waited"] is False for row in payload["results"])
assert not hub._pending_commands
asyncio.run(scenario())
def test_timer_fast_contract_and_build_version() -> None:
assert 'delivery_mode: str = Field(default="", max_length=32)' in BRIDGE
assert 'fast_timer = delivery_mode == "timer-fast"' in BRIDGE
assert 'transport = "timer-batch-no-ack" if supports_batch else "timer-no-ack"' in BRIDGE
assert '"type": "vmix.batch"' in BRIDGE
assert 'BUILD_VERSION = "2026.08.24.6"' in APP

View File

@@ -4643,10 +4643,10 @@ function startCustomTooltips() {
} }
function sendRuntimeVmixTimerSequence(commands) { function sendRuntimeVmixTimerSequence(commands) {
// BUILD101: timer transport is deliberately independent from the generic // BUILD103: timer transport is deliberately independent from the generic
// shortcut/title queue. The web timer has already changed state before this // shortcut/title/Mapping ACK queue. The web timer has already changed state before
// function is called, so Agent/vMix delivery is best-effort and must never // this function is called. Server delivery_mode=timer-fast sends the tiny native
// block the operator from pressing Start/Stop again. // Countdown pair to Agent immediately without waiting for ACK, preferably as one batch.
const rawCommands = typeof commands === "function" ? commands() : commands; const rawCommands = typeof commands === "function" ? commands() : commands;
const clean = (rawCommands || []).map(compactVmixCommand).filter((command) => command.Function); const clean = (rawCommands || []).map(compactVmixCommand).filter((command) => command.Function);
if (!clean.length) return Promise.resolve({ ok: true, applied: 0, requested: 0, results: [] }); if (!clean.length) return Promise.resolve({ ok: true, applied: 0, requested: 0, results: [] });
@@ -4664,6 +4664,7 @@ function startCustomTooltips() {
commands: clean, commands: clean,
device_id: currentRuntimeVmixDeviceId(), device_id: currentRuntimeVmixDeviceId(),
session_token: currentRuntimeHockeySessionToken(), session_token: currentRuntimeHockeySessionToken(),
delivery_mode: "timer-fast",
}), }),
}).then(async (response) => { }).then(async (response) => {
let payload = {}; let payload = {};