удаления еще раз 2

This commit is contained in:
2026-08-20 16:22:47 +03:00
parent 9429659c14
commit 54fd7dbbed
9 changed files with 167 additions and 51 deletions

4
app.py
View File

@@ -29,7 +29,9 @@ 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.20.13" 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" # compatibility: BUILD_VERSION = "2026.08.19.28"
# compatibility: BUILD_VERSION = "2026.08.19.24" # compatibility: BUILD_VERSION = "2026.08.19.24"
# compatibility: BUILD_VERSION = "2026.08.19.22" # compatibility: BUILD_VERSION = "2026.08.19.22"

View File

@@ -14,7 +14,7 @@ from sqlalchemy import and_, delete, desc, func, or_, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .client import Stat2TVClient, Stat2TVNotFoundError from .client import Stat2TVClient, Stat2TVNotFoundError
from .config import DEFAULT_PERIOD_STATUS_LABELS, HockeySettingsStore from .config import DEFAULT_PERIOD_STATUS_LABELS, DEFAULT_STRENGTH_STATE_LABELS, HockeySettingsStore
from .database import HockeyDatabase from .database import HockeyDatabase
from .iso_country_codes import iso2_from_code from .iso_country_codes import iso2_from_code
from .localization import ( from .localization import (
@@ -5524,40 +5524,28 @@ class HockeyDataService:
configured_state = phase_labels_map.get(state_key) configured_state = phase_labels_map.get(state_key)
state_labels = configured_state if isinstance(configured_state, dict) else {} state_labels = configured_state if isinstance(configured_state, dict) else {}
language_key = "en" if language == "en" else "ru" language_key = "en" if language == "en" else "ru"
has_configured_label = language_key in state_labels
state_label = str(state_labels.get(language_key) or "").strip()
# Backwards-compatible fallback only when an old configuration does not # BUILD94: the captions configured in "Таймеры и численные составы" are
# contain this matrix cell at all. An explicitly empty cell means that # the single source of truth for strength text everywhere: Runtime,
# the operator wants no extra caption for that numerical state. # Mapping and vMix. Do not synthesize a second caption from state_key and
if not has_configured_label: # do not silently substitute the legacy PP/PK strings. When a raw/legacy
legacy_pp = str(settings.get("strength_powerplay_label") or "PP").strip() or "PP" # settings dictionary is missing a matrix cell, use the same default
if advantage_side and state_key == "5x4": # matrix that the settings UI/store uses. An explicitly present empty
state_label = legacy_pp # value stays empty.
elif state_key not in {"5x5", ""}: if language_key in state_labels:
state_label = state_key.replace("x", " on " if language_key == "en" else " на ") state_label = str(state_labels.get(language_key) or "").strip()
else:
default_phase = DEFAULT_STRENGTH_STATE_LABELS.get(label_phase, {})
default_state = default_phase.get(state_key, {}) if isinstance(default_phase, dict) else {}
state_label = str(default_state.get(language_key) or "").strip()
# BUILD92: Mapping captions describe the current numerical condition. # HOME/AWAY are separate Mapping destinations, but their text must come
# The advantage side keeps its configured state caption (PP, "Power play", # from the same configured state caption. If penalties are active, both
# etc.), while a penalized non-advantage side receives the numerical # side-label identifiers expose that configured caption; the one-plate
# condition itself (5 на 4 / 5 on 4, 5 на 3 / 5 on 3, ...). For an # television routing still decides which side is actually shown on air.
# equal coincidental state both penalized sides receive the same numerical penalty_state_active = bool(state_key and (home_penalties > 0 or away_penalties > 0))
# condition. This is Mapping-only data; television output still follows home_label = state_label if penalty_state_active else ""
# the single-penalty-plate runtime logic. away_label = state_label if penalty_state_active else ""
numerical_state_label = (
state_key.replace("x", " on " if language_key == "en" else " на ")
if state_key else ""
)
home_label = (
state_label if advantage_side == "home"
else numerical_state_label if home_penalties > 0
else ""
)
away_label = (
state_label if advantage_side == "away"
else numerical_state_label if away_penalties > 0
else ""
)
phase_labels = { phase_labels = {
"regulation": ("Основное время", "Regulation"), "regulation": ("Основное время", "Regulation"),
"regular_overtime": ("Овертайм регулярки", "Regular OT"), "regular_overtime": ("Овертайм регулярки", "Regular OT"),

View File

@@ -32,7 +32,7 @@ def test_complex_two_vs_one_keeps_real_strength_and_outputs_caption_on_advantage
) )
assert value["strength_label"] == "3×4" assert value["strength_label"] == "3×4"
assert value["advantage_side"] == "away" assert value["advantage_side"] == "away"
assert value["home_label"] == "4 на 3" assert value["home_label"] == "Играют в большинстве"
assert value["away_label"] == "Играют в большинстве" assert value["away_label"] == "Играют в большинстве"
@@ -50,7 +50,7 @@ def test_single_remaining_penalty_outputs_pp_caption_on_opposite_team():
language="ru", language="ru",
) )
assert value["advantage_side"] == "away" assert value["advantage_side"] == "away"
assert value["home_label"] == "5 на 4" assert value["home_label"] == "Играют в большинстве"
assert value["away_label"] == "Играют в большинстве" assert value["away_label"] == "Играют в большинстве"

View File

@@ -55,7 +55,7 @@ def test_two_vs_one_penalties_caption_only_advantage_team():
) )
assert payload["strength_label"] == "3×4" assert payload["strength_label"] == "3×4"
assert payload["advantage_side"] == "away" assert payload["advantage_side"] == "away"
assert payload["home_label"] == "4 на 3" assert payload["home_label"] == "Играют в большинстве"
assert payload["away_label"] == "Играют в большинстве" assert payload["away_label"] == "Играют в большинстве"

View File

@@ -64,7 +64,7 @@ def test_play_by_play_defaults_collapsed_until_operator_opens_it():
def test_penalized_side_gets_numeric_condition_and_advantage_side_keeps_pp_caption(): def test_penalized_side_gets_numeric_condition_and_advantage_side_keeps_pp_caption():
payload = _strength(home=1, away=0) payload = _strength(home=1, away=0)
assert payload["home_label"] == "5 на 4" assert payload["home_label"] == "Играют в большинстве"
assert payload["away_label"] == "Играют в большинстве" assert payload["away_label"] == "Играют в большинстве"
@@ -77,5 +77,5 @@ def test_equal_coincidental_penalties_expose_numeric_condition_on_both_mapping_s
def test_english_penalty_side_uses_numeric_condition(): def test_english_penalty_side_uses_numeric_condition():
payload = _strength(home=1, away=0, language="en") payload = _strength(home=1, away=0, language="en")
assert payload["home_label"] == "5 on 4" assert payload["home_label"] == "Power play"
assert payload["away_label"] == "Power play" assert payload["away_label"] == "Power play"

View File

@@ -33,7 +33,7 @@ def payload(home, away, labels=None):
def test_one_home_penalty_uses_5_on_4_on_penalized_side(): def test_one_home_penalty_uses_5_on_4_on_penalized_side():
result = payload(1, 0) result = payload(1, 0)
assert result["home_label"] == "5 на 4" assert result["home_label"] == "Играют в большинстве"
assert result["away_label"] == "Играют в большинстве" assert result["away_label"] == "Играют в большинстве"
@@ -53,5 +53,5 @@ def test_complex_two_vs_one_keeps_majority_caption_and_numeric_penalty_side():
labels = {"regulation": {"4x3": {"ru": "Большинство 4 на 3"}}} labels = {"regulation": {"4x3": {"ru": "Большинство 4 на 3"}}}
result = payload(2, 1, labels) result = payload(2, 1, labels)
assert result["advantage_side"] == "away" assert result["advantage_side"] == "away"
assert result["home_label"] == "4 на 3" assert result["home_label"] == "Большинство 4 на 3"
assert result["away_label"] == "Большинство 4 на 3" assert result["away_label"] == "Большинство 4 на 3"

View File

@@ -0,0 +1,40 @@
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 test_game_countdown_resume_is_reseeded_before_start():
block = APP_JS.split("async function syncActiveVmixGameCountdown", 1)[1].split("function penaltyMirrorKey", 1)[0]
assert '["timer_start", "timer_restart", "timer_resume"].includes(eventName)' in block
set_pos = block.index('Function: "SetCountdown"')
start_pos = block.index('Function: "StartCountdown"')
assert set_pos < start_pos
assert 'eventName === "timer_resume"' not in block
def test_hockey_timer_shortcut_reseeds_game_and_penalty_on_resume():
block = APP_JS.split('case "hockey_vmix_timers_start":', 1)[1].split('case "delay":', 1)[0]
# Resume must no longer have a StartCountdown-only branch.
assert '} else if (action === "resume") {' not in block
assert block.count('Function: "SetCountdown"') >= 2
assert block.count('Function: "StartCountdown"') >= 2
def test_penalty_rebalance_pairs_every_start_with_current_web_value():
block = APP_JS.split("async function rebalanceVmixPenaltyTargets", 1)[1].split("function currentHockeyPenaltyEntries", 1)[0]
assert "const startingCountdown" in block
assert "force || assignmentChanged || startingCountdown" in block
assert 'Function: "SetCountdown"' in block
assert 'Function: "StartCountdown"' in block
def test_native_mode_still_has_no_per_second_countdown_transport():
assert 'eventName === "timer_tick" && state.vmixTimerMirrors.has(component.action_id)' in APP_JS
tick_branch = APP_JS.split('eventName === "timer_tick" && state.vmixTimerMirrors.has(component.action_id)', 1)[1].split('} else if (eventName !== "timer_tick"', 1)[0]
assert "SetCountdown" not in tick_branch
def test_build93_runtime_version():
assert 'BUILD_VERSION = "2026.08.20.14"' in APP

View File

@@ -0,0 +1,84 @@
from hockey_data.service import HockeyDataService
def penalty(side: str):
return {
"side": side,
"infraction": {"id": "minor"},
"preset": "2m",
"durationMs": 120000,
"remainingMs": 120000,
"finished": False,
}
def strength(settings, *, home=0, away=0, language="ru"):
timer_state = {
"penalty_board": {
"penalties": [penalty("home") for _ in range(home)] + [penalty("away") for _ in range(away)]
}
}
base = {
"strength_regulation_skaters": 5,
"strength_regular_overtime_skaters": 3,
"strength_playoff_overtime_skaters": 5,
"strength_min_skaters": 3,
}
base.update(settings)
return HockeyDataService._strength_payload(
base, stage="regular", current_period="1", timer_state=timer_state, language=language
)
def test_home_and_away_labels_use_exact_configured_strength_caption():
result = strength({
"strength_state_labels": {
"regulation": {"5x4": {"ru": "МОЯ ПОДПИСЬ 5x4", "en": "MY 5x4 CAPTION"}}
}
}, home=1)
assert result["state_key"] == "5x4"
assert result["state_label"] == "МОЯ ПОДПИСЬ 5x4"
assert result["home_label"] == "МОЯ ПОДПИСЬ 5x4"
assert result["away_label"] == "МОЯ ПОДПИСЬ 5x4"
def test_complex_strength_uses_configured_caption_without_numeric_regeneration():
result = strength({
"strength_state_labels": {
"regulation": {"4x3": {"ru": "СТУДИЙНОЕ 4x3", "en": "STUDIO 4x3"}}
}
}, home=2, away=1)
assert result["advantage_side"] == "away"
assert result["state_label"] == "СТУДИЙНОЕ 4x3"
assert result["home_label"] == "СТУДИЙНОЕ 4x3"
assert result["away_label"] == "СТУДИЙНОЕ 4x3"
def test_explicit_empty_strength_caption_remains_empty_everywhere():
result = strength({
"strength_state_labels": {
"regulation": {"5x4": {"ru": "", "en": ""}}
}
}, home=1)
assert result["state_label"] == ""
assert result["home_label"] == ""
assert result["away_label"] == ""
def test_missing_raw_matrix_cell_uses_same_settings_default_matrix():
result = strength({"strength_state_labels": {}}, home=1)
assert result["state_key"] == "5x4"
assert result["state_label"] == "PP"
assert result["home_label"] == "PP"
assert result["away_label"] == "PP"
def test_english_uses_english_caption_from_same_settings_cell():
result = strength({
"strength_state_labels": {
"regulation": {"5x3": {"ru": "ПЯТЬ НА ТРИ", "en": "FIVE ON THREE"}}
}
}, home=2, language="en")
assert result["state_label"] == "FIVE ON THREE"
assert result["home_label"] == "FIVE ON THREE"
assert result["away_label"] == "FIVE ON THREE"

View File

@@ -4606,11 +4606,11 @@ function startCustomTooltips() {
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 target = { Input: input, SelectedName: selectedName }; const target = { Input: input, SelectedName: selectedName };
if (["timer_start", "timer_restart"].includes(eventName)) { 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.
commands.push({ Function: "SetCountdown", ...target, Value: vmixCountdownValue(timerState.currentMs) }); commands.push({ Function: "SetCountdown", ...target, Value: vmixCountdownValue(timerState.currentMs) });
commands.push({ Function: "StartCountdown", ...target }); commands.push({ Function: "StartCountdown", ...target });
} else if (eventName === "timer_resume") {
commands.push({ Function: "StartCountdown", ...target });
} else if (eventName === "timer_pause") { } else if (eventName === "timer_pause") {
commands.push({ Function: "PauseCountdown", ...target }); commands.push({ Function: "PauseCountdown", ...target });
} else if (["timer_stop", "timer_finished"].includes(eventName)) { } else if (["timer_stop", "timer_finished"].includes(eventName)) {
@@ -4893,11 +4893,15 @@ function startCustomTooltips() {
eventKey, input: target.input, selectedName: target.selected_name, overlay, sourceSide, targetSide: side, eventKey, input: target.input, selectedName: target.selected_name, overlay, sourceSide, targetSide: side,
mode: "countdown", running: Boolean(entry.event.running), mode: "countdown", running: Boolean(entry.event.running),
}); });
if (force || assignmentChanged) { const startingCountdown = Boolean(entry.event.running)
&& (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) }); setCommands.push({ Function: "SetCountdown", Input: target.input, SelectedName: target.selected_name, Value: vmixCountdownValue(entry.event.remainingMs) });
} }
if (entry.event.running) { if (entry.event.running) {
if (force || assignmentChanged || previous?.running !== true) { if (startingCountdown) {
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) {
@@ -5078,9 +5082,8 @@ function startCustomTooltips() {
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({ Function: "PauseCountdown", Input: step.game_vmix_input, SelectedName: step.game_vmix_selected_name }); commands.push({ Function: "PauseCountdown", Input: step.game_vmix_input, SelectedName: step.game_vmix_selected_name });
} else if (action === "resume") {
commands.push({ Function: "StartCountdown", Input: step.game_vmix_input, SelectedName: step.game_vmix_selected_name });
} else { } else {
// BUILD93: start and resume are both a hard Runtime -> vMix sync.
commands.push({ Function: "SetCountdown", Input: step.game_vmix_input, SelectedName: step.game_vmix_selected_name, Value: vmixCountdownValue(gameTimerState.currentMs) }); 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 }); commands.push({ Function: "StartCountdown", Input: step.game_vmix_input, SelectedName: step.game_vmix_selected_name });
} }
@@ -5111,9 +5114,8 @@ function startCustomTooltips() {
}); });
if (pausing) { if (pausing) {
commands.push({ Function: "PauseCountdown", Input: target.input, SelectedName: target.selected_name }); commands.push({ Function: "PauseCountdown", Input: target.input, SelectedName: target.selected_name });
} else if (action === "resume") {
commands.push({ Function: "StartCountdown", Input: target.input, SelectedName: target.selected_name });
} else { } else {
// BUILD93: 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: "SetCountdown", Input: target.input, SelectedName: target.selected_name, Value: vmixCountdownValue(event.remainingMs) });
commands.push({ Function: "StartCountdown", Input: target.input, SelectedName: target.selected_name }); commands.push({ Function: "StartCountdown", Input: target.input, SelectedName: target.selected_name });
} }
@@ -13461,7 +13463,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> рекомендуется и используется по умолчанию: веб отправляет <code>SetCountdown</code> только при установке/коррекции времени и затем <code>StartCountdown</code>; каждую секунду значение больше не передаётся. <b>Text mirror</b> оставлен только как режим совместимости для старых титров. Для верхнего счёта используется одна penalty-плашка: при реальном большинстве она показывается на стороне команды преимущества и отсчитывает ближайшее изменение численного состава; при чистом равном обоюдном удалении плашка не выводится. Режим «Все удаления по слотам» оставлен как дополнительный. Действие по окончании показывает выбранный Input в заданном Overlay и автоматически убирает его через указанное время.</p> <p class="shortcut-step-note">Режим <b>Countdown vMix</b> рекомендуется и используется по умолчанию: при каждом запуске или продолжении веб сначала отправляет актуальное время через <code>SetCountdown</code>, затем <code>StartCountdown</code>; каждую секунду значение не передаётся. <b>Text mirror</b> оставлен только как режим совместимости для старых титров. Для верхнего счёта используется одна penalty-плашка: при реальном большинстве она показывается на стороне команды преимущества и отсчитывает ближайшее изменение численного состава; при чистом равном обоюдном удалении плашка не выводится. Режим «Все удаления по слотам» оставлен как дополнительный. Действие по окончании показывает выбранный Input в заданном Overlay и автоматически убирает его через указанное время.</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>`;