diff --git a/app.py b/app.py index 7691ef4..ff3bef0 100644 --- a/app.py +++ b/app.py @@ -29,7 +29,7 @@ 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.3" +BUILD_VERSION = "2026.08.24.4" # compatibility: BUILD_VERSION = "2026.08.24.2" # compatibility: BUILD_VERSION = "2026.08.24.1" # compatibility: BUILD_VERSION = "2026.08.21.1" diff --git a/tests/test_build100_shortcut_delivery_key_edge.py b/tests/test_build100_shortcut_delivery_key_edge.py index 0b2987e..e9297e7 100644 --- a/tests/test_build100_shortcut_delivery_key_edge.py +++ b/tests/test_build100_shortcut_delivery_key_edge.py @@ -34,7 +34,7 @@ def test_build100_frontend_shortcut_edges_and_pending_press_are_guarded() -> Non 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 + assert 'BUILD_VERSION = "2026.08.24.4"' in APP def test_build100_interactive_shortcut_is_single_command_acked_and_continues_after_failure(tmp_path: Path) -> None: diff --git a/tests/test_build101_simple_timer_runtime.py b/tests/test_build101_simple_timer_runtime.py new file mode 100644 index 0000000..93235df --- /dev/null +++ b/tests/test_build101_simple_timer_runtime.py @@ -0,0 +1,58 @@ +from pathlib import Path + +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") + + +def _block(start: str, end: str) -> str: + return APP_JS.split(start, 1)[1].split(end, 1)[0] + + +def test_timer_transport_is_independent_from_generic_vmix_queue(): + block = _block("function sendRuntimeVmixTimerSequence", "function splitVmixInputs") + assert 'fetch("/api/hockey/vmix/sequence"' in block + assert 'keepalive: true' in block + assert 'state.vmixCommandQueue' not in block + assert 'sequence_id' not in block.split('body: JSON.stringify({', 1)[1] + assert 'Runtime continues locally' in block + + +def test_timer_start_stop_protocol_is_minimal(): + helper = _block("function vmixCountdownSyncCommands", "function buildShortcutRuntimeContext") + assert 'if (action === "stop" || action === "pause")' in helper + stop_branch = helper.split('if (action === "stop" || action === "pause")', 1)[1].split('return [', 1)[1].split('];', 1)[0] + assert stop_branch.index('Function: "StopCountdown"') < stop_branch.index('Function: "SetCountdown"') + start_branch = helper.rsplit('return [', 1)[1] + assert start_branch.index('Function: "SetCountdown"') < start_branch.index('Function: "StartCountdown"') + assert 'SuspendCountdown' not in helper + assert 'PauseCountdown' not in helper + + +def test_combined_hockey_timer_shortcut_never_waits_for_agent(): + block = APP_JS.split('case "hockey_vmix_timers_start":', 1)[1].split('case "delay":', 1)[0] + assert 'sendRuntimeVmixTimerSequence(commands);' in block + assert 'await sendRuntimeVmixSequence(commands' not in block + assert 'fire-and-forget' in block + # Local state changes remain ahead of any vMix transport. + assert block.index('if (step.start_web_game)') < block.index('if (commands.length) sendRuntimeVmixTimerSequence(commands);') + + +def test_main_and_penalty_resync_do_not_use_generic_queue(): + game = _block("async function syncActiveVmixGameCountdown", "function penaltyMirrorKey") + penalty = _block("async function rebalanceVmixPenaltyTargets", "function finishActionMatchesSource") + assert 'sendRuntimeVmixTimerSequence(commands);' in game + assert 'await sendRuntimeVmixSequence(commands)' not in game + assert 'sendRuntimeVmixTimerSequence(commands);' in penalty + assert 'await sendRuntimeVmixSequence(commands)' not in penalty + + +def test_busy_shortcut_can_toggle_timer_without_waiting_for_title_ack(): + block = _block("function shortcutSequenceTimerControlSteps", "function handleConfiguredShortcutCombo") + assert 'runShortcutTimerControlsWhileBusy(sequence, meta)' in block + assert 'timer controls are never queued behind title/Agent ACK traffic' in block + assert '["timer_command", "hockey_vmix_timers_start"]' in block + + +def test_build101_runtime_version(): + assert 'BUILD_VERSION = "2026.08.24.4"' in APP diff --git a/tests/test_build95_countdown_explicit_start_stop_sync.py b/tests/test_build95_countdown_explicit_start_stop_sync.py index eb64d98..74adda8 100644 --- a/tests/test_build95_countdown_explicit_start_stop_sync.py +++ b/tests/test_build95_countdown_explicit_start_stop_sync.py @@ -32,15 +32,16 @@ def test_main_countdown_start_pause_stop_all_hard_sync_web_value(): assert 'vmixCountdownSyncCommands(input, selectedName, valueProvider, "stop")' in block helper = _block("function vmixCountdownSyncCommands", "function buildShortcutRuntimeContext") start_branch = helper.rsplit("return [", 1)[1] - assert start_branch.index('Function: "SuspendCountdown"') < start_branch.index('Function: "SetCountdown"') + assert 'Function: "SuspendCountdown"' not in start_branch assert start_branch.index('Function: "SetCountdown"') < start_branch.index('Function: "StartCountdown"') def test_penalty_pause_freezes_before_reseed_and_start_reseeds_before_run(): block = _block("async function rebalanceVmixPenaltyTargets", "function finishActionMatchesSource") - assert 'stopCommands.push({ Function: "SuspendCountdown"' in block + assert 'stopCommands.push({ Function: "StopCountdown"' in block assert 'setCommands.push({ Function: "SetCountdown"' in block assert 'runCommands.push({ Function: "StartCountdown"' in block + assert 'sendRuntimeVmixTimerSequence(commands)' in block assert "prepared/paused penalty" in block diff --git a/tests/test_build98_timer_countdown_sync_fixed_pin.py b/tests/test_build98_timer_countdown_sync_fixed_pin.py index e715e82..d4b4a62 100644 --- a/tests/test_build98_timer_countdown_sync_fixed_pin.py +++ b/tests/test_build98_timer_countdown_sync_fixed_pin.py @@ -22,27 +22,31 @@ def test_vmix_queue_resolves_countdown_values_at_actual_send_time(): assert 'const clean = (rawCommands || []).map(compactVmixCommand)' in sender -def test_countdown_start_is_deterministic_suspend_set_start(): +def test_countdown_start_is_simple_set_start(): helper = _block("function vmixCountdownSyncCommands", "function buildShortcutRuntimeContext") start_branch = helper.split('return [', 3)[-1] - assert start_branch.index('Function: "SuspendCountdown"') < start_branch.index('Function: "SetCountdown"') + assert 'Function: "SuspendCountdown"' not in start_branch assert start_branch.index('Function: "SetCountdown"') < start_branch.index('Function: "StartCountdown"') assert 'Value: currentValue' in helper -def test_pause_uses_suspend_not_toggle_pausecountdown(): +def test_pause_and_stop_use_stop_then_set(): helper = _block("function vmixCountdownSyncCommands", "function buildShortcutRuntimeContext") - pause = helper.split('if (action === "pause")', 1)[1].split('return [', 1)[1].split('];', 1)[0] - assert 'Function: "SuspendCountdown"' in pause - assert 'Function: "PauseCountdown"' not in pause - assert pause.index('Function: "SuspendCountdown"') < pause.index('Function: "SetCountdown"') + branch = helper.split('if (action === "stop" || action === "pause")', 1)[1].split('return [', 1)[1].split('];', 1)[0] + assert 'Function: "StopCountdown"' in branch + assert 'Function: "SetCountdown"' in branch + assert branch.index('Function: "StopCountdown"') < branch.index('Function: "SetCountdown"') + assert 'Function: "SuspendCountdown"' not in branch + assert 'Function: "PauseCountdown"' not in branch def test_penalty_rebalance_hard_syncs_running_countdown_and_pauses_stably(): block = _block("async function rebalanceVmixPenaltyTargets", "function finishActionMatchesSource") - assert 'stopCommands.push({ Function: "SuspendCountdown"' in block + assert 'stopCommands.push({ Function: "StopCountdown"' in block assert 'Value: () => vmixCountdownValue(entry.event.remainingMs)' in block assert 'runCommands.push({ Function: "StartCountdown"' in block + assert 'sendRuntimeVmixTimerSequence(commands)' in block + assert 'Function: "SuspendCountdown"' not in block assert 'Function: "PauseCountdown"' not in block @@ -64,4 +68,4 @@ def test_fixed_pin_1993_replaces_daily_login(tmp_path, monkeypatch): def test_build98_runtime_version(): - assert 'BUILD_VERSION = "2026.08.24.1"' in APP + assert 'BUILD_VERSION = "2026.08.24.4"' in APP diff --git a/tests/test_shortcut_sequences.py b/tests/test_shortcut_sequences.py index 020499f..72b24f2 100644 --- a/tests/test_shortcut_sequences.py +++ b/tests/test_shortcut_sequences.py @@ -106,7 +106,8 @@ def test_shortcut_editor_layout_fixes_add_step_buttons_and_fullscreen_modal() -> def test_hockey_timer_shortcut_supports_native_countdown_and_legacy_text_mirror() -> None: app_js = Path("ui_builder/static/app.js").read_text(encoding="utf-8") - assert 'Function: "SuspendCountdown"' in app_js + assert 'Function: "StopCountdown"' in app_js + assert 'Function: "SuspendCountdown"' not in app_js assert 'Function: "StopCountdown"' in app_js assert 'hockey_timer_command' in app_js assert 'game_vmix_mode' in app_js @@ -252,7 +253,8 @@ def test_build90_native_countdown_avoids_per_second_transport() -> None: app_js = Path("ui_builder/static/app.js").read_text(encoding="utf-8") assert 'game_vmix_mode !== "countdown"' in app_js assert 'const countdownMode = step.penalty_vmix_mode === "countdown"' in app_js - assert 'Function: "SuspendCountdown"' in app_js + assert 'Function: "StopCountdown"' in app_js + assert 'Function: "SuspendCountdown"' not in app_js assert 'Function: "StopCountdown"' in app_js assert 'signal: controller.signal' in app_js assert 'vmixCommandQueue: Promise.resolve()' in app_js diff --git a/ui_builder/static/app.js b/ui_builder/static/app.js index b73c36a..c4f83ea 100644 --- a/ui_builder/static/app.js +++ b/ui_builder/static/app.js @@ -4153,30 +4153,22 @@ function startCustomTooltips() { } function vmixCountdownSyncCommands(input, selectedName, millisecondsProvider, action = "start") { + // BUILD101: keep timer transport intentionally small and deterministic. + // Runtime is authoritative. vMix is only reseeded from the current Runtime + // value when the operator changes state; no ACK is required to change the web timer. const target = { Input: String(input || "").trim(), SelectedName: String(selectedName || "").trim() }; const currentValue = () => { const milliseconds = typeof millisecondsProvider === "function" ? millisecondsProvider() : millisecondsProvider; return vmixCountdownValue(milliseconds); }; if (!target.Input || !target.SelectedName) return []; - if (action === "stop") { + if (action === "stop" || action === "pause") { return [ { Function: "StopCountdown", ...target }, { Function: "SetCountdown", ...target, Value: currentValue }, ]; } - if (action === "pause") { - // vMix PauseCountdown is a toggle (pause/resume). SuspendCountdown is the - // deterministic pause-only command, so it cannot accidentally resume a timer. - return [ - { Function: "SuspendCountdown", ...target }, - { Function: "SetCountdown", ...target, Value: currentValue }, - ]; - } return [ - // Hard-sync every launch: freeze any stale title countdown first, sample - // Runtime at actual send time, then start from exactly that value. - { Function: "SuspendCountdown", ...target }, { Function: "SetCountdown", ...target, Value: currentValue }, { Function: "StartCountdown", ...target }, ]; @@ -4648,6 +4640,55 @@ function startCustomTooltips() { return queued; } + function sendRuntimeVmixTimerSequence(commands) { + // BUILD101: timer transport is deliberately independent from the generic + // shortcut/title queue. The web timer has already changed state before this + // function is called, so Agent/vMix delivery is best-effort and must never + // block the operator from pressing Start/Stop again. + const rawCommands = typeof commands === "function" ? commands() : commands; + const clean = (rawCommands || []).map(compactVmixCommand).filter((command) => command.Function); + if (!clean.length) return Promise.resolve({ ok: true, applied: 0, requested: 0, results: [] }); + + const request = fetch("/api/hockey/vmix/sequence", { + method: "POST", + cache: "no-store", + credentials: "same-origin", + keepalive: true, + headers: { "Content-Type": "application/json" }, + // Intentionally omit sequence_id/button_id. Agent 1.4+ can then send the + // tiny SetCountdown+Start/Stop pair as one vmix.batch instead of waiting + // for several interactive ACK round-trips. + body: JSON.stringify({ + commands: clean, + device_id: currentRuntimeVmixDeviceId(), + session_token: currentRuntimeHockeySessionToken(), + }), + }).then(async (response) => { + let payload = {}; + try { payload = await response.json(); } catch (_) {} + if (!response.ok) { + console.warn("vMix timer sync skipped", payload?.detail || `HTTP ${response.status}`); + return { ok: false, applied: 0, requested: clean.length, results: [], error: payload?.detail || `HTTP ${response.status}` }; + } + 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, null); + if (payload?.overlay_state) applyServerRuntimeOverlayState(payload.overlay_state); + if (payload?.ok === false) console.warn("vMix timer sync partially failed", payload); + return payload; + }).catch((error) => { + console.warn("vMix timer sync unavailable; Runtime continues locally", error); + return { ok: false, applied: 0, requested: clean.length, results: [], error: String(error?.message || error || "vmix_timer_sync_error") }; + }); + + // Do not await this from timer controls. The returned Promise is only useful + // for diagnostics/tests; local Runtime state is never rolled back on failure. + return request; + } + function splitVmixInputs(value) { return String(value || "").split(/[;,\n]+/).map((item) => item.trim()).filter(Boolean); } @@ -4710,7 +4751,7 @@ function startCustomTooltips() { } } if (!commands.length) return false; - await sendRuntimeVmixSequence(commands); + sendRuntimeVmixTimerSequence(commands); return true; } @@ -5024,9 +5065,9 @@ function startCustomTooltips() { && (force || assignmentChanged || previous?.running !== true); if (entry.event.running) { if (force || assignmentChanged || startingCountdown) { - // BUILD98: deterministic hard sync. Suspend is pause-only in vMix; - // PauseCountdown is a toggle and could accidentally resume a stale timer. - stopCommands.push({ Function: "SuspendCountdown", Input: target.input, SelectedName: target.selected_name }); + // BUILD101: while the web penalty is running, only reseed then run. + // Do not inject an extra Stop before every Start; Runtime already owns + // the state and the short SetCountdown+Start pair is enough. setCommands.push({ Function: "SetCountdown", Input: target.input, SelectedName: target.selected_name, Value: () => vmixCountdownValue(entry.event.remainingMs) }); } if (startingCountdown) { @@ -5035,7 +5076,7 @@ function startCustomTooltips() { } else if (force || assignmentChanged || previous?.running !== false) { // Freeze the title and seed the exact Runtime value, but never run a // prepared/paused penalty until the web event itself is running. - stopCommands.push({ Function: "SuspendCountdown", Input: target.input, SelectedName: target.selected_name }); + stopCommands.push({ Function: "StopCountdown", Input: target.input, SelectedName: target.selected_name }); setCommands.push({ Function: "SetCountdown", Input: target.input, SelectedName: target.selected_name, Value: () => vmixCountdownValue(entry.event.remainingMs) }); } } else { @@ -5098,7 +5139,7 @@ function startCustomTooltips() { } // Old plate OUT/Stop first, then set/start the single current countdown, then IN. const commands = [...outCommands, ...stopCommands, ...setCommands, ...runCommands, ...inCommands]; - if (commands.length) await sendRuntimeVmixSequence(commands); + if (commands.length) sendRuntimeVmixTimerSequence(commands); return commands.length; } @@ -5218,9 +5259,13 @@ function startCustomTooltips() { const penalties = currentHockeyPenaltyEntries(); if (step.sync_vmix_game && step.game_vmix_input) { - if (!gameTimerState) throw new Error(`Основной таймер «${step.game_timer_action_id}» не найден`); - if (!step.game_vmix_selected_name) throw new Error("Выберите Text / SelectedName основного таймера в vMix"); - if (step.game_vmix_mode === "text") { + if (!gameTimerState || !step.game_vmix_selected_name) { + console.warn("vMix game timer sync skipped: timer target is incomplete", { + action_id: step.game_timer_action_id || "hockey_game_timer", + input: step.game_vmix_input || "", + selected_name: step.game_vmix_selected_name || "", + }); + } else if (step.game_vmix_mode === "text") { setVmixTimerMirror(step.game_timer_action_id || "hockey_game_timer", step.game_vmix_input, step.game_vmix_selected_name); commands.push({ Function: "SetText", Input: step.game_vmix_input, SelectedName: step.game_vmix_selected_name, Value: formatTimerValue(gameTimer, gameTimerState) }); } else if (pausing) { @@ -5245,7 +5290,10 @@ function startCustomTooltips() { sideEntries.forEach(({ component, event }, index) => { const target = targets[index] || null; if (!target?.input) return; - if (!target.selected_name) throw new Error(`Для таймера удаления ${side === "home" ? "HOME" : "AWAY"} выберите Text / SelectedName`); + if (!target.selected_name) { + console.warn(`vMix penalty timer ${side === "home" ? "HOME" : "AWAY"} skipped: SelectedName is empty`, target); + return; + } const sourceSide = String(event.player?.side || event.side || side); if (step.penalty_vmix_mode === "text") { setVmixPenaltyMirror(component, event, target.input, target.selected_name, { stepId: step.id, targetId: target.id, side: sourceSide, overlay: target.overlay }); @@ -5267,17 +5315,21 @@ function startCustomTooltips() { }); } - const vmixResult = commands.length ? await sendRuntimeVmixSequence(commands, execution) : { ok: true, applied: 0 }; + // BUILD101: local timer state is already final at this point. vMix sync is + // fire-and-forget and cannot keep the Shortcut in a "running" state while + // Agent is offline/slow. The next Space press therefore always controls + // Runtime immediately. + if (commands.length) sendRuntimeVmixTimerSequence(commands); if (step.start_web_game && step.game_vmix_mode === "text" && gameTimer && gameTimerState) { - await pushVmixTimerMirror(gameTimer, gameTimerState, { force: true }); + pushVmixTimerMirror(gameTimer, gameTimerState, { force: true }).catch(() => {}); } if (step.penalty_vmix_mode === "text" && !pausing) { - for (const { component, event } of penalties) { - await pushVmixPenaltyMirror(component, event, { force: true }); - } + penalties.forEach(({ component, event }) => { + pushVmixPenaltyMirror(component, event, { force: true }).catch(() => {}); + }); } - return { ok: true, action, vmix: vmixResult, penalties: penalties.length }; + return { ok: true, action, vmix: { ok: true, queued: commands.length }, penalties: penalties.length }; } case "delay": await new Promise((resolve) => setTimeout(resolve, clamp(Number(step.milliseconds) || 0, 0, 10000))); @@ -5290,13 +5342,33 @@ function startCustomTooltips() { } } + function shortcutSequenceTimerControlSteps(sequence) { + return (sequence?.steps || []).filter((step) => step?.enabled !== false && ["timer_command", "hockey_vmix_timers_start"].includes(step?.type)); + } + + function runShortcutTimerControlsWhileBusy(sequence, meta = {}) { + const timerSteps = shortcutSequenceTimerControlSteps(sequence); + if (!timerSteps.length) return false; + const execution = { sequence_id: "", sequence_name: "", button_id: "" }; + timerSteps.forEach((step) => { + // runShortcutSequenceStep performs the local state change synchronously before + // its Promise resolves. BUILD101 timer/vMix transport does not await Agent. + runShortcutSequenceStep(sequence, step, execution).catch((error) => { + console.warn("Timer shortcut while sequence busy failed locally", error); + }); + }); + return true; + } + 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)) { - // 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. + // BUILD101: timer controls are never queued behind title/Agent ACK traffic. + // If the timer is already visibly running, the next physical Space press must + // stop it immediately even while some unrelated title command is still waiting. + if (runShortcutTimerControlsWhileBusy(sequence, meta)) return true; + // Non-timer shortcuts keep one safe follow-up action rather than accumulating. if (!state.pendingShortcutSequenceRuns.has(sequence.id)) { state.pendingShortcutSequenceRuns.set(sequence.id, { ...meta, queued: true }); } @@ -13774,7 +13846,7 @@ function renderHockeyPenaltyDashboard(node, component, runtime) {
Режим Countdown vMix рекомендуется и используется по умолчанию: при каждом запуске или продолжении веб сначала отправляет актуальное время через SetCountdown, затем StartCountdown; каждую секунду значение не передаётся. Text mirror оставлен только как режим совместимости для старых титров. Для верхнего счёта используется одна penalty-плашка: при реальном большинстве она показывается на стороне команды преимущества и отсчитывает ближайшее изменение численного состава; при чистом равном обоюдном удалении плашка не выводится. Режим «Все удаления по слотам» оставлен как дополнительный. Действие по окончании показывает выбранный Input в заданном Overlay и автоматически убирает его через указанное время.
Режим Countdown vMix: веб-таймер является главным. Start сразу меняет состояние Runtime и независимо отправляет SetCountdown → StartCountdown в vMix; Pause/Stop сразу останавливают Runtime и независимо отправляют StopCountdown → SetCountdown. Ошибка Agent не блокирует управление таймером. Каждую секунду значение в vMix не передаётся. Text mirror оставлен только для совместимости со старыми титрами. Для верхнего счёта используется одна penalty-плашка: при реальном большинстве она показывает ближайшее изменение численного состава; при чистом равном обоюдном удалении сама по себе не появляется.