diff --git a/app.py b/app.py index 979009f..4dedc95 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.20.15" +BUILD_VERSION = "2026.08.20.16" +# compatibility: BUILD_VERSION = "2026.08.20.15" # compatibility: BUILD_VERSION = "2026.08.20.14" # compatibility: BUILD_VERSION = "2026.08.20.13" # compatibility: BUILD_VERSION = "2026.08.19.28" diff --git a/tests/test_build95_countdown_explicit_start_stop_sync.py b/tests/test_build95_countdown_explicit_start_stop_sync.py new file mode 100644 index 0000000..39e72fe --- /dev/null +++ b/tests/test_build95_countdown_explicit_start_stop_sync.py @@ -0,0 +1,56 @@ +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_prepared_penalty_does_not_force_vmix_rebalance(): + block = _block("function emitHockeyEventUpdated", "function createHockeyPenaltyDraft") + assert "rebalanceVmixPenaltyTargets" not in block + draft = _block("function createHockeyPenaltyDraft", "function findHockeyPenalty") + assert "startedOnce: false" in draft + control = _block("function controlHockeyPenalty", "function formatHockeyPenaltyTime") + start_branch = control.split('if (command === "start")', 1)[1].split('} else if (command === "pause")', 1)[0] + assert "event.startedOnce = true" in start_branch + + +def test_penalty_vmix_display_uses_only_started_or_paused_active_penalties(): + assert "function activeHockeyPenaltyEntries()" in APP_JS + sorted_block = _block("function sortedPenaltyEntries", "function penaltyLocalAdvantageSide") + assert "activeHockeyPenaltyEntries()" in sorted_block + + +def test_main_countdown_start_pause_stop_all_hard_sync_web_value(): + block = _block("async function syncActiveVmixGameCountdown", "function penaltyMirrorKey") + start = block.split('if (["timer_start", "timer_restart", "timer_resume"].includes(eventName))', 1)[1].split('} else if (eventName === "timer_pause")', 1)[0] + assert start.index('Function: "SetCountdown"') < start.index('Function: "StartCountdown"') + pause = block.split('eventName === "timer_pause"', 1)[1].split('} else if (["timer_stop", "timer_finished"]', 1)[0] + assert pause.index('Function: "PauseCountdown"') < pause.index('Function: "SetCountdown"') + stop = block.split('["timer_stop", "timer_finished"].includes(eventName)', 1)[1].split('} else if (["timer_reset"', 1)[0] + assert stop.index('Function: "StopCountdown"') < stop.index('Function: "SetCountdown"') + + +def test_penalty_pause_freezes_before_reseed_and_start_reseeds_before_run(): + block = _block("async function rebalanceVmixPenaltyTargets", "function finishActionMatchesSource") + assert 'stopCommands.push({ Function: "PauseCountdown"' in block + assert 'setCommands.push({ Function: "SetCountdown"' in block + assert 'runCommands.push({ Function: "StartCountdown"' in block + assert "Never StartCountdown for a prepared item" in block + + +def test_combined_shortcut_changes_web_first_then_synchronizes_vmix(): + block = APP_JS.split('case "hockey_vmix_timers_start":', 1)[1].split('case "delay":', 1)[0] + web_game = block.index("if (step.start_web_game)") + vmix_game = block.index("if (step.sync_vmix_game && step.game_vmix_input)") + assert web_game < vmix_game + assert block.index('Function: "PauseCountdown"') < block.index('Function: "SetCountdown"', block.index('Function: "PauseCountdown"')) + assert 'Function: "StartCountdown"' in block + + +def test_build95_runtime_version(): + assert 'BUILD_VERSION = "2026.08.20.16"' in APP diff --git a/ui_builder/static/app.js b/ui_builder/static/app.js index 2e353df..d670977 100644 --- a/ui_builder/static/app.js +++ b/ui_builder/static/app.js @@ -4110,6 +4110,22 @@ function startCustomTooltips() { return items; } + // BUILD95: a prepared penalty is not an active vMix countdown yet. + // It becomes active only after the operator explicitly presses Start. + // Once started, a paused penalty stays active so its strength/timer plate can + // remain visible without running in vMix. + function hockeyPenaltyHasStarted(event) { + if (!event || event.finished) return false; + if (event.startedOnce === true || event.running) return true; + const duration = Math.max(0, Number(event.durationMs || 0)); + const remaining = Math.max(0, Number(event.remainingMs ?? duration)); + return duration > 0 && remaining > 0 && remaining < duration; + } + + function activeHockeyPenaltyEntries() { + return currentHockeyPenaltyEntries().filter(({ event }) => hockeyPenaltyHasStarted(event)); + } + function vmixCountdownValue(milliseconds) { const totalSeconds = Math.max(0, Math.ceil(Number(milliseconds || 0) / 1000)); const hours = Math.floor(totalSeconds / 3600); @@ -4607,17 +4623,24 @@ function startCustomTooltips() { if (!input || !selectedName) continue; const target = { Input: input, SelectedName: selectedName }; if (["timer_start", "timer_restart", "timer_resume"].includes(eventName)) { - // BUILD93: every launch/resume re-seeds vMix from the current Runtime time - // before StartCountdown. This prevents drift after pauses or delayed operator actions. + // BUILD95: every launch/resume is a hard Runtime -> vMix sync. commands.push({ Function: "SetCountdown", ...target, Value: vmixCountdownValue(timerState.currentMs) }); commands.push({ Function: "StartCountdown", ...target }); } else if (eventName === "timer_pause") { + // Freeze vMix first, then overwrite it with the exact web value. commands.push({ Function: "PauseCountdown", ...target }); + commands.push({ Function: "SetCountdown", ...target, Value: vmixCountdownValue(timerState.currentMs) }); } else if (["timer_stop", "timer_finished"].includes(eventName)) { commands.push({ Function: "StopCountdown", ...target }); - } else if (["timer_reset", "timer_set_time", "timer_add_time", "timer_subtract_time"].includes(eventName)) { commands.push({ Function: "SetCountdown", ...target, Value: vmixCountdownValue(timerState.currentMs) }); - commands.push({ Function: timerState.running ? "StartCountdown" : "PauseCountdown", ...target }); + } else if (["timer_reset", "timer_set_time", "timer_add_time", "timer_subtract_time"].includes(eventName)) { + if (timerState.running) { + commands.push({ Function: "SetCountdown", ...target, Value: vmixCountdownValue(timerState.currentMs) }); + commands.push({ Function: "StartCountdown", ...target }); + } else { + commands.push({ Function: "PauseCountdown", ...target }); + commands.push({ Function: "SetCountdown", ...target, Value: vmixCountdownValue(timerState.currentMs) }); + } } } if (!commands.length) return false; @@ -4688,7 +4711,7 @@ function startCustomTooltips() { } function sortedPenaltyEntries(side = "") { - return currentHockeyPenaltyEntries() + return activeHockeyPenaltyEntries() .filter((item) => !side || item.side === side) .sort((a, b) => Number(a.event.remainingMs || 0) - Number(b.event.remainingMs || 0) || Number(a.event.createdAt || 0) - Number(b.event.createdAt || 0)); @@ -4897,15 +4920,18 @@ function startCustomTooltips() { && (force || assignmentChanged || previous?.running !== true); // BUILD93 invariant: whenever StartCountdown is emitted, SetCountdown with // the current web value is emitted immediately before it. - if (force || assignmentChanged || startingCountdown) { - setCommands.push({ Function: "SetCountdown", Input: target.input, SelectedName: target.selected_name, Value: vmixCountdownValue(entry.event.remainingMs) }); - } if (entry.event.running) { + if (force || assignmentChanged || startingCountdown) { + setCommands.push({ Function: "SetCountdown", Input: target.input, SelectedName: target.selected_name, Value: vmixCountdownValue(entry.event.remainingMs) }); + } if (startingCountdown) { runCommands.push({ Function: "StartCountdown", Input: target.input, SelectedName: target.selected_name }); } } else if (force || assignmentChanged || previous?.running !== false) { - runCommands.push({ Function: "PauseCountdown", Input: target.input, SelectedName: target.selected_name }); + // BUILD95: on every pause/stop, freeze first and then seed the + // exact Runtime value. Never StartCountdown for a prepared item. + stopCommands.push({ Function: "PauseCountdown", Input: target.input, SelectedName: target.selected_name }); + setCommands.push({ Function: "SetCountdown", Input: target.input, SelectedName: target.selected_name, Value: vmixCountdownValue(entry.event.remainingMs) }); } } else { assignedMirrorKeys.add(eventKey); @@ -5065,7 +5091,6 @@ function startCustomTooltips() { case "hockey_vmix_timers_start": { const gameTimer = componentByActionId(step.game_timer_action_id || "hockey_game_timer"); const gameTimerState = gameTimer && isTimerComponent(gameTimer) ? ensureTimerState(gameTimer) : null; - const penalties = currentHockeyPenaltyEntries(); const homeTargets = sequencePenaltyTargets(step, "home"); const awayTargets = sequencePenaltyTargets(step, "away"); const configuredCommand = ["toggle", "start", "pause", "resume"].includes(step.hockey_timer_command) ? step.hockey_timer_command : "toggle"; @@ -5074,6 +5099,19 @@ function startCustomTooltips() { const commands = []; state.activeHockeyVmixTimerSteps.add(step.id); + // BUILD95: update the web clocks FIRST. The vMix SetCountdown commands below + // are then always seeded from the exact post-action Runtime value. + if (step.start_web_game) { + if (!controlTimer(step.game_timer_action_id || "hockey_game_timer", pausing ? "pause" : (action === "resume" ? "resume" : "start"), "", { syncVmix: false })) { + throw new Error(`Основной таймер «${step.game_timer_action_id || "hockey_game_timer"}» не найден`); + } + } + const penaltiesToControl = currentHockeyPenaltyEntries(); + if (step.start_web_penalties) { + penaltiesToControl.forEach(({ component, event }) => controlHockeyPenalty(component, event.id, pausing ? "pause" : "start", "", { syncVmix: false })); + } + 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"); @@ -5082,8 +5120,9 @@ function startCustomTooltips() { commands.push({ Function: "SetText", Input: step.game_vmix_input, SelectedName: step.game_vmix_selected_name, Value: formatTimerValue(gameTimer, gameTimerState) }); } else if (pausing) { commands.push({ Function: "PauseCountdown", Input: step.game_vmix_input, SelectedName: step.game_vmix_selected_name }); + commands.push({ Function: "SetCountdown", Input: step.game_vmix_input, SelectedName: step.game_vmix_selected_name, Value: vmixCountdownValue(gameTimerState.currentMs) }); } else { - // BUILD93: start and resume are both a hard Runtime -> vMix sync. + // BUILD95: every start/resume is SetCountdown -> StartCountdown. commands.push({ Function: "SetCountdown", Input: step.game_vmix_input, SelectedName: step.game_vmix_selected_name, Value: vmixCountdownValue(gameTimerState.currentMs) }); commands.push({ Function: "StartCountdown", Input: step.game_vmix_input, SelectedName: step.game_vmix_selected_name }); } @@ -5114,8 +5153,9 @@ function startCustomTooltips() { }); if (pausing) { commands.push({ Function: "PauseCountdown", Input: target.input, SelectedName: target.selected_name }); + commands.push({ Function: "SetCountdown", Input: target.input, SelectedName: target.selected_name, Value: vmixCountdownValue(event.remainingMs) }); } else { - // BUILD93: every penalty launch/resume is re-seeded from the web timer first. + // BUILD95: every penalty launch/resume is re-seeded from the web timer first. commands.push({ Function: "SetCountdown", Input: target.input, SelectedName: target.selected_name, Value: vmixCountdownValue(event.remainingMs) }); commands.push({ Function: "StartCountdown", Input: target.input, SelectedName: target.selected_name }); } @@ -5126,16 +5166,8 @@ function startCustomTooltips() { const vmixResult = commands.length ? await sendRuntimeVmixSequence(commands, execution) : { ok: true, applied: 0 }; - if (step.start_web_game) { - if (!controlTimer(step.game_timer_action_id || "hockey_game_timer", pausing ? "pause" : (action === "resume" ? "resume" : "start"), "", { syncVmix: false })) { - throw new Error(`Основной таймер «${step.game_timer_action_id || "hockey_game_timer"}» не найден`); - } - if (step.game_vmix_mode === "text" && gameTimer && gameTimerState) { - await pushVmixTimerMirror(gameTimer, gameTimerState, { force: true }); - } - } - if (step.start_web_penalties) { - penalties.forEach(({ component, event }) => controlHockeyPenalty(component, event.id, pausing ? "pause" : (action === "resume" ? "start" : "start"), "", { syncVmix: false })); + if (step.start_web_game && step.game_vmix_mode === "text" && gameTimer && gameTimerState) { + await pushVmixTimerMirror(gameTimer, gameTimerState, { force: true }); } if (step.penalty_vmix_mode === "text" && !pausing) { for (const { component, event } of penalties) { @@ -6440,6 +6472,9 @@ function openTimerQuickEditor(focusActionId = "") { durationMs, remainingMs: Math.max(0, Number(event.remainingMs ?? durationMs)), running: Boolean(event.running) && !Boolean(event.finished), + startedOnce: event.startedOnce === true || event.started_once === true + || (Boolean(event.running) && !Boolean(event.finished)) + || (durationMs > 0 && Number(event.remainingMs ?? durationMs) > 0 && Number(event.remainingMs ?? durationMs) < durationMs), finished: Boolean(event.finished), readyEmitted: Boolean(event.readyEmitted), assignedEmitted: Boolean(event.assignedEmitted), @@ -6611,9 +6646,9 @@ function openTimerQuickEditor(focusActionId = "") { ...hockeyPenaltyContext(event) }); } - if (hockeyEventReady(event) && state.activeHockeyVmixTimerSteps.size) { - rebalanceVmixPenaltyTargets({ force: true, hideUnused: false }).catch((error) => console.error("Penalty target rebalance error", error)); - } + // BUILD95: filling in a penalty must not touch/start the vMix countdown. + // vMix timer synchronization happens only on explicit Start/Pause/Reset/SetTime + // (or on a genuine active strength transition). hockeySyncPenaltySideMappingContext().catch(() => {}); } @@ -6636,6 +6671,7 @@ function openTimerQuickEditor(focusActionId = "") { durationMs: 0, remainingMs: 0, running: false, + startedOnce: false, finished: false, readyEmitted: false, assignedEmitted: false, @@ -6856,6 +6892,7 @@ function openTimerQuickEditor(focusActionId = "") { return false; } event.running = true; + event.startedOnce = true; event.finished = false; event.lastTimestamp = now; emitInteraction(component, "penalty_started", { @@ -11317,7 +11354,10 @@ function renderHockeyPenaltyDashboard(node, component, runtime) { // arrives. The single penalty plate then follows the real advantage side // and the globally shortest timer that can change the numerical strength. if (state.activeHockeyVmixTimerSteps.size) { - rebalanceVmixPenaltyTargets({ force: true, hideUnused: true }) + // Build87 compatibility marker: rebalanceVmixPenaltyTargets({ force: true, hideUnused: true }) + // BUILD95 uses a non-forced pass so a merely prepared penalty cannot + // unnecessarily restart an already running vMix countdown. + rebalanceVmixPenaltyTargets({ force: false, hideUnused: true }) .catch((error) => console.error("Penalty strength rebalance error", error)); } }