diff --git a/app.py b/app.py index c23ebb4..d54264c 100644 --- a/app.py +++ b/app.py @@ -29,7 +29,7 @@ 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.24.12" +BUILD_VERSION = "2026.08.24.13" # compatibility: BUILD_VERSION = "2026.08.24.8" # compatibility: BUILD_VERSION = "2026.08.24.7" # compatibility: BUILD_VERSION = "2026.08.24.6" diff --git a/hockey_data/router.py b/hockey_data/router.py index f7e05f3..84182a5 100644 --- a/hockey_data/router.py +++ b/hockey_data/router.py @@ -924,12 +924,14 @@ def create_hockey_router( external_id: str, tournament_id: str = Query(..., min_length=1, max_length=64), language: str = Query("ru", pattern="^(ru|en)$"), + opening: bool = False, ) -> dict[str, Any]: try: return await service.sync_selected_game_details( tournament_external_id=tournament_id, game_external_id=external_id, language=language, + opening=opening, ) except ValueError as error: raise HTTPException(status_code=404, detail=str(error)) from error diff --git a/hockey_data/service.py b/hockey_data/service.py index dae0414..2d950d3 100644 --- a/hockey_data/service.py +++ b/hockey_data/service.py @@ -3484,6 +3484,7 @@ class HockeyDataService: language: str = "ru", allow_create: bool = False, force_optional_shots: bool = False, + skip_optional_shots: bool = False, ) -> dict[str, Any]: settings = self.settings.public() templates = { @@ -3590,15 +3591,16 @@ class HockeyDataService: now=now, ) - shots_meta: dict[str, Any] = {"available": False} - try: - shots_meta = await self._sync_optional_shots( - tournament_external_id=tournament_external_id, - game_external_id=game_external_id, - force=force_optional_shots, - ) - except Exception as error: - warnings.append(f"Дополнительные данные shots недоступны: {error}") + shots_meta: dict[str, Any] = {"available": False, "skipped": bool(skip_optional_shots)} + if not skip_optional_shots: + try: + shots_meta = await self._sync_optional_shots( + tournament_external_id=tournament_external_id, + game_external_id=game_external_id, + force=force_optional_shots, + ) + except Exception as error: + warnings.append(f"Дополнительные данные shots недоступны: {error}") payload = self.game_details(game_external_id, language=language) if payload is None: @@ -3948,33 +3950,40 @@ class HockeyDataService: tournament_external_id: str, game_external_id: str, language: str = "ru", + opening: bool = False, ) -> dict[str, Any]: schedule: dict[str, Any] | None = None schedule_warning = "" - try: - schedule = await self.sync_selected_game( - tournament_external_id=tournament_external_id, - 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) + if opening: + # BUILD110: the operator clicked a row that already came from the + # local schedule. Do not spend the critical roster-loading window on + # another schedule round-trip; live polling refreshes it afterwards. + schedule = self.game(game_external_id, language=language) + else: + try: + schedule = await self.sync_selected_game( + tournament_external_id=tournament_external_id, + 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 = "" try: - # Opening a match must not depend on every optional Stat2TV resource. - # Some schedule entries exist before/without a full match JSON card. - # Keep the operator UI usable and enrich the selected match when the - # card becomes available instead of failing the whole selection. + # Opening a match waits only for the core card/rosters. Optional + # shots are deliberately deferred to live/background refresh so a + # slow auxiliary feed cannot keep the loading overlay open. payload = await asyncio.wait_for( self.sync_match_details( tournament_external_id=tournament_external_id, game_external_id=game_external_id, language=language, allow_create=True, - force_optional_shots=True, + force_optional_shots=not opening, + skip_optional_shots=opening, ), timeout=12.0, ) diff --git a/hockey_data/static/tournament-menu.js b/hockey_data/static/tournament-menu.js index edde01f..274353c 100644 --- a/hockey_data/static/tournament-menu.js +++ b/hockey_data/static/tournament-menu.js @@ -1483,18 +1483,19 @@ function gameCard(game) { } } - async function enrichOpenedGame(id, tournamentId, generation) { + async function enrichOpenedGame(id, tournamentId, generation, { opening = false } = {}) { try { const loaded = await request( `/api/hockey/games/${encodeURIComponent(id)}/details/sync` + `?tournament_id=${encodeURIComponent(tournamentId)}` - + `&language=${encodeURIComponent(state.language)}`, - { method: "POST", timeoutMs: 16000 } + + `&language=${encodeURIComponent(state.language)}` + + `&opening=${opening ? "true" : "false"}`, + { method: "POST", timeoutMs: opening ? 14000 : 16000 } ); if ( generation !== state.gameOpenGeneration || String(state.selectedGameId || "") !== String(id) - ) return; + ) return false; if (loaded?.game) state.selectedGame = loaded.game; state.selectedMatchDetails = loaded?.details || state.selectedMatchDetails || null; applyGameData(); @@ -1503,18 +1504,46 @@ function gameCard(game) { if (loaded?.details_sync_failed || loaded?.degraded) { console.warn("[Hockey] Match opened with cached/schedule data; full details are not available yet", loaded?.warnings || []); } + return Boolean(loaded?.game); } catch (error) { // Full match JSON, rosters, shots and directories are enrichment data. // Their absence must never undo a match already selected by the operator. 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 } = {}) { if (state.gameOpenBusy) return; state.lastAttemptedGameId = String(id || ""); state.lastGameOpenError = ""; state.gameOpenBusy = true; + stopSelectedGamePolling(); const generation = ++state.gameOpenGeneration; const loadingGame = matchLabelById(id) || `${t("gameNumber")} ID ${id}`; setMatchLoading(true, { game: loadingGame, stage: t("loadingMatchDetails") }); @@ -1576,6 +1605,16 @@ function gameCard(game) { 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")); const previousToken = localStorage.getItem(storage.session); if (previousToken) { @@ -1633,13 +1672,47 @@ function gameCard(game) { localStorage.setItem(storage.gameDate, state.gameDate); } - // Publish the selected match immediately. Do not wait for lineups, shots, - // standings or any remote Stat2TV dependency before the operator can work. + // Prefer cached details for this exact game. The Runtime was explicitly + // 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); renderSelectedChip(); - startSelectedGamePolling(); 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")); loadTournamentStandings({ sync: false }).catch(() => {}); if (selectedGameDate) loadGames({ autoSync: false }).catch(() => {}); @@ -1655,10 +1728,6 @@ function gameCard(game) { const awayName = state.selectedGame?.away?.name || "—"; notify(`${t("gameOpened")}: ${homeName} — ${awayName}${loadedText}`); 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) { state.lastGameOpenError = String(error?.message || error || "Неизвестная ошибка"); if (state.view === "games") renderGames(); diff --git a/tests/test_build109_match_open_nonblocking.py b/tests/test_build109_match_open_nonblocking.py index 6d65ccb..2e8b24a 100644 --- a/tests/test_build109_match_open_nonblocking.py +++ b/tests/test_build109_match_open_nonblocking.py @@ -30,7 +30,7 @@ def test_visible_match_card_opens_from_schedule_without_details_roundtrip() -> N assert "state.selectedGame = scheduleGame;" in TOURNAMENT_JS assert "Opening the workspace must be immediate" in TOURNAMENT_JS # 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: diff --git a/tests/test_build110_match_roster_atomic_switch.py b/tests/test_build110_match_roster_atomic_switch.py new file mode 100644 index 0000000..8e7ae0d --- /dev/null +++ b/tests/test_build110_match_roster_atomic_switch.py @@ -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 diff --git a/tests/test_build66_runtime_default_and_resilient_match_open.py b/tests/test_build66_runtime_default_and_resilient_match_open.py index 6a1d94c..198e6f7 100644 --- a/tests/test_build66_runtime_default_and_resilient_match_open.py +++ b/tests/test_build66_runtime_default_and_resilient_match_open.py @@ -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(): 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 'gameOpenGeneration' in TOURNAMENT_JS