тест 8
This commit is contained in:
2
app.py
2
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
|
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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
52
tests/test_build110_match_roster_atomic_switch.py
Normal file
52
tests/test_build110_match_roster_atomic_switch.py
Normal 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
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user