from __future__ import annotations from pathlib import Path from ui_builder.manager import UIBuilderManager def test_shortcut_sequences_are_normalized_and_persisted(tmp_path: Path) -> None: manager = UIBuilderManager(tmp_path, filename="ui.json") saved = manager.save({ "version": 20, "project_name": "Hockey", "data_source": "hockey", "canvas": {"width": 1440, "height": 900}, "tabs": [{"id": "main", "label": "Main"}], "components": [], "triggers": [{ "id": "trigger-1", "name": "Start from event", "enabled": True, "source_action_id": "clock", "event": "click", "action": {"type": "run_sequence", "sequence_id": "start-clock"}, }], "shortcut_sequences": [{ "id": "start-clock", "name": "Старт времени", "description": "Запуск веба и vMix", "combo": "F1", "steps": [{ "id": "sync", "type": "hockey_vmix_timers_start", "condition": "has_penalties", "game_timer_action_id": "hockey_game_timer", "hockey_timer_command": "toggle", "game_vmix_mode": "countdown", "game_vmix_input": "53", "game_vmix_selected_name": "CLOCK.Text", "penalty_display_mode": "soonest", "home_penalty_inputs": "54,55", "away_penalty_inputs": "56,57", }, { "id": "pause", "type": "delay", "milliseconds": "not-a-number", }], }], }) assert saved["shortcut_sequences"][0]["combo"] == "F1" assert saved["shortcut_sequences"][0]["steps"][0]["type"] == "hockey_vmix_timers_start" assert saved["shortcut_sequences"][0]["steps"][0]["condition"] == "has_penalties" assert saved["shortcut_sequences"][0]["steps"][0]["hockey_timer_command"] == "toggle" assert saved["shortcut_sequences"][0]["steps"][0]["game_vmix_mode"] == "countdown" assert saved["shortcut_sequences"][0]["steps"][0]["game_vmix_selected_name"] == "CLOCK.Text" assert saved["shortcut_sequences"][0]["steps"][0]["penalty_display_mode"] == "soonest" assert saved["shortcut_sequences"][0]["steps"][1]["milliseconds"] == 0 assert saved["triggers"][0]["action"]["sequence_id"] == "start-clock" def test_shortcut_ui_contains_editor_reference_and_vmix_timer_sequence() -> None: app_js = Path("ui_builder/static/app.js").read_text(encoding="utf-8") index_html = Path("ui_builder/static/index.html").read_text(encoding="utf-8") assert "showShortcutsEditor" in app_js assert "showShortcutsReference" in app_js assert "hockey_vmix_timers_start" in app_js assert 'Function: "SetCountdown"' in app_js assert 'Function: "StartCountdown"' in app_js assert 'id="runtimeShortcutsBtn"' in index_html assert 'id="shortcutsBtn"' in index_html def test_shortcut_vmix_editor_uses_searchable_inventory_controls() -> None: app_js = Path("ui_builder/static/app.js").read_text(encoding="utf-8") assert 'api("/vmix-inventory")' in app_js assert "vmixFunctionOptions" in app_js assert "vmixInputOptions" in app_js assert "vmixSelectedNameOptions" in app_js assert 'data-vmix-function-select' in app_js assert 'data-step-field="input"' in app_js assert 'data-step-field="selected_name"' in app_js assert "SetTextColour" in app_js assert "OverlayInput1In" in app_js def test_shortcut_editor_collapses_existing_sequences_and_is_locked() -> None: app_js = Path("ui_builder/static/app.js").read_text(encoding="utf-8") assert "shortcutSequenceOpenId" in app_js assert "shortcut-sequence-compact" in app_js assert "data-sequence-edit" in app_js assert '{ locked: true, className: "modal-shortcuts-full" }' in app_js assert "Это окно защищено от случайного закрытия" in app_js def test_shortcut_editor_layout_fixes_add_step_buttons_and_fullscreen_modal() -> None: css = Path("ui_builder/static/styles.css").read_text(encoding="utf-8") assert ".shortcut-add-steps .mini-btn" in css assert "width: auto;" in css assert ".modal-card.modal-shortcuts-full" in css assert "height: calc(100vh - 20px);" in css def test_hockey_timer_shortcut_supports_native_countdown_and_legacy_text_mirror() -> None: app_js = Path("ui_builder/static/app.js").read_text(encoding="utf-8") assert 'Function: "StopCountdown"' in app_js assert 'Function: "SuspendCountdown"' not in app_js assert 'Function: "StopCountdown"' in app_js assert 'hockey_timer_command' in app_js assert 'game_vmix_mode' in app_js assert 'game_vmix_selected_name' in app_js assert 'vmixTimerMirrors' in app_js assert 'Function: "SetText"' in app_js assert 'combo: "Space"' in app_js def test_global_shortcuts_capture_keys_before_focused_runtime_controls() -> None: app_js = Path("ui_builder/static/app.js").read_text(encoding="utf-8") assert 'window.addEventListener("keydown", (event) => {' in app_js assert ' }, true);\n\n window.addEventListener("keyup"' in app_js assert 'event.stopImmediatePropagation()' in app_js assert 'function shortcutSequenceAllowed(sequence, event)' in app_js # Global shortcut sequences are intentionally independent of the active UI tab. sequence_allowed = app_js.split('function shortcutSequenceAllowed(sequence, event)', 1)[1].split('function currentHockeyPenaltyEntries()', 1)[0] assert 'isEffectivelyOnActiveTab' not in sequence_allowed assert 'isEditingTarget(event.target)' in sequence_allowed def test_hockey_timer_targets_persist_selected_names_and_finish_actions(tmp_path: Path) -> None: manager = UIBuilderManager(tmp_path, filename="ui.json") saved = manager.save({ "version": 20, "project_name": "Hockey", "data_source": "hockey", "canvas": {"width": 1440, "height": 900}, "tabs": [{"id": "main", "label": "Main"}], "components": [], "triggers": [], "shortcut_sequences": [{ "id": "timers", "name": "Timers", "combo": "Space", "steps": [{ "id": "sync", "type": "hockey_vmix_timers_start", "game_vmix_input": "53", "game_vmix_selected_name": "CLOCK.Text", "home_penalty_targets": [ {"id": "home-1", "input": "54", "selected_name": "PENALTY1.Text"}, {"id": "home-2", "input": "55", "selected_name": "PENALTY2.Text"}, ], "away_penalty_targets": [ {"id": "away-1", "input": "56", "selected_name": "PENALTY1.Text"}, ], "timer_finish_actions": [{ "id": "finish-1", "enabled": True, "source": "game", "input": "70", "overlay": "3", "duration_ms": 4500, }], }], }], }) step = saved["shortcut_sequences"][0]["steps"][0] assert step["game_vmix_selected_name"] == "CLOCK.Text" assert step["penalty_display_mode"] == "soonest" assert step["home_penalty_targets"][0] == { "id": "home-1", "input": "54", "selected_name": "PENALTY1.Text", "overlay": "2", "auto_hide_on_finish": True, } assert step["away_penalty_targets"][0]["selected_name"] == "PENALTY1.Text" assert step["timer_finish_actions"][0]["source"] == "game" assert step["timer_finish_actions"][0]["overlay"] == "3" assert step["timer_finish_actions"][0]["duration_ms"] == 4500 def test_hockey_countdown_uses_selected_name_for_game_and_penalty_targets() -> None: app_js = Path("ui_builder/static/app.js").read_text(encoding="utf-8") assert 'function vmixCountdownSyncCommands(input, selectedName' in app_js assert 'SelectedName: String(selectedName || "").trim()' in app_js assert 'step.game_vmix_selected_name, () => gameTimerState.currentMs' in app_js assert 'target.input, target.selected_name, () => event.remainingMs' in app_js assert 'Input: target.input, SelectedName: target.selected_name' in app_js assert 'vmixTextSelectedNameOptions' in app_js assert 'data-penalty-target-field="selected_name"' in app_js assert 'data-finish-action-add' in app_js assert 'OverlayInput${overlay}In' in app_js assert 'OverlayInput${overlay}Out' in app_js def test_overlay_group_repeat_uses_transition_out_not_all_off(): js = Path("ui_builder/static/app.js").read_text(encoding="utf-8") assert 'Function: "OverlayInputAllOff"' not in js for layer in range(1, 5): assert f'Function: "OverlayInput{layer}Out"' in js def test_build90_migrates_old_text_timer_transport_to_native_countdown(tmp_path: Path) -> None: manager = UIBuilderManager(tmp_path, filename="ui.json") saved = manager.save({ "version": 21, "project_name": "Hockey", "data_source": "hockey", "canvas": {"width": 1440, "height": 900}, "tabs": [{"id": "main", "label": "Main"}], "components": [], "triggers": [], "shortcut_sequences": [{ "id": "timers", "name": "Timers", "combo": "Space", "steps": [{ "id": "sync", "type": "hockey_vmix_timers_start", "game_vmix_mode": "text", "penalty_vmix_mode": "text", }], }], }) step = saved["shortcut_sequences"][0]["steps"][0] assert saved["version"] == 22 assert step["game_vmix_mode"] == "countdown" assert step["penalty_vmix_mode"] == "countdown" def test_build90_keeps_explicit_legacy_text_mode_after_v22(tmp_path: Path) -> None: manager = UIBuilderManager(tmp_path, filename="ui.json") saved = manager.save({ "version": 22, "project_name": "Hockey", "data_source": "hockey", "canvas": {"width": 1440, "height": 900}, "tabs": [{"id": "main", "label": "Main"}], "components": [], "triggers": [], "shortcut_sequences": [{ "id": "timers", "name": "Timers", "combo": "Space", "steps": [{ "id": "sync", "type": "hockey_vmix_timers_start", "game_vmix_mode": "text", "penalty_vmix_mode": "text", }], }], }) step = saved["shortcut_sequences"][0]["steps"][0] assert step["game_vmix_mode"] == "text" assert step["penalty_vmix_mode"] == "text" def test_build90_native_countdown_avoids_per_second_transport() -> None: app_js = Path("ui_builder/static/app.js").read_text(encoding="utf-8") assert 'game_vmix_mode !== "countdown"' in app_js assert 'const countdownMode = step.penalty_vmix_mode === "countdown"' in app_js assert 'Function: "StopCountdown"' in app_js assert 'Function: "SuspendCountdown"' not in app_js assert 'Function: "StopCountdown"' in app_js assert 'signal: controller.signal' in app_js assert 'vmixCommandQueue: Promise.resolve()' in app_js # Per-second pushes are restricted to the legacy Text mirror map. assert 'eventName === "timer_tick" && state.vmixTimerMirrors.has(component.action_id)' in app_js