test 6
This commit is contained in:
2
app.py
2
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
|
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.10"
|
BUILD_VERSION = "2026.08.24.11"
|
||||||
# compatibility: BUILD_VERSION = "2026.08.24.8"
|
# compatibility: BUILD_VERSION = "2026.08.24.8"
|
||||||
# compatibility: BUILD_VERSION = "2026.08.24.7"
|
# compatibility: BUILD_VERSION = "2026.08.24.7"
|
||||||
# compatibility: BUILD_VERSION = "2026.08.24.6"
|
# compatibility: BUILD_VERSION = "2026.08.24.6"
|
||||||
|
|||||||
66
tests/test_build108_native_pause_no_resync.py
Normal file
66
tests/test_build108_native_pause_no_resync.py
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
APP_JS = Path(__file__).resolve().parents[1] / "ui_builder" / "static" / "app.js"
|
||||||
|
TEXT = APP_JS.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def block(start_marker: str, end_marker: str) -> str:
|
||||||
|
start = TEXT.index(start_marker)
|
||||||
|
end = TEXT.index(end_marker, start)
|
||||||
|
return TEXT[start:end]
|
||||||
|
|
||||||
|
|
||||||
|
def test_game_clock_helper_only_seeds_on_set():
|
||||||
|
helper = block(
|
||||||
|
"function vmixGameCountdownControlCommands",
|
||||||
|
"function buildShortcutRuntimeContext",
|
||||||
|
)
|
||||||
|
pause_branch = helper[helper.index('if (action === "pause"'):helper.index('if (action === "set"')]
|
||||||
|
set_branch = helper[helper.index('if (action === "set"'):helper.index('return [{ Function: "StartCountdown"')]
|
||||||
|
start_branch = helper[helper.index('return [{ Function: "StartCountdown"'):]
|
||||||
|
|
||||||
|
assert 'Function: "PauseCountdown"' in pause_branch
|
||||||
|
assert 'ChangeCountdown' not in pause_branch
|
||||||
|
assert 'SetCountdown' not in pause_branch
|
||||||
|
assert 'StopCountdown' not in pause_branch
|
||||||
|
|
||||||
|
assert 'Function: "ChangeCountdown"' in set_branch
|
||||||
|
assert 'Function: "StartCountdown"' not in set_branch
|
||||||
|
assert 'Function: "PauseCountdown"' not in set_branch
|
||||||
|
|
||||||
|
assert 'Function: "StartCountdown"' in start_branch
|
||||||
|
assert 'ChangeCountdown' not in start_branch
|
||||||
|
assert 'PauseCountdown' not in start_branch
|
||||||
|
|
||||||
|
|
||||||
|
def test_space_game_timer_uses_control_only_helper():
|
||||||
|
section = block('case "hockey_vmix_timers_start":', 'case "delay":')
|
||||||
|
game = section[:section.index('if (step.sync_vmix_penalties)')]
|
||||||
|
assert 'vmixGameCountdownControlCommands' in game
|
||||||
|
assert '"pause"' in game
|
||||||
|
assert '"start"' in game
|
||||||
|
# Generic helper may still be used later for penalty countdowns, but never in main game branch.
|
||||||
|
assert 'vmixCountdownSyncCommands' not in game
|
||||||
|
|
||||||
|
|
||||||
|
def test_explicit_time_change_seeds_even_before_first_space():
|
||||||
|
section = block(
|
||||||
|
'async function syncActiveVmixGameCountdown',
|
||||||
|
'function configuredHockeyVmixGameCountdownTargets',
|
||||||
|
)
|
||||||
|
assert 'explicitSeedEvent' in section
|
||||||
|
assert 'timer_set_time' in section
|
||||||
|
assert 'timer_reset' in section
|
||||||
|
assert 'state.config.shortcut_sequences' in section
|
||||||
|
assert 'vmixGameCountdownControlCommands(input, selectedName, valueProvider, "set")' in section
|
||||||
|
|
||||||
|
|
||||||
|
def test_pause_event_never_writes_browser_time_to_vmix():
|
||||||
|
section = block(
|
||||||
|
'async function syncActiveVmixGameCountdown',
|
||||||
|
'function configuredHockeyVmixGameCountdownTargets',
|
||||||
|
)
|
||||||
|
pause = section[section.index('eventName === "timer_pause"'):section.index('timer_stop', section.index('eventName === "timer_pause"'))]
|
||||||
|
assert '"pause"' in pause
|
||||||
|
assert '"set"' not in pause
|
||||||
|
assert 'ChangeCountdown' not in pause
|
||||||
@@ -4196,6 +4196,23 @@ function startCustomTooltips() {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function vmixGameCountdownControlCommands(input, selectedName, millisecondsProvider, action = "start") {
|
||||||
|
// BUILD108: the native vMix game clock is seeded only on an explicit time change.
|
||||||
|
// Space must never re-seed the countdown: Start/Resume only starts it and Pause/Stop
|
||||||
|
// only pauses it. This keeps the already-running native vMix countdown continuous
|
||||||
|
// and removes jumps caused by writing the browser value during a pause transition.
|
||||||
|
const target = { Input: String(input || "").trim(), SelectedName: String(selectedName || "").trim() };
|
||||||
|
if (!target.Input || !target.SelectedName) return [];
|
||||||
|
if (action === "pause" || action === "stop") {
|
||||||
|
return [{ Function: "PauseCountdown", ...target }];
|
||||||
|
}
|
||||||
|
if (action === "set") {
|
||||||
|
const milliseconds = typeof millisecondsProvider === "function" ? millisecondsProvider() : millisecondsProvider;
|
||||||
|
return [{ Function: "ChangeCountdown", ...target, Value: vmixCountdownValue(milliseconds) }];
|
||||||
|
}
|
||||||
|
return [{ Function: "StartCountdown", ...target }];
|
||||||
|
}
|
||||||
|
|
||||||
function buildShortcutRuntimeContext(sequence = null) {
|
function buildShortcutRuntimeContext(sequence = null) {
|
||||||
const timers = {};
|
const timers = {};
|
||||||
state.config.components.filter((component) => isTimerComponent(component)).forEach((component) => {
|
state.config.components.filter((component) => isTimerComponent(component)).forEach((component) => {
|
||||||
@@ -4754,25 +4771,50 @@ function startCustomTooltips() {
|
|||||||
|
|
||||||
async function syncActiveVmixGameCountdown(component, timerState, eventName) {
|
async function syncActiveVmixGameCountdown(component, timerState, eventName) {
|
||||||
if (!component?.action_id || eventName === "timer_tick") return false;
|
if (!component?.action_id || eventName === "timer_tick") return false;
|
||||||
|
const explicitSeedEvent = ["timer_reset", "timer_set_time", "timer_add_time", "timer_subtract_time", "timer_restart"].includes(eventName);
|
||||||
|
const candidateSteps = [];
|
||||||
|
if (explicitSeedEvent) {
|
||||||
|
// BUILD108: an explicit time edit must seed the configured vMix game clock even
|
||||||
|
// before Space has ever been pressed. This is the moment where 20:00 / 05:00
|
||||||
|
// (or any manual value) is transferred to vMix.
|
||||||
|
(state.config.shortcut_sequences || []).forEach((sequence) => {
|
||||||
|
if (sequence?.enabled === false) return;
|
||||||
|
(sequence.steps || []).forEach((step) => {
|
||||||
|
if (step?.enabled === false || step?.type !== "hockey_vmix_timers_start") return;
|
||||||
|
candidateSteps.push(step);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
Array.from(state.activeHockeyVmixTimerSteps).forEach((stepId) => {
|
||||||
|
const step = hockeyTimerSyncStepById(stepId);
|
||||||
|
if (step) candidateSteps.push(step);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const commands = [];
|
const commands = [];
|
||||||
for (const stepId of Array.from(state.activeHockeyVmixTimerSteps)) {
|
const seenTargets = new Set();
|
||||||
const step = hockeyTimerSyncStepById(stepId);
|
for (const step of candidateSteps) {
|
||||||
if (!step || step.enabled === false || !step.sync_vmix_game || step.game_vmix_mode !== "countdown") continue;
|
if (!step || step.enabled === false || !step.sync_vmix_game || step.game_vmix_mode !== "countdown") continue;
|
||||||
if (String(step.game_timer_action_id || "hockey_game_timer") !== String(component.action_id)) continue;
|
if (String(step.game_timer_action_id || "hockey_game_timer") !== String(component.action_id)) continue;
|
||||||
const input = String(step.game_vmix_input || "").trim();
|
const input = String(step.game_vmix_input || "").trim();
|
||||||
const selectedName = String(step.game_vmix_selected_name || "").trim();
|
const selectedName = String(step.game_vmix_selected_name || "").trim();
|
||||||
if (!input || !selectedName) continue;
|
if (!input || !selectedName) continue;
|
||||||
|
const targetKey = `${input}\u0000${selectedName}`;
|
||||||
|
if (seenTargets.has(targetKey)) continue;
|
||||||
|
seenTargets.add(targetKey);
|
||||||
const valueProvider = () => timerState.currentMs;
|
const valueProvider = () => timerState.currentMs;
|
||||||
if (["timer_start", "timer_restart", "timer_resume"].includes(eventName)) {
|
|
||||||
commands.push(...vmixCountdownSyncCommands(input, selectedName, valueProvider, "start"));
|
if (["timer_start", "timer_resume"].includes(eventName)) {
|
||||||
|
commands.push(...vmixGameCountdownControlCommands(input, selectedName, valueProvider, "start"));
|
||||||
|
} else if (eventName === "timer_restart") {
|
||||||
|
commands.push(...vmixGameCountdownControlCommands(input, selectedName, valueProvider, "set"));
|
||||||
|
commands.push(...vmixGameCountdownControlCommands(input, selectedName, valueProvider, "start"));
|
||||||
} else if (eventName === "timer_pause") {
|
} else if (eventName === "timer_pause") {
|
||||||
commands.push(...vmixCountdownSyncCommands(input, selectedName, valueProvider, "pause"));
|
commands.push(...vmixGameCountdownControlCommands(input, selectedName, valueProvider, "pause"));
|
||||||
} else if (["timer_stop", "timer_finished"].includes(eventName)) {
|
} else if (["timer_stop", "timer_finished"].includes(eventName)) {
|
||||||
commands.push(...vmixCountdownSyncCommands(input, selectedName, valueProvider, "stop"));
|
commands.push(...vmixGameCountdownControlCommands(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(...vmixGameCountdownControlCommands(input, selectedName, valueProvider, "set"));
|
||||||
input, selectedName, valueProvider, timerState.running ? "start" : "set"
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!commands.length) return false;
|
if (!commands.length) return false;
|
||||||
@@ -4805,12 +4847,13 @@ function startCustomTooltips() {
|
|||||||
async function syncConfiguredScoreboardCountdownsToRuntime() {
|
async function syncConfiguredScoreboardCountdownsToRuntime() {
|
||||||
const commands = [];
|
const commands = [];
|
||||||
configuredHockeyVmixGameCountdownTargets().forEach(({ input, selectedName, timerState }) => {
|
configuredHockeyVmixGameCountdownTargets().forEach(({ input, selectedName, timerState }) => {
|
||||||
commands.push(...vmixCountdownSyncCommands(
|
// F1 only seeds a stopped/paused game clock. If it is already running,
|
||||||
input,
|
// do not touch its native vMix time at all.
|
||||||
selectedName,
|
if (!timerState.running) {
|
||||||
() => timerState.currentMs,
|
commands.push(...vmixGameCountdownControlCommands(
|
||||||
timerState.running ? "start" : "set"
|
input, selectedName, () => timerState.currentMs, "set"
|
||||||
));
|
));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
if (!commands.length) return { ok: true, applied: 0, requested: 0 };
|
if (!commands.length) return { ok: true, applied: 0, requested: 0 };
|
||||||
// Await only WebSocket dispatch (never vMix ACK) so the countdown reseed reaches the
|
// Await only WebSocket dispatch (never vMix ACK) so the countdown reseed reaches the
|
||||||
@@ -5335,11 +5378,14 @@ function startCustomTooltips() {
|
|||||||
setVmixTimerMirror(step.game_timer_action_id || "hockey_game_timer", step.game_vmix_input, step.game_vmix_selected_name);
|
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) });
|
commands.push({ Function: "SetText", Input: step.game_vmix_input, SelectedName: step.game_vmix_selected_name, Value: formatTimerValue(gameTimer, gameTimerState) });
|
||||||
} else if (pausing) {
|
} else if (pausing) {
|
||||||
commands.push(...vmixCountdownSyncCommands(
|
// BUILD108 Space pause: native pause only, absolutely no time writeback.
|
||||||
|
commands.push(...vmixGameCountdownControlCommands(
|
||||||
step.game_vmix_input, step.game_vmix_selected_name, () => gameTimerState.currentMs, "pause"
|
step.game_vmix_input, step.game_vmix_selected_name, () => gameTimerState.currentMs, "pause"
|
||||||
));
|
));
|
||||||
} else {
|
} else {
|
||||||
commands.push(...vmixCountdownSyncCommands(
|
// BUILD108 Space start/resume: native start only. The countdown was seeded
|
||||||
|
// when the operator set/reset the period time (or by F1 before first start).
|
||||||
|
commands.push(...vmixGameCountdownControlCommands(
|
||||||
step.game_vmix_input, step.game_vmix_selected_name, () => gameTimerState.currentMs, "start"
|
step.game_vmix_input, step.game_vmix_selected_name, () => gameTimerState.currentMs, "start"
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
@@ -13921,7 +13967,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/Resume vMix выполняет <code>ChangeCountdown(время Runtime) → StartCountdown</code>; при Pause/Stop выполняет <code>PauseCountdown → ChangeCountdown(время Runtime)</code>. <code>StopCountdown</code> в обычном управлении не используется, потому что он сбрасывает countdown. Команды идут в Agent без ACK отдельными кадрами строго по порядку. Каждую секунду значение в vMix не передаётся. <b>Text mirror</b> оставлен только для совместимости со старыми титрами. Для верхнего счёта используется одна penalty-плашка: при реальном большинстве она показывает ближайшее изменение численного состава; при чистом равном обоюдном удалении сама по себе не появляется.</p>
|
<p class="shortcut-step-note">Режим <b>Countdown vMix</b>: время передаётся в vMix только при явной установке/сбросе (например, 20:00 или 05:00). После этого Space не синхронизирует значение: Start/Resume отправляет только <code>StartCountdown</code>, Pause/Stop — только <code>PauseCountdown</code>. Это исключает скачки времени при паузе. <code>StopCountdown</code> в обычном управлении не используется. Команды идут в Agent без ACK. <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