тест 9
This commit is contained in:
3
app.py
3
app.py
@@ -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"
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,18 +1171,16 @@ 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
|
||||||
# carries a stale Agent id from another WFL account.
|
# carries a stale Agent id from another WFL account.
|
||||||
|
|||||||
@@ -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"));
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
93
tests/test_build111_agent_match_handoff.py
Normal file
93
tests/test_build111_agent_match_handoff.py
Normal 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
|
||||||
Reference in New Issue
Block a user