diff --git a/app.py b/app.py index 05cb89f..78871a0 100644 --- a/app.py +++ b/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.14" +BUILD_VERSION = "2026.08.24.15" +# compatibility: 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" diff --git a/hockey_data/agent_bridge.py b/hockey_data/agent_bridge.py index 750084a..72f71ca 100644 --- a/hockey_data/agent_bridge.py +++ b/hockey_data/agent_bridge.py @@ -354,11 +354,21 @@ class VmixAgentHub: return key.split(".", 1)[0] if key else "" @staticmethod - def _agent_supports_batch(version: Any) -> bool: + def _agent_version_tuple(version: Any) -> tuple[int, int, int] | None: match = re.search(r"(\d+)\.(\d+)\.(\d+)", str(version or "")) if not match: - return False - return tuple(int(part) for part in match.groups()) >= (1, 4, 0) + return None + return tuple(int(part) for part in match.groups()) + + @classmethod + def _agent_supports_mapping_inventory(cls, version: Any) -> bool: + parsed = cls._agent_version_tuple(version) + return bool(parsed is not None and parsed >= (1, 3, 0)) + + @classmethod + def _agent_supports_batch(cls, version: Any) -> bool: + parsed = cls._agent_version_tuple(version) + return bool(parsed is not None and parsed >= (1, 4, 0)) @staticmethod @@ -1068,6 +1078,14 @@ class VmixAgentHub: if not assignment_id or not match_id: return {"ok": False, "reason": "no_match_assignment", "device_id": device_id} if not fingerprint: + if agent_version and not self._agent_supports_mapping_inventory(agent_version): + return { + "ok": False, + "reason": "agent_mapping_unsupported", + "device_id": device_id, + "agent_version": agent_version, + "required_agent_version": "1.3.0", + } return {"ok": False, "reason": "no_project_inventory", "device_id": device_id} if not vmix_connected: return {"ok": False, "reason": "vmix_not_connected", "device_id": device_id} @@ -2354,6 +2372,8 @@ class VmixAgentHub: "device_id": row.device_uuid, "name": row.name or row.hostname or row.device_uuid, "hostname": row.hostname, + "agent_version": row.agent_version or "", + "mapping_supported": self._agent_supports_mapping_inventory(row.agent_version), "online": row.device_uuid in self._live, "vmix_connected": bool(row.vmix_connected), "vmix_version": row.vmix_version, diff --git a/hockey_data/static/admin-directories.js b/hockey_data/static/admin-directories.js index b83d8cf..2001120 100644 --- a/hockey_data/static/admin-directories.js +++ b/hockey_data/static/admin-directories.js @@ -2205,13 +2205,22 @@ const profiles = state.mappingProfiles?.profiles || []; const profile = state.mappingActiveProfile; const usableDevices = devices.filter((item) => item.project_fingerprint); - const deviceRows = devices.length ? devices.map((item) => ` + const deviceRows = devices.length ? devices.map((item) => { + const agentVersion = String(item.agent_version || "").trim(); + const tooOldForMapping = Boolean(agentVersion && item.mapping_supported === false); + const mappingState = tooOldForMapping + ? `Agent ${escapeHtml(agentVersion)} слишком старый для Mapping · нужен 1.3.0+` + : (item.mapping + ? `Mapping: ${escapeHtml(item.mapping.name)} · v${Number(item.mapping.version || 1)}` + : (item.project_fingerprint ? "Mapping не назначен" : "Ожидание структуры vMix")); + return `
${escapeHtml(item.name || item.device_id)}${escapeHtml(item.device_id)}
- ${item.vmix_connected ? "vMix подключён" : "vMix не найден"} + ${item.vmix_connected ? "vMix подключён" : "vMix не найден"}${agentVersion ? ` · Agent ${escapeHtml(agentVersion)}` : ""} ${Number(item.input_count || 0)} Inputs · ${Number(item.field_count || 0)} полей - ${item.mapping ? `Mapping: ${escapeHtml(item.mapping.name)} · v${Number(item.mapping.version || 1)}` : (item.project_fingerprint ? "Mapping не назначен" : "Ожидание структуры vMix")} -
`).join("") : `
Agent пока не передал структуру ни одного vMix.
`; + ${mappingState} + `; + }).join("") : `
Agent пока не передал структуру ни одного vMix.
`; const selectedDeviceId = String(localStorage.getItem("hockey.vmix.selected_device") || "").trim(); const selectedDevice = devices.find((item) => String(item.device_id || "") === selectedDeviceId) || null; @@ -2242,7 +2251,14 @@ editor = `
Выберите профиль слева или создайте новый из подключённого vMix.
`; } else { const currentInput = mappingCurrentInput(profile); - const matchingDevices = devices.filter((item) => item.project_fingerprint && item.project_fingerprint === profile.project_fingerprint && item.online && item.vmix_connected); + const profileId = Number(profile.id || 0); + const matchingDevices = devices.filter((item) => { + if (!item.online || !item.vmix_connected || item.mapping_supported === false) return false; + const exactFingerprint = Boolean(item.project_fingerprint && item.project_fingerprint === profile.project_fingerprint); + const resolvedProfileId = Number(item.mapping?.source_profile_id || item.mapping?.id || 0); + return exactFingerprint || (profileId > 0 && resolvedProfileId === profileId); + }); + const onlineOldAgents = devices.filter((item) => item.online && item.vmix_connected && item.mapping_supported === false); if (!state.mappingTestDeviceId || !matchingDevices.some((item) => item.device_id === state.mappingTestDeviceId)) state.mappingTestDeviceId = matchingDevices[0]?.device_id || ""; const catalog = mappingDataCatalog(); const gameReady = Boolean(catalog.game && (catalog.game.external_id || catalog.game.id)); @@ -2260,7 +2276,7 @@
${gameReady ? "● ЖИВОЙ ПРИМЕР" : "○ НЕТ ТЕСТОВОГО МАТЧА"}${escapeHtml(catalog.gameLabel)}${gameReady ? "Значения получены через SQL Data Sources из PostgreSQL." : "Выберите матч; системный context game_id заполнится автоматически."}
- +
${mappingDataBrowser(profile, currentInput)} diff --git a/tests/test_build112_agent_mapping_version.py b/tests/test_build112_agent_mapping_version.py new file mode 100644 index 0000000..5d50920 --- /dev/null +++ b/tests/test_build112_agent_mapping_version.py @@ -0,0 +1,35 @@ +from pathlib import Path +import asyncio + +from hockey_data.agent_bridge import VmixAgentHub + +ROOT = Path(__file__).resolve().parents[1] +JS = (ROOT / 'hockey_data/static/admin-directories.js').read_text(encoding='utf-8') +BRIDGE = (ROOT / 'hockey_data/agent_bridge.py').read_text(encoding='utf-8') +APP = (ROOT / 'app.py').read_text(encoding='utf-8') + + +def test_agent_version_gates(): + assert VmixAgentHub._agent_supports_mapping_inventory('1.2.4') is False + assert VmixAgentHub._agent_supports_mapping_inventory('1.3.0') is True + assert VmixAgentHub._agent_supports_mapping_inventory('1.4.0') is True + assert VmixAgentHub._agent_supports_batch('1.3.9') is False + assert VmixAgentHub._agent_supports_batch('1.4.0') is True + + +def test_mapping_device_payload_exposes_version_support(): + assert '"agent_version": row.agent_version or ""' in BRIDGE + assert '"mapping_supported": self._agent_supports_mapping_inventory(row.agent_version)' in BRIDGE + assert '"reason": "agent_mapping_unsupported"' in BRIDGE + assert '"required_agent_version": "1.3.0"' in BRIDGE + + +def test_mapping_editor_uses_resolved_profile_not_only_exact_fingerprint(): + assert 'resolvedProfileId === profileId' in JS + assert 'item.mapping_supported === false' in JS + assert 'слишком старый для Mapping' in JS + assert 'нужен 1.3.0+' in JS + + +def test_runtime_version(): + assert 'BUILD_VERSION = "2026.08.24.15"' in APP