тест 2
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
|
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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
37
tests/test_build104_timer_stop_no_rollback.py
Normal file
37
tests/test_build104_timer_stop_no_rollback.py
Normal 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
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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>`;
|
||||||
|
|||||||
Reference in New Issue
Block a user