тест 2

This commit is contained in:
2026-08-24 16:25:46 +03:00
parent c82ec2cbfb
commit f9df04bd00
6 changed files with 81 additions and 23 deletions

3
app.py
View File

@@ -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.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.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"

View File

@@ -22,7 +22,8 @@ def test_timer_start_stop_protocol_is_minimal():
helper = _block("function vmixCountdownSyncCommands", "function buildShortcutRuntimeContext") helper = _block("function vmixCountdownSyncCommands", "function buildShortcutRuntimeContext")
assert 'if (action === "stop" || action === "pause")' in helper 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] 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] start_branch = helper.rsplit('return [', 1)[1]
assert start_branch.index('Function: "SetCountdown"') < start_branch.index('Function: "StartCountdown"') assert start_branch.index('Function: "SetCountdown"') < start_branch.index('Function: "StartCountdown"')
assert 'SuspendCountdown' not in helper assert 'SuspendCountdown' not in helper

View File

@@ -89,7 +89,6 @@ def test_timer_fast_backend_uses_one_no_ack_batch_and_bypasses_mapping_lock(tmp_
user, user,
[ [
{"Function": "StopCountdown", "Input": "SCORE", "SelectedName": "TIME.Text"}, {"Function": "StopCountdown", "Input": "SCORE", "SelectedName": "TIME.Text"},
{"Function": "SetCountdown", "Input": "SCORE", "SelectedName": "TIME.Text", "Value": "00:18:41"},
], ],
delivery_mode="timer-fast", 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"] 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[0]["commands"]] == ["SetCountdown", "StartCountdown"]
assert [item["Function"] for item in batches[1]["commands"]] == ["StopCountdown", "SetCountdown"] assert len(singles) == 1
assert not [item for item in ws.sent if item.get("type") == "vmix.command"] assert singles[0]["command"]["Function"] == "StopCountdown"
for payload in (result, stop_result): assert result["ok"] is True
assert payload["ok"] is True assert result["transport"] == "timer-batch-no-ack"
assert payload["transport"] == "timer-batch-no-ack" assert result["confirmation"] == "not_waited"
assert payload["confirmation"] == "not_waited" assert result["applied"] == 2
assert payload["applied"] == 2 assert all(row["ack_waited"] is False for row in result["results"])
assert all(row["ack_waited"] is False for row in payload["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 assert not hub._pending_commands
asyncio.run(scenario()) asyncio.run(scenario())

View File

@@ -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

View File

@@ -30,12 +30,11 @@ def test_countdown_start_is_simple_set_start():
assert 'Value: currentValue' in helper 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") helper = _block("function vmixCountdownSyncCommands", "function buildShortcutRuntimeContext")
branch = helper.split('if (action === "stop" || action === "pause")', 1)[1].split('return [', 1)[1].split('];', 1)[0] branch = helper.split('if (action === "stop" || action === "pause")', 1)[1].split('return [', 1)[1].split('];', 1)[0]
assert 'Function: "StopCountdown"' in branch assert 'Function: "StopCountdown"' in branch
assert 'Function: "SetCountdown"' in branch assert 'Function: "SetCountdown"' not in branch
assert branch.index('Function: "StopCountdown"') < branch.index('Function: "SetCountdown"')
assert 'Function: "SuspendCountdown"' not in branch assert 'Function: "SuspendCountdown"' not in branch
assert 'Function: "PauseCountdown"' not in branch assert 'Function: "PauseCountdown"' not in branch

View File

@@ -4163,6 +4163,16 @@ function startCustomTooltips() {
}; };
if (!target.Input || !target.SelectedName) return []; if (!target.Input || !target.SelectedName) return [];
if (action === "stop" || action === "pause") { 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 [ return [
{ Function: "StopCountdown", ...target }, { Function: "StopCountdown", ...target },
{ Function: "SetCountdown", ...target, Value: currentValue }, { Function: "SetCountdown", ...target, Value: currentValue },
@@ -4749,7 +4759,7 @@ function startCustomTooltips() {
commands.push(...vmixCountdownSyncCommands(input, selectedName, valueProvider, "stop")); commands.push(...vmixCountdownSyncCommands(input, selectedName, valueProvider, "stop"));
} else if (["timer_reset", "timer_set_time", "timer_add_time", "timer_subtract_time"].includes(eventName)) { } else if (["timer_reset", "timer_set_time", "timer_add_time", "timer_subtract_time"].includes(eventName)) {
commands.push(...vmixCountdownSyncCommands( 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 || "")}`; 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 outCommands = [];
const stopCommands = []; const stopCommands = [];
const setCommands = []; const setCommands = [];
@@ -5077,10 +5087,13 @@ function startCustomTooltips() {
runCommands.push({ Function: "StartCountdown", Input: target.input, SelectedName: target.selected_name }); runCommands.push({ Function: "StartCountdown", Input: target.input, SelectedName: target.selected_name });
} }
} else if (force || assignmentChanged || previous?.running !== false) { } else if (force || assignmentChanged || previous?.running !== false) {
// Freeze the title and seed the exact Runtime value, but never run a // BUILD104: a normal Pause of a prepared/paused penalty freezes the native vMix countdown only.
// prepared/paused penalty until the web event itself is running. // 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 }); 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 { } else {
assignedMirrorKeys.add(eventKey); assignedMirrorKeys.add(eventKey);
@@ -7220,8 +7233,11 @@ function openTimerQuickEditor(focusActionId = "") {
persistHockeyBoard(component, board, true); persistHockeyBoard(component, board, true);
refreshHockeyBoardNodes(component); refreshHockeyBoardNodes(component);
if (options.syncVmix !== false && state.activeHockeyVmixTimerSteps.size && ["start", "pause", "reset", "set_time"].includes(command)) { if (options.syncVmix !== false && state.activeHockeyVmixTimerSteps.size && ["start", "pause", "reset", "set_time"].includes(command)) {
rebalanceVmixPenaltyTargets({ force: true, hideUnused: true }) rebalanceVmixPenaltyTargets({
.catch((error) => console.error("Penalty countdown state sync error", error)); force: true,
hideUnused: true,
preservePausedCountdown: command === "pause",
}).catch((error) => console.error("Penalty countdown state sync error", error));
} }
return true; return true;
} }
@@ -13852,7 +13868,7 @@ function renderHockeyPenaltyDashboard(node, component, runtime) {
<div class="shortcut-finish-action-list">${timerFinishActionRows(step)}</div> <div class="shortcut-finish-action-list">${timerFinishActionRows(step)}</div>
<div class="shortcut-vmix-inventory-note"><span>${escapeHtml(shortcutInventoryLabel())}</span><small>Для каждого countdown теперь обязательно выбирается конкретный Text / SelectedName. Это исключает отправку времени в первый текстовый элемент по умолчанию.</small></div> <div class="shortcut-vmix-inventory-note"><span>${escapeHtml(shortcutInventoryLabel())}</span><small>Для каждого countdown теперь обязательно выбирается конкретный Text / SelectedName. Это исключает отправку времени в первый текстовый элемент по умолчанию.</small></div>
<p class="shortcut-step-note">Режим <b>Countdown vMix</b>: веб-таймер является главным. Start сразу меняет состояние Runtime и независимо отправляет <code>SetCountdown → StartCountdown</code> в vMix; Pause/Stop сразу останавливают Runtime и независимо отправляют <code>StopCountdown → SetCountdown</code>. Ошибка Agent не блокирует управление таймером. Каждую секунду значение в vMix не передаётся. <b>Text mirror</b> оставлен только для совместимости со старыми титрами. Для верхнего счёта используется одна penalty-плашка: при реальном большинстве она показывает ближайшее изменение численного состава; при чистом равном обоюдном удалении сама по себе не появляется.</p> <p class="shortcut-step-note">Режим <b>Countdown vMix</b>: веб-таймер является главным. Start сразу меняет состояние Runtime и независимо отправляет <code>SetCountdown → StartCountdown</code> в vMix; Pause/Stop сразу останавливают Runtime и независимо отправляют только <code>StopCountdown</code>, без повторной установки времени. Ошибка Agent не блокирует управление таймером. Каждую секунду значение в vMix не передаётся. <b>Text mirror</b> оставлен только для совместимости со старыми титрами. Для верхнего счёта используется одна penalty-плашка: при реальном большинстве она показывает ближайшее изменение численного состава; при чистом равном обоюдном удалении сама по себе не появляется.</p>
</div>`; </div>`;
} else if (step.type === "delay") { } else if (step.type === "delay") {
body.innerHTML = `<div class="shortcut-step-grid"><label>Задержка, мс<input type="number" min="0" max="10000" step="10" data-step-field="milliseconds" value="${Number(step.milliseconds) || 0}"></label></div>`; body.innerHTML = `<div class="shortcut-step-grid"><label>Задержка, мс<input type="number" min="0" max="10000" step="10" data-step-field="milliseconds" value="${Number(step.milliseconds) || 0}"></label></div>`;