From f9df04bd00d38c38f5a8aa1dd3177b1995e18517 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=AE=D1=80=D0=B8=D0=B9=20=D0=A7=D0=B5=D1=80=D0=BD=D0=B5?= =?UTF-8?q?=D0=BD=D0=BA=D0=BE?= Date: Mon, 24 Aug 2026 16:25:46 +0300 Subject: [PATCH] =?UTF-8?q?=D1=82=D0=B5=D1=81=D1=82=202?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app.py | 3 +- tests/test_build101_simple_timer_runtime.py | 3 +- ...est_build103_space_timer_fast_transport.py | 24 +++++++----- tests/test_build104_timer_stop_no_rollback.py | 37 +++++++++++++++++++ ..._build98_timer_countdown_sync_fixed_pin.py | 5 +-- ui_builder/static/app.js | 32 ++++++++++++---- 6 files changed, 81 insertions(+), 23 deletions(-) create mode 100644 tests/test_build104_timer_stop_no_rollback.py diff --git a/app.py b/app.py index ea52ae1..17a6100 100644 --- a/app.py +++ b/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.6" +BUILD_VERSION = "2026.08.24.7" +# compatibility: 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.2" diff --git a/tests/test_build101_simple_timer_runtime.py b/tests/test_build101_simple_timer_runtime.py index 93235df..312812e 100644 --- a/tests/test_build101_simple_timer_runtime.py +++ b/tests/test_build101_simple_timer_runtime.py @@ -22,7 +22,8 @@ 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"') + assert 'Function: "StopCountdown"' in stop_branch + assert 'Function: "SetCountdown"' not in stop_branch start_branch = helper.rsplit('return [', 1)[1] assert start_branch.index('Function: "SetCountdown"') < start_branch.index('Function: "StartCountdown"') assert 'SuspendCountdown' not in helper diff --git a/tests/test_build103_space_timer_fast_transport.py b/tests/test_build103_space_timer_fast_transport.py index 9818b73..0dca393 100644 --- a/tests/test_build103_space_timer_fast_transport.py +++ b/tests/test_build103_space_timer_fast_transport.py @@ -89,7 +89,6 @@ def test_timer_fast_backend_uses_one_no_ack_batch_and_bypasses_mapping_lock(tmp_ user, [ {"Function": "StopCountdown", "Input": "SCORE", "SelectedName": "TIME.Text"}, - {"Function": "SetCountdown", "Input": "SCORE", "SelectedName": "TIME.Text", "Value": "00:18:41"}, ], delivery_mode="timer-fast", ), @@ -97,16 +96,21 @@ def test_timer_fast_backend_uses_one_no_ack_batch_and_bypasses_mapping_lock(tmp_ ) batches = [item for item in ws.sent if item.get("type") == "vmix.batch"] - assert len(batches) == 2 + singles = [item for item in ws.sent if item.get("type") == "vmix.command"] + assert len(batches) == 1 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 len(singles) == 1 + assert singles[0]["command"]["Function"] == "StopCountdown" + assert result["ok"] is True + assert result["transport"] == "timer-batch-no-ack" + assert result["confirmation"] == "not_waited" + assert result["applied"] == 2 + assert all(row["ack_waited"] is False for row in result["results"]) + assert stop_result["ok"] is True + assert stop_result["transport"] == "timer-no-ack" + assert stop_result["confirmation"] == "not_waited" + assert stop_result["applied"] == 1 + assert all(row["ack_waited"] is False for row in stop_result["results"]) assert not hub._pending_commands asyncio.run(scenario()) diff --git a/tests/test_build104_timer_stop_no_rollback.py b/tests/test_build104_timer_stop_no_rollback.py new file mode 100644 index 0000000..f2366e4 --- /dev/null +++ b/tests/test_build104_timer_stop_no_rollback.py @@ -0,0 +1,37 @@ +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_pause_stop_sends_only_stopcountdown(): + helper = _block("function vmixCountdownSyncCommands", "function buildShortcutRuntimeContext") + branch = helper.split('if (action === "stop" || action === "pause")', 1)[1].split('if (action === "set")', 1)[0] + assert 'Function: "StopCountdown"' in branch + assert 'Function: "SetCountdown"' not in branch + + +def test_explicit_edit_still_can_seed_stopped_clock(): + helper = _block("function vmixCountdownSyncCommands", "function buildShortcutRuntimeContext") + branch = helper.split('if (action === "set")', 1)[1].split('return [', 2)[1] + assert 'Function: "StopCountdown"' in branch + assert 'Function: "SetCountdown"' in branch + sync = _block("async function syncActiveVmixGameCountdown", "function penaltyMirrorKey") + assert 'timerState.running ? "start" : "set"' in sync + + +def test_penalty_pause_preserves_native_vmix_frozen_value(): + rebalance = _block("async function rebalanceVmixPenaltyTargets", "function finishActionMatchesSource") + assert "preservePausedCountdown = false" in rebalance + assert "if (!preservePausedCountdown || assignmentChanged)" in rebalance + control = _block("function controlHockeyPenalty", "function formatHockeyPenaltyTime") + assert 'preservePausedCountdown: command === "pause"' in control + + +def test_build104_runtime_version(): + assert 'BUILD_VERSION = "2026.08.24.7"' in APP diff --git a/tests/test_build98_timer_countdown_sync_fixed_pin.py b/tests/test_build98_timer_countdown_sync_fixed_pin.py index d4b4a62..fa79760 100644 --- a/tests/test_build98_timer_countdown_sync_fixed_pin.py +++ b/tests/test_build98_timer_countdown_sync_fixed_pin.py @@ -30,12 +30,11 @@ def test_countdown_start_is_simple_set_start(): assert 'Value: currentValue' in helper -def test_pause_and_stop_use_stop_then_set(): +def test_pause_and_stop_freeze_without_reseed(): helper = _block("function vmixCountdownSyncCommands", "function buildShortcutRuntimeContext") 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: "SetCountdown"' not in branch assert 'Function: "SuspendCountdown"' not in branch assert 'Function: "PauseCountdown"' not in branch diff --git a/ui_builder/static/app.js b/ui_builder/static/app.js index 4174902..65bc364 100644 --- a/ui_builder/static/app.js +++ b/ui_builder/static/app.js @@ -4163,6 +4163,16 @@ function startCustomTooltips() { }; if (!target.Input || !target.SelectedName) return []; if (action === "stop" || action === "pause") { + // BUILD104: never reseed immediately after StopCountdown. vMix has already + // frozen its native countdown at the exact frame it received StopCountdown; + // a following SetCountdown from the browser can be slightly older and makes + // the on-air clock visibly jump backwards. + return [ + { Function: "StopCountdown", ...target }, + ]; + } + if (action === "set") { + // Explicit edits/reset still need to write the Runtime value while stopped. return [ { Function: "StopCountdown", ...target }, { Function: "SetCountdown", ...target, Value: currentValue }, @@ -4749,7 +4759,7 @@ function startCustomTooltips() { commands.push(...vmixCountdownSyncCommands(input, selectedName, valueProvider, "stop")); } else if (["timer_reset", "timer_set_time", "timer_add_time", "timer_subtract_time"].includes(eventName)) { commands.push(...vmixCountdownSyncCommands( - input, selectedName, valueProvider, timerState.running ? "start" : "pause" + input, selectedName, valueProvider, timerState.running ? "start" : "set" )); } } @@ -5019,7 +5029,7 @@ function startCustomTooltips() { return `${String(step?.id || "")}:${side}:${String(target?.id || "")}`; } - async function rebalanceVmixPenaltyTargets({ force = false, hideUnused = true } = {}) { + async function rebalanceVmixPenaltyTargets({ force = false, hideUnused = true, preservePausedCountdown = false } = {}) { const outCommands = []; const stopCommands = []; const setCommands = []; @@ -5077,10 +5087,13 @@ function startCustomTooltips() { runCommands.push({ Function: "StartCountdown", Input: target.input, SelectedName: target.selected_name }); } } 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. + // BUILD104: a normal Pause of a prepared/paused penalty freezes the native vMix countdown only. + // Re-seeding after StopCountdown can visibly roll the clock back. + // Explicit reset/set-time/assignment changes may still seed a value. 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) }); + if (!preservePausedCountdown || assignmentChanged) { + setCommands.push({ Function: "SetCountdown", Input: target.input, SelectedName: target.selected_name, Value: () => vmixCountdownValue(entry.event.remainingMs) }); + } } } else { assignedMirrorKeys.add(eventKey); @@ -7220,8 +7233,11 @@ function openTimerQuickEditor(focusActionId = "") { persistHockeyBoard(component, board, true); refreshHockeyBoardNodes(component); if (options.syncVmix !== false && state.activeHockeyVmixTimerSteps.size && ["start", "pause", "reset", "set_time"].includes(command)) { - rebalanceVmixPenaltyTargets({ force: true, hideUnused: true }) - .catch((error) => console.error("Penalty countdown state sync error", error)); + rebalanceVmixPenaltyTargets({ + force: true, + hideUnused: true, + preservePausedCountdown: command === "pause", + }).catch((error) => console.error("Penalty countdown state sync error", error)); } return true; } @@ -13852,7 +13868,7 @@ function renderHockeyPenaltyDashboard(node, component, runtime) {
${timerFinishActionRows(step)}
${escapeHtml(shortcutInventoryLabel())}Для каждого countdown теперь обязательно выбирается конкретный Text / SelectedName. Это исключает отправку времени в первый текстовый элемент по умолчанию.
-

Режим Countdown vMix: веб-таймер является главным. Start сразу меняет состояние Runtime и независимо отправляет SetCountdown → StartCountdown в vMix; Pause/Stop сразу останавливают Runtime и независимо отправляют StopCountdown → SetCountdown. Ошибка Agent не блокирует управление таймером. Каждую секунду значение в vMix не передаётся. Text mirror оставлен только для совместимости со старыми титрами. Для верхнего счёта используется одна penalty-плашка: при реальном большинстве она показывает ближайшее изменение численного состава; при чистом равном обоюдном удалении сама по себе не появляется.

+

Режим Countdown vMix: веб-таймер является главным. Start сразу меняет состояние Runtime и независимо отправляет SetCountdown → StartCountdown в vMix; Pause/Stop сразу останавливают Runtime и независимо отправляют только StopCountdown, без повторной установки времени. Ошибка Agent не блокирует управление таймером. Каждую секунду значение в vMix не передаётся. Text mirror оставлен только для совместимости со старыми титрами. Для верхнего счёта используется одна penalty-плашка: при реальном большинстве она показывает ближайшее изменение численного состава; при чистом равном обоюдном удалении сама по себе не появляется.

`; } else if (step.type === "delay") { body.innerHTML = `
`;