выделение удалений, и заготовки

This commit is contained in:
2026-08-20 13:33:42 +03:00
parent 3cea13c103
commit 381e630c77
7 changed files with 1511 additions and 49 deletions

View File

@@ -2,15 +2,36 @@ from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
APP = (ROOT / "ui_builder/static/app.js").read_text(encoding="utf-8")
ADMIN = (ROOT / "hockey_data/static/admin-directories.js").read_text(encoding="utf-8")
MAPPING = (ROOT / "hockey_data/mapping_context.py").read_text(encoding="utf-8")
def test_side_penalty_mapping_context_is_derived_from_active_penalties():
def test_side_penalty_mapping_context_is_derived_from_active_penalties_without_overwriting_manual_selection():
start = APP.index("async function hockeySyncPenaltySideMappingContext")
end = APP.index("function penaltyTargetAssignmentKey", start)
snippet = APP[start:end]
assert 'sortedPenaltyEntries("home")[0]' in snippet
assert 'sortedPenaltyEntries("away")[0]' in snippet
for key in (
"active_home_penalty_id",
"active_home_penalty_player_id",
"active_home_penalty_player_db_id",
"active_home_penalty_team_penalty",
"active_away_penalty_id",
"active_away_penalty_player_id",
"active_away_penalty_player_db_id",
"active_away_penalty_team_penalty",
):
assert f"{key}:" in snippet
# BUILD84: selected_* is an explicit operator choice, so the automatic mirror
# must never replace it with the first active penalty.
assert "selected_home_penalty_id:" not in snippet
assert "selected_away_penalty_id:" not in snippet
def test_manual_side_penalty_selection_keeps_separate_mapping_identifiers():
start = APP.index("async function hockeySelectPenaltyForPreview")
end = APP.index("function hockeyPenaltyCard", start)
snippet = APP[start:end]
for key in (
"selected_home_penalty_id",
"selected_home_penalty_player_id",
@@ -22,25 +43,5 @@ def test_side_penalty_mapping_context_is_derived_from_active_penalties():
"selected_away_penalty_team_penalty",
):
assert f"{key}:" in snippet
assert 'fetch("/api/hockey/context/batch"' in snippet
def test_penalty_context_sync_runs_when_penalty_changes_and_clears_on_finish():
assert "hockeySyncPenaltySideMappingContext().catch(() => {});" in APP
assert APP.count("hockeySyncPenaltySideMappingContext({ force: true }).catch(() => {});") >= 3
def test_saved_penalty_player_keeps_external_and_database_ids():
start = APP.index("function hockeyCompactPlayer")
end = APP.index("function hockeyCompactInfraction", start)
snippet = APP[start:end]
assert "const externalId" in snippet
assert "const dbId" in snippet
assert "externalId," in snippet
assert "dbId," in snippet
def test_mapping_admin_refreshes_live_after_runtime_context_update():
assert 'window.addEventListener("hockey:mapping-context-updated"' in ADMIN
assert "await loadMappingCatalog()" in ADMIN
assert "renderMapping();" in ADMIN
assert key in MAPPING
assert "const playerIds = hockeyPenaltyPlayerIdentifiers(component, event);" in snippet

View File

@@ -0,0 +1,192 @@
from pathlib import Path
from hockey_data.agent_bridge import VmixAgentHub
from hockey_data.models import VmixPreparedTitle
ROOT = Path(__file__).resolve().parents[1]
APP = (ROOT / "ui_builder/static/app.js").read_text(encoding="utf-8")
MAPPING = (ROOT / "hockey_data/mapping_context.py").read_text(encoding="utf-8")
BRIDGE = (ROOT / "hockey_data/agent_bridge.py").read_text(encoding="utf-8")
STYLES = (ROOT / "ui_builder/static/styles.css").read_text(encoding="utf-8")
def test_home_and_away_penalties_have_independent_manual_selection():
assert 'selectedPreviewEventIds: { home: "", away: "" }' in APP
assert 'board.selectedPreviewEventIds[side] = event.id' in APP
assert 'card.classList.toggle("is-preview-selected", hockeyPenaltyIsPreviewSelected(board, event));' in APP
# The old global selectedEventId must not drive the visual selection class.
penalty_start = APP.index("function hockeyPenaltyCard")
penalty_end = APP.index("function createHockeyDraftFromDrop", penalty_start)
penalty_card = APP[penalty_start:penalty_end]
assert 'card.classList.toggle("is-selected", board.selectedEventId === event.id)' not in penalty_card
assert '.hpd-team-penalty-card.team-home.is-preview-selected' in STYLES
assert '.hpd-team-penalty-card.team-away.is-preview-selected' in STYLES
def test_selected_penalty_context_is_manual_and_active_penalty_context_is_separate():
select_start = APP.index("async function hockeySelectPenaltyForPreview")
select_end = APP.index("function hockeyPenaltyCard", select_start)
select_block = APP[select_start:select_end]
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",
):
assert key in select_block
assert key in MAPPING
sync_start = APP.index("async function hockeySyncPenaltySideMappingContext")
sync_end = APP.index("function penaltyTargetAssignmentKey", sync_start)
sync_block = APP[sync_start:sync_end]
assert "active_home_penalty_id:" in sync_block
assert "active_away_penalty_id:" in sync_block
assert "selected_home_penalty_id:" not in sync_block
assert "selected_away_penalty_id:" not in sync_block
def test_player_selection_values_survive_reload_using_server_state_and_browser_backup():
for token in (
"function hockeyPlayerSelectionStorageKey",
"function hockeyBackupPlayerSelectionValues",
"function hockeyStoredPlayerSelectionValues",
"hockeyBackupPlayerSelectionValues(gameId, payload?.values || {})",
'if (!control || String(control.game_id || "") !== String(gameId)) return false;',
"const hasServerSelectionKeys",
):
assert token in APP
assert "localStorage.setItem(hockeyPlayerSelectionStorageKey(id)" in APP
def test_player_block_editor_is_compact_single_line():
assert "/* BUILD84 — compact, single-line player-block editor. */" in STYLES
assert "min-height:48px" in STYLES
assert "grid-template-columns:24px 66px" in STYLES
assert '> Главный</label>' in APP
assert '> Свёрнут</label>' in APP
def test_prepared_titles_workspace_exists_and_clones_to_vmix():
assert '{ id: "prepared_titles", label: "Заготовки" }' in APP
for token in (
"function renderHockeyPreparedTitlesWorkspace()",
"function hockeyOpenPreparedFromPlayerPanel(panel)",
"data-player-panel-prepared",
"/api/hockey/prepared-titles",
"Сохранить новым Input",
"Подставить из Mapping",
"▶ Preview",
):
assert token in APP
assert VmixPreparedTitle.__tablename__ == "hockey_vmix_prepared_titles"
assert '{"Function": "CreateVirtualInput", "Input": source_ref}' in BRIDGE
assert '{"Function": "PreviewInput", "Input": input_ref}' in BRIDGE
assert '@router.post("/api/hockey/prepared-titles")' in BRIDGE
assert '@router.get("/api/hockey/prepared-titles/mapping-snapshot")' in BRIDGE
def test_prepared_input_resolution_prefers_stable_key_then_number_then_unique_title():
inventory = {
"inputs": [
{"key": "stable-a", "number": "7", "title": "TITLE"},
{"key": "stable-b", "number": "9", "title": "OTHER"},
]
}
assert VmixAgentHub._prepared_find_input(inventory, key="stable-b")["number"] == "9"
assert VmixAgentHub._prepared_find_input(inventory, number="7")["key"] == "stable-a"
assert VmixAgentHub._prepared_find_input(inventory, title="OTHER")["key"] == "stable-b"
duplicate = {"inputs": [{"key": "a", "title": "SAME"}, {"key": "b", "title": "SAME"}]}
assert VmixAgentHub._prepared_find_input(duplicate, title="SAME") is None
def test_prepared_title_creation_clones_last_input_and_applies_field_snapshot(tmp_path):
import asyncio
import json
import types
from types import SimpleNamespace
from sqlalchemy import select
from hockey_data.auth_bridge import HockeyUser
from hockey_data.models import VmixDevice, VmixPreparedTitle
from tests.support import LocalTestDatabase
database = LocalTestDatabase(tmp_path / "prepared.sqlite3")
database.create_all()
hub = VmixAgentHub(database) # type: ignore[arg-type]
user = HockeyUser(id="prep-user", login="prep", display_name="Prep")
source_inventory = {
"inputs": [
{
"key": "source-key",
"number": "12",
"title": "COMPARE",
"type": "GT",
"fields": [
{"name": "Name.Text", "type": "text", "index": "0"},
{"name": "Photo.Source", "type": "image", "index": "1"},
],
}
]
}
with database.session() as session:
session.add(VmixDevice(
device_uuid="prep-device",
device_secret_hash="x" * 64,
name="Prep device",
wfl_user_id=user.id,
is_active_for_account=True,
vmix_connected=True,
current_match_external_id="9001",
project_inventory_json=json.dumps(source_inventory),
))
calls = []
async def fake_sequence(self, _user, commands, *, device_id="", session_token="", timeout=0):
calls.append(commands)
if commands and commands[0].get("Function") == "CreateVirtualInput":
cloned = {
"inputs": source_inventory["inputs"] + [
{
"key": "clone-key",
"number": "13",
"title": "COMPARE",
"type": "GT",
"fields": source_inventory["inputs"][0]["fields"],
}
]
}
with database.session() as session:
device = session.scalar(select(VmixDevice).where(VmixDevice.device_uuid == "prep-device"))
device.project_inventory_json = json.dumps(cloned)
return {"ok": True, "device_id": device_id or "prep-device", "match_id": "9001"}
hub.run_vmix_sequence_for_user = types.MethodType(fake_sequence, hub)
payload = SimpleNamespace(
name="Compare ready",
device_id="prep-device",
session_token="session",
source_input_key="source-key",
source_input_number="12",
source_input_title="COMPARE",
source_kind="player_selection",
source_ref="compare_home",
field_values={
"Name.Text": {"type": "text", "value": "Иванов"},
"Photo.Source": {"type": "image", "value": r"D:\\Photo\\ivanov.png"},
},
)
result = asyncio.run(hub.create_prepared_title(user, payload))
assert result["clone_input"]["number"] == "13"
assert calls[0] == [{"Function": "CreateVirtualInput", "Input": "source-key"}]
assert any(command.get("Function") == "SetText" and command.get("Input") == "clone-key" for command in calls[1])
assert any(command.get("Function") == "SetImage" and command.get("SelectedName") == "Photo.Source" for command in calls[1])
with database.session() as session:
row = session.scalar(select(VmixPreparedTitle))
assert row is not None
assert row.clone_input_key == "clone-key"
assert row.clone_input_number == "13"
assert row.source_ref == "compare_home"