test 7
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.11"
|
BUILD_VERSION = "2026.08.24.12"
|
||||||
# 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"
|
||||||
|
|||||||
@@ -1991,6 +1991,7 @@ class VmixAgentHub:
|
|||||||
tournament_external_id: str = "",
|
tournament_external_id: str = "",
|
||||||
device_id: str = "",
|
device_id: str = "",
|
||||||
operator_session_token: str = "",
|
operator_session_token: str = "",
|
||||||
|
wait_for_mapping: bool = True,
|
||||||
) -> dict[str, Any] | None:
|
) -> dict[str, Any] | None:
|
||||||
"""Assign a match to exactly one Agent. Never broadcast to all active Agents."""
|
"""Assign a match to exactly one Agent. Never broadcast to all active Agents."""
|
||||||
game_external_id = str(game_external_id or "").strip()
|
game_external_id = str(game_external_id or "").strip()
|
||||||
@@ -2094,7 +2095,17 @@ class VmixAgentHub:
|
|||||||
)
|
)
|
||||||
payload["delivered"] = delivered
|
payload["delivered"] = delivered
|
||||||
if 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
|
return payload
|
||||||
|
|
||||||
async def select_device_for_session(
|
async def select_device_for_session(
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from datetime import date
|
|||||||
from time import perf_counter
|
from time import perf_counter
|
||||||
from typing import Any, Callable
|
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 pydantic import BaseModel, ConfigDict, Field
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
|
|
||||||
@@ -1149,6 +1149,7 @@ def create_hockey_router(
|
|||||||
@router.post("/sessions")
|
@router.post("/sessions")
|
||||||
async def open_session(
|
async def open_session(
|
||||||
payload: SessionPayload,
|
payload: SessionPayload,
|
||||||
|
background_tasks: BackgroundTasks,
|
||||||
user: HockeyUser = Depends(auth_dependency),
|
user: HockeyUser = Depends(auth_dependency),
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
try:
|
try:
|
||||||
@@ -1169,6 +1170,17 @@ def create_hockey_router(
|
|||||||
tournament_external_id=str(result.get("tournament_external_id") or ""),
|
tournament_external_id=str(result.get("tournament_external_id") or ""),
|
||||||
device_id=vmix_device_id,
|
device_id=vmix_device_id,
|
||||||
operator_session_token=str(result.get("token") or ""),
|
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:
|
except Exception as agent_error:
|
||||||
# The match/session must remain usable even when a browser
|
# The match/session must remain usable even when a browser
|
||||||
|
|||||||
@@ -368,7 +368,9 @@
|
|||||||
return payload;
|
return payload;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error?.name === "AbortError") {
|
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;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
@@ -1529,27 +1531,20 @@ function gameCard(game) {
|
|||||||
);
|
);
|
||||||
state.selectedGame = discovered.game;
|
state.selectedGame = discovered.game;
|
||||||
state.selectedMatchDetails = discovered.details || null;
|
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 {
|
} else {
|
||||||
try {
|
// Defensive fallback for a stale DOM/list state. This path is local DB
|
||||||
const cached = await request(
|
// only and is not used for the normal visible match-card click.
|
||||||
`/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(
|
state.selectedGame = await request(
|
||||||
`/api/hockey/games/${encodeURIComponent(id)}?language=${encodeURIComponent(state.language)}`,
|
`/api/hockey/games/${encodeURIComponent(id)}?language=${encodeURIComponent(state.language)}`,
|
||||||
{ timeoutMs: 5000 }
|
{ timeoutMs: 5000 }
|
||||||
);
|
);
|
||||||
state.selectedMatchDetails = null;
|
state.selectedMatchDetails = null;
|
||||||
} catch (baseError) {
|
|
||||||
if (!scheduleGame) throw baseError;
|
|
||||||
state.selectedGame = scheduleGame;
|
|
||||||
state.selectedMatchDetails = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const actualTournamentId = String(
|
const actualTournamentId = String(
|
||||||
|
|||||||
94
tests/test_build109_match_open_nonblocking.py
Normal file
94
tests/test_build109_match_open_nonblocking.py
Normal 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())
|
||||||
Reference in New Issue
Block a user