экспорт маппинга

This commit is contained in:
2026-08-19 17:32:13 +03:00
parent c643a183d8
commit 614a61168a
17 changed files with 1012 additions and 35 deletions

View File

@@ -0,0 +1,45 @@
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
RUNTIME = (ROOT / "ui_builder" / "static" / "runtime.html").read_text(encoding="utf-8")
EDITOR = (ROOT / "ui_builder" / "static" / "index.html").read_text(encoding="utf-8")
INTEGRATION = (ROOT / "ui_builder" / "integration.py").read_text(encoding="utf-8")
APP_JS = (ROOT / "ui_builder" / "static" / "app.js").read_text(encoding="utf-8")
TOURNAMENT_JS = (ROOT / "hockey_data" / "static" / "tournament-menu.js").read_text(encoding="utf-8")
APP_PY = (ROOT / "app.py").read_text(encoding="utf-8")
def test_runtime_has_no_builder_dom():
assert 'id="editor"' not in RUNTIME
assert 'id="componentLibrary"' not in RUNTIME
assert 'id="canvasStage"' not in RUNTIME
assert 'id="inspector"' not in RUNTIME
assert 'class="topbar editor-only"' not in RUNTIME
assert 'id="runtimeView"' in RUNTIME
assert 'runtime-view runtime-only hidden' not in RUNTIME
def test_editor_keeps_full_builder():
assert 'id="editor"' in EDITOR
assert 'id="componentLibrary"' in EDITOR
assert 'id="canvasStage"' in EDITOR
assert 'id="inspector"' in EDITOR
def test_integration_uses_separate_runtime_template():
assert 'runtime_template = (assets_dir / "runtime.html")' in INTEGRATION
assert 'template = editor_template if mode == "editor" else runtime_template' in INTEGRATION
def test_editor_button_is_admin_only_and_hotkey_respects_it():
assert 'id="runtimeEditorBtn" class="icon-btn hidden"' in RUNTIME
assert 'editorButton?.classList.toggle("hidden", !state.isAdmin);' in TOURNAMENT_JS
assert 'document.body.dataset.hockeyAdmin = state.isAdmin ? "1" : "0";' in TOURNAMENT_JS
assert 'el.runtimeEditorBtn?.classList.contains("hidden")' in APP_JS
def test_editor_server_paths_are_admin_protected():
assert 'path == "/editor"' in APP_PY
assert 'path.startswith("/api/ui-builder/editor/")' in APP_PY
assert 'path.startswith("/api/ui-builder/auth/")' in APP_PY
assert '(khl_admin_path or editor_path) and not user.is_admin' in APP_PY

View File

@@ -0,0 +1,30 @@
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
JS = (ROOT / "ui_builder" / "static" / "app.js").read_text(encoding="utf-8")
CSS = (ROOT / "ui_builder" / "static" / "styles.css").read_text(encoding="utf-8")
APP = (ROOT / "app.py").read_text(encoding="utf-8")
def test_build70_prefers_css_zoom_for_runtime_canvas():
assert 'CSS.supports("zoom", "1")' in JS
assert 'el.runtimeStage.style.zoom = String(scale);' in JS
assert 'el.runtimeStage.style.transform = "none";' in JS
def test_build70_fallback_does_not_force_translate3d_layer():
start = JS.index('const canUseCssZoom')
snippet = JS[start:start + 1500]
assert 'style.transform = `translate3d' not in snippet
assert 'el.runtimeStage.style.transform = `scale(${scale})`;' in snippet
def test_build70_runtime_topbar_has_no_backdrop_blur():
start = CSS.index('.runtime-topbar {')
runtime_topbar = CSS[start:CSS.index('}', start) + 1]
assert 'backdrop-filter' not in runtime_topbar
assert 'background: #080d15' in runtime_topbar
def test_build70_version():
assert 'BUILD_VERSION = "2026.08.19.28"' in APP

View File

@@ -0,0 +1,52 @@
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
JS = (ROOT / "ui_builder/static/app.js").read_text(encoding="utf-8")
MAPPING = (ROOT / "hockey_data/mapping_context.py").read_text(encoding="utf-8")
SERVICE = (ROOT / "hockey_data/service.py").read_text(encoding="utf-8")
CSS = (ROOT / "ui_builder/static/styles.css").read_text(encoding="utf-8")
def test_player_payload_exposes_database_id_without_replacing_external_id():
assert '"id": player.external_id' in SERVICE
assert '"external_id": player.external_id' in SERVICE
assert '"db_id": player.id' in SERVICE
def test_mapping_has_independent_home_and_away_penalty_identifiers():
for key in (
"selected_home_penalty_id",
"selected_home_penalty_player_id",
"selected_home_penalty_player_db_id",
"selected_home_penalty_team_penalty",
"selected_away_penalty_id",
"selected_away_penalty_player_id",
"selected_away_penalty_player_db_id",
"selected_away_penalty_team_penalty",
"selected_penalty_player_db_id",
"selected_penalty_team_penalty",
):
assert f'"key": "{key}"' in MAPPING
def test_penalty_preview_writes_side_specific_context_and_team_flag():
for key in (
"selected_home_penalty_id",
"selected_home_penalty_player_db_id",
"selected_home_penalty_team_penalty",
"selected_away_penalty_id",
"selected_away_penalty_player_db_id",
"selected_away_penalty_team_penalty",
):
assert f"{key}:" in JS
assert 'selected_penalty_team_penalty: detail.team_penalty ? "1" : "0"' in JS
assert 'selected_penalty_player_db_id: detail.player_db_id' in JS
def test_preview_selection_is_kept_per_side_and_visible():
assert 'selectedPreviewEventIds: { home: "", away: "" }' in JS
assert 'board.selectedPreviewEventIds[side] = event.id' in JS
assert 'hockeyPenaltyIsPreviewSelected(board, event)' in JS
assert 'Player DB ID' in JS
assert 'Командное</b>${event.teamPenalty ? "1" : "0"}' in JS
assert '.hpd-team-card-identifiers' in CSS

View File

@@ -0,0 +1,145 @@
from __future__ import annotations
import asyncio
import json
from pathlib import Path
from types import SimpleNamespace
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_portable_rebind_uses_title_when_input_key_changed() -> None:
fields = [{
"graphic": "score",
"data_key": "game.home_score",
"vmix_input_key": "old-key",
"vmix_input_number": "5",
"vmix_input_title": "SCORE BUG",
"vmix_field": "HomeScore.Text",
"field_type": "text",
"enabled": True,
"rule": {"enabled": True, "operator": "eq", "right_value": "1"},
}]
inventory = {"inputs": [{
"key": "new-key",
"number": "17",
"title": "SCORE BUG",
"type": "GT",
"fields": [{"name": "HomeScore.Text", "type": "text", "index": "0"}],
}]}
rebound, report = VmixAgentHub._rebind_portable_mapping_fields(fields, inventory)
assert report["mapped"] == 1
assert report["skipped"] == 0
assert report["matched_by"]["title"] == 1
assert rebound[0]["vmix_input_key"] == "new-key"
assert rebound[0]["vmix_input_number"] == "17"
assert rebound[0]["vmix_input_title"] == "SCORE BUG"
assert rebound[0]["vmix_field"] == "HomeScore.Text"
assert rebound[0]["rule"]["operator"] == "eq"
def test_portable_rebind_reports_missing_field_without_guessing() -> None:
fields = [{
"data_key": "game.clock",
"vmix_input_key": "old-key",
"vmix_input_title": "SCORE BUG",
"vmix_field": "Clock.Text",
"field_type": "text",
"enabled": True,
}]
inventory = {"inputs": [{
"key": "new-key",
"number": "2",
"title": "SCORE BUG",
"fields": [{"name": "Period.Text", "type": "text", "index": "0"}],
}]}
rebound, report = VmixAgentHub._rebind_portable_mapping_fields(fields, inventory)
assert rebound == []
assert report["mapped"] == 0
assert report["skipped"] == 1
assert report["skipped_items"][0]["reason"] == "field_not_found"
def test_copy_profile_creates_target_specific_mapping_and_keeps_source(tmp_path: Path) -> None:
database = LocalTestDatabase(tmp_path / "portable-mapping.sqlite3")
database.create_all()
hub = VmixAgentHub(database) # type: ignore[arg-type]
user = HockeyUser(id="admin-1", login="admin", display_name="Admin")
source_inventory = {
"fingerprint": "a" * 64,
"inputs": [{
"key": "source-score-key", "number": "3", "title": "SCORE BUG", "type": "GT",
"fields": [{"name": "HomeScore.Text", "type": "text", "index": "0"}],
}],
"input_count": 1, "field_count": 1,
}
target_inventory = {
"fingerprint": "b" * 64,
"inputs": [{
"key": "target-score-key", "number": "11", "title": "SCORE BUG", "type": "GT",
"fields": [{"name": "HomeScore.Text", "type": "text", "index": "4"}],
}],
"input_count": 1, "field_count": 1,
}
with database.session() as session:
source = VmixMappingProfile(
name="KHL MAIN", description="portable", project_fingerprint="a" * 64,
inventory_json=json.dumps(source_inventory), version=7, 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="score", data_key="game.home_score",
vmix_input_key="source-score-key", vmix_input_number="", vmix_input_title="SCORE BUG",
vmix_field="HomeScore.Text", field_type="text", rule_json="{}", enabled=True, sort_order=0,
))
session.add(VmixDevice(
device_uuid="GFX-PORTABLE-02", device_secret_hash="x" * 64, name="GFX 2",
project_fingerprint="b" * 64, project_inventory_json=json.dumps(target_inventory),
project_input_count=1, project_field_count=1, vmix_connected=True,
))
payload = SimpleNamespace(device_id="GFX-PORTABLE-02", name="", replace_existing=False, apply_now=False)
result = asyncio.run(hub.copy_mapping_profile_to_device(source_id, payload, user))
assert result["report"]["mapped"] == 1
assert result["report"]["matched_by"]["title"] == 1
assert result["profile"]["project_fingerprint"] == "b" * 64
assert result["profile"]["fields"][0]["vmix_input_key"] == "target-score-key"
assert result["profile"]["fields"][0]["vmix_input_title"] == "SCORE BUG"
with database.session() as session:
profiles = list(session.scalars(select(VmixMappingProfile).order_by(VmixMappingProfile.id)))
assert len(profiles) == 2
assert profiles[0].id == source_id and profiles[0].active is True
assert profiles[0].project_fingerprint == "a" * 64
assert profiles[1].active is True
assert profiles[1].project_fingerprint == "b" * 64
def test_admin_ui_has_export_import_and_copy_to_agent_controls() -> None:
assert "data-map-export" in JS
assert "data-map-copy-to-device" in JS
assert "data-mapping-import" in JS
assert "/copy-to-device" in JS
assert "/api/hockey/admin/vmix-mapping/import" in JS
assert "hockey-mapping.json" in JS
assert "Input ищется по key → названию → номеру" in JS
assert "portable vMix Mapping transfer/import" in CSS
assert '"/api/hockey/admin/vmix-mapping/profiles/{profile_id}/export"' in BRIDGE
assert '"/api/hockey/admin/vmix-mapping/import"' in BRIDGE