идентификаторы удалений из вкладки Игра

This commit is contained in:
2026-08-20 11:26:56 +03:00
parent a28b0fc062
commit 38aa23cebe
3 changed files with 149 additions and 1 deletions

View File

@@ -45,6 +45,7 @@
mappingSqlPreview: null,
mappingSqlDraft: null,
mappingLoadErrors: {},
mappingContextRefreshTimer: null,
countries: null,
players: null,
referees: null,
@@ -3173,6 +3174,16 @@
render();
});
window.addEventListener("hockey:mapping-context-updated", () => {
if (!state.open || !isMappingSection()) return;
if (state.mappingContextRefreshTimer) clearTimeout(state.mappingContextRefreshTimer);
state.mappingContextRefreshTimer = setTimeout(async () => {
state.mappingContextRefreshTimer = null;
try { await loadMappingCatalog(); } catch (_) {}
renderMapping();
}, 80);
});
window.addEventListener("hockey:tournament-selected", () => {
if (!state.open) return;
if (state.section === "khl_site" && !isKhlTournamentSelected()) state.section = "teams";

View File

@@ -0,0 +1,46 @@
from pathlib import Path
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")
def test_side_penalty_mapping_context_is_derived_from_active_penalties():
start = APP.index("async function hockeySyncPenaltySideMappingContext")
end = APP.index("function penaltyTargetAssignmentKey", start)
snippet = APP[start:end]
assert 'sortedPenaltyEntries("home")[0]' in snippet
assert 'sortedPenaltyEntries("away")[0]' in snippet
for key in (
"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",
):
assert f"{key}:" in snippet
assert 'fetch("/api/hockey/context/batch"' in snippet
def test_penalty_context_sync_runs_when_penalty_changes_and_clears_on_finish():
assert "hockeySyncPenaltySideMappingContext().catch(() => {});" in APP
assert APP.count("hockeySyncPenaltySideMappingContext({ force: true }).catch(() => {});") >= 3
def test_saved_penalty_player_keeps_external_and_database_ids():
start = APP.index("function hockeyCompactPlayer")
end = APP.index("function hockeyCompactInfraction", start)
snippet = APP[start:end]
assert "const externalId" in snippet
assert "const dbId" in snippet
assert "externalId," in snippet
assert "dbId," in snippet
def test_mapping_admin_refreshes_live_after_runtime_context_update():
assert 'window.addEventListener("hockey:mapping-context-updated"' in ADMIN
assert "await loadMappingCatalog()" in ADMIN
assert "renderMapping();" in ADMIN

View File

@@ -83,6 +83,9 @@
vmixPenaltyMirrors: new Map(),
activeHockeyVmixTimerSteps: new Set(),
vmixPenaltyTargetAssignments: new Map(),
hockeyPenaltyMappingContextSignature: "",
hockeyPenaltyMappingContextPending: false,
hockeyPenaltyMappingContextQueued: false,
vmixFinishOverlayTimers: new Map(),
vmixStrengthMappingRefreshPending: false,
vmixStrengthMappingRefreshQueued: null,
@@ -4493,6 +4496,84 @@ function startCustomTooltips() {
|| Number(a.event.createdAt || 0) - Number(b.event.createdAt || 0));
}
function hockeyPenaltySideMappingDetail(item, side) {
if (!item?.event) return null;
const event = item.event;
const playerIds = hockeyPenaltyPlayerIdentifiers(item.component, event);
return {
penalty_id: String(event.external_id || event.id || ""),
player_id: String(playerIds.externalId || ""),
player_db_id: String(playerIds.dbId || ""),
team_penalty: Boolean(event.teamPenalty),
side: String(side || event.player?.side || event.side || ""),
};
}
async function hockeySyncPenaltySideMappingContext({ force = false } = {}) {
const gameId = String(hockeyTimerSelectedGameId() || "").trim();
if (!gameId) return false;
const home = hockeyPenaltySideMappingDetail(sortedPenaltyEntries("home")[0] || null, "home");
const away = hockeyPenaltySideMappingDetail(sortedPenaltyEntries("away")[0] || null, "away");
const values = {
selected_home_penalty_id: home?.penalty_id || "",
selected_home_penalty_player_id: home?.player_id || "",
selected_home_penalty_player_db_id: home?.player_db_id || "",
selected_home_penalty_team_penalty: home ? (home.team_penalty ? "1" : "0") : "",
selected_away_penalty_id: away?.penalty_id || "",
selected_away_penalty_player_id: away?.player_id || "",
selected_away_penalty_player_db_id: away?.player_db_id || "",
selected_away_penalty_team_penalty: away ? (away.team_penalty ? "1" : "0") : "",
};
const signature = JSON.stringify([gameId, values]);
if (!force && signature === state.hockeyPenaltyMappingContextSignature) return false;
if (state.hockeyPenaltyMappingContextPending) {
state.hockeyPenaltyMappingContextQueued = true;
return true;
}
state.hockeyPenaltyMappingContextPending = true;
try {
const response = await fetch("/api/hockey/context/batch", {
method: "POST", cache: "no-store", credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
values,
context: {
game_id: gameId,
device_id: currentRuntimeVmixDeviceId(),
session_token: currentRuntimeHockeySessionToken(),
},
}),
});
let payload = {};
try { payload = await response.json(); } catch (_) {}
if (!response.ok) {
throw new Error(errorDetailText(payload.detail, `HTTP ${response.status}`));
}
state.hockeyPenaltyMappingContextSignature = signature;
window.UIBuilderRuntime?.patchData?.({
hockey: {
active_penalties: { home, away },
},
}, { render: false });
window.dispatchEvent(new CustomEvent("hockey:mapping-context-updated", {
detail: { game_id: gameId, values: clone(values) },
}));
return true;
} catch (error) {
console.error("Penalty side Mapping context error", error);
return false;
} finally {
state.hockeyPenaltyMappingContextPending = false;
if (state.hockeyPenaltyMappingContextQueued) {
state.hockeyPenaltyMappingContextQueued = false;
hockeySyncPenaltySideMappingContext({ force: true }).catch(() => {});
}
}
}
function penaltyTargetAssignmentKey(step, side, target) {
return `${String(step?.id || "")}:${side}:${String(target?.id || "")}`;
}
@@ -6211,6 +6292,7 @@ function openTimerQuickEditor(focusActionId = "") {
if (hockeyEventReady(event) && state.activeHockeyVmixTimerSteps.size) {
rebalanceVmixPenaltyTargets({ force: true, hideUnused: false }).catch((error) => console.error("Penalty target rebalance error", error));
}
hockeySyncPenaltySideMappingContext().catch(() => {});
}
function createHockeyPenaltyDraft(component, seed = {}, source = "manual") {
@@ -6492,6 +6574,7 @@ function openTimerQuickEditor(focusActionId = "") {
if (board.selectedPreviewEventIds?.[side] === event.id) board.selectedPreviewEventIds[side] = "";
persistHockeyBoard(component, board, true);
refreshHockeyBoardNodes(component);
hockeySyncPenaltySideMappingContext({ force: true }).catch(() => {});
rebalanceVmixPenaltyTargets({ force: true, hideUnused: true })
.catch((error) => console.error("Penalty target finish rebalance error", error))
.finally(() => fireConfiguredTimerFinishActions("penalty", { side, component, event, remaining_on_side: remainingOnSide }));
@@ -6526,6 +6609,7 @@ function openTimerQuickEditor(focusActionId = "") {
...hockeyPenaltyContext(event)
});
state.vmixPenaltyMirrors.delete(penaltyMirrorKey(component, event));
hockeySyncPenaltySideMappingContext({ force: true }).catch(() => {});
rebalanceVmixPenaltyTargets({ force: true, hideUnused: true }).catch((error) => console.error("Penalty target remove rebalance error", error));
return true;
}
@@ -6620,6 +6704,7 @@ function openTimerQuickEditor(focusActionId = "") {
if (completed.has(board.selectedEventId)) board.selectedEventId = null;
persistHockeyBoard(component, board, true);
refreshHockeyBoardNodes(component);
hockeySyncPenaltySideMappingContext({ force: true }).catch(() => {});
const finishBySide = new Map();
completedEvents.forEach((event) => {
const side = String(event.player?.side || event.side || "").toLowerCase();
@@ -10129,8 +10214,12 @@ function renderHockeyPenaltyDashboard(node, component, runtime) {
function hockeyCompactPlayer(player) {
if (!player || typeof player !== "object") return null;
const externalId = String(player.externalId || player.external_id || player.id || player.raw?.external_id || player.raw?.id || "");
const dbId = String(player.dbId || player.db_id || player.database_id || player.raw?.db_id || player.raw?.database_id || "");
return {
id: String(player.id || ""),
id: externalId,
externalId,
dbId,
side: player.side === "away" ? "away" : player.side === "home" ? "home" : "",
number: String(player.number || ""),
name: String(player.name || ""),
@@ -10254,6 +10343,8 @@ function renderHockeyPenaltyDashboard(node, component, runtime) {
refreshHockeyBoardNodes(boardComponent);
}
state.hockeyTimerGameId = String(gameId || "");
state.hockeyPenaltyMappingContextSignature = "";
hockeySyncPenaltySideMappingContext({ force: true }).catch(() => {});
}
async function hockeyPersistGameTimers(gameId, { keepalive = false, force = false } = {}) {