тест 9

This commit is contained in:
2026-08-24 18:38:12 +03:00
parent aac9e5b8bc
commit 10a65e4d47
6 changed files with 233 additions and 22 deletions

3
app.py
View File

@@ -29,7 +29,8 @@ 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.13" BUILD_VERSION = "2026.08.24.14"
# compatibility: 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"

View File

@@ -2001,6 +2001,7 @@ class VmixAgentHub:
return None return None
now = _utcnow() now = _utcnow()
with self.database.session() as session: with self.database.session() as session:
device = None
if requested_device_id: if requested_device_id:
requested_device_id = self.normalise_device_id(requested_device_id) requested_device_id = self.normalise_device_id(requested_device_id)
device = session.scalar( device = session.scalar(
@@ -2012,9 +2013,12 @@ class VmixAgentHub:
) )
) )
) )
else:
# Legacy/single-Agent fallback only. If several devices are enabled, if device is None:
# routing must be explicit so a browser cannot control the wrong vMix. # BUILD111: a browser may keep a stale localStorage device id after
# reconnect/update. Do not silently leave the new match unassigned.
# Automatic fallback is allowed only when routing is unambiguous:
# exactly one live active Agent, or exactly one active Agent total.
candidates = list(session.scalars( candidates = list(session.scalars(
select(VmixDevice) select(VmixDevice)
.where( .where(
@@ -2025,7 +2029,12 @@ class VmixAgentHub:
) )
.order_by(desc(VmixDevice.last_seen_at)) .order_by(desc(VmixDevice.last_seen_at))
)) ))
device = candidates[0] if len(candidates) == 1 else None live_candidates = [item for item in candidates if item.device_uuid in self._live]
if len(live_candidates) == 1:
device = live_candidates[0]
elif len(candidates) == 1:
device = candidates[0]
if device is None: if device is None:
return None return None
@@ -2114,6 +2123,7 @@ class VmixAgentHub:
user: HockeyUser, user: HockeyUser,
*, *,
session_token: str = "", session_token: str = "",
wait_for_mapping: bool = True,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Bind this browser/operator session to one enabled Agent.""" """Bind this browser/operator session to one enabled Agent."""
device_id = self.normalise_device_id(device_id) device_id = self.normalise_device_id(device_id)
@@ -2152,6 +2162,7 @@ class VmixAgentHub:
tournament_external_id=tournament_id, tournament_external_id=tournament_id,
device_id=device_id, device_id=device_id,
operator_session_token=session_token, operator_session_token=session_token,
wait_for_mapping=bool(wait_for_mapping),
) )
return { return {
"ok": True, "ok": True,
@@ -3978,6 +3989,9 @@ class RuntimeVmixSequencePayload(BaseModel):
class SelectSessionDevicePayload(BaseModel): class SelectSessionDevicePayload(BaseModel):
session_token: str = Field(default="", max_length=128) session_token: str = Field(default="", max_length=128)
# Runtime match switching can assign immediately and apply Mapping only
# after the new roster has been loaded. Existing Agent UI keeps True.
apply_mapping: bool = True
class MappingTestValuePayload(BaseModel): class MappingTestValuePayload(BaseModel):
@@ -4243,7 +4257,12 @@ def create_hockey_agent_router(
payload: SelectSessionDevicePayload, payload: SelectSessionDevicePayload,
user: HockeyUser = Depends(auth_dependency), user: HockeyUser = Depends(auth_dependency),
) -> dict[str, Any]: ) -> dict[str, Any]:
return await hub.select_device_for_session(device_id, user, session_token=payload.session_token) return await hub.select_device_for_session(
device_id,
user,
session_token=payload.session_token,
wait_for_mapping=bool(payload.apply_mapping),
)
@router.delete("/api/hockey/agents/devices/{device_id}/pair") @router.delete("/api/hockey/agents/devices/{device_id}/pair")
async def unpair_device( async def unpair_device(

View File

@@ -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, BackgroundTasks, Depends, HTTPException, Query, Request from fastapi import APIRouter, Depends, HTTPException, Query, Request
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy import text from sqlalchemy import text
@@ -1151,7 +1151,6 @@ 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:
@@ -1172,17 +1171,15 @@ 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 # BUILD111: session opening sends only match.assign. Mapping
# Mapping push to vMix. The Agent receives match.assign # is applied after the selected match roster/details are in
# immediately; Mapping starts only after the HTTP response. # the database, otherwise vMix can receive stale match data.
wait_for_mapping=False, wait_for_mapping=False,
) )
assignment = result.get("agent_assignment") or {} assignment = result.get("agent_assignment") or {}
if assignment.get("delivered") and assignment.get("device_id"): result["agent_status"] = (
background_tasks.add_task( "delivered" if assignment.get("delivered")
agent_hub.apply_mapping_to_device, else ("offline" if assignment.get("device_id") else "unassigned")
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

View File

@@ -308,6 +308,82 @@
return String(window.HockeyAgentRuntime?.currentDeviceId?.() || localStorage.getItem(storage.vmixDevice) || "").trim(); return String(window.HockeyAgentRuntime?.currentDeviceId?.() || localStorage.getItem(storage.vmixDevice) || "").trim();
} }
function rememberAssignedAgent(deviceId) {
const clean = String(deviceId || "").trim();
if (!clean) return;
localStorage.setItem(storage.vmixDevice, clean);
try { window.HockeyAgentRuntime?.selectDevice?.(clean); } catch (_) {}
}
async function ensureSessionAgentAssignment(session) {
const token = String(session?.token || localStorage.getItem(storage.session) || "").trim();
let assignment = session?.agent_assignment && typeof session.agent_assignment === "object"
? session.agent_assignment
: null;
if (assignment?.device_id) rememberAssignedAgent(assignment.device_id);
if (assignment?.delivered) return assignment;
if (!token) return assignment;
// BUILD111: opening the web match is not proof that Agent received
// match.assign. Recover from stale/empty localStorage only when routing is
// unambiguous: the selected live Agent or exactly one live active Agent.
let devicesPayload = null;
try {
devicesPayload = await request("/api/hockey/agents/devices", { timeoutMs: 3500 });
} catch (_) {
return assignment;
}
const devices = Array.isArray(devicesPayload?.devices) ? devicesPayload.devices : [];
const live = devices.filter((item) => (
item?.paired_to_me
&& item?.active_for_account
&& item?.online
));
const preferredIds = [
String(assignment?.device_id || "").trim(),
currentAgentDeviceId(),
].filter(Boolean);
let target = null;
for (const preferred of preferredIds) {
target = live.find((item) => String(item.device_id || "") === preferred) || null;
if (target) break;
}
if (!target && live.length === 1) target = live[0];
if (!target?.device_id) return assignment;
try {
const rebound = await request(
`/api/hockey/agents/devices/${encodeURIComponent(target.device_id)}/select-session`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ session_token: token, apply_mapping: false }),
timeoutMs: 5000,
}
);
assignment = rebound?.assignment || assignment;
if (assignment?.device_id) rememberAssignedAgent(assignment.device_id);
if (session && assignment) session.agent_assignment = assignment;
return assignment;
} catch (error) {
console.warn("[Hockey] Agent match.assign retry failed:", error);
return assignment;
}
}
async function refreshSessionAgentMapping(session) {
const assignment = await ensureSessionAgentAssignment(session);
const deviceId = String(assignment?.device_id || "").trim();
if (!deviceId || !assignment?.delivered) {
return { ok: false, reason: "agent_match_not_delivered" };
}
return request(
`/api/hockey/agents/devices/${encodeURIComponent(deviceId)}/apply-mapping`,
{ method: "POST", timeoutMs: 60000 }
);
}
async function ensureAccountScopedRuntimeState() { async function ensureAccountScopedRuntimeState() {
let user = null; let user = null;
try { try {
@@ -1660,6 +1736,12 @@ function gameCard(game) {
} }
} }
// Confirm that the concrete Agent actually received this new match.
// A successful /sessions response alone is not enough: assignment may be
// null/offline when the browser stored a stale device id.
const initialAgentAssignment = await ensureSessionAgentAssignment(session);
if (initialAgentAssignment && session) session.agent_assignment = initialAgentAssignment;
state.lastGameOpenError = ""; state.lastGameOpenError = "";
state.selectedGameId = String(id); state.selectedGameId = String(id);
localStorage.setItem(storage.game, state.selectedGameId); localStorage.setItem(storage.game, state.selectedGameId);
@@ -1700,17 +1782,34 @@ function gameCard(game) {
if ( if (
generation === state.gameOpenGeneration generation === state.gameOpenGeneration
&& String(state.selectedGameId || "") === String(id) && String(state.selectedGameId || "") === String(id)
) startSelectedGamePolling(); ) {
startSelectedGamePolling();
void refreshSessionAgentMapping(session).then((mapping) => {
if (mapping?.ok === false && mapping?.reason === "agent_match_not_delivered") {
notify("Матч открыт, но Agent не получил новый матч", true);
}
}).catch((error) => console.warn("[Hockey] Mapping refresh after roster failed:", error));
}
} else if (!manual) { } else if (!manual) {
// This exact match already has its own cached roster, so vMix can be
// refreshed immediately without waiting for the optional network sync.
void refreshSessionAgentMapping(session).then((mapping) => {
if (mapping?.ok === false && mapping?.reason === "agent_match_not_delivered") {
notify("Матч открыт, но Agent не получил новый матч", true);
}
}).catch((error) => console.warn("[Hockey] Mapping refresh from cached roster failed:", error));
void enrichOpenedGame(String(id), state.selectedId, generation, { opening: true }) void enrichOpenedGame(String(id), state.selectedId, generation, { opening: true })
.finally(() => { .finally(() => {
if ( if (
generation === state.gameOpenGeneration generation === state.gameOpenGeneration
&& String(state.selectedGameId || "") === String(id) && String(state.selectedGameId || "") === String(id)
) startSelectedGamePolling(); ) {
startSelectedGamePolling();
}
}); });
} else { } else {
startSelectedGamePolling(); startSelectedGamePolling();
void refreshSessionAgentMapping(session).catch((error) => console.warn("[Hockey] Mapping refresh for manual match failed:", error));
} }
updateMatchLoading(t("loadingMatchFinishing")); updateMatchLoading(t("loadingMatchFinishing"));

View File

@@ -39,10 +39,12 @@ def test_browser_timeout_no_longer_blames_stat2tv_for_every_endpoint() -> None:
def test_session_assignment_defers_full_mapping() -> None: def test_session_assignment_defers_full_mapping() -> None:
assert "background_tasks: BackgroundTasks" in ROUTER
assert "wait_for_mapping=False" in ROUTER assert "wait_for_mapping=False" in ROUTER
assert "background_tasks.add_task(" in ROUTER # BUILD111 keeps BUILD109's non-blocking match open, but Mapping is no
assert "agent_hub.apply_mapping_to_device" in ROUTER # longer queued here because it must wait for the new roster/details.
open_session = ROUTER[ROUTER.index('@router.post("/sessions")'):ROUTER.index('@router.get("/sessions/{token}")')]
assert "background_tasks.add_task(" not in open_session
assert 'result["agent_status"]' in open_session
def test_assign_match_can_return_without_running_mapping(tmp_path: Path) -> None: def test_assign_match_can_return_without_running_mapping(tmp_path: Path) -> None:

View File

@@ -0,0 +1,93 @@
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]
JS = (ROOT / "hockey_data" / "static" / "tournament-menu.js").read_text(encoding="utf-8")
AGENT = (ROOT / "hockey_data" / "agent_bridge.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")
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_stale_browser_device_falls_back_to_only_live_active_agent(tmp_path: Path) -> None:
database = LocalTestDatabase(tmp_path / "build111.sqlite3")
database.create_all()
hub = VmixAgentHub(database) # type: ignore[arg-type]
ws = FakeWebSocket()
user = HockeyUser(id="111", login="operator111", display_name="Operator 111")
async def scenario() -> None:
await hub.register(
ws, # type: ignore[arg-type]
{
"device_id": "GFX-BUILD111",
"device_secret": "x" * 40,
"device_name": "GFX BUILD111",
"hostname": "GFX-BUILD111",
"agent_version": "1.4.0",
"vmix": {"connected": True, "url": "http://127.0.0.1:8088/api/"},
},
)
await hub.pair_device("GFX-BUILD111", user)
result = await hub.assign_match(
wfl_user_id=user.id,
tournament_external_id="1437",
game_external_id="902918",
device_id="STALE-BROWSER-DEVICE",
wait_for_mapping=False,
)
assert result is not None
assert result["device_id"] == "GFX-BUILD111"
assert result["delivered"] is True
assert result["mapping_apply"]["deferred"] is True
match_assigns = [item for item in ws.sent if item.get("type") == "match.assign"]
assert len(match_assigns) == 1
assert match_assigns[0]["game_id"] == "902918"
asyncio.run(scenario())
def test_browser_retries_match_assign_without_mapping_when_needed() -> None:
assert "async function ensureSessionAgentAssignment(session)" in JS
assert 'body: JSON.stringify({ session_token: token, apply_mapping: false })' in JS
assert 'item?.paired_to_me' in JS
assert 'item?.active_for_account' in JS
assert 'item?.online' in JS
assert 'live.length === 1' in JS
def test_mapping_is_refreshed_only_after_roster_enrichment() -> None:
assert "async function refreshSessionAgentMapping(session)" in JS
branch = JS.index("if (!manual && !cachedRostersReady)")
enrich = JS.index("await enrichOpenedGame", branch)
mapping = JS.index("refreshSessionAgentMapping(session)", enrich)
assert enrich < mapping
open_session = ROUTER[ROUTER.index('@router.post("/sessions")'):ROUTER.index('@router.get("/sessions/{token}")')]
assert "agent_hub.apply_mapping_to_device" not in open_session
def test_select_session_supports_assignment_without_early_mapping() -> None:
assert "apply_mapping: bool = True" in AGENT
assert "wait_for_mapping=bool(payload.apply_mapping)" in AGENT
def test_runtime_version_build111() -> None:
assert 'BUILD_VERSION = "2026.08.24.14"' in APP