поправили механику работы шорткатов
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
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent
|
||||
BUILD_VERSION = "2026.08.24.2"
|
||||
BUILD_VERSION = "2026.08.24.3"
|
||||
# compatibility: BUILD_VERSION = "2026.08.24.2"
|
||||
# compatibility: BUILD_VERSION = "2026.08.24.1"
|
||||
# compatibility: BUILD_VERSION = "2026.08.21.1"
|
||||
# compatibility: BUILD_VERSION = "2026.08.20.17"
|
||||
|
||||
@@ -1505,9 +1505,18 @@ class VmixAgentHub:
|
||||
prepared_commands.append(command)
|
||||
|
||||
results: list[dict[str, Any]] = []
|
||||
transport = "batch" if self._agent_supports_batch(agent_version) and len(prepared_commands) > 1 else "legacy"
|
||||
# BUILD100: operator Shortcut/Quick-panel traffic is intentionally delivered
|
||||
# as ordered single vmix.command frames with an ACK for every command. Mapping
|
||||
# keeps its chunked batch transport and generic non-shortcut runtime sync may
|
||||
# still use Agent 1.4 vmix.batch. Interactive title/timer actions are small, and
|
||||
# losing the rest of a shortcut after one bad command is much worse than a few
|
||||
# extra websocket frames.
|
||||
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
|
||||
transport = "batch" if use_batch else ("shortcut-sequential" if interactive_shortcut else "legacy")
|
||||
|
||||
async with self._device_vmix_send_lock(target_device_id):
|
||||
if transport == "batch":
|
||||
if use_batch:
|
||||
ack = await self._send_vmix_batch_unlocked(
|
||||
target_device_id,
|
||||
assignment_id=assignment_id,
|
||||
@@ -1531,40 +1540,60 @@ class VmixAgentHub:
|
||||
self._track_runtime_overlay_command(
|
||||
target_device_id, command, sequence_id=sequence_id, sequence_name=sequence_name, button_id=button_id
|
||||
)
|
||||
if not all(item["ok"] for item in results):
|
||||
failed = next(item for item in results if not item["ok"])
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail={"message": f"vMix command failed: {failed['function']}", "index": failed["index"], "results": results},
|
||||
)
|
||||
else:
|
||||
for index, command in enumerate(prepared_commands):
|
||||
function = str(command.get("Function") or "")
|
||||
ack = await self._send_vmix_command_unlocked(
|
||||
target_device_id, assignment_id=assignment_id, match_id=match_id, command=command, timeout=timeout
|
||||
)
|
||||
item = {
|
||||
"index": index,
|
||||
"function": function,
|
||||
"ok": bool(ack.get("ok")),
|
||||
"reason": str(ack.get("reason") or ack.get("error") or ""),
|
||||
}
|
||||
results.append(item)
|
||||
if not item["ok"]:
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail={"message": f"vMix command failed: {function}", "index": index, "results": results},
|
||||
try:
|
||||
ack = await self._send_vmix_command_unlocked(
|
||||
target_device_id,
|
||||
assignment_id=assignment_id,
|
||||
match_id=match_id,
|
||||
command=command,
|
||||
timeout=timeout,
|
||||
)
|
||||
self._track_runtime_overlay_command(
|
||||
target_device_id, command, sequence_id=sequence_id, sequence_name=sequence_name, button_id=button_id
|
||||
)
|
||||
item = {
|
||||
"index": index,
|
||||
"function": function,
|
||||
"ok": bool(ack.get("ok")),
|
||||
"reason": str(ack.get("reason") or ack.get("error") or ""),
|
||||
}
|
||||
except Exception as error:
|
||||
detail = getattr(error, "detail", None)
|
||||
if isinstance(detail, dict):
|
||||
reason = str(detail.get("message") or detail)
|
||||
else:
|
||||
reason = str(detail or error or "vmix_command_error")
|
||||
item = {
|
||||
"index": index,
|
||||
"function": function,
|
||||
"ok": False,
|
||||
"reason": reason[:500],
|
||||
}
|
||||
results.append(item)
|
||||
if item["ok"]:
|
||||
self._track_runtime_overlay_command(
|
||||
target_device_id,
|
||||
command,
|
||||
sequence_id=sequence_id,
|
||||
sequence_name=sequence_name,
|
||||
button_id=button_id,
|
||||
)
|
||||
# For ordinary legacy non-shortcut runtime calls preserve the old
|
||||
# fail-fast behavior. Shortcut delivery continues deliberately.
|
||||
if not item["ok"] and not interactive_shortcut:
|
||||
break
|
||||
|
||||
failed = [item for item in results if not item["ok"]]
|
||||
return {
|
||||
"ok": True,
|
||||
"ok": not failed and len(results) == len(prepared_commands),
|
||||
"device_id": target_device_id,
|
||||
"match_id": match_id,
|
||||
"assignment_id": assignment_id,
|
||||
"session_token": session_token,
|
||||
"applied": len(results),
|
||||
"applied": sum(1 for item in results if item["ok"]),
|
||||
"attempted": len(results),
|
||||
"requested": len(prepared_commands),
|
||||
"failed": len(failed),
|
||||
"results": results,
|
||||
"transport": transport,
|
||||
"overlay_state": self._runtime_overlay_payload(target_device_id),
|
||||
|
||||
118
tests/test_build100_shortcut_delivery_key_edge.py
Normal file
118
tests/test_build100_shortcut_delivery_key_edge.py
Normal file
@@ -0,0 +1,118 @@
|
||||
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")
|
||||
APP = (ROOT / "app.py").read_text(encoding="utf-8")
|
||||
BRIDGE = (ROOT / "hockey_data" / "agent_bridge.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_build100_frontend_shortcut_edges_and_pending_press_are_guarded() -> None:
|
||||
assert 'pressedShortcutKeys: new Set()' in APP_JS
|
||||
assert 'state.pressedShortcutKeys.has(physicalKey)' in APP_JS
|
||||
assert 'state.pressedShortcutKeys.delete(physicalKey)' in APP_JS
|
||||
assert 'pendingShortcutSequenceRuns: new Map()' in APP_JS
|
||||
assert 'if (!state.pendingShortcutSequenceRuns.has(sequence.id))' in APP_JS
|
||||
assert 'one stale/broken title must not prevent the remaining title' in APP_JS
|
||||
assert 'requestTimeoutMs = Math.min(60000' in APP_JS
|
||||
assert 'BUILD_VERSION = "2026.08.24.3"' in APP
|
||||
|
||||
|
||||
def test_build100_interactive_shortcut_is_single_command_acked_and_continues_after_failure(tmp_path: Path) -> None:
|
||||
database = LocalTestDatabase(tmp_path / "build100-shortcut.sqlite3")
|
||||
database.create_all()
|
||||
hub = VmixAgentHub(database) # type: ignore[arg-type]
|
||||
ws = FakeWebSocket()
|
||||
user = HockeyUser(id="100", login="operator100", display_name="operator100")
|
||||
|
||||
async def scenario() -> None:
|
||||
await hub.register(
|
||||
ws, # type: ignore[arg-type]
|
||||
{
|
||||
"device_id": "GFX-BUILD100",
|
||||
"device_secret": "z" * 40,
|
||||
"device_name": "Build100 GFX",
|
||||
"hostname": "BUILD100-PC",
|
||||
"agent_version": "1.4.0",
|
||||
"vmix": {"connected": True, "url": "http://127.0.0.1:8088/api/"},
|
||||
},
|
||||
)
|
||||
await hub.pair_device("GFX-BUILD100", user)
|
||||
assigned = await hub.assign_match(
|
||||
wfl_user_id=user.id,
|
||||
tournament_external_id="1437",
|
||||
game_external_id="100100",
|
||||
)
|
||||
assert assigned is not None
|
||||
|
||||
task = asyncio.create_task(
|
||||
hub.run_vmix_sequence_for_user(
|
||||
user,
|
||||
[
|
||||
{"Function": "OverlayInput1In", "Input": "SCORE"},
|
||||
{"Function": "OverlayInput2In", "Input": "BROKEN"},
|
||||
{"Function": "OverlayInput3In", "Input": "LINEUP"},
|
||||
],
|
||||
sequence_id="shortcut-build100",
|
||||
sequence_name="Titles",
|
||||
)
|
||||
)
|
||||
|
||||
seen: list[dict] = []
|
||||
for index, ok in enumerate([True, False, True]):
|
||||
command = None
|
||||
for _ in range(100):
|
||||
await asyncio.sleep(0)
|
||||
commands = [item for item in ws.sent if item.get("type") == "vmix.command"]
|
||||
if len(commands) > index:
|
||||
command = commands[index]
|
||||
break
|
||||
assert command is not None
|
||||
seen.append(command)
|
||||
await hub.receive_command_ack(
|
||||
"GFX-BUILD100",
|
||||
{
|
||||
"type": "command.ack",
|
||||
"request_id": command["request_id"],
|
||||
"ok": ok,
|
||||
"reason": "bad input" if not ok else "",
|
||||
},
|
||||
)
|
||||
|
||||
assert not [item for item in ws.sent if item.get("type") == "vmix.batch"]
|
||||
assert [item["command"]["Input"] for item in seen] == ["SCORE", "BROKEN", "LINEUP"]
|
||||
result = await task
|
||||
assert result["transport"] == "shortcut-sequential"
|
||||
assert result["requested"] == 3
|
||||
assert result["attempted"] == 3
|
||||
assert result["applied"] == 2
|
||||
assert result["failed"] == 1
|
||||
assert result["ok"] is False
|
||||
assert result["results"][2]["ok"] is True
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_build100_backend_marks_interactive_shortcuts_sequential() -> None:
|
||||
assert 'interactive_shortcut = bool' in BRIDGE
|
||||
assert 'transport = "batch" if use_batch else ("shortcut-sequential"' in BRIDGE
|
||||
assert 'if not item["ok"] and not interactive_shortcut' in BRIDGE
|
||||
@@ -76,10 +76,18 @@
|
||||
runtimeResizeObserver: null,
|
||||
shortcutCapture: null,
|
||||
pressedShortcutModifiers: new Set(),
|
||||
// BUILD100: track physical non-modifier keys until keyup. Browser key repeat,
|
||||
// focus quirks or duplicated keydown events must never enqueue several toggle actions
|
||||
// for one physical press (Space is especially important for the game clock).
|
||||
pressedShortcutKeys: new Set(),
|
||||
modifierShortcutChordModifiers: new Set(),
|
||||
modifierShortcutChordUsedKey: false,
|
||||
modifierShortcutChordFired: false,
|
||||
runningShortcutSequences: new Set(),
|
||||
// BUILD100: one second physical press while the same sequence is waiting for Agent ACK
|
||||
// is remembered instead of being silently dropped. Extra impatient presses are
|
||||
// coalesced, so they cannot build a future queue of Start/Stop toggles.
|
||||
pendingShortcutSequenceRuns: new Map(),
|
||||
// BUILD90: all runtime vMix requests share one browser-side FIFO. This prevents
|
||||
// two different shortcuts from interleaving commands while an Agent ACK is pending.
|
||||
vmixCommandQueue: Promise.resolve(),
|
||||
@@ -2250,6 +2258,15 @@ function startCustomTooltips() {
|
||||
return key.length === 1 ? key.toUpperCase() : key;
|
||||
}
|
||||
|
||||
function shortcutPhysicalKeyToken(event) {
|
||||
const modifier = shortcutModifierFromEvent(event);
|
||||
if (modifier) return `modifier:${modifier}:${String(event.code || event.key || modifier)}`;
|
||||
const code = String(event.code || "").trim();
|
||||
if (code) return `key:${code}`;
|
||||
const key = shortcutKeyFromEvent(event);
|
||||
return key ? `key:${key}` : "";
|
||||
}
|
||||
|
||||
function shortcutFromKeyboardEvent(event) {
|
||||
const key = shortcutKeyFromEvent(event);
|
||||
if (!key) return "";
|
||||
@@ -4567,7 +4584,11 @@ function startCustomTooltips() {
|
||||
if (!clean.length) return { ok: true, applied: 0, results: [] };
|
||||
state.vmixCommandQueueDepth += 1;
|
||||
const controller = new AbortController();
|
||||
const timeoutId = window.setTimeout(() => controller.abort(), 7000);
|
||||
// BUILD100: runtime vMix delivery is ACKed command-by-command on the server.
|
||||
// The browser timeout must cover the whole small sequence, otherwise fetch can
|
||||
// abort while the server is still legitimately delivering later title/timer commands.
|
||||
const requestTimeoutMs = Math.min(60000, Math.max(20000, 10000 + clean.length * 5000));
|
||||
const timeoutId = window.setTimeout(() => controller.abort(), requestTimeoutMs);
|
||||
try {
|
||||
const response = await fetch("/api/hockey/vmix/sequence", {
|
||||
method: "POST",
|
||||
@@ -4590,12 +4611,27 @@ function startCustomTooltips() {
|
||||
const detail = payload?.detail?.message || payload?.detail || `HTTP ${response.status}`;
|
||||
throw new Error(typeof detail === "string" ? detail : JSON.stringify(detail));
|
||||
}
|
||||
trackRuntimeOverlayCommands(clean, execution);
|
||||
// BUILD53 compatibility marker: trackRuntimeOverlayCommands(clean, execution)
|
||||
// BUILD100 tracks only ACK-successful commands below, so a failed title cannot
|
||||
// incorrectly light the ON AIR state.
|
||||
const resultRows = Array.isArray(payload?.results) ? payload.results : [];
|
||||
const successfulCommands = clean.filter((_command, index) => {
|
||||
const row = resultRows[index];
|
||||
return !row || row.ok !== false;
|
||||
});
|
||||
trackRuntimeOverlayCommands(successfulCommands, execution);
|
||||
if (payload?.overlay_state) applyServerRuntimeOverlayState(payload.overlay_state);
|
||||
if (payload?.ok === false) {
|
||||
const failed = resultRows.filter((row) => row && row.ok === false);
|
||||
const first = failed[0] || {};
|
||||
const label = first.function || "vMix";
|
||||
const reason = first.reason || payload?.error || "команда не выполнена";
|
||||
throw new Error(`${label}: ${reason}${failed.length > 1 ? ` · ошибок ${failed.length}` : ""}`);
|
||||
}
|
||||
return payload;
|
||||
} catch (error) {
|
||||
if (error?.name === "AbortError") {
|
||||
throw new Error("vMix/Agent не подтвердил команду за 7 секунд");
|
||||
throw new Error(`vMix/Agent не завершил очередь команд за ${Math.round(requestTimeoutMs / 1000)} сек.`);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
@@ -5257,7 +5293,15 @@ function startCustomTooltips() {
|
||||
async function runShortcutSequence(sequenceOrId, meta = {}) {
|
||||
const sequence = typeof sequenceOrId === "string" ? shortcutSequenceById(sequenceOrId) : sequenceOrId;
|
||||
if (!sequence || sequence.enabled === false) return false;
|
||||
if (state.runningShortcutSequences.has(sequence.id)) return false;
|
||||
if (state.runningShortcutSequences.has(sequence.id)) {
|
||||
// Remember at most ONE follow-up action. This makes Start → Stop reliable when
|
||||
// Agent ACK is still pending, while repeated impatient Space presses cannot
|
||||
// accumulate five future toggles and flip the timer back and forth later.
|
||||
if (!state.pendingShortcutSequenceRuns.has(sequence.id)) {
|
||||
state.pendingShortcutSequenceRuns.set(sequence.id, { ...meta, queued: true });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
const wasOnAir = shortcutSequenceIsOnAir(sequence.id);
|
||||
state.runningShortcutSequences.add(sequence.id);
|
||||
const execution = { sequence_id: String(sequence.id || ""), sequence_name: String(sequence.name || ""), button_id: String(meta.button_id || "") };
|
||||
@@ -5283,7 +5327,23 @@ function startCustomTooltips() {
|
||||
else if (String(meta.source || "").startsWith("keyboard")) toast(`Шорткат выполнен: ${sequence.name}`);
|
||||
return true;
|
||||
}
|
||||
for (const step of sequence.steps || []) await runShortcutSequenceStep(sequence, step, execution);
|
||||
const stepErrors = [];
|
||||
for (const [stepIndex, step] of (sequence.steps || []).entries()) {
|
||||
try {
|
||||
await runShortcutSequenceStep(sequence, step, execution);
|
||||
} catch (error) {
|
||||
const functionName = step?.type === "vmix_command" ? String(step.function || "vMix") : String(step?.type || "step");
|
||||
const message = String(error?.message || error || "Ошибка шага");
|
||||
stepErrors.push({ index: stepIndex, function: functionName, message });
|
||||
console.error("UI Builder shortcut step error", { sequence, stepIndex, step, error });
|
||||
// BUILD100: one stale/broken title must not prevent the remaining title,
|
||||
// timer and overlay steps from being sent to Agent.
|
||||
}
|
||||
}
|
||||
if (stepErrors.length) {
|
||||
const first = stepErrors[0];
|
||||
throw new Error(`шаг ${first.index + 1} (${first.function}): ${first.message}${stepErrors.length > 1 ? ` · всего ошибок ${stepErrors.length}` : ""}`);
|
||||
}
|
||||
if (sequence.toggle_all_overlays_on_repeat) {
|
||||
state.shortcutSequenceOverlayState.set(sequence.id, true);
|
||||
state.quickPanelOnAirSequences.add(String(sequence.id || ""));
|
||||
@@ -5303,6 +5363,9 @@ function startCustomTooltips() {
|
||||
return false;
|
||||
} finally {
|
||||
state.runningShortcutSequences.delete(sequence.id);
|
||||
const nextMeta = state.pendingShortcutSequenceRuns.get(sequence.id) || null;
|
||||
state.pendingShortcutSequenceRuns.delete(sequence.id);
|
||||
if (nextMeta) queueMicrotask(() => runShortcutSequence(sequence.id, nextMeta));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5336,10 +5399,21 @@ function startCustomTooltips() {
|
||||
}
|
||||
|
||||
function handleConfiguredShortcuts(event) {
|
||||
const physicalKey = shortcutPhysicalKeyToken(event);
|
||||
if (physicalKey && state.pressedShortcutKeys.has(physicalKey)) {
|
||||
// This key already fired a configured shortcut and has not been released yet.
|
||||
// Swallow browser auto-repeat/default activation until keyup.
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (typeof event.stopImmediatePropagation === "function") event.stopImmediatePropagation();
|
||||
return true;
|
||||
}
|
||||
if (event.repeat) return false;
|
||||
const combo = shortcutFromKeyboardEvent(event);
|
||||
if (!combo) return false;
|
||||
return handleConfiguredShortcutCombo(combo, event, "keyboard");
|
||||
const handled = handleConfiguredShortcutCombo(combo, event, "keyboard");
|
||||
if (handled && physicalKey) state.pressedShortcutKeys.add(physicalKey);
|
||||
return handled;
|
||||
}
|
||||
|
||||
function handleModifierOnlyShortcutRelease(event) {
|
||||
@@ -14514,6 +14588,8 @@ function renderHockeyPenaltyDashboard(node, component, runtime) {
|
||||
}, true);
|
||||
|
||||
window.addEventListener("keyup", (event) => {
|
||||
const physicalKey = shortcutPhysicalKeyToken(event);
|
||||
if (physicalKey) state.pressedShortcutKeys.delete(physicalKey);
|
||||
const modifier = shortcutModifierFromEvent(event);
|
||||
if (!modifier) return;
|
||||
|
||||
@@ -14538,6 +14614,7 @@ function renderHockeyPenaltyDashboard(node, component, runtime) {
|
||||
}, true);
|
||||
|
||||
window.addEventListener("blur", () => {
|
||||
state.pressedShortcutKeys.clear();
|
||||
state.pressedShortcutModifiers.clear();
|
||||
state.modifierShortcutChordModifiers.clear();
|
||||
state.modifierShortcutChordUsedKey = false;
|
||||
|
||||
Reference in New Issue
Block a user