From 4453e2324829023031a4795b944c56dad56d0c80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=AE=D1=80=D0=B8=D0=B9=20=D0=A7=D0=B5=D1=80=D0=BD=D0=B5?= =?UTF-8?q?=D0=BD=D0=BA=D0=BE?= Date: Wed, 19 Aug 2026 18:00:04 +0300 Subject: [PATCH] =?UTF-8?q?=D0=BA=D0=BE=D0=BF=D0=B8=D1=80=D0=BE=D0=B2?= =?UTF-8?q?=D0=B0=D1=82=D1=8C=20=D0=BC=D0=B0=D0=BF=D0=BF=D0=B8=D0=BD=D0=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- hockey_data/agent_bridge.py | 204 +++++++++++++++++- hockey_data/static/admin-directories.css | 31 ++- hockey_data/static/admin-directories.js | 70 ++++-- .../test_build75_mapping_collapsed_rescan.py | 157 ++++++++++++++ tests/test_build76_mapping_compact_copy.py | 130 +++++++++++ 5 files changed, 573 insertions(+), 19 deletions(-) create mode 100644 tests/test_build75_mapping_collapsed_rescan.py create mode 100644 tests/test_build76_mapping_compact_copy.py diff --git a/hockey_data/agent_bridge.py b/hockey_data/agent_bridge.py index 46b81fb..3efaf4f 100644 --- a/hockey_data/agent_bridge.py +++ b/hockey_data/agent_bridge.py @@ -2546,6 +2546,65 @@ class VmixAgentHub: await self._broadcast_mapping_for_fingerprint(result["project_fingerprint"]) return result + async def duplicate_mapping_profile(self, profile_id: int, user: HockeyUser) -> dict[str, Any]: + """Create an independent editable copy of a visible Mapping config. + + The copy keeps the source inventory, all field links and rules, but starts + inactive so it cannot unexpectedly replace the live Mapping for the same + vMix project. It can then be rescanned against another Agent/project and + edited without touching the original config. + """ + now = _utcnow() + with self.database.session() as session: + source = session.get(VmixMappingProfile, profile_id) + if source is None or self._runtime_mapping_source_id(source): + raise HTTPException(status_code=404, detail="Mapping-конфиг не найден") + + copy_name = self._portable_profile_name( + session, + f"{str(source.name or 'Mapping').strip()} — копия", + "Mapping — копия", + ) + clone = VmixMappingProfile( + name=copy_name, + description=str(source.description or ""), + project_fingerprint=str(source.project_fingerprint or ""), + inventory_json=str(source.inventory_json or "{}"), + version=1, + active=False, + created_by=user.login, + updated_by=user.login, + created_at=now, + updated_at=now, + ) + session.add(clone) + session.flush() + + source_fields = list(session.scalars( + select(VmixMappingField) + .where(VmixMappingField.profile_id == source.id) + .order_by(VmixMappingField.sort_order, VmixMappingField.id) + )) + for item in source_fields: + session.add(VmixMappingField( + profile_id=clone.id, + graphic=str(item.graphic or "")[:100], + data_key=str(item.data_key or "")[:200], + vmix_input_key=str(item.vmix_input_key or "")[:128], + vmix_input_number=str(item.vmix_input_number or "")[:32], + vmix_input_title=str(item.vmix_input_title or "")[:300], + vmix_field=str(item.vmix_field or "")[:300], + field_type=str(item.field_type or "text")[:32], + rule_json=str(item.rule_json or "{}")[:12000], + enabled=bool(item.enabled), + sort_order=int(item.sort_order or 0), + )) + session.flush() + result = self._mapping_profile_payload(session, clone, include_inventory=True, include_fields=True) + result["copied_from_profile_id"] = source.id + result["copied_fields"] = len(source_fields) + return result + async def update_mapping_profile(self, profile_id: int, payload: Any, user: HockeyUser) -> dict[str, Any]: now = _utcnow() with self.database.session() as session: @@ -2570,7 +2629,66 @@ class VmixAgentHub: await self._broadcast_mapping_for_fingerprint(result["project_fingerprint"]) return result + @classmethod + def _mapping_inventory_additions(cls, old_inventory: dict[str, Any], new_inventory: dict[str, Any]) -> dict[str, int]: + """Count newly discovered Inputs/fields while tolerating stable-key changes. + + Existing Inputs are resolved with the same conservative identity order used + by portable Mapping rebinding: key, unique title, then unique number. This is + only a UI report; it never changes or guesses Mapping links. + """ + old_inputs = old_inventory.get("inputs") if isinstance(old_inventory, dict) and isinstance(old_inventory.get("inputs"), list) else [] + new_inputs = new_inventory.get("inputs") if isinstance(new_inventory, dict) and isinstance(new_inventory.get("inputs"), list) else [] + by_key: dict[str, dict[str, Any]] = {} + by_title: dict[str, list[dict[str, Any]]] = {} + by_number: dict[str, list[dict[str, Any]]] = {} + for item in new_inputs: + if not isinstance(item, dict): + continue + key = str(item.get("key") or "").strip() + title = cls._portable_text(item.get("title")) + number = str(item.get("number") or "").strip() + if key: + by_key[key] = item + if title: + by_title.setdefault(title, []).append(item) + if number: + by_number.setdefault(number, []).append(item) + + used: set[int] = set() + added_fields = 0 + for old in old_inputs: + if not isinstance(old, dict): + continue + target = None + key = str(old.get("key") or "").strip() + title = cls._portable_text(old.get("title")) + number = str(old.get("number") or "").strip() + if key: + target = by_key.get(key) + if target is None and title and len(by_title.get(title, [])) == 1: + target = by_title[title][0] + if target is None and number and len(by_number.get(number, [])) == 1: + target = by_number[number][0] + if target is None: + continue + used.add(id(target)) + old_names = {cls._portable_text(field.get("name")) for field in (old.get("fields") or []) if isinstance(field, dict) and str(field.get("name") or "").strip()} + new_names = {cls._portable_text(field.get("name")) for field in (target.get("fields") or []) if isinstance(field, dict) and str(field.get("name") or "").strip()} + added_fields += len(new_names - old_names) + + added_inputs = [item for item in new_inputs if isinstance(item, dict) and id(item) not in used] + added_fields += sum(len([field for field in (item.get("fields") or []) if isinstance(field, dict) and str(field.get("name") or "").strip()]) for item in added_inputs) + return {"new_inputs": len(added_inputs), "new_fields": added_fields} + async def refresh_mapping_inventory(self, profile_id: int, device_id: str, user: HockeyUser) -> dict[str, Any]: + """Refresh vMix structure without resetting existing Mapping links. + + New titles/fields only expand the inventory. Existing links are rebound to + the freshly scanned project by Input key -> unique title -> unique number, + and the exact GT field name must still exist. A link that cannot currently + be resolved is kept unchanged in the database instead of being deleted. + """ device_id = self.normalise_device_id(device_id) now = _utcnow() with self.database.session() as session: @@ -2582,14 +2700,94 @@ class VmixAgentHub: raise HTTPException(status_code=409, detail="Устройство не передало структуру vMix") conflicting = session.scalar(select(VmixMappingProfile).where(and_(VmixMappingProfile.project_fingerprint == device.project_fingerprint, VmixMappingProfile.id != row.id, VmixMappingProfile.active.is_(True)))) if conflicting is not None: - raise HTTPException(status_code=409, detail=f"Эта структура уже используется mapping «{conflicting.name}»") + # Build 74 may have created a hidden runtime adaptation of this same + # visible config for the Agent. An explicit rescan means the admin now + # wants the visible config itself to adopt that concrete vMix structure. + if self._runtime_mapping_source_id(conflicting) == row.id: + conflicting.active = False + conflicting.updated_by = user.login + conflicting.updated_at = now + elif row.active: + raise HTTPException(status_code=409, detail=f"Эта структура уже используется mapping «{conflicting.name}»") + # An inactive visible copy is a draft. It may adopt the same vMix + # inventory as another live config so the admin can reuse the links, + # rescan against another project and edit the copy independently. + + try: + old_inventory = json.loads(row.inventory_json or "{}") + if not isinstance(old_inventory, dict): + old_inventory = {} + except Exception: + old_inventory = {} + try: + new_inventory = json.loads(device.project_inventory_json or "{}") + if not isinstance(new_inventory, dict): + new_inventory = {} + except Exception: + new_inventory = {} + + link_rows = list(session.scalars( + select(VmixMappingField) + .where(VmixMappingField.profile_id == profile_id) + .order_by(VmixMappingField.sort_order, VmixMappingField.id) + )) + matched_by = {"key": 0, "title": 0, "number": 0} + preserved = 0 + unresolved = 0 + unresolved_items: list[dict[str, Any]] = [] + for field_row in link_rows: + raw = { + "graphic": field_row.graphic, + "data_key": field_row.data_key, + "vmix_input_key": field_row.vmix_input_key, + "vmix_input_number": field_row.vmix_input_number, + "vmix_input_title": field_row.vmix_input_title, + "vmix_field": field_row.vmix_field, + "field_type": field_row.field_type, + "rule": _mapping_rule_payload(field_row.rule_json), + "enabled": field_row.enabled, + } + rebound, report = self._rebind_portable_mapping_fields([raw], new_inventory) + if rebound: + target = rebound[0] + field_row.vmix_input_key = str(target.get("vmix_input_key") or "")[:128] + field_row.vmix_input_number = str(target.get("vmix_input_number") or "")[:32] + field_row.vmix_input_title = str(target.get("vmix_input_title") or "")[:300] + field_row.vmix_field = str(target.get("vmix_field") or field_row.vmix_field)[:300] + preserved += 1 + for method in matched_by: + matched_by[method] += int((report.get("matched_by") or {}).get(method, 0) or 0) + else: + # Keep the old link. This protects operator work from a temporary + # incomplete scan and lets it recover automatically if the target + # returns on the next refresh. + unresolved += 1 + if len(unresolved_items) < 100: + skipped = (report.get("skipped_items") or [{}])[0] + unresolved_items.append({ + "data_key": field_row.data_key, + "input": field_row.vmix_input_title or field_row.vmix_input_key or field_row.vmix_input_number, + "vmix_field": field_row.vmix_field, + "reason": skipped.get("reason", "not_found"), + }) + + additions = self._mapping_inventory_additions(old_inventory, new_inventory) old_fingerprint = row.project_fingerprint row.project_fingerprint = device.project_fingerprint row.inventory_json = device.project_inventory_json row.version = int(row.version or 0) + 1 row.updated_by = user.login row.updated_at = now + session.flush() result = self._mapping_profile_payload(session, row, include_inventory=True, include_fields=True) + result["refresh_report"] = { + "total_links": len(link_rows), + "preserved": preserved, + "unresolved": unresolved, + "matched_by": matched_by, + "unresolved_items": unresolved_items, + **additions, + } if old_fingerprint and old_fingerprint != result["project_fingerprint"]: await self._broadcast_mapping_for_fingerprint(old_fingerprint) await self._broadcast_mapping_for_fingerprint(result["project_fingerprint"]) @@ -3064,6 +3262,10 @@ def create_hockey_agent_router( async def mapping_profile_use(profile_id: int, payload: MappingProfileUsePayload, user: HockeyUser = Depends(auth_dependency)) -> dict[str, Any]: return await hub.use_mapping_profile_for_user(profile_id, payload, user) + @router.post("/api/hockey/admin/vmix-mapping/profiles/{profile_id}/duplicate", dependencies=admin) + async def mapping_profile_duplicate(profile_id: int, user: HockeyUser = Depends(auth_dependency)) -> dict[str, Any]: + return await hub.duplicate_mapping_profile(profile_id, user) + @router.get("/api/hockey/admin/vmix-mapping/profiles/{profile_id}/export", dependencies=admin) async def mapping_profile_export(profile_id: int) -> dict[str, Any]: return await hub.export_mapping_profile(profile_id) diff --git a/hockey_data/static/admin-directories.css b/hockey_data/static/admin-directories.css index 843ee14..ad9a823 100644 --- a/hockey_data/static/admin-directories.css +++ b/hockey_data/static/admin-directories.css @@ -1693,15 +1693,36 @@ /* Build 74: reusable Mapping configs + one-click apply to current Agent. */ .hockey-map-config-hint{font-size:11px;line-height:1.45;color:#8ea0b6;background:#0d1520;border:1px solid #2b394c;border-radius:10px;padding:9px 10px} -.hockey-mapping-profile-row{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:7px;align-items:stretch;border-radius:11px} -.hockey-mapping-profile-row.is-active{box-shadow:0 0 0 1px rgba(56,189,248,.18)} -.hockey-mapping-profile-row .hockey-mapping-profile{min-width:0;width:100%} -.hockey-map-use-profile{min-width:106px;border:1px solid #2f5870;background:#102433;color:#9bdcff;border-radius:10px;padding:7px 9px;font-size:11px;font-weight:700;cursor:pointer;white-space:normal;line-height:1.25} +.hockey-mapping-profile-row{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:5px;align-items:center;border-radius:9px;padding:2px} +.hockey-mapping-profile-row.is-active{background:rgba(56,189,248,.045);box-shadow:0 0 0 1px rgba(56,189,248,.18)} +.hockey-mapping-profile-row .hockey-mapping-profile{min-width:0;width:100%;padding:7px 8px;border-radius:8px} +.hockey-mapping-profile-row .hockey-mapping-profile strong{font-size:11px;line-height:1.25} +.hockey-mapping-profile-row .hockey-mapping-profile small{font-size:9px;line-height:1.2;opacity:.82} +.hockey-map-profile-actions{display:flex;align-items:center;gap:4px;padding-right:2px} +.hockey-map-profile-icon{width:28px;height:28px;min-width:28px;display:grid;place-items:center;border:1px solid #31465b;background:#0f1d2a;color:#9cb4c8;border-radius:8px;padding:0;font-size:12px;font-weight:850;line-height:1;cursor:pointer;transition:border-color .12s ease,background .12s ease,color .12s ease,transform .12s ease} +.hockey-map-profile-icon:hover{border-color:#4d718e;background:#14283a;color:#e7f4ff;transform:translateY(-1px)} +.hockey-map-use-profile{color:#8fdcff} .hockey-map-use-profile:hover{border-color:#38bdf8;background:#123047;color:#d9f4ff} .hockey-map-use-profile.is-used{border-color:#276749;background:#102b20;color:#8ff0b5} +.hockey-map-copy-profile{font-size:15px;color:#b9c9da} +.hockey-map-copy-profile:hover{border-color:#7c5cc7;background:#211a38;color:#ddceff} .hockey-mapping-create-wrap{border:1px solid #334155;background:#101722;border-radius:12px;overflow:hidden} .hockey-mapping-create-wrap>summary{cursor:pointer;padding:10px 12px;color:#b9c7d8;font-size:12px;font-weight:700;list-style:none} .hockey-mapping-create-wrap>summary::-webkit-details-marker{display:none} .hockey-mapping-create-wrap[open]>summary{border-bottom:1px solid #2e3b4e} .hockey-mapping-create-wrap .hockey-mapping-create{border:0;border-radius:0;background:transparent} -@media(max-width:520px){.hockey-mapping-profile-row{grid-template-columns:1fr}.hockey-map-use-profile{min-height:34px}} +@media(max-width:520px){.hockey-mapping-profile-row{grid-template-columns:minmax(0,1fr) auto}.hockey-map-profile-icon{width:30px;height:30px;min-width:30px}} + + +/* Build 75: compact Mapping data browser. Heavy SQL tools and match/period + data groups start collapsed and remember their open state while editing. */ +.hockey-map-sql-cell-quick>summary,.hockey-map-table-picker>summary{list-style:none;cursor:pointer;user-select:none} +.hockey-map-sql-cell-quick>summary::-webkit-details-marker,.hockey-map-table-picker>summary::-webkit-details-marker{display:none} +.hockey-map-sql-cell-quick>summary{display:flex;align-items:flex-end;justify-content:space-between;gap:10px} +.hockey-map-sql-cell-quick>summary>div{display:grid;gap:2px}.hockey-map-sql-cell-quick>summary span{font-size:8px;font-weight:950;letter-spacing:.12em;color:#69c9ef}.hockey-map-sql-cell-quick.is-target-ready>summary span{color:#48dfbd}.hockey-map-sql-cell-quick>summary strong{font-size:12px;color:#edf5ff}.hockey-map-sql-cell-quick>summary small{max-width:48%;font-size:9px;color:#7e96ad;text-align:right;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.hockey-map-sql-cell-quick>summary::after,.hockey-map-table-picker>summary::after,.hockey-map-data-group>summary::after{content:"▸";color:#6f879e;font-size:11px;transition:transform .12s ease} +.hockey-map-sql-cell-quick[open]>summary::after,.hockey-map-table-picker[open]>summary::after,.hockey-map-data-group[open]>summary::after{transform:rotate(90deg)} +.hockey-map-table-picker>summary{display:flex;align-items:center;justify-content:space-between;gap:10px}.hockey-map-table-picker>summary>div{display:grid;gap:2px;min-width:0}.hockey-map-table-picker>summary span{font-size:8px;font-weight:950;letter-spacing:.12em;color:#69c9ef}.hockey-map-table-picker>summary strong{font-size:11px;color:#dceaf6}.hockey-map-table-picker>summary>b{margin-left:auto;padding:4px 7px;border-radius:999px;background:#173148;color:#8ed5ef;font-size:9px;white-space:nowrap} +.hockey-map-sql-cell-quick:not([open]),.hockey-map-table-picker:not([open]){min-height:0!important;height:auto!important;display:block;padding:10px} +.hockey-map-sql-cell-quick:not([open])>summary,.hockey-map-table-picker:not([open])>summary{margin:0} +@media(max-width:1100px){.hockey-map-sql-cell-quick>summary{align-items:flex-start;flex-direction:column}.hockey-map-sql-cell-quick>summary small{max-width:none;text-align:left}} diff --git a/hockey_data/static/admin-directories.js b/hockey_data/static/admin-directories.js index 3a02913..6e53c19 100644 --- a/hockey_data/static/admin-directories.js +++ b/hockey_data/static/admin-directories.js @@ -33,6 +33,9 @@ mappingSelectedTableRow: 1, mappingSelectedTableColumn: "", mappingTableRowSearch: "", + mappingSqlCellOpen: false, + mappingSqlTableOpen: false, + mappingOpenDataGroups: {}, mappingTestDeviceId: "", mappingWorkspaceTab: "links", mappingCatalog: null, @@ -1837,8 +1840,8 @@ category.items.push(item); } const selectedSource = state.mappingSelectedSourceKey; - const groups = categories.map((category, index) => ` -
+ const groups = categories.map((category) => ` +
${escapeHtml(category.name)}${category.items.length}
${category.items.map((item) => `
- `; +
`; const rowSearch = String(state.mappingTableRowSearch || "").trim().toLowerCase(); const allRows = selectedTable?.rows || []; const filteredRows = allRows.filter((row) => { @@ -1908,8 +1911,8 @@ }).join("")} `).join(""); tableMarkup = ` -
-
ТАБЛИЦА SQLВыберите конкретную строку и столбец
${Number(selectedTable?.row_count || 0)} строк
+
+
ТАБЛИЦА SQLВыберите конкретную строку и столбец
${Number(selectedTable?.row_count || 0)} строк
@@ -1917,7 +1920,7 @@ ${selectedTable?.skipped ? `
Не хватает параметров: ${escapeHtml((selectedTable.missing || []).map((x) => `:${x}`).join(", "))}
` : selectedTable?.error ? `
${escapeHtml(selectedTable.error)}
` : `
${columns.map((column) => ``).join("")}${cells || ``}
Строка${escapeHtml(column.label || column.key)}${column.localized ? ` AUTO ${String(column.language || selectedTable?.language || "ru").toUpperCase()}` : ""}${escapeHtml(column.key)}
Нет строк
${filteredRows.length > visibleRows.length ? `Показаны первые ${visibleRows.length} из ${filteredRows.length} строк. Используйте поиск строки.` : ""}`} -
`; +
`; } const targetHint = targetField @@ -2160,11 +2163,14 @@ const isUsed = Boolean(selectedDevice?.mapping && Number(selectedDevice.mapping.source_profile_id || selectedDevice.mapping.id || 0) === Number(item.id)); return `
- - +
+ + +
`; }).join("") : `
Mapping-конфигов ещё нет.
`; @@ -2220,7 +2226,7 @@

vMix проекты

${deviceRows}

Конфиги Mapping

-
Выберите любой готовый конфиг — свой или чужой — и нажмите «Применить к моему Agent». Он будет использован для текущего открытого матча.
+
▶ применяет готовый конфиг к вашему Agent. ⧉ создаёт независимую копию со всеми связями — её можно переименовать, пересчитать под другой vMix и отредактировать.
${profileButtons}
+ Создать новый конфиг из vMix @@ -2456,6 +2462,27 @@ renderMapping(); })); + modal.querySelectorAll("[data-map-copy-profile]").forEach((button) => button.addEventListener("click", async (event) => { + event.stopPropagation(); + const profileId = Number(button.dataset.mapCopyProfile || 0); + if (!profileId) return; + const source = (state.mappingProfiles?.profiles || []).find((item) => Number(item.id) === profileId); + button.disabled = true; + button.textContent = "…"; + try { + const created = await request(`/api/hockey/admin/vmix-mapping/profiles/${profileId}/duplicate`, { method: "POST" }); + state.mappingSelectedInputKey = ""; + state.mappingTargetField = ""; + state.mappingSelectedSourceKey = ""; + state.mappingVmixScrollTop = 0; + await loadMapping(created.id); + setStatus(`Копия «${created.name}» создана · ${Number(created.copied_fields || created.fields?.length || 0)} связей. Можно переименовать, считать структуру другого vMix и редактировать.`); + } catch (error) { + setStatus(error.message || `Не удалось скопировать конфиг «${source?.name || profileId}»`, true); + } + renderMapping(); + })); + modal.querySelectorAll("[data-mapping-profile]").forEach((button) => button.addEventListener("click", async () => { try { state.mappingActiveProfile = await request(`/api/hockey/admin/vmix-mapping/profiles/${button.dataset.mappingProfile}`); @@ -2536,6 +2563,16 @@ state.mappingTableRowSearch = ""; renderMapping(); }); + modal.querySelectorAll("[data-map-data-group]").forEach((details) => details.addEventListener("toggle", (event) => { + const key = String(event.currentTarget.dataset.mapDataGroup || ""); + if (key) state.mappingOpenDataGroups[key] = Boolean(event.currentTarget.open); + })); + modal.querySelector("[data-map-sql-cell-details]")?.addEventListener("toggle", (event) => { + state.mappingSqlCellOpen = Boolean(event.currentTarget.open); + }); + modal.querySelector("[data-map-sql-table-details]")?.addEventListener("toggle", (event) => { + state.mappingSqlTableOpen = Boolean(event.currentTarget.open); + }); modal.querySelector("[data-map-quick-table-source]")?.addEventListener("change", (event) => { state.mappingSelectedTableSource = event.currentTarget.value; state.mappingSelectedTableRow = 1; @@ -2817,7 +2854,14 @@ if (!deviceId) return setStatus("Выберите Agent, из которого нужно считать структуру.", true), renderMapping(); try { const result = await request(`/api/hockey/admin/vmix-mapping/profiles/${state.mappingActiveProfile.id}/inventory`, { method: "POST", body: JSON.stringify({ device_id: deviceId }) }); - await loadMapping(result.id); state.mappingSelectedInputKey = ""; state.mappingVmixScrollTop = 0; setStatus("Структура vMix обновлена."); + await loadMapping(result.id); state.mappingSelectedInputKey = ""; state.mappingVmixScrollTop = 0; + const report = result.refresh_report || {}; + const preserved = Number(report.preserved || 0); + const total = Number(report.total_links || preserved); + const addedInputs = Number(report.new_inputs || 0); + const addedFields = Number(report.new_fields || 0); + const unresolved = Number(report.unresolved || 0); + setStatus(`Структура vMix обновлена · связи ${preserved}/${total} сохранены${addedInputs ? ` · новых Inputs ${addedInputs}` : ""}${addedFields ? ` · новых полей ${addedFields}` : ""}${unresolved ? ` · требуют проверки ${unresolved}` : ""}.`, unresolved > 0); } catch (error) { setStatus(error.message, true); } renderMapping(); }); diff --git a/tests/test_build75_mapping_collapsed_rescan.py b/tests/test_build75_mapping_collapsed_rescan.py new file mode 100644 index 0000000..7711edd --- /dev/null +++ b/tests/test_build75_mapping_collapsed_rescan.py @@ -0,0 +1,157 @@ +from __future__ import annotations + +import asyncio +import json +from pathlib import Path + +from sqlalchemy import select + +from hockey_data.agent_bridge import VmixAgentHub +from hockey_data.auth_bridge import HockeyUser +from hockey_data.models import VmixDevice, VmixMappingField, VmixMappingProfile +from tests.support import LocalTestDatabase + +ROOT = Path(__file__).resolve().parents[1] +JS = (ROOT / "hockey_data/static/admin-directories.js").read_text(encoding="utf-8") +CSS = (ROOT / "hockey_data/static/admin-directories.css").read_text(encoding="utf-8") + + +def test_mapping_heavy_data_sections_are_collapsed_by_default() -> None: + assert 'data-map-sql-cell-details' in JS + assert 'data-map-sql-table-details' in JS + assert 'mappingSqlCellOpen: false' in JS + assert 'mappingSqlTableOpen: false' in JS + assert 'mappingOpenDataGroups: {}' in JS + assert 'data-map-data-group="${escapeHtml(category.name)}"' in JS + assert 'index < 3 || query' not in JS + assert 'query || state.mappingOpenDataGroups[category.name]' in JS + assert '.hockey-map-sql-cell-quick:not([open])' in CSS + assert '.hockey-map-table-picker:not([open])' in CSS + + +def test_rescan_preserves_links_and_only_adds_new_inventory_items(tmp_path: Path) -> None: + database = LocalTestDatabase(tmp_path / "build75-rescan.sqlite3") + database.create_all() + hub = VmixAgentHub(database) # type: ignore[arg-type] + user = HockeyUser(id="admin-1", login="admin", display_name="Admin") + + old_inventory = { + "fingerprint": "a" * 64, + "inputs": [{ + "key": "score-old-key", "number": "3", "title": "SCORE BUG", "type": "GT", + "fields": [ + {"name": "HomeScore.Text", "type": "text", "index": "0"}, + {"name": "OldOnly.Text", "type": "text", "index": "1"}, + ], + }], + "input_count": 1, "field_count": 2, + } + new_inventory = { + "fingerprint": "b" * 64, + "inputs": [ + { + "key": "score-new-key", "number": "9", "title": "SCORE BUG", "type": "GT", + "fields": [ + {"name": "HomeScore.Text", "type": "text", "index": "4"}, + {"name": "AwayScore.Text", "type": "text", "index": "5"}, + ], + }, + { + "key": "lower-third-key", "number": "10", "title": "PLAYER LOWER THIRD", "type": "GT", + "fields": [{"name": "Name.Text", "type": "text", "index": "0"}], + }, + ], + "input_count": 2, "field_count": 3, + } + + with database.session() as session: + profile = VmixMappingProfile( + name="KHL MAIN", description="", project_fingerprint="a" * 64, + inventory_json=json.dumps(old_inventory), version=5, active=True, + created_by="admin", updated_by="admin", + ) + session.add(profile) + session.flush() + profile_id = profile.id + session.add_all([ + VmixMappingField( + profile_id=profile.id, graphic="score", data_key="game.home_score", + vmix_input_key="score-old-key", vmix_input_number="", vmix_input_title="SCORE BUG", + vmix_field="HomeScore.Text", field_type="text", rule_json="{}", enabled=True, sort_order=0, + ), + VmixMappingField( + profile_id=profile.id, graphic="score", data_key="game.legacy", + vmix_input_key="score-old-key", vmix_input_number="", vmix_input_title="SCORE BUG", + vmix_field="OldOnly.Text", field_type="text", rule_json="{}", enabled=True, sort_order=1, + ), + ]) + session.add(VmixDevice( + device_uuid="GFX-BUILD75", device_secret_hash="x" * 64, name="GFX", + project_fingerprint="b" * 64, project_inventory_json=json.dumps(new_inventory), + project_input_count=2, project_field_count=3, vmix_connected=True, + )) + + result = asyncio.run(hub.refresh_mapping_inventory(profile_id, "GFX-BUILD75", user)) + report = result["refresh_report"] + assert report["total_links"] == 2 + assert report["preserved"] == 1 + assert report["unresolved"] == 1 + assert report["matched_by"]["title"] == 1 + assert report["new_inputs"] == 1 + assert report["new_fields"] == 2 + assert result["inventory"]["input_count"] == 2 + + with database.session() as session: + rows = list(session.scalars( + select(VmixMappingField) + .where(VmixMappingField.profile_id == profile_id) + .order_by(VmixMappingField.sort_order) + )) + # Existing successful link is rebound to the newly scanned Input key. + assert rows[0].vmix_input_key == "score-new-key" + assert rows[0].vmix_field == "HomeScore.Text" + # Temporarily missing target is not deleted, protecting the operator's work. + assert rows[1].vmix_input_key == "score-old-key" + assert rows[1].vmix_field == "OldOnly.Text" + assert len(rows) == 2 + + +def test_rescan_can_replace_hidden_runtime_copy_of_same_config(tmp_path: Path) -> None: + database = LocalTestDatabase(tmp_path / "build75-hidden-runtime.sqlite3") + database.create_all() + hub = VmixAgentHub(database) # type: ignore[arg-type] + user = HockeyUser(id="admin-1", login="admin", display_name="Admin") + old_inventory = {"inputs": [{"key": "old", "number": "1", "title": "TITLE", "fields": [{"name": "Name.Text", "type": "text"}]}]} + new_inventory = {"inputs": [{"key": "new", "number": "2", "title": "TITLE", "fields": [{"name": "Name.Text", "type": "text"}]}], "input_count": 1, "field_count": 1} + + with database.session() as session: + source = VmixMappingProfile( + name="SHARED", description="", project_fingerprint="a" * 64, + inventory_json=json.dumps(old_inventory), version=1, active=True, + created_by="admin", updated_by="admin", + ) + session.add(source); session.flush(); source_id = source.id + session.add(VmixMappingField( + profile_id=source.id, graphic="lt", data_key="player.name", + vmix_input_key="old", vmix_input_number="", vmix_input_title="TITLE", + vmix_field="Name.Text", field_type="text", rule_json="{}", enabled=True, sort_order=0, + )) + runtime = VmixMappingProfile( + name=hub._runtime_mapping_profile_name(source.id, "b" * 64), + description="runtime", project_fingerprint="b" * 64, + inventory_json=json.dumps(new_inventory), version=1, active=True, + created_by="admin", updated_by="admin", + ) + session.add(runtime); session.flush(); runtime_id = runtime.id + session.add(VmixDevice( + device_uuid="GFX-HIDDEN-75", device_secret_hash="x" * 64, name="GFX", + project_fingerprint="b" * 64, project_inventory_json=json.dumps(new_inventory), + project_input_count=1, project_field_count=1, vmix_connected=True, + )) + + result = asyncio.run(hub.refresh_mapping_inventory(source_id, "GFX-HIDDEN-75", user)) + assert result["project_fingerprint"] == "b" * 64 + assert result["fields"][0]["vmix_input_key"] == "new" + with database.session() as session: + runtime = session.get(VmixMappingProfile, runtime_id) + assert runtime.active is False diff --git a/tests/test_build76_mapping_compact_copy.py b/tests/test_build76_mapping_compact_copy.py new file mode 100644 index 0000000..8db047e --- /dev/null +++ b/tests/test_build76_mapping_compact_copy.py @@ -0,0 +1,130 @@ +from __future__ import annotations + +import asyncio +import json +from pathlib import Path + +from sqlalchemy import select + +from hockey_data.agent_bridge import VmixAgentHub +from hockey_data.auth_bridge import HockeyUser +from hockey_data.models import VmixDevice, VmixMappingField, VmixMappingProfile +from tests.support import LocalTestDatabase + +ROOT = Path(__file__).resolve().parents[1] +JS = (ROOT / "hockey_data/static/admin-directories.js").read_text(encoding="utf-8") +CSS = (ROOT / "hockey_data/static/admin-directories.css").read_text(encoding="utf-8") +BRIDGE = (ROOT / "hockey_data/agent_bridge.py").read_text(encoding="utf-8") + + +def test_mapping_config_list_is_compact_and_has_copy_action() -> None: + assert 'data-map-copy-profile="${item.id}"' in JS + assert 'title="Копировать конфиг"' in JS + assert '>⧉' in JS + assert '${isUsed ? "✓" : "▶"}' in JS + assert 'hockey-map-profile-actions' in CSS + assert 'hockey-map-profile-icon' in CSS + assert 'width:28px;height:28px' in CSS + assert '/profiles/${profileId}/duplicate' in JS + assert 'Копия «${created.name}» создана' in JS + + +def test_duplicate_creates_independent_inactive_profile_with_all_links(tmp_path: Path) -> None: + database = LocalTestDatabase(tmp_path / "build76-copy.sqlite3") + database.create_all() + hub = VmixAgentHub(database) # type: ignore[arg-type] + user = HockeyUser(id="admin-1", login="admin", display_name="Admin") + inventory = { + "inputs": [{ + "key": "score", "number": "3", "title": "SCORE", "type": "GT", + "fields": [{"name": "Home.Text", "type": "text"}, {"name": "BG.Color", "type": "color"}], + }], + "input_count": 1, + "field_count": 2, + } + with database.session() as session: + source = VmixMappingProfile( + name="KHL MAIN", description="base package", project_fingerprint="a" * 64, + inventory_json=json.dumps(inventory), version=7, active=True, + created_by="other-admin", updated_by="other-admin", + ) + session.add(source); session.flush(); source_id = source.id + session.add_all([ + VmixMappingField( + profile_id=source.id, graphic="score", data_key="game.home_score", + vmix_input_key="score", vmix_input_number="", vmix_input_title="SCORE", + vmix_field="Home.Text", field_type="text", rule_json='{"mode":"upper"}', enabled=True, sort_order=0, + ), + VmixMappingField( + profile_id=source.id, graphic="score", data_key="home.color", + vmix_input_key="score", vmix_input_number="", vmix_input_title="SCORE", + vmix_field="BG.Color", field_type="color", rule_json="{}", enabled=False, sort_order=1, + ), + ]) + + first = asyncio.run(hub.duplicate_mapping_profile(source_id, user)) + second = asyncio.run(hub.duplicate_mapping_profile(source_id, user)) + assert first["name"] == "KHL MAIN — копия" + assert second["name"] == "KHL MAIN — копия (2)" + assert first["active"] is False + assert first["version"] == 1 + assert first["description"] == "base package" + assert first["copied_from_profile_id"] == source_id + assert first["copied_fields"] == 2 + assert len(first["fields"]) == 2 + assert first["fields"][0]["rule"] == {"mode": "upper"} + assert first["fields"][1]["enabled"] is False + + with database.session() as session: + source = session.get(VmixMappingProfile, source_id) + clone = session.get(VmixMappingProfile, first["id"]) + assert source.active is True + assert source.version == 7 + assert clone.active is False + assert clone.project_fingerprint == source.project_fingerprint + assert len(list(session.scalars(select(VmixMappingField).where(VmixMappingField.profile_id == clone.id)))) == 2 + + +def test_inactive_copy_can_rescan_to_project_that_already_has_live_mapping(tmp_path: Path) -> None: + database = LocalTestDatabase(tmp_path / "build76-rescan-copy.sqlite3") + database.create_all() + hub = VmixAgentHub(database) # type: ignore[arg-type] + user = HockeyUser(id="admin-1", login="admin", display_name="Admin") + old_inventory = {"inputs": [{"key": "old", "number": "1", "title": "TITLE", "fields": [{"name": "Name.Text", "type": "text"}]}]} + new_inventory = {"inputs": [{"key": "new", "number": "2", "title": "TITLE", "fields": [{"name": "Name.Text", "type": "text"}, {"name": "Photo.Source", "type": "image"}]}], "input_count": 1, "field_count": 2} + + with database.session() as session: + draft = VmixMappingProfile( + name="COPY DRAFT", description="", project_fingerprint="a" * 64, + inventory_json=json.dumps(old_inventory), version=1, active=False, + created_by="admin", updated_by="admin", + ) + live = VmixMappingProfile( + name="OTHER LIVE", description="", project_fingerprint="b" * 64, + inventory_json=json.dumps(new_inventory), version=1, active=True, + created_by="admin", updated_by="admin", + ) + session.add_all([draft, live]); session.flush(); draft_id = draft.id; live_id = live.id + session.add(VmixMappingField( + profile_id=draft.id, graphic="lt", data_key="player.name", + vmix_input_key="old", vmix_input_number="", vmix_input_title="TITLE", + vmix_field="Name.Text", field_type="text", rule_json="{}", enabled=True, sort_order=0, + )) + session.add(VmixDevice( + device_uuid="GFX-COPY-76", device_secret_hash="x" * 64, name="GFX Copy", + project_fingerprint="b" * 64, project_inventory_json=json.dumps(new_inventory), + project_input_count=1, project_field_count=2, vmix_connected=True, + )) + + result = asyncio.run(hub.refresh_mapping_inventory(draft_id, "GFX-COPY-76", user)) + assert result["project_fingerprint"] == "b" * 64 + assert result["active"] is False + assert result["fields"][0]["vmix_input_key"] == "new" + assert result["refresh_report"]["new_fields"] == 1 + with database.session() as session: + assert session.get(VmixMappingProfile, live_id).active is True + + +def test_backend_exposes_duplicate_endpoint() -> None: + assert '"/api/hockey/admin/vmix-mapping/profiles/{profile_id}/duplicate"' in BRIDGE + assert 'duplicate_mapping_profile' in BRIDGE