diff --git a/app.py b/app.py index 6e98fc0..c23ebb4 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.11" +BUILD_VERSION = "2026.08.24.12" # 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/agent_bridge.py b/hockey_data/agent_bridge.py index 7fb0c20..4dbb808 100644 --- a/hockey_data/agent_bridge.py +++ b/hockey_data/agent_bridge.py @@ -1991,6 +1991,7 @@ class VmixAgentHub: tournament_external_id: str = "", device_id: str = "", operator_session_token: str = "", + wait_for_mapping: bool = True, ) -> dict[str, Any] | None: """Assign a match to exactly one Agent. Never broadcast to all active Agents.""" game_external_id = str(game_external_id or "").strip() @@ -2094,7 +2095,17 @@ class VmixAgentHub: ) payload["delivered"] = delivered if delivered: - payload["mapping_apply"] = await self.apply_mapping_to_device(resolved_device_id, reason="match_assigned") + if wait_for_mapping: + payload["mapping_apply"] = await self.apply_mapping_to_device( + resolved_device_id, reason="match_assigned" + ) + else: + payload["mapping_apply"] = { + "ok": True, + "deferred": True, + "reason": "match_assigned", + "device_id": resolved_device_id, + } return payload async def select_device_for_session( diff --git a/hockey_data/router.py b/hockey_data/router.py index 6336033..f7e05f3 100644 --- a/hockey_data/router.py +++ b/hockey_data/router.py @@ -5,7 +5,7 @@ from datetime import date from time import perf_counter from typing import Any, Callable -from fastapi import APIRouter, Depends, HTTPException, Query, Request +from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, Request from pydantic import BaseModel, ConfigDict, Field from sqlalchemy import text @@ -1149,6 +1149,7 @@ def create_hockey_router( @router.post("/sessions") async def open_session( payload: SessionPayload, + background_tasks: BackgroundTasks, user: HockeyUser = Depends(auth_dependency), ) -> dict[str, Any]: try: @@ -1169,7 +1170,18 @@ def create_hockey_router( tournament_external_id=str(result.get("tournament_external_id") or ""), device_id=vmix_device_id, operator_session_token=str(result.get("token") or ""), + # BUILD109: selecting a match must not wait for a full + # Mapping push to vMix. The Agent receives match.assign + # immediately; Mapping starts only after the HTTP response. + wait_for_mapping=False, ) + assignment = result.get("agent_assignment") or {} + if assignment.get("delivered") and assignment.get("device_id"): + background_tasks.add_task( + agent_hub.apply_mapping_to_device, + str(assignment.get("device_id")), + reason="match_assigned", + ) except Exception as agent_error: # The match/session must remain usable even when a browser # carries a stale Agent id from another WFL account. diff --git a/hockey_data/static/tournament-menu.js b/hockey_data/static/tournament-menu.js index 386fb52..edde01f 100644 --- a/hockey_data/static/tournament-menu.js +++ b/hockey_data/static/tournament-menu.js @@ -368,7 +368,9 @@ return payload; } catch (error) { if (error?.name === "AbortError") { - throw new Error("Превышено время ожидания ответа Stat2TV"); + // BUILD109: this helper is used for local DB/session/Agent endpoints too. + // Calling every HTTP timeout a Stat2TV timeout hides the real subsystem. + throw new Error("Превышено время ожидания ответа сервера"); } throw error; } finally { @@ -1529,27 +1531,20 @@ function gameCard(game) { ); state.selectedGame = discovered.game; state.selectedMatchDetails = discovered.details || null; + } else if (scheduleGame) { + // BUILD109: the card the operator clicked is already a complete local + // schedule snapshot. Opening the workspace must be immediate; cached + // details/full match data are optional enrichment and are loaded below. + state.selectedGame = scheduleGame; + state.selectedMatchDetails = null; } else { - try { - const cached = await request( - `/api/hockey/games/${encodeURIComponent(id)}/details?language=${encodeURIComponent(state.language)}`, - { timeoutMs: 5000 } - ); - state.selectedGame = cached.game; - state.selectedMatchDetails = cached.details || null; - } catch (_) { - try { - state.selectedGame = await request( - `/api/hockey/games/${encodeURIComponent(id)}?language=${encodeURIComponent(state.language)}`, - { timeoutMs: 5000 } - ); - state.selectedMatchDetails = null; - } catch (baseError) { - if (!scheduleGame) throw baseError; - state.selectedGame = scheduleGame; - state.selectedMatchDetails = null; - } - } + // Defensive fallback for a stale DOM/list state. This path is local DB + // only and is not used for the normal visible match-card click. + state.selectedGame = await request( + `/api/hockey/games/${encodeURIComponent(id)}?language=${encodeURIComponent(state.language)}`, + { timeoutMs: 5000 } + ); + state.selectedMatchDetails = null; } const actualTournamentId = String( diff --git a/tests/test_build109_match_open_nonblocking.py b/tests/test_build109_match_open_nonblocking.py new file mode 100644 index 0000000..6d65ccb --- /dev/null +++ b/tests/test_build109_match_open_nonblocking.py @@ -0,0 +1,94 @@ +from __future__ import annotations + +import asyncio +from pathlib import Path +from types import SimpleNamespace + +from hockey_data.agent_bridge import VmixAgentHub +from hockey_data.auth_bridge import HockeyUser +from tests.support import LocalTestDatabase + +ROOT = Path(__file__).resolve().parents[1] +TOURNAMENT_JS = (ROOT / "hockey_data" / "static" / "tournament-menu.js").read_text(encoding="utf-8") +ROUTER = (ROOT / "hockey_data" / "router.py").read_text(encoding="utf-8") + + +class FakeWebSocket: + def __init__(self) -> None: + self.client = SimpleNamespace(host="127.0.0.1") + self.sent: list[dict] = [] + + async def send_json(self, payload: dict) -> None: + self.sent.append(payload) + + async def close(self, **_kwargs) -> None: + return None + + +def test_visible_match_card_opens_from_schedule_without_details_roundtrip() -> None: + assert "} else if (scheduleGame) {" in TOURNAMENT_JS + 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 + + +def test_browser_timeout_no_longer_blames_stat2tv_for_every_endpoint() -> None: + assert 'throw new Error("Превышено время ожидания ответа сервера")' in TOURNAMENT_JS + assert 'throw new Error("Превышено время ожидания ответа Stat2TV")' not in TOURNAMENT_JS + + +def test_session_assignment_defers_full_mapping() -> None: + assert "background_tasks: BackgroundTasks" in ROUTER + assert "wait_for_mapping=False" in ROUTER + assert "background_tasks.add_task(" in ROUTER + assert "agent_hub.apply_mapping_to_device" in ROUTER + + +def test_assign_match_can_return_without_running_mapping(tmp_path: Path) -> None: + database = LocalTestDatabase(tmp_path / "build109.sqlite3") + database.create_all() + hub = VmixAgentHub(database) # type: ignore[arg-type] + ws = FakeWebSocket() + user = HockeyUser(id="109", login="operator109", display_name="Operator 109") + + called = False + + async def slow_mapping(*_args, **_kwargs): + nonlocal called + called = True + await asyncio.sleep(30) + return {"ok": True} + + async def scenario() -> None: + await hub.register( + ws, # type: ignore[arg-type] + { + "device_id": "GFX-BUILD109", + "device_secret": "x" * 40, + "device_name": "GFX BUILD109", + "hostname": "GFX-BUILD109", + "agent_version": "1.4.0", + "vmix": {"connected": True, "url": "http://127.0.0.1:8088/api/"}, + }, + ) + await hub.pair_device("GFX-BUILD109", user) + hub.apply_mapping_to_device = slow_mapping # type: ignore[method-assign] + + result = await asyncio.wait_for( + hub.assign_match( + wfl_user_id=user.id, + tournament_external_id="1437", + game_external_id="902918", + device_id="GFX-BUILD109", + wait_for_mapping=False, + ), + timeout=0.5, + ) + assert result is not None + assert result["delivered"] is True + assert result["mapping_apply"]["deferred"] is True + assert called is False + assert any(item.get("type") == "match.assign" for item in ws.sent) + + asyncio.run(scenario())