тест 1 времени
This commit is contained in:
3
app.py
3
app.py
@@ -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"
|
||||||
|
|||||||
@@ -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,13 +1523,56 @@ 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()
|
||||||
|
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")
|
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):
|
||||||
|
if fast_timer and supports_batch:
|
||||||
|
request_id = secrets.token_urlsafe(12)
|
||||||
|
delivered = await self.send(
|
||||||
|
target_device_id,
|
||||||
|
{
|
||||||
|
"type": "vmix.batch",
|
||||||
|
"protocol": AGENT_PROTOCOL_VERSION,
|
||||||
|
"request_id": request_id,
|
||||||
|
"device_id": target_device_id,
|
||||||
|
"assignment_id": assignment_id,
|
||||||
|
"match_id": match_id,
|
||||||
|
"commands": prepared_commands,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
for index, command in enumerate(prepared_commands):
|
||||||
|
function = str(command.get("Function") or "")
|
||||||
|
results.append({
|
||||||
|
"index": index,
|
||||||
|
"function": function,
|
||||||
|
"ok": bool(delivered),
|
||||||
|
"reason": "" if delivered else "Agent сейчас offline",
|
||||||
|
"delivery": "agent-websocket-batch",
|
||||||
|
"ack_waited": False,
|
||||||
|
})
|
||||||
|
if delivered:
|
||||||
|
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):
|
for index, command in enumerate(prepared_commands):
|
||||||
function = str(command.get("Function") or "")
|
function = str(command.get("Function") or "")
|
||||||
request_id = secrets.token_urlsafe(12)
|
request_id = secrets.token_urlsafe(12)
|
||||||
@@ -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")
|
||||||
|
|||||||
120
tests/test_build103_space_timer_fast_transport.py
Normal file
120
tests/test_build103_space_timer_fast_transport.py
Normal 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
|
||||||
@@ -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 = {};
|
||||||
|
|||||||
Reference in New Issue
Block a user