тест 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
|
||||
|
||||
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.7"
|
||||
# compatibility: BUILD_VERSION = "2026.08.24.6"
|
||||
|
||||
@@ -2001,6 +2001,7 @@ class VmixAgentHub:
|
||||
return None
|
||||
now = _utcnow()
|
||||
with self.database.session() as session:
|
||||
device = None
|
||||
if requested_device_id:
|
||||
requested_device_id = self.normalise_device_id(requested_device_id)
|
||||
device = session.scalar(
|
||||
@@ -2012,9 +2013,12 @@ class VmixAgentHub:
|
||||
)
|
||||
)
|
||||
)
|
||||
else:
|
||||
# Legacy/single-Agent fallback only. If several devices are enabled,
|
||||
# routing must be explicit so a browser cannot control the wrong vMix.
|
||||
|
||||
if device is None:
|
||||
# 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(
|
||||
select(VmixDevice)
|
||||
.where(
|
||||
@@ -2025,7 +2029,12 @@ class VmixAgentHub:
|
||||
)
|
||||
.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:
|
||||
return None
|
||||
|
||||
@@ -2114,6 +2123,7 @@ class VmixAgentHub:
|
||||
user: HockeyUser,
|
||||
*,
|
||||
session_token: str = "",
|
||||
wait_for_mapping: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
"""Bind this browser/operator session to one enabled Agent."""
|
||||
device_id = self.normalise_device_id(device_id)
|
||||
@@ -2152,6 +2162,7 @@ class VmixAgentHub:
|
||||
tournament_external_id=tournament_id,
|
||||
device_id=device_id,
|
||||
operator_session_token=session_token,
|
||||
wait_for_mapping=bool(wait_for_mapping),
|
||||
)
|
||||
return {
|
||||
"ok": True,
|
||||
@@ -3978,6 +3989,9 @@ class RuntimeVmixSequencePayload(BaseModel):
|
||||
|
||||
class SelectSessionDevicePayload(BaseModel):
|
||||
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):
|
||||
@@ -4243,7 +4257,12 @@ def create_hockey_agent_router(
|
||||
payload: SelectSessionDevicePayload,
|
||||
user: HockeyUser = Depends(auth_dependency),
|
||||
) -> 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")
|
||||
async def unpair_device(
|
||||
|
||||
@@ -5,7 +5,7 @@ from datetime import date
|
||||
from time import perf_counter
|
||||
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 sqlalchemy import text
|
||||
|
||||
@@ -1151,7 +1151,6 @@ 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:
|
||||
@@ -1172,18 +1171,16 @@ 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.
|
||||
# BUILD111: session opening sends only match.assign. Mapping
|
||||
# is applied after the selected match roster/details are in
|
||||
# the database, otherwise vMix can receive stale match data.
|
||||
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",
|
||||
)
|
||||
result["agent_status"] = (
|
||||
"delivered" if assignment.get("delivered")
|
||||
else ("offline" if assignment.get("device_id") else "unassigned")
|
||||
)
|
||||
except Exception as agent_error:
|
||||
# The match/session must remain usable even when a browser
|
||||
# carries a stale Agent id from another WFL account.
|
||||
|
||||
@@ -308,6 +308,82 @@
|
||||
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() {
|
||||
let user = null;
|
||||
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.selectedGameId = String(id);
|
||||
localStorage.setItem(storage.game, state.selectedGameId);
|
||||
@@ -1700,17 +1782,34 @@ function gameCard(game) {
|
||||
if (
|
||||
generation === state.gameOpenGeneration
|
||||
&& 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) {
|
||||
// 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 })
|
||||
.finally(() => {
|
||||
if (
|
||||
generation === state.gameOpenGeneration
|
||||
&& String(state.selectedGameId || "") === String(id)
|
||||
) startSelectedGamePolling();
|
||||
) {
|
||||
startSelectedGamePolling();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
startSelectedGamePolling();
|
||||
void refreshSessionAgentMapping(session).catch((error) => console.warn("[Hockey] Mapping refresh for manual match failed:", error));
|
||||
}
|
||||
|
||||
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:
|
||||
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
|
||||
# BUILD111 keeps BUILD109's non-blocking match open, but Mapping is no
|
||||
# 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:
|
||||
|
||||
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