выделение удалений, и заготовки
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user