копировать маппинг
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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) => `
|
||||
<details class="hockey-map-data-group" ${index < 3 || query ? "open" : ""}>
|
||||
const groups = categories.map((category) => `
|
||||
<details class="hockey-map-data-group" data-map-data-group="${escapeHtml(category.name)}" ${query || state.mappingOpenDataGroups[category.name] ? "open" : ""}>
|
||||
<summary><span>${escapeHtml(category.name)}</span><b>${category.items.length}</b></summary>
|
||||
<div>${category.items.map((item) => `
|
||||
<button type="button" class="hockey-map-data-item ${selectedSource === item.key ? "is-selected" : ""}" data-map-source="${escapeHtml(item.key)}">
|
||||
@@ -1872,11 +1875,11 @@
|
||||
let quickTableMarkup = "";
|
||||
if (tables.length) {
|
||||
quickTableMarkup = `
|
||||
<section class="hockey-map-sql-cell-quick ${targetField ? "is-target-ready" : ""}">
|
||||
<header>
|
||||
<details class="hockey-map-sql-cell-quick ${targetField ? "is-target-ready" : ""}" data-map-sql-cell-details ${state.mappingSqlCellOpen ? "open" : ""}>
|
||||
<summary>
|
||||
<div><span>SQL ЯЧЕЙКА</span><strong>Источник → строка → столбец</strong></div>
|
||||
<small>${targetField ? `Для ${escapeHtml(currentInput?.title || "Input")} → ${escapeHtml(targetField.name || "")}` : "Сначала выберите поле vMix справа"}</small>
|
||||
</header>
|
||||
</summary>
|
||||
<div class="hockey-map-sql-cell-grid">
|
||||
<label><span>SQL источник</span><select data-map-quick-table-source>${tables.map((table) => `<option value="${escapeHtml(table.code)}" ${String(table.code) === String(selectedTable?.code) ? "selected" : ""}>${escapeHtml(table.name || table.code)}</option>`).join("")}</select></label>
|
||||
<label><span>Строка</span><select data-map-quick-table-row ${selectedTableRows.length ? "" : "disabled"}>${selectedTableRows.map((row) => `<option value="${Number(row.index || 0)}" ${Number(row.index || 0) === Number(selectedTableRow?.index || 0) ? "selected" : ""}>#${Number(row.index || 0)} · ${escapeHtml(row.label || `Строка ${row.index}`)}</option>`).join("")}</select></label>
|
||||
@@ -1886,7 +1889,7 @@
|
||||
<div><span>Текущее значение</span><strong>${escapeHtml(mappingDisplayValue(quickCellSource?.value, quickCellSource?.kind || "text"))}</strong><code>${escapeHtml(quickCellKey || "—")}</code></div>
|
||||
<button type="button" data-map-quick-cell-link ${quickLinkEnabled ? "" : "disabled"}>${targetField ? `Связать с ${escapeHtml(targetField.name || "полем")}` : "Выберите поле vMix"}</button>
|
||||
</div>
|
||||
</section>`;
|
||||
</details>`;
|
||||
const rowSearch = String(state.mappingTableRowSearch || "").trim().toLowerCase();
|
||||
const allRows = selectedTable?.rows || [];
|
||||
const filteredRows = allRows.filter((row) => {
|
||||
@@ -1908,8 +1911,8 @@
|
||||
}).join("")}
|
||||
</tr>`).join("");
|
||||
tableMarkup = `
|
||||
<section class="hockey-map-table-picker">
|
||||
<header><div><span>ТАБЛИЦА SQL</span><strong>Выберите конкретную строку и столбец</strong></div><b>${Number(selectedTable?.row_count || 0)} строк</b></header>
|
||||
<details class="hockey-map-table-picker" data-map-sql-table-details ${state.mappingSqlTableOpen ? "open" : ""}>
|
||||
<summary><div><span>ТАБЛИЦА SQL</span><strong>Выберите конкретную строку и столбец</strong></div><b>${Number(selectedTable?.row_count || 0)} строк</b></summary>
|
||||
<div class="hockey-map-table-controls">
|
||||
<select data-map-table-source>${tables.map((table) => `<option value="${escapeHtml(table.code)}" ${String(table.code) === String(selectedTable?.code) ? "selected" : ""}>${escapeHtml(table.name || table.code)} · ${Number(table.row_count || 0)} строк</option>`).join("")}</select>
|
||||
<label><span>⌕</span><input data-map-table-row-search value="${escapeHtml(state.mappingTableRowSearch)}" placeholder="Поиск строки по любому значению…"></label>
|
||||
@@ -1917,7 +1920,7 @@
|
||||
${selectedTable?.skipped ? `<div class="hockey-directory-empty">Не хватает параметров: ${escapeHtml((selectedTable.missing || []).map((x) => `:${x}`).join(", "))}</div>` : selectedTable?.error ? `<div class="hockey-directory-empty is-error">${escapeHtml(selectedTable.error)}</div>` : `
|
||||
<div class="hockey-map-table-grid" style="height:${tableGridHeight}px"><table><thead><tr><th>Строка</th>${columns.map((column) => `<th><strong>${escapeHtml(column.label || column.key)}${column.localized ? ` <i class="hockey-map-lang-auto">AUTO ${String(column.language || selectedTable?.language || "ru").toUpperCase()}</i>` : ""}</strong><small>${escapeHtml(column.key)}</small></th>`).join("")}</tr></thead><tbody>${cells || `<tr><td colspan="${columns.length + 1}">Нет строк</td></tr>`}</tbody></table></div>
|
||||
${filteredRows.length > visibleRows.length ? `<small class="hockey-map-table-limit">Показаны первые ${visibleRows.length} из ${filteredRows.length} строк. Используйте поиск строки.</small>` : ""}`}
|
||||
</section>`;
|
||||
</details>`;
|
||||
}
|
||||
|
||||
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 `
|
||||
<div class="hockey-mapping-profile-row ${profile?.id === item.id ? "is-active" : ""}">
|
||||
<button type="button" class="hockey-mapping-profile ${profile?.id === item.id ? "is-active" : ""}" data-mapping-profile="${item.id}">
|
||||
<button type="button" class="hockey-mapping-profile ${profile?.id === item.id ? "is-active" : ""}" data-mapping-profile="${item.id}" title="Открыть и редактировать конфиг">
|
||||
<strong>${escapeHtml(item.name)}</strong>
|
||||
<small>v${Number(item.version || 1)} · ${Number(item.field_count || 0)} связей${item.created_by ? ` · ${escapeHtml(item.created_by)}` : ""}</small>
|
||||
</button>
|
||||
<button type="button" class="hockey-map-use-profile ${isUsed ? "is-used" : ""}" data-map-use-profile="${item.id}" ${isUsed ? 'title="Этот конфиг уже выбран на вашем Agent"' : ""}>${isUsed ? "✓ Используется" : "Применить к моему Agent"}</button>
|
||||
<div class="hockey-map-profile-actions">
|
||||
<button type="button" class="hockey-map-profile-icon hockey-map-use-profile ${isUsed ? "is-used" : ""}" data-map-use-profile="${item.id}" title="${isUsed ? "Этот конфиг уже используется на моём Agent" : "Применить к моему Agent"}" aria-label="${isUsed ? "Используется на моём Agent" : "Применить к моему Agent"}">${isUsed ? "✓" : "▶"}</button>
|
||||
<button type="button" class="hockey-map-profile-icon hockey-map-copy-profile" data-map-copy-profile="${item.id}" title="Копировать конфиг" aria-label="Копировать конфиг">⧉</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join("") : `<div class="hockey-directory-empty">Mapping-конфигов ещё нет.</div>`;
|
||||
|
||||
@@ -2220,7 +2226,7 @@
|
||||
<h3>vMix проекты</h3>
|
||||
<div class="hockey-mapping-devices">${deviceRows}</div>
|
||||
<h3>Конфиги Mapping</h3>
|
||||
<div class="hockey-map-config-hint">Выберите любой готовый конфиг — свой или чужой — и нажмите «Применить к моему Agent». Он будет использован для текущего открытого матча.</div>
|
||||
<div class="hockey-map-config-hint">▶ применяет готовый конфиг к вашему Agent. ⧉ создаёт независимую копию со всеми связями — её можно переименовать, пересчитать под другой vMix и отредактировать.</div>
|
||||
<div class="hockey-mapping-profiles">${profileButtons}</div>
|
||||
<details class="hockey-mapping-create-wrap">
|
||||
<summary>+ Создать новый конфиг из vMix</summary>
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
157
tests/test_build75_mapping_collapsed_rescan.py
Normal file
157
tests/test_build75_mapping_collapsed_rescan.py
Normal file
@@ -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
|
||||
130
tests/test_build76_mapping_compact_copy.py
Normal file
130
tests/test_build76_mapping_compact_copy.py
Normal file
@@ -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 '>⧉</button>' 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
|
||||
Reference in New Issue
Block a user