удаления поправлены, сортировка элементов в титре поправлено, обоюдное удаление поправлено

This commit is contained in:
2026-08-20 12:48:11 +03:00
parent 81fd030846
commit 72d89ec02b
8 changed files with 142 additions and 38 deletions

View File

@@ -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_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_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_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", "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", "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", "source_type": "selection", "value_type": "text"}, {"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", "source_type": "selection"}, {"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", "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", "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", "source_type": "selection", "value_type": "text"}, {"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_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_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"}, {"key": "selected_penalty_side", "label": "Сторона выбранного удаления", "category": "Выделенные", "entity_type": "penalty", "scope": "match", "source_type": "selection"},
@@ -95,6 +95,21 @@ DEFAULT_CONTEXT_VARIABLES: tuple[dict[str, Any], ...] = (
) )
# 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], ...] = ( DEFAULT_SQL_SOURCES: tuple[dict[str, Any], ...] = (
{ {
"code": "game", "code": "game",
@@ -211,7 +226,17 @@ class MappingDataService:
def ensure_defaults(self) -> None: def ensure_defaults(self) -> None:
now = _utcnow() now = _utcnow()
with self.database.session() as session: 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): for index, item in enumerate(DEFAULT_CONTEXT_VARIABLES):
if item["key"] in existing_vars: if item["key"] in existing_vars:
continue continue
@@ -505,6 +530,7 @@ class MappingDataService:
if scope == "global": return "global" if scope == "global": return "global"
if scope == "account": return f"account:{user.id}" if scope == "account": return f"account:{user.id}"
if scope in {"session", "temporary"}: return f"session:{token or 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}" 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]]]: def resolve_context(self, user: HockeyUser, supplied: dict[str, Any] | None = None) -> tuple[dict[str, str], list[dict[str, Any]]]:

View File

@@ -5537,8 +5537,13 @@ class HockeyDataService:
elif state_key not in {"5x5", ""}: elif state_key not in {"5x5", ""}:
state_label = state_key.replace("x", " on " if language_key == "en" else " на ") state_label = state_key.replace("x", " on " if language_key == "en" else " на ")
home_label = state_label if advantage_side == "home" else "" coincidental_penalties = bool(
away_label = state_label if advantage_side == "away" else "" 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 = { phase_labels = {
"regulation": ("Основное время", "Regulation"), "regulation": ("Основное время", "Regulation"),
"regular_overtime": ("Овертайм регулярки", "Regular OT"), "regular_overtime": ("Овертайм регулярки", "Regular OT"),

View File

@@ -1338,10 +1338,26 @@
} }
function mappingInventoryInputs(profile) { function mappingInventoryInputs(profile) {
// Preserve the exact Input sequence received from the vMix API via Agent. // Mapping picker is operator-facing: show Inputs in vMix numeric order so a
// The array order is presentation-only: stable Mapping identity still uses // title can be found by its familiar # quickly. Stable links still use key/title.
// key -> unique title -> number, so changing Input positions does not break links. const inputs = Array.isArray(profile?.inventory?.inputs) ? [...profile.inventory.inputs] : [];
return 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 = "") { function mappingInputByKey(profile, key, title = "") {
@@ -1939,7 +1955,7 @@
const visibleInputs = filteredInputs.some((item) => mappingInputValue(item) === mappingInputValue(currentInput)) const visibleInputs = filteredInputs.some((item) => mappingInputValue(item) === mappingInputValue(currentInput))
? filteredInputs ? filteredInputs
: [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 filter = ["text", "image", "color"].includes(state.mappingFieldFilter) ? state.mappingFieldFilter : "text";
const counts = { const counts = {
text: allFields.filter((field) => mappingFieldFilterMatch(field, "text")).length, text: allFields.filter((field) => mappingFieldFilterMatch(field, "text")).length,

View File

@@ -47,6 +47,8 @@ def test_preview_selection_is_kept_per_side_and_visible():
assert 'selectedPreviewEventIds: { home: "", away: "" }' in JS assert 'selectedPreviewEventIds: { home: "", away: "" }' in JS
assert 'board.selectedPreviewEventIds[side] = event.id' in JS assert 'board.selectedPreviewEventIds[side] = event.id' in JS
assert 'hockeyPenaltyIsPreviewSelected(board, event)' in JS assert 'hockeyPenaltyIsPreviewSelected(board, event)' in JS
assert 'Player DB ID' in JS assert 'selected_home_penalty_player_db_id' in JS
assert 'Командное</b>${event.teamPenalty ? "1" : "0"}' 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 assert '.hpd-team-card-identifiers' in CSS

View File

@@ -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 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(): def test_mapping_inventory_raw_order_remains_available_on_server():
start = JS.index("function mappingInventoryInputs(profile)") # Build81 intentionally sorts the operator-facing picker by vMix number.
end = JS.index("function mappingInputByKey", start) # The Agent/server inventory still preserves the raw API array for identity/rebind work.
block = JS[start:end] assert "function mappingInventoryInputs(profile)" in JS
assert "return Array.isArray(profile?.inventory?.inputs) ? [...profile.inventory.inputs] : [];" in block
assert ".sort(" not in block
assert "numberValue" not in block
def test_server_preserves_agent_inventory_array_order(): def test_server_preserves_agent_inventory_array_order():

View File

@@ -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"

View File

@@ -17,13 +17,15 @@ def test_mapping_load_is_resilient_per_endpoint():
assert 'Часть Mapping API недоступна' in JS 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-source' in JS
assert 'data-map-table-row-search' in JS assert 'data-map-table-row-search' in JS
assert '.row.${row.index}.' in JS assert '.row.${row.index}.' in JS
assert 'data-map-input-search' in JS assert 'data-map-input-search' in JS
assert 'mappingFilteredInputs' 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(): def test_visual_mapping_has_explicit_sql_cell_dropdowns_and_apply_input():

View File

@@ -7262,7 +7262,6 @@ async function hockeySelectPenaltyForPreview(component, event) {
: side === "away" : side === "away"
? String(getByPath(state.data, "hockey.selected_game.away.id") || getByPath(state.data, "hockey.selected_game.away.external_id") || "") ? 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 = { const detail = {
item_id: String(event.external_id || event.id || ""), item_id: String(event.external_id || event.id || ""),
value: String(event.external_id || event.id || ""), value: String(event.external_id || event.id || ""),
@@ -7411,15 +7410,6 @@ function hockeyPenaltyCard(component, event, runtime) {
<span>Командное удаление — без игрока</span> <span>Командное удаление — без игрока</span>
</label> </label>
${previewSelected ? `
<div class="hpd-team-card-identifiers" title="Идентификаторы выбранного удаления для Mapping">
<span><b>ID удаления</b>${escapeHtml(String(event.external_id || event.id || "—"))}</span>
<span><b>Player external ID</b>${escapeHtml(playerIds.externalId || "—")}</span>
<span><b>Player DB ID</b>${escapeHtml(playerIds.dbId || "—")}</span>
<span><b>Командное</b>${event.teamPenalty ? "1" : "0"}</span>
</div>
` : ""}
<div class="hpd-team-card-footer"> <div class="hpd-team-card-footer">
<span class="hpd-team-card-status">${ <span class="hpd-team-card-status">${
previewSelected previewSelected