From 54fd7dbbed482fc3edaf043916568ffcf0a5bb47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=AE=D1=80=D0=B8=D0=B9=20=D0=A7=D0=B5=D1=80=D0=BD=D0=B5?= =?UTF-8?q?=D0=BD=D0=BA=D0=BE?= Date: Thu, 20 Aug 2026 16:22:47 +0300 Subject: [PATCH] =?UTF-8?q?=D1=83=D0=B4=D0=B0=D0=BB=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D1=8F=20=D0=B5=D1=89=D0=B5=20=D1=80=D0=B0=D0=B7=202?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app.py | 4 +- hockey_data/service.py | 54 +++++------- tests/test_build88_complex_penalty_display.py | 4 +- ...build89_single_penalty_plate_transition.py | 2 +- ...ld91_runtime_version_pbp_penalty_labels.py | 4 +- .../test_build92_numerical_penalty_labels.py | 4 +- .../test_build93_countdown_reseed_on_start.py | 40 +++++++++ ...t_build94_strength_labels_from_settings.py | 84 +++++++++++++++++++ ui_builder/static/app.js | 22 ++--- 9 files changed, 167 insertions(+), 51 deletions(-) create mode 100644 tests/test_build93_countdown_reseed_on_start.py create mode 100644 tests/test_build94_strength_labels_from_settings.py diff --git a/app.py b/app.py index f1315c8..979009f 100644 --- a/app.py +++ b/app.py @@ -29,7 +29,9 @@ 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.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.24" # compatibility: BUILD_VERSION = "2026.08.19.22" diff --git a/hockey_data/service.py b/hockey_data/service.py index 8037542..4df56fe 100644 --- a/hockey_data/service.py +++ b/hockey_data/service.py @@ -14,7 +14,7 @@ from sqlalchemy import and_, delete, desc, func, or_, select from sqlalchemy.orm import Session 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 .iso_country_codes import iso2_from_code from .localization import ( @@ -5524,40 +5524,28 @@ class HockeyDataService: configured_state = phase_labels_map.get(state_key) state_labels = configured_state if isinstance(configured_state, dict) else {} 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 - # contain this matrix cell at all. An explicitly empty cell means that - # the operator wants no extra caption for that numerical state. - if not has_configured_label: - legacy_pp = str(settings.get("strength_powerplay_label") or "PP").strip() or "PP" - if advantage_side and state_key == "5x4": - state_label = legacy_pp - elif state_key not in {"5x5", ""}: - state_label = state_key.replace("x", " on " if language_key == "en" else " на ") + # BUILD94: the captions configured in "Таймеры и численные составы" are + # the single source of truth for strength text everywhere: Runtime, + # Mapping and vMix. Do not synthesize a second caption from state_key and + # do not silently substitute the legacy PP/PK strings. When a raw/legacy + # settings dictionary is missing a matrix cell, use the same default + # matrix that the settings UI/store uses. An explicitly present empty + # value stays empty. + if language_key in state_labels: + 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. - # The advantage side keeps its configured state caption (PP, "Power play", - # etc.), while a penalized non-advantage side receives the numerical - # condition itself (5 на 4 / 5 on 4, 5 на 3 / 5 on 3, ...). For an - # equal coincidental state both penalized sides receive the same numerical - # condition. This is Mapping-only data; television output still follows - # the single-penalty-plate runtime logic. - 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 "" - ) + # HOME/AWAY are separate Mapping destinations, but their text must come + # from the same configured state caption. If penalties are active, both + # side-label identifiers expose that configured caption; the one-plate + # television routing still decides which side is actually shown on air. + penalty_state_active = bool(state_key and (home_penalties > 0 or away_penalties > 0)) + home_label = state_label if penalty_state_active else "" + away_label = state_label if penalty_state_active else "" phase_labels = { "regulation": ("Основное время", "Regulation"), "regular_overtime": ("Овертайм регулярки", "Regular OT"), diff --git a/tests/test_build88_complex_penalty_display.py b/tests/test_build88_complex_penalty_display.py index 7b2b863..6646e5a 100644 --- a/tests/test_build88_complex_penalty_display.py +++ b/tests/test_build88_complex_penalty_display.py @@ -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["advantage_side"] == "away" - assert value["home_label"] == "4 на 3" + assert value["home_label"] == "Играют в большинстве" assert value["away_label"] == "Играют в большинстве" @@ -50,7 +50,7 @@ def test_single_remaining_penalty_outputs_pp_caption_on_opposite_team(): language="ru", ) assert value["advantage_side"] == "away" - assert value["home_label"] == "5 на 4" + assert value["home_label"] == "Играют в большинстве" assert value["away_label"] == "Играют в большинстве" diff --git a/tests/test_build89_single_penalty_plate_transition.py b/tests/test_build89_single_penalty_plate_transition.py index f81aa6d..4147a91 100644 --- a/tests/test_build89_single_penalty_plate_transition.py +++ b/tests/test_build89_single_penalty_plate_transition.py @@ -55,7 +55,7 @@ def test_two_vs_one_penalties_caption_only_advantage_team(): ) assert payload["strength_label"] == "3×4" assert payload["advantage_side"] == "away" - assert payload["home_label"] == "4 на 3" + assert payload["home_label"] == "Играют в большинстве" assert payload["away_label"] == "Играют в большинстве" diff --git a/tests/test_build91_runtime_version_pbp_penalty_labels.py b/tests/test_build91_runtime_version_pbp_penalty_labels.py index 3360f6e..848e198 100644 --- a/tests/test_build91_runtime_version_pbp_penalty_labels.py +++ b/tests/test_build91_runtime_version_pbp_penalty_labels.py @@ -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(): payload = _strength(home=1, away=0) - assert payload["home_label"] == "5 на 4" + assert payload["home_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(): 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" diff --git a/tests/test_build92_numerical_penalty_labels.py b/tests/test_build92_numerical_penalty_labels.py index 0ca66ce..5d28ab7 100644 --- a/tests/test_build92_numerical_penalty_labels.py +++ b/tests/test_build92_numerical_penalty_labels.py @@ -33,7 +33,7 @@ def payload(home, away, labels=None): def test_one_home_penalty_uses_5_on_4_on_penalized_side(): result = payload(1, 0) - assert result["home_label"] == "5 на 4" + assert result["home_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"}}} result = payload(2, 1, labels) assert result["advantage_side"] == "away" - assert result["home_label"] == "4 на 3" + assert result["home_label"] == "Большинство 4 на 3" assert result["away_label"] == "Большинство 4 на 3" diff --git a/tests/test_build93_countdown_reseed_on_start.py b/tests/test_build93_countdown_reseed_on_start.py new file mode 100644 index 0000000..7b3ae73 --- /dev/null +++ b/tests/test_build93_countdown_reseed_on_start.py @@ -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 diff --git a/tests/test_build94_strength_labels_from_settings.py b/tests/test_build94_strength_labels_from_settings.py new file mode 100644 index 0000000..7efa3b8 --- /dev/null +++ b/tests/test_build94_strength_labels_from_settings.py @@ -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" diff --git a/ui_builder/static/app.js b/ui_builder/static/app.js index e390185..2e353df 100644 --- a/ui_builder/static/app.js +++ b/ui_builder/static/app.js @@ -4606,11 +4606,11 @@ function startCustomTooltips() { const selectedName = String(step.game_vmix_selected_name || "").trim(); if (!input || !selectedName) continue; 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: "StartCountdown", ...target }); - } else if (eventName === "timer_resume") { - commands.push({ Function: "StartCountdown", ...target }); } else if (eventName === "timer_pause") { commands.push({ Function: "PauseCountdown", ...target }); } 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, 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) }); } if (entry.event.running) { - if (force || assignmentChanged || previous?.running !== true) { + if (startingCountdown) { runCommands.push({ Function: "StartCountdown", Input: target.input, SelectedName: target.selected_name }); } } 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) }); } else if (pausing) { 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 { + // 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: "StartCountdown", Input: step.game_vmix_input, SelectedName: step.game_vmix_selected_name }); } @@ -5111,9 +5114,8 @@ function startCustomTooltips() { }); if (pausing) { 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 { + // 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: "StartCountdown", Input: target.input, SelectedName: target.selected_name }); } @@ -13461,7 +13463,7 @@ function renderHockeyPenaltyDashboard(node, component, runtime) {
${timerFinishActionRows(step)}
${escapeHtml(shortcutInventoryLabel())}Для каждого countdown теперь обязательно выбирается конкретный Text / SelectedName. Это исключает отправку времени в первый текстовый элемент по умолчанию.
-

Режим Countdown vMix рекомендуется и используется по умолчанию: веб отправляет SetCountdown только при установке/коррекции времени и затем StartCountdown; каждую секунду значение больше не передаётся. Text mirror оставлен только как режим совместимости для старых титров. Для верхнего счёта используется одна penalty-плашка: при реальном большинстве она показывается на стороне команды преимущества и отсчитывает ближайшее изменение численного состава; при чистом равном обоюдном удалении плашка не выводится. Режим «Все удаления по слотам» оставлен как дополнительный. Действие по окончании показывает выбранный Input в заданном Overlay и автоматически убирает его через указанное время.

+

Режим Countdown vMix рекомендуется и используется по умолчанию: при каждом запуске или продолжении веб сначала отправляет актуальное время через SetCountdown, затем StartCountdown; каждую секунду значение не передаётся. Text mirror оставлен только как режим совместимости для старых титров. Для верхнего счёта используется одна penalty-плашка: при реальном большинстве она показывается на стороне команды преимущества и отсчитывает ближайшее изменение численного состава; при чистом равном обоюдном удалении плашка не выводится. Режим «Все удаления по слотам» оставлен как дополнительный. Действие по окончании показывает выбранный Input в заданном Overlay и автоматически убирает его через указанное время.

`; } else if (step.type === "delay") { body.innerHTML = `
`;