тест 8

This commit is contained in:
2026-08-24 18:17:26 +03:00
parent 65a2521156
commit aac9e5b8bc
7 changed files with 170 additions and 38 deletions

2
app.py
View File

@@ -29,7 +29,7 @@ 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.24.12" BUILD_VERSION = "2026.08.24.13"
# compatibility: BUILD_VERSION = "2026.08.24.8" # compatibility: BUILD_VERSION = "2026.08.24.8"
# compatibility: BUILD_VERSION = "2026.08.24.7" # compatibility: BUILD_VERSION = "2026.08.24.7"
# compatibility: BUILD_VERSION = "2026.08.24.6" # compatibility: BUILD_VERSION = "2026.08.24.6"

View File

@@ -924,12 +924,14 @@ def create_hockey_router(
external_id: str, external_id: str,
tournament_id: str = Query(..., min_length=1, max_length=64), tournament_id: str = Query(..., min_length=1, max_length=64),
language: str = Query("ru", pattern="^(ru|en)$"), language: str = Query("ru", pattern="^(ru|en)$"),
opening: bool = False,
) -> dict[str, Any]: ) -> dict[str, Any]:
try: try:
return await service.sync_selected_game_details( return await service.sync_selected_game_details(
tournament_external_id=tournament_id, tournament_external_id=tournament_id,
game_external_id=external_id, game_external_id=external_id,
language=language, language=language,
opening=opening,
) )
except ValueError as error: except ValueError as error:
raise HTTPException(status_code=404, detail=str(error)) from error raise HTTPException(status_code=404, detail=str(error)) from error

View File

@@ -3484,6 +3484,7 @@ class HockeyDataService:
language: str = "ru", language: str = "ru",
allow_create: bool = False, allow_create: bool = False,
force_optional_shots: bool = False, force_optional_shots: bool = False,
skip_optional_shots: bool = False,
) -> dict[str, Any]: ) -> dict[str, Any]:
settings = self.settings.public() settings = self.settings.public()
templates = { templates = {
@@ -3590,15 +3591,16 @@ class HockeyDataService:
now=now, now=now,
) )
shots_meta: dict[str, Any] = {"available": False} shots_meta: dict[str, Any] = {"available": False, "skipped": bool(skip_optional_shots)}
try: if not skip_optional_shots:
shots_meta = await self._sync_optional_shots( try:
tournament_external_id=tournament_external_id, shots_meta = await self._sync_optional_shots(
game_external_id=game_external_id, tournament_external_id=tournament_external_id,
force=force_optional_shots, game_external_id=game_external_id,
) force=force_optional_shots,
except Exception as error: )
warnings.append(f"Дополнительные данные shots недоступны: {error}") except Exception as error:
warnings.append(f"Дополнительные данные shots недоступны: {error}")
payload = self.game_details(game_external_id, language=language) payload = self.game_details(game_external_id, language=language)
if payload is None: if payload is None:
@@ -3948,33 +3950,40 @@ class HockeyDataService:
tournament_external_id: str, tournament_external_id: str,
game_external_id: str, game_external_id: str,
language: str = "ru", language: str = "ru",
opening: bool = False,
) -> dict[str, Any]: ) -> dict[str, Any]:
schedule: dict[str, Any] | None = None schedule: dict[str, Any] | None = None
schedule_warning = "" schedule_warning = ""
try: if opening:
schedule = await self.sync_selected_game( # BUILD110: the operator clicked a row that already came from the
tournament_external_id=tournament_external_id, # local schedule. Do not spend the critical roster-loading window on
game_external_id=game_external_id, # another schedule round-trip; live polling refreshes it afterwards.
language=language, schedule = self.game(game_external_id, language=language)
) else:
except Exception as error: try:
if self.game(game_external_id, language=language) is None: schedule = await self.sync_selected_game(
raise tournament_external_id=tournament_external_id,
schedule_warning = str(error) game_external_id=game_external_id,
language=language,
)
except Exception as error:
if self.game(game_external_id, language=language) is None:
raise
schedule_warning = str(error)
details_warning = "" details_warning = ""
try: try:
# Opening a match must not depend on every optional Stat2TV resource. # Opening a match waits only for the core card/rosters. Optional
# Some schedule entries exist before/without a full match JSON card. # shots are deliberately deferred to live/background refresh so a
# Keep the operator UI usable and enrich the selected match when the # slow auxiliary feed cannot keep the loading overlay open.
# card becomes available instead of failing the whole selection.
payload = await asyncio.wait_for( payload = await asyncio.wait_for(
self.sync_match_details( self.sync_match_details(
tournament_external_id=tournament_external_id, tournament_external_id=tournament_external_id,
game_external_id=game_external_id, game_external_id=game_external_id,
language=language, language=language,
allow_create=True, allow_create=True,
force_optional_shots=True, force_optional_shots=not opening,
skip_optional_shots=opening,
), ),
timeout=12.0, timeout=12.0,
) )

View File

@@ -1483,18 +1483,19 @@ function gameCard(game) {
} }
} }
async function enrichOpenedGame(id, tournamentId, generation) { async function enrichOpenedGame(id, tournamentId, generation, { opening = false } = {}) {
try { try {
const loaded = await request( const loaded = await request(
`/api/hockey/games/${encodeURIComponent(id)}/details/sync` `/api/hockey/games/${encodeURIComponent(id)}/details/sync`
+ `?tournament_id=${encodeURIComponent(tournamentId)}` + `?tournament_id=${encodeURIComponent(tournamentId)}`
+ `&language=${encodeURIComponent(state.language)}`, + `&language=${encodeURIComponent(state.language)}`
{ method: "POST", timeoutMs: 16000 } + `&opening=${opening ? "true" : "false"}`,
{ method: "POST", timeoutMs: opening ? 14000 : 16000 }
); );
if ( if (
generation !== state.gameOpenGeneration generation !== state.gameOpenGeneration
|| String(state.selectedGameId || "") !== String(id) || String(state.selectedGameId || "") !== String(id)
) return; ) return false;
if (loaded?.game) state.selectedGame = loaded.game; if (loaded?.game) state.selectedGame = loaded.game;
state.selectedMatchDetails = loaded?.details || state.selectedMatchDetails || null; state.selectedMatchDetails = loaded?.details || state.selectedMatchDetails || null;
applyGameData(); applyGameData();
@@ -1503,18 +1504,46 @@ function gameCard(game) {
if (loaded?.details_sync_failed || loaded?.degraded) { if (loaded?.details_sync_failed || loaded?.degraded) {
console.warn("[Hockey] Match opened with cached/schedule data; full details are not available yet", loaded?.warnings || []); console.warn("[Hockey] Match opened with cached/schedule data; full details are not available yet", loaded?.warnings || []);
} }
return Boolean(loaded?.game);
} catch (error) { } catch (error) {
// Full match JSON, rosters, shots and directories are enrichment data. // Full match JSON, rosters, shots and directories are enrichment data.
// Their absence must never undo a match already selected by the operator. // Their absence must never undo a match already selected by the operator.
console.warn(`[Hockey] Optional match details sync failed for ${id}:`, error); console.warn(`[Hockey] Optional match details sync failed for ${id}:`, error);
return false;
} }
} }
function clearRuntimeMatchSpecificData() {
// BUILD110: patchData() performs a deep merge. A schedule row normally has
// no home.players/away.players fields, so without an explicit reset the
// previous match roster survives until the new details request completes.
window.UIBuilderRuntime?.patchData?.({
hockey: {
selected_game: null,
home: null,
away: null,
referees: [],
match_details: null,
game_control: null,
selected_penalties: { home: null, away: null },
active_penalties: { home: null, away: null },
},
});
}
function cachedMatchHasRosters(payload) {
if (!payload?.game || !payload?.details?.loaded) return false;
const home = Array.isArray(payload.game?.home?.players) ? payload.game.home.players.length : 0;
const away = Array.isArray(payload.game?.away?.players) ? payload.game.away.players.length : 0;
return (home + away) > 0;
}
async function openGame(id, { manual = false } = {}) { async function openGame(id, { manual = false } = {}) {
if (state.gameOpenBusy) return; if (state.gameOpenBusy) return;
state.lastAttemptedGameId = String(id || ""); state.lastAttemptedGameId = String(id || "");
state.lastGameOpenError = ""; state.lastGameOpenError = "";
state.gameOpenBusy = true; state.gameOpenBusy = true;
stopSelectedGamePolling();
const generation = ++state.gameOpenGeneration; const generation = ++state.gameOpenGeneration;
const loadingGame = matchLabelById(id) || `${t("gameNumber")} ID ${id}`; const loadingGame = matchLabelById(id) || `${t("gameNumber")} ID ${id}`;
setMatchLoading(true, { game: loadingGame, stage: t("loadingMatchDetails") }); setMatchLoading(true, { game: loadingGame, stage: t("loadingMatchDetails") });
@@ -1576,6 +1605,16 @@ function gameCard(game) {
localStorage.setItem(storage.tournament, state.selectedId); localStorage.setItem(storage.tournament, state.selectedId);
} }
// Read only the selected game from the local DB while the session is being
// created. This never contacts Stat2TV and lets a previously loaded match
// restore its own roster immediately, without exposing another game's data.
const cachedDetailsPromise = manual
? Promise.resolve(null)
: request(
`/api/hockey/games/${encodeURIComponent(id)}/details?language=${encodeURIComponent(state.language)}`,
{ timeoutMs: 2000 }
).catch(() => null);
updateMatchLoading(t("loadingMatchSession")); updateMatchLoading(t("loadingMatchSession"));
const previousToken = localStorage.getItem(storage.session); const previousToken = localStorage.getItem(storage.session);
if (previousToken) { if (previousToken) {
@@ -1633,13 +1672,47 @@ function gameCard(game) {
localStorage.setItem(storage.gameDate, state.gameDate); localStorage.setItem(storage.gameDate, state.gameDate);
} }
// Publish the selected match immediately. Do not wait for lineups, shots, // Prefer cached details for this exact game. The Runtime was explicitly
// standings or any remote Stat2TV dependency before the operator can work. // cleared above, so even a missing cache can only show an empty new match,
// never the previous match roster.
// The new session is confirmed. From this point the old match-specific
// Runtime payload must not survive even for a single render.
clearRuntimeMatchSpecificData();
const cachedDetails = await cachedDetailsPromise;
const cachedRostersReady = cachedMatchHasRosters(cachedDetails);
if (cachedDetails?.game) {
state.selectedGame = cachedDetails.game;
state.selectedMatchDetails = cachedDetails.details || null;
}
applyGameData(session); applyGameData(session);
renderSelectedChip(); renderSelectedChip();
startSelectedGamePolling();
setOpen(false); setOpen(false);
// For a first-time match, keep the loading overlay until the critical
// roster card has arrived. Mapping and optional shots remain non-blocking.
// If this game's own cached roster already exists, let the operator work
// immediately and refresh it in the background.
if (!manual && !cachedRostersReady) {
updateMatchLoading(t("loadingMatchDetails"));
await enrichOpenedGame(String(id), state.selectedId, generation, { opening: true });
if (
generation === state.gameOpenGeneration
&& String(state.selectedGameId || "") === String(id)
) startSelectedGamePolling();
} else if (!manual) {
void enrichOpenedGame(String(id), state.selectedId, generation, { opening: true })
.finally(() => {
if (
generation === state.gameOpenGeneration
&& String(state.selectedGameId || "") === String(id)
) startSelectedGamePolling();
});
} else {
startSelectedGamePolling();
}
updateMatchLoading(t("loadingMatchFinishing")); updateMatchLoading(t("loadingMatchFinishing"));
loadTournamentStandings({ sync: false }).catch(() => {}); loadTournamentStandings({ sync: false }).catch(() => {});
if (selectedGameDate) loadGames({ autoSync: false }).catch(() => {}); if (selectedGameDate) loadGames({ autoSync: false }).catch(() => {});
@@ -1655,10 +1728,6 @@ function gameCard(game) {
const awayName = state.selectedGame?.away?.name || "—"; const awayName = state.selectedGame?.away?.name || "—";
notify(`${t("gameOpened")}: ${homeName}${awayName}${loadedText}`); notify(`${t("gameOpened")}: ${homeName}${awayName}${loadedText}`);
if (manual) state.root?.querySelectorAll("[data-manual-game-id]").forEach((manualInput) => { manualInput.value = ""; }); if (manual) state.root?.querySelectorAll("[data-manual-game-id]").forEach((manualInput) => { manualInput.value = ""; });
// Enrich in the background. If a particular match has no full JSON card,
// the already-open schedule match remains active and usable.
void enrichOpenedGame(String(id), state.selectedId, generation);
} catch (error) { } catch (error) {
state.lastGameOpenError = String(error?.message || error || "Неизвестная ошибка"); state.lastGameOpenError = String(error?.message || error || "Неизвестная ошибка");
if (state.view === "games") renderGames(); if (state.view === "games") renderGames();

View File

@@ -30,7 +30,7 @@ def test_visible_match_card_opens_from_schedule_without_details_roundtrip() -> N
assert "state.selectedGame = scheduleGame;" in TOURNAMENT_JS assert "state.selectedGame = scheduleGame;" in TOURNAMENT_JS
assert "Opening the workspace must be immediate" in TOURNAMENT_JS assert "Opening the workspace must be immediate" in TOURNAMENT_JS
# Full details remain optional enrichment after the match is selected. # Full details remain optional enrichment after the match is selected.
assert "void enrichOpenedGame(String(id), state.selectedId, generation);" in TOURNAMENT_JS assert "enrichOpenedGame(String(id), state.selectedId, generation, { opening: true })" in TOURNAMENT_JS
def test_browser_timeout_no_longer_blames_stat2tv_for_every_endpoint() -> None: def test_browser_timeout_no_longer_blames_stat2tv_for_every_endpoint() -> None:

View File

@@ -0,0 +1,52 @@
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
JS = (ROOT / "hockey_data" / "static" / "tournament-menu.js").read_text(encoding="utf-8")
SERVICE = (ROOT / "hockey_data" / "service.py").read_text(encoding="utf-8")
ROUTER = (ROOT / "hockey_data" / "router.py").read_text(encoding="utf-8")
APP = (ROOT / "app.py").read_text(encoding="utf-8")
def test_previous_match_runtime_rosters_are_explicitly_cleared():
assert "function clearRuntimeMatchSpecificData()" in JS
assert "selected_game: null" in JS
assert "home: null" in JS
assert "away: null" in JS
assert "referees: []" in JS
assert "match_details: null" in JS
assert "clearRuntimeMatchSpecificData();" in JS
def test_old_polling_stops_before_new_match_selection():
start = JS.index("async function openGame")
stop = JS.index("stopSelectedGamePolling();", start)
generation = JS.index("const generation = ++state.gameOpenGeneration;", start)
assert stop < generation
def test_selected_game_cache_is_match_scoped_and_local():
assert "/api/hockey/games/${encodeURIComponent(id)}/details?language=" in JS
assert "cachedMatchHasRosters" in JS
assert "cachedRostersReady" in JS
def test_first_time_match_waits_for_core_roster_before_polling():
assert "if (!manual && !cachedRostersReady)" in JS
assert "await enrichOpenedGame(String(id), state.selectedId, generation, { opening: true });" in JS
branch = JS.index("if (!manual && !cachedRostersReady)")
enrich = JS.index("await enrichOpenedGame", branch)
poll = JS.index("startSelectedGamePolling();", enrich)
assert enrich < poll
def test_opening_sync_skips_schedule_roundtrip_and_optional_shots():
assert "opening: bool = False" in ROUTER
assert "opening=opening" in ROUTER
assert "if opening:" in SERVICE
assert "schedule = self.game(game_external_id, language=language)" in SERVICE
assert "skip_optional_shots=opening" in SERVICE
assert "if not skip_optional_shots:" in SERVICE
def test_runtime_version_build110():
assert 'BUILD_VERSION = "2026.08.24.13"' in APP

View File

@@ -17,7 +17,7 @@ def test_runtime_is_default_even_for_legacy_editor_bookmarks():
def test_match_selection_no_longer_depends_on_full_details_sync(): def test_match_selection_no_longer_depends_on_full_details_sync():
assert 'Match selection itself depends only on the local/cached schedule row.' in TOURNAMENT_JS assert 'Match selection itself depends only on the local/cached schedule row.' in TOURNAMENT_JS
assert 'void enrichOpenedGame(String(id), state.selectedId, generation);' in TOURNAMENT_JS assert 'enrichOpenedGame(String(id), state.selectedId, generation, { opening: true })' in TOURNAMENT_JS
assert 'Full match JSON, rosters, shots and directories are enrichment data.' in TOURNAMENT_JS assert 'Full match JSON, rosters, shots and directories are enrichment data.' in TOURNAMENT_JS
assert 'gameOpenGeneration' in TOURNAMENT_JS assert 'gameOpenGeneration' in TOURNAMENT_JS