This commit is contained in:
2026-08-24 17:59:33 +03:00
parent 1fbe845b30
commit 65a2521156
5 changed files with 136 additions and 24 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
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"

View File

@@ -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(

View File

@@ -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.

View File

@@ -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(

View File

@@ -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())