diff --git a/hockey_data/agent_bridge.py b/hockey_data/agent_bridge.py index 38de173..0d3b011 100644 --- a/hockey_data/agent_bridge.py +++ b/hockey_data/agent_bridge.py @@ -3906,12 +3906,9 @@ def create_hockey_agent_router( async def mapping_data_catalog(payload: MappingDataContextPayload, user: HockeyUser = Depends(auth_dependency)) -> dict[str, Any]: return hub.mapping_data.data_catalog(user, payload.context) - @router.post("/api/hockey/context/{key}") - async def hockey_context_set(key: str, payload: ContextValuePayload, user: HockeyUser = Depends(auth_dependency)) -> dict[str, Any]: - result = hub.mapping_data.set_context_value(key, payload.value, user, payload.context) - result["mapping_apply"] = await hub.apply_mapping_for_user(user, reason=f"context_changed:{key}") - return result - + # BUILD86: static context routes MUST be registered before /{key}. + # Starlette matches routes in registration order; the old order treated the + # literal word "batch" (and "resolve") as a context variable key. @router.post("/api/hockey/context/batch") async def hockey_context_batch(payload: ContextValuesPayload, user: HockeyUser = Depends(auth_dependency)) -> dict[str, Any]: if len(payload.values) > 32: @@ -3934,6 +3931,12 @@ def create_hockey_agent_router( context, variables = hub.mapping_data.resolve_context(user, payload.context) return {"context": context, "variables": variables} + @router.post("/api/hockey/context/{key}") + async def hockey_context_set(key: str, payload: ContextValuePayload, user: HockeyUser = Depends(auth_dependency)) -> dict[str, Any]: + result = hub.mapping_data.set_context_value(key, payload.value, user, payload.context) + result["mapping_apply"] = await hub.apply_mapping_for_user(user, reason=f"context_changed:{key}") + return result + @router.websocket("/ws/hockey-agent") async def agent_socket(websocket: WebSocket) -> None: await websocket.accept() diff --git a/hockey_data/mapping_context.py b/hockey_data/mapping_context.py index c8cac8e..9791ac9 100644 --- a/hockey_data/mapping_context.py +++ b/hockey_data/mapping_context.py @@ -687,9 +687,39 @@ class MappingDataService: context, _ = self.resolve_context(user, supplied) with self.database.session() as session: variable = session.scalar(select(MappingContextVariable).where(MappingContextVariable.key == key)) - if variable is None: raise HTTPException(status_code=404, detail="Переменная не найдена") + if variable is None: + # BUILD86: be defensive with long-lived production databases. + # Penalty selection keys were added over several builds; if a row + # is missing for any reason, restore the known default contract + # immediately instead of breaking an operator click with 404. + default_index = next((i for i, item in enumerate(DEFAULT_CONTEXT_VARIABLES) if item.get("key") == key), -1) + default_item = DEFAULT_CONTEXT_VARIABLES[default_index] if default_index >= 0 else None + if default_item is not None: + now = _utcnow() + variable = MappingContextVariable( + key=default_item["key"], + label=default_item["label"], + category=default_item.get("category", "Контекст"), + description=default_item.get("description", ""), + value_type=default_item.get("value_type", "id"), + entity_type=default_item.get("entity_type", ""), + scope=default_item.get("scope", "match"), + source_type=default_item.get("source_type", "manual"), + default_value=default_item.get("default_value", ""), + is_system=default_item.get("source_type") == "system", + enabled=True, + sort_order=default_index, + created_by="system", + updated_by="system", + created_at=now, + updated_at=now, + ) + session.add(variable) + session.flush() + else: + raise HTTPException(status_code=404, detail=f"Переменная не найдена: {key}") if variable.source_type == "system" or variable.is_system: - raise HTTPException(status_code=409, detail="Системная переменная задаётся программой") + raise HTTPException(status_code=409, detail=f"Системная переменная задаётся программой: {key}") scope_key = self._scope_key(variable, user, context) row = session.scalar(select(MappingContextValue).where(and_(MappingContextValue.variable_id == variable.id, MappingContextValue.scope_key == scope_key))) if row is None: diff --git a/tests/test_build86_penalty_context_prepared_titles.py b/tests/test_build86_penalty_context_prepared_titles.py new file mode 100644 index 0000000..9cedbe0 --- /dev/null +++ b/tests/test_build86_penalty_context_prepared_titles.py @@ -0,0 +1,84 @@ +from pathlib import Path + +from hockey_data.auth_bridge import HockeyUser +from hockey_data.mapping_context import MappingDataService +from tests.support import LocalTestDatabase + +ROOT = Path(__file__).resolve().parents[1] +APP = (ROOT / "ui_builder/static/app.js").read_text(encoding="utf-8") + + +def test_prepared_titles_is_forced_to_last_runtime_tab_and_uses_hockey_api_directly(): + assert 'state.config.tabs = state.config.tabs.filter((tab) => tab?.id !== "prepared_titles")' in APP + assert 'state.config.tabs.push({ id: "prepared_titles", label: "Заготовки" });' in APP + # BUILD84 accidentally routed these through boot.api (/api/ui-builder/runtime), producing 404 Not Found. + prepared_slice = APP[APP.index("async function hockeyLoadPreparedMappingSources"):APP.index("function renderHockeyPreparedTitlesWorkspace")] + assert 'api(`/api/hockey/prepared-titles' not in prepared_slice + assert 'api("/api/hockey/prepared-titles' not in prepared_slice + assert 'hockeyGameControlRequest(`/prepared-titles' in prepared_slice + assert 'hockeyGameControlRequest(`/agents/vmix-inventory' in prepared_slice + + +def test_prepared_title_browser_keeps_title_inputs_visible_even_without_field_inventory(): + start = APP.index("function hockeyPreparedInventoryInputs") + end = APP.index("function hockeyPreparedSourceIdentity", start) + block = APP[start:end] + assert 'fields.length > 0' in block + assert '(GT|XAML|TITLE)' in block + + +def test_penalty_side_context_is_match_shared_between_operator_and_admin(tmp_path): + db = LocalTestDatabase(tmp_path / "ctx.sqlite3") + db.create_all() + service = MappingDataService(db) + operator = HockeyUser(id="op", login="operator", display_name="Operator") + admin = HockeyUser(id="admin", login="admin", display_name="Admin") + + service.set_context_value("selected_home_penalty_id", "pen-home-17", operator, {"game_id": "game-1"}) + service.set_context_value("selected_away_penalty_id", "pen-away-9", operator, {"game_id": "game-1"}) + context, variables = service.resolve_context(admin, {"game_id": "game-1"}) + values = {item["key"]: item.get("value", "") for item in variables} + + assert context["selected_home_penalty_id"] == "pen-home-17" + assert context["selected_away_penalty_id"] == "pen-away-9" + assert values["selected_home_penalty_id"] == "pen-home-17" + assert values["selected_away_penalty_id"] == "pen-away-9" + + +def test_context_batch_route_precedes_dynamic_context_key_route(tmp_path): + from fastapi import FastAPI + from fastapi.testclient import TestClient + from hockey_data.agent_bridge import VmixAgentHub, create_hockey_agent_router + + db = LocalTestDatabase(tmp_path / "batch.sqlite3") + db.create_all() + hub = VmixAgentHub(db) # type: ignore[arg-type] + user = HockeyUser(id="op", login="operator", display_name="Operator") + + async def auth(): + return user + + app = FastAPI() + app.include_router(create_hockey_agent_router(hub, auth_dependency=auth)) + client = TestClient(app) + response = client.post( + "/api/hockey/context/batch", + json={ + "values": { + "selected_home_penalty_id": "pen-home-17", + "selected_home_penalty_player_id": "777", + "selected_home_penalty_player_db_id": "42", + "selected_home_penalty_team_penalty": "0", + }, + "context": {"game_id": "game-1"}, + }, + ) + assert response.status_code == 200, response.text + payload = response.json() + assert payload["ok"] is True + assert {item["key"] for item in payload["items"]} == { + "selected_home_penalty_id", + "selected_home_penalty_player_id", + "selected_home_penalty_player_db_id", + "selected_home_penalty_team_penalty", + } diff --git a/ui_builder/static/app.js b/ui_builder/static/app.js index 74bd8ae..9a750f8 100644 --- a/ui_builder/static/app.js +++ b/ui_builder/static/app.js @@ -2561,13 +2561,11 @@ function startCustomTooltips() { state.config.tabs = (Array.isArray(state.config.tabs) ? state.config.tabs : []).filter((tab) => tab?.id !== "prematch"); state.config.components = components.filter((component) => component?.type !== "hockey_prematch_panel" && component?.action_id !== "hockey_prematch_panel"); if (!state.config.tabs.length) state.config.tabs = [{ id: "main", label: "Игра" }]; - // BUILD84: "Заготовки" is a built-in runtime workspace rather than a canvas - // component. Keep it near the game tab and recreate it if an older config - // does not contain it yet. - if (!state.config.tabs.some((tab) => tab?.id === "prepared_titles")) { - const mainIndex = Math.max(0, state.config.tabs.findIndex((tab) => tab?.id === "main")); - state.config.tabs.splice(mainIndex + 1, 0, { id: "prepared_titles", label: "Заготовки" }); - } + // BUILD86: "Заготовки" is always the final top-level runtime tab. + // Reorder it on every config normalisation as older published configs may + // already contain the tab near "Игра" from BUILD84/85. + state.config.tabs = state.config.tabs.filter((tab) => tab?.id !== "prepared_titles"); + state.config.tabs.push({ id: "prepared_titles", label: "Заготовки" }); if (state.activeTab === "prematch") state.activeTab = state.config.tabs.find((tab) => tab.id === "main")?.id || state.config.tabs[0].id; } @@ -8934,7 +8932,15 @@ function hockeyPreparedNumber(value, fallback = 999999) { function hockeyPreparedInventoryInputs() { const inventory = state.preparedTitleInventory?.inventory || {}; return (Array.isArray(inventory.inputs) ? inventory.inputs : []) - .filter((item) => item && typeof item === "object" && Array.isArray(item.fields) && item.fields.length) + .filter((item) => { + if (!item || typeof item !== "object") return false; + const fields = Array.isArray(item.fields) ? item.fields : []; + const type = String(item.type || "").trim(); + // BUILD86: keep title Inputs visible even when an older Agent inventory + // did not yet include their child fields. This makes the left-hand title + // browser useful instead of showing an empty list. + return fields.length > 0 || /(?:^|\b)(GT|XAML|TITLE)(?:$|\b)/i.test(type); + }) .slice() .sort((a, b) => hockeyPreparedNumber(a.number) - hockeyPreparedNumber(b.number) || String(a.title || "").localeCompare(String(b.title || ""), "ru", { numeric: true, sensitivity: "base" })); } @@ -8978,6 +8984,8 @@ function hockeyPreparedWorkspaceKey() { return [currentRuntimeVmixDeviceId(), hockeyTimerSelectedGameId()].join("|"); } +// BUILD86: direct backend endpoint family: /api/hockey/prepared-titles +// hockeyGameControlRequest() adds the /api/hockey prefix exactly once. async function hockeyLoadPreparedMappingSources(panelId = state.preparedTitlePanelId, { render = false } = {}) { const clean = String(panelId || "").trim(); if (!clean) { @@ -8990,7 +8998,7 @@ async function hockeyLoadPreparedMappingSources(panelId = state.preparedTitlePan const deviceId = currentRuntimeVmixDeviceId(); if (deviceId) params.set("device_id", deviceId); params.set("panel_id", clean); - const payload = await api(`/api/hockey/prepared-titles/mapping-sources?${params.toString()}`); + const payload = await hockeyGameControlRequest(`/prepared-titles/mapping-sources?${params.toString()}`); state.preparedTitleMappingSources = Array.isArray(payload?.items) ? payload.items : []; if (!state.preparedTitleSourceKey && state.preparedTitleMappingSources.length === 1) { const source = state.preparedTitleMappingSources[0]; @@ -9030,10 +9038,26 @@ async function hockeyLoadPreparedTitlesWorkspace({ force = false, render = true const listParams = new URLSearchParams(); if (deviceId) listParams.set("device_id", deviceId); if (gameId) listParams.set("game_id", gameId); - const [inventoryPayload, preparedPayload] = await Promise.all([ - api(`/api/hockey/agents/vmix-inventory${invParams.toString() ? `?${invParams}` : ""}`), - api(`/api/hockey/prepared-titles${listParams.toString() ? `?${listParams}` : ""}`), - ]); + let inventoryPayload = null; + try { + inventoryPayload = await hockeyGameControlRequest(`/agents/vmix-inventory${invParams.toString() ? `?${invParams}` : ""}`); + } catch (inventoryError) { + // A browser can retain an obsolete locally-selected Agent id. Retry the + // active account Agent before giving up, so the title browser still opens. + if (deviceId) { + inventoryPayload = await hockeyGameControlRequest("/agents/vmix-inventory"); + } else { + throw inventoryError; + } + } + let preparedPayload = { items: [] }; + try { + preparedPayload = await hockeyGameControlRequest(`/prepared-titles${listParams.toString() ? `?${listParams}` : ""}`); + } catch (preparedListError) { + console.error("Prepared titles saved-list error", preparedListError); + // Saved-list failure must not hide the source title inventory/editor. + preparedPayload = { items: [] }; + } state.preparedTitleInventory = inventoryPayload || { device_id: "", device_name: "", inventory: { inputs: [] } }; state.preparedTitles = Array.isArray(preparedPayload?.items) ? preparedPayload.items : []; state.preparedTitlesLoadedKey = key; @@ -9088,7 +9112,7 @@ async function hockeyApplyPreparedMappingSnapshot() { if (input.number) params.set("input_number", input.number); if (input.title) params.set("input_title", input.title); if (state.preparedTitlePanelId) params.set("panel_id", state.preparedTitlePanelId); - const payload = await api(`/api/hockey/prepared-titles/mapping-snapshot?${params.toString()}`); + const payload = await hockeyGameControlRequest(`/prepared-titles/mapping-snapshot?${params.toString()}`); const fields = Array.isArray(payload?.fields) ? payload.fields : []; if (!fields.length) { toast(state.preparedTitlePanelId ? "Для этого блока и Input нет Mapping-связей" : "Для этого Input нет Mapping-связей", true); @@ -9130,7 +9154,7 @@ async function hockeyCreatePreparedTitle() { state.preparedTitlesLoading = true; renderRuntime(); try { - const payload = await api("/api/hockey/prepared-titles", { + const payload = await hockeyGameControlRequest("/prepared-titles", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ @@ -9163,7 +9187,7 @@ async function hockeyCreatePreparedTitle() { async function hockeyPreviewPreparedTitle(id) { try { - await api(`/api/hockey/prepared-titles/${encodeURIComponent(id)}/preview`, { + await hockeyGameControlRequest(`/prepared-titles/${encodeURIComponent(id)}/preview`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ device_id: currentRuntimeVmixDeviceId(), session_token: currentRuntimeHockeySessionToken() }), @@ -9179,7 +9203,7 @@ async function hockeyPreviewPreparedTitle(id) { async function hockeyDeletePreparedTitle(id) { if (!confirm("Убрать заготовку из списка? Сам vMix Input останется в проекте.")) return false; try { - await api(`/api/hockey/prepared-titles/${encodeURIComponent(id)}`, { method: "DELETE" }); + await hockeyGameControlRequest(`/prepared-titles/${encodeURIComponent(id)}`, { method: "DELETE" }); state.preparedTitles = state.preparedTitles.filter((item) => String(item.id) !== String(id)); renderRuntime(); return true;