From 72d89ec02b27bb110cc665a98421ae2ee01d0227 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 12:48:11 +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=BF=D0=BE=D0=BF=D1=80=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD?= =?UTF-8?q?=D1=8B,=20=D1=81=D0=BE=D1=80=D1=82=D0=B8=D1=80=D0=BE=D0=B2?= =?UTF-8?q?=D0=BA=D0=B0=20=D1=8D=D0=BB=D0=B5=D0=BC=D0=B5=D0=BD=D1=82=D0=BE?= =?UTF-8?q?=D0=B2=20=D0=B2=20=D1=82=D0=B8=D1=82=D1=80=D0=B5=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=BF=D1=80=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=BE,=20=D0=BE?= =?UTF-8?q?=D0=B1=D0=BE=D1=8E=D0=B4=D0=BD=D0=BE=D0=B5=20=D1=83=D0=B4=D0=B0?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BF=D0=BE=D0=BF=D1=80=D0=B0?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=D0=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- hockey_data/mapping_context.py | 46 ++++++++++--- hockey_data/service.py | 9 ++- hockey_data/static/admin-directories.js | 26 ++++++-- .../test_build71_penalty_side_identifiers.py | 6 +- ...t_build79_mapping_alignment_input_order.py | 11 ++-- ..._penalty_mapping_sort_and_mutual_labels.py | 66 +++++++++++++++++++ tests/test_mapping_admin_visibility.py | 6 +- ui_builder/static/app.js | 10 --- 8 files changed, 142 insertions(+), 38 deletions(-) create mode 100644 tests/test_build81_penalty_mapping_sort_and_mutual_labels.py diff --git a/hockey_data/mapping_context.py b/hockey_data/mapping_context.py index 651329f..f5677d6 100644 --- a/hockey_data/mapping_context.py +++ b/hockey_data/mapping_context.py @@ -68,14 +68,14 @@ DEFAULT_CONTEXT_VARIABLES: tuple[dict[str, Any], ...] = ( {"key": "selected_penalty_team_penalty", "label": "Выбранное удаление командное (1/0)", "category": "Выделенные", "entity_type": "penalty", "scope": "match", "source_type": "selection", "value_type": "text"}, {"key": "selected_penalty_team_id", "label": "Команда выбранного удаления", "category": "Выделенные", "entity_type": "team", "scope": "match", "source_type": "selection"}, - {"key": "selected_home_penalty_id", "label": "Удаление левой команды — ID удаления", "category": "Удаления — левая команда", "entity_type": "penalty", "scope": "match", "source_type": "selection"}, - {"key": "selected_home_penalty_player_id", "label": "Удаление левой команды — игрок external ID", "category": "Удаления — левая команда", "entity_type": "player", "scope": "match", "source_type": "selection"}, - {"key": "selected_home_penalty_player_db_id", "label": "Удаление левой команды — игрок DB ID", "category": "Удаления — левая команда", "entity_type": "player", "scope": "match", "source_type": "selection"}, - {"key": "selected_home_penalty_team_penalty", "label": "Удаление левой команды — командное (1/0)", "category": "Удаления — левая команда", "entity_type": "penalty", "scope": "match", "source_type": "selection", "value_type": "text"}, - {"key": "selected_away_penalty_id", "label": "Удаление правой команды — ID удаления", "category": "Удаления — правая команда", "entity_type": "penalty", "scope": "match", "source_type": "selection"}, - {"key": "selected_away_penalty_player_id", "label": "Удаление правой команды — игрок external ID", "category": "Удаления — правая команда", "entity_type": "player", "scope": "match", "source_type": "selection"}, - {"key": "selected_away_penalty_player_db_id", "label": "Удаление правой команды — игрок DB ID", "category": "Удаления — правая команда", "entity_type": "player", "scope": "match", "source_type": "selection"}, - {"key": "selected_away_penalty_team_penalty", "label": "Удаление правой команды — командное (1/0)", "category": "Удаления — правая команда", "entity_type": "penalty", "scope": "match", "source_type": "selection", "value_type": "text"}, + {"key": "selected_home_penalty_id", "label": "Удаление левой команды — ID удаления", "category": "Удаления — левая команда", "entity_type": "penalty", "scope": "match_shared", "source_type": "selection"}, + {"key": "selected_home_penalty_player_id", "label": "Удаление левой команды — игрок external ID", "category": "Удаления — левая команда", "entity_type": "player", "scope": "match_shared", "source_type": "selection"}, + {"key": "selected_home_penalty_player_db_id", "label": "Удаление левой команды — игрок DB ID", "category": "Удаления — левая команда", "entity_type": "player", "scope": "match_shared", "source_type": "selection"}, + {"key": "selected_home_penalty_team_penalty", "label": "Удаление левой команды — командное (1/0)", "category": "Удаления — левая команда", "entity_type": "penalty", "scope": "match_shared", "source_type": "selection", "value_type": "text"}, + {"key": "selected_away_penalty_id", "label": "Удаление правой команды — ID удаления", "category": "Удаления — правая команда", "entity_type": "penalty", "scope": "match_shared", "source_type": "selection"}, + {"key": "selected_away_penalty_player_id", "label": "Удаление правой команды — игрок external ID", "category": "Удаления — правая команда", "entity_type": "player", "scope": "match_shared", "source_type": "selection"}, + {"key": "selected_away_penalty_player_db_id", "label": "Удаление правой команды — игрок DB ID", "category": "Удаления — правая команда", "entity_type": "player", "scope": "match_shared", "source_type": "selection"}, + {"key": "selected_away_penalty_team_penalty", "label": "Удаление правой команды — командное (1/0)", "category": "Удаления — правая команда", "entity_type": "penalty", "scope": "match_shared", "source_type": "selection", "value_type": "text"}, {"key": "selected_penalty_infraction_id", "label": "Нарушение выбранного удаления", "category": "Выделенные", "entity_type": "penalty", "scope": "match", "source_type": "selection"}, {"key": "selected_penalty_preset_id", "label": "Длительность выбранного удаления", "category": "Выделенные", "entity_type": "penalty", "scope": "match", "source_type": "selection"}, {"key": "selected_penalty_side", "label": "Сторона выбранного удаления", "category": "Выделенные", "entity_type": "penalty", "scope": "match", "source_type": "selection"}, @@ -92,7 +92,22 @@ DEFAULT_CONTEXT_VARIABLES: tuple[dict[str, Any], ...] = ( {"key": "compare_player2_id", "label": "Игрок сравнения 2", "category": "Сравнение", "entity_type": "player", "scope": "session", "source_type": "manual"}, {"key": "compare_team1_id", "label": "Команда сравнения 1", "category": "Сравнение", "entity_type": "team", "scope": "session", "source_type": "manual"}, {"key": "compare_team2_id", "label": "Команда сравнения 2", "category": "Сравнение", "entity_type": "team", "scope": "session", "source_type": "manual"}, -) + ) + + +# These values describe the live state of the match itself, not a private UI +# selection of one account. They must be visible to Mapping regardless of which +# operator/admin account opened the Mapping editor. +MATCH_SHARED_CONTEXT_KEYS: frozenset[str] = frozenset({ + "selected_home_penalty_id", + "selected_home_penalty_player_id", + "selected_home_penalty_player_db_id", + "selected_home_penalty_team_penalty", + "selected_away_penalty_id", + "selected_away_penalty_player_id", + "selected_away_penalty_player_db_id", + "selected_away_penalty_team_penalty", +}) DEFAULT_SQL_SOURCES: tuple[dict[str, Any], ...] = ( @@ -211,7 +226,17 @@ class MappingDataService: def ensure_defaults(self) -> None: now = _utcnow() with self.database.session() as session: - existing_vars = {row.key for row in session.scalars(select(MappingContextVariable))} + existing_var_rows = {row.key: row for row in session.scalars(select(MappingContextVariable))} + existing_vars = set(existing_var_rows) + # Build78 initially stored automatic HOME/AWAY penalty identifiers as + # account-scoped match values. Migrate existing databases in-place so + # an operator can set a penalty and an admin can immediately see it in Mapping. + for key in MATCH_SHARED_CONTEXT_KEYS: + row = existing_var_rows.get(key) + if row is not None and row.scope != "match_shared": + row.scope = "match_shared" + row.updated_by = "system" + row.updated_at = now for index, item in enumerate(DEFAULT_CONTEXT_VARIABLES): if item["key"] in existing_vars: continue @@ -505,6 +530,7 @@ class MappingDataService: if scope == "global": return "global" if scope == "account": return f"account:{user.id}" if scope in {"session", "temporary"}: return f"session:{token or user.id}" + if scope in {"match_shared", "shared_match"}: return f"match:{game_id or '-'}" return f"match:{game_id or '-'}:account:{user.id}" def resolve_context(self, user: HockeyUser, supplied: dict[str, Any] | None = None) -> tuple[dict[str, str], list[dict[str, Any]]]: diff --git a/hockey_data/service.py b/hockey_data/service.py index 91753e4..fba537d 100644 --- a/hockey_data/service.py +++ b/hockey_data/service.py @@ -5537,8 +5537,13 @@ class HockeyDataService: elif state_key not in {"5x5", ""}: state_label = state_key.replace("x", " on " if language_key == "en" else " на ") - home_label = state_label if advantage_side == "home" else "" - away_label = state_label if advantage_side == "away" else "" + coincidental_penalties = bool( + home_penalties > 0 + and away_penalties > 0 + and home_skaters == away_skaters + ) + home_label = state_label if advantage_side == "home" or coincidental_penalties else "" + away_label = state_label if advantage_side == "away" or coincidental_penalties else "" phase_labels = { "regulation": ("Основное время", "Regulation"), "regular_overtime": ("Овертайм регулярки", "Regular OT"), diff --git a/hockey_data/static/admin-directories.js b/hockey_data/static/admin-directories.js index 8e0e8ca..b8777a3 100644 --- a/hockey_data/static/admin-directories.js +++ b/hockey_data/static/admin-directories.js @@ -1338,10 +1338,26 @@ } function mappingInventoryInputs(profile) { - // Preserve the exact Input sequence received from the vMix API via Agent. - // The array order is presentation-only: stable Mapping identity still uses - // key -> unique title -> number, so changing Input positions does not break links. - return Array.isArray(profile?.inventory?.inputs) ? [...profile.inventory.inputs] : []; + // Mapping picker is operator-facing: show Inputs in vMix numeric order so a + // title can be found by its familiar # quickly. Stable links still use key/title. + const inputs = Array.isArray(profile?.inventory?.inputs) ? [...profile.inventory.inputs] : []; + return inputs.sort((left, right) => { + const leftNumber = Number(left?.number); + const rightNumber = Number(right?.number); + const leftRank = Number.isFinite(leftNumber) ? leftNumber : Number.MAX_SAFE_INTEGER; + const rightRank = Number.isFinite(rightNumber) ? rightNumber : Number.MAX_SAFE_INTEGER; + return leftRank - rightRank + || String(left?.title || "").localeCompare(String(right?.title || ""), "ru", { numeric: true, sensitivity: "base" }); + }); + } + + function mappingVmixFieldOrder(left, right) { + // Natural sort keeps Player2.Text before Player10.Text and makes long GT + // templates much easier to scan. Type suffix stays part of the comparison. + return String(left?.name || "").localeCompare(String(right?.name || ""), "ru", { + numeric: true, + sensitivity: "base", + }); } function mappingInputByKey(profile, key, title = "") { @@ -1939,7 +1955,7 @@ const visibleInputs = filteredInputs.some((item) => mappingInputValue(item) === mappingInputValue(currentInput)) ? filteredInputs : [currentInput, ...filteredInputs]; - const allFields = currentInput.fields || []; + const allFields = [...(currentInput.fields || [])].sort(mappingVmixFieldOrder); const filter = ["text", "image", "color"].includes(state.mappingFieldFilter) ? state.mappingFieldFilter : "text"; const counts = { text: allFields.filter((field) => mappingFieldFilterMatch(field, "text")).length, diff --git a/tests/test_build71_penalty_side_identifiers.py b/tests/test_build71_penalty_side_identifiers.py index 56e00bd..eb91d6f 100644 --- a/tests/test_build71_penalty_side_identifiers.py +++ b/tests/test_build71_penalty_side_identifiers.py @@ -47,6 +47,8 @@ def test_preview_selection_is_kept_per_side_and_visible(): assert 'selectedPreviewEventIds: { home: "", away: "" }' in JS assert 'board.selectedPreviewEventIds[side] = event.id' in JS assert 'hockeyPenaltyIsPreviewSelected(board, event)' in JS - assert 'Player DB ID' in JS - assert 'Командное${event.teamPenalty ? "1" : "0"}' in JS + assert 'selected_home_penalty_player_db_id' in JS + assert 'selected_away_penalty_player_db_id' in JS + assert 'selected_home_penalty_team_penalty' in JS + assert 'selected_away_penalty_team_penalty' in JS assert '.hpd-team-card-identifiers' in CSS diff --git a/tests/test_build79_mapping_alignment_input_order.py b/tests/test_build79_mapping_alignment_input_order.py index 1dfab4b..0280adf 100644 --- a/tests/test_build79_mapping_alignment_input_order.py +++ b/tests/test_build79_mapping_alignment_input_order.py @@ -12,13 +12,10 @@ def test_mapping_data_group_count_is_right_aligned_before_fixed_arrow(): assert ".hockey-map-data-group summary>span{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}" in CSS -def test_mapping_vmix_input_selector_preserves_inventory_api_order(): - start = JS.index("function mappingInventoryInputs(profile)") - end = JS.index("function mappingInputByKey", start) - block = JS[start:end] - assert "return Array.isArray(profile?.inventory?.inputs) ? [...profile.inventory.inputs] : [];" in block - assert ".sort(" not in block - assert "numberValue" not in block +def test_mapping_inventory_raw_order_remains_available_on_server(): + # Build81 intentionally sorts the operator-facing picker by vMix number. + # The Agent/server inventory still preserves the raw API array for identity/rebind work. + assert "function mappingInventoryInputs(profile)" in JS def test_server_preserves_agent_inventory_array_order(): diff --git a/tests/test_build81_penalty_mapping_sort_and_mutual_labels.py b/tests/test_build81_penalty_mapping_sort_and_mutual_labels.py new file mode 100644 index 0000000..41604d9 --- /dev/null +++ b/tests/test_build81_penalty_mapping_sort_and_mutual_labels.py @@ -0,0 +1,66 @@ +from pathlib import Path + +from hockey_data.auth_bridge import HockeyUser +from hockey_data.mapping_context import MappingDataService +from hockey_data.models import Game +from hockey_data.service import HockeyDataService +from tests.support import LocalTestDatabase + +ROOT = Path(__file__).resolve().parents[1] +APP = (ROOT / "ui_builder/static/app.js").read_text(encoding="utf-8") +ADMIN = (ROOT / "hockey_data/static/admin-directories.js").read_text(encoding="utf-8") +MAPPING = (ROOT / "hockey_data/mapping_context.py").read_text(encoding="utf-8") + + +def test_penalty_card_no_longer_renders_technical_id_block(): + start = APP.index("function hockeyPenaltyCard") + end = APP.index("function hockeyPenaltyContext", start) if "function hockeyPenaltyContext" in APP[start:] else start + 12000 + snippet = APP[start:end] + assert "hpd-team-card-identifiers" not in snippet + assert "Player external ID" not in snippet + assert "Player DB ID" not in snippet + + +def test_side_penalty_context_is_shared_between_accounts(tmp_path: Path): + database = LocalTestDatabase(tmp_path / "shared-penalty-context.sqlite3") + database.create_all() + with database.session() as session: + session.add(Game(external_id="G1", tournament_external_id="T1", home_team_external_id="H", away_team_external_id="A")) + service = MappingDataService(database) # type: ignore[arg-type] + operator = HockeyUser(id="10", login="operator", display_name="Operator", is_admin=False, role="operator") + admin = HockeyUser(id="99", login="admin", display_name="Admin", is_admin=True, role="admin") + + service.set_context_value("selected_home_penalty_id", "PEN-17", operator, {"game_id": "G1", "session_token": "OP"}) + service.set_context_value("selected_home_penalty_player_db_id", "321", operator, {"game_id": "G1", "session_token": "OP"}) + context, variables = service.resolve_context(admin, {"game_id": "G1", "session_token": "ADM"}) + assert context["selected_home_penalty_id"] == "PEN-17" + assert context["selected_home_penalty_player_db_id"] == "321" + variable = next(item for item in variables if item["key"] == "selected_home_penalty_id") + assert variable["scope"] == "match_shared" + + +def test_mapping_input_picker_sorts_by_number_and_fields_naturally(): + start = ADMIN.index("function mappingInventoryInputs(profile)") + end = ADMIN.index("function mappingInputByKey", start) + block = ADMIN[start:end] + assert "leftRank - rightRank" in block + assert "Number(left?.number)" in block + assert "function mappingVmixFieldOrder" in block + assert "numeric: true" in block + assert "const allFields = [...(currentInput.fields || [])].sort(mappingVmixFieldOrder);" in ADMIN + + +def test_coincidental_penalties_fill_both_home_and_away_labels(): + settings = { + "strength_regulation_skaters": 5, + "strength_min_skaters": 3, + "strength_state_labels": {"regulation": {"4x4": {"ru": "4 на 4", "en": "4 on 4"}}}, + } + def penalty(side: str): + return {"side": side, "infraction": {"code": "TEST"}, "preset": "2", "durationMs": 120000, "remainingMs": 90000, "finished": False} + timer_state = {"penalty_board": {"penalties": [penalty("home"), penalty("away")]}} + value = HockeyDataService._strength_payload(settings, stage="regular", current_period="1", timer_state=timer_state, language="ru") + assert value["advantage_side"] == "" + assert value["state_key"] == "4x4" + assert value["home_label"] == "4 на 4" + assert value["away_label"] == "4 на 4" diff --git a/tests/test_mapping_admin_visibility.py b/tests/test_mapping_admin_visibility.py index bb79f39..7655e25 100644 --- a/tests/test_mapping_admin_visibility.py +++ b/tests/test_mapping_admin_visibility.py @@ -17,13 +17,15 @@ def test_mapping_load_is_resilient_per_endpoint(): assert 'Часть Mapping API недоступна' in JS -def test_visual_mapping_has_sql_table_cell_picker_and_searchable_api_order_inputs(): +def test_visual_mapping_has_sql_table_cell_picker_and_searchable_number_sorted_inputs(): assert 'data-map-table-source' in JS assert 'data-map-table-row-search' in JS assert '.row.${row.index}.' in JS assert 'data-map-input-search' in JS assert 'mappingFilteredInputs' in JS - assert 'return Array.isArray(profile?.inventory?.inputs) ? [...profile.inventory.inputs] : [];' in JS + assert 'const inputs = Array.isArray(profile?.inventory?.inputs) ? [...profile.inventory.inputs] : [];' in JS + assert 'Number(left?.number)' in JS + assert 'leftRank - rightRank' in JS def test_visual_mapping_has_explicit_sql_cell_dropdowns_and_apply_input(): diff --git a/ui_builder/static/app.js b/ui_builder/static/app.js index 876cb56..73a3301 100644 --- a/ui_builder/static/app.js +++ b/ui_builder/static/app.js @@ -7262,7 +7262,6 @@ async function hockeySelectPenaltyForPreview(component, event) { : side === "away" ? String(getByPath(state.data, "hockey.selected_game.away.id") || getByPath(state.data, "hockey.selected_game.away.external_id") || "") : ""; - const playerIds = hockeyPenaltyPlayerIdentifiers(component, event); const detail = { item_id: String(event.external_id || event.id || ""), value: String(event.external_id || event.id || ""), @@ -7411,15 +7410,6 @@ function hockeyPenaltyCard(component, event, runtime) { Командное удаление — без игрока - ${previewSelected ? ` -
- ID удаления${escapeHtml(String(event.external_id || event.id || "—"))} - Player external ID${escapeHtml(playerIds.externalId || "—")} - Player DB ID${escapeHtml(playerIds.dbId || "—")} - Командное${event.teamPenalty ? "1" : "0"} -
- ` : ""} -