from __future__ import annotations import json from copy import deepcopy import pytest from fastapi import FastAPI from fastapi.testclient import TestClient from hockey_data.config import DEFAULT_SCOREBOARD_TEAM_STATES, HockeySettingsStore from hockey_data.models import Game, Tournament from hockey_data.service import HockeyDataService from tests.support import LocalTestDatabase from ui_builder.manager import DEFAULT_CONFIG, UIBuilderManager from ui_builder.router import create_ui_builder_router DELAY_CASES = [ pytest.param(0, 0.0, id="zero"), pytest.param(0.1, 0.1, id="fraction"), pytest.param("2.36", 2.4, id="numeric-string-rounded"), pytest.param(60, 60.0, id="maximum"), pytest.param(-1, 0.0, id="negative-clamped"), pytest.param(61, 60.0, id="large-clamped"), pytest.param(None, 0.0, id="null"), pytest.param(True, 0.0, id="boolean"), pytest.param("invalid", 0.0, id="invalid-string"), pytest.param(float("nan"), 0.0, id="nan"), pytest.param(float("inf"), 0.0, id="infinity"), pytest.param(float("-inf"), 0.0, id="negative-infinity"), ] def timer_config(delay): config = deepcopy(DEFAULT_CONFIG) config["data_source"] = "hockey" config["shortcut_sequences"] = [{ "id": "scoreboard", "name": "Scoreboard", "combo": "F7", "is_scoreboard_sequence": True, "steps": [{ "id": "timers", "type": "hockey_vmix_timers_start", "game_vmix_input": "SCORE", "game_vmix_selected_name": "Clock.Text", "scoreboard_delay_seconds": delay, "home_penalty_targets": [{ "id": "home", "input": "PEN HOME", "selected_name": "Penalty.Text", "overlay": "4", "auto_hide_on_finish": False, "scoreboard_delay_seconds": delay, }], "away_penalty_targets": [{ "id": "away", "input": "PEN AWAY", "selected_name": "Penalty.Text", "overlay": "5", "scoreboard_delay_seconds": delay, }], }, { "id": "delayed-flag", "type": "vmix_command", "function": "OverlayInput3In", "input": "EMPTY NET", "condition": "home_empty_net", "scoreboard_delay_seconds": delay, }], }] return config def assert_delays(config, expected): timer_step, flag_step = config["shortcut_sequences"][0]["steps"] for step in (timer_step, flag_step): assert step["scoreboard_delay_seconds"] == expected assert isinstance(step["scoreboard_delay_seconds"], float) for key in ("home_penalty_targets", "away_penalty_targets"): assert timer_step[key][0]["scoreboard_delay_seconds"] == expected assert timer_step["home_penalty_targets"][0]["input"] == "PEN HOME" assert timer_step["home_penalty_targets"][0]["selected_name"] == "Penalty.Text" assert timer_step["home_penalty_targets"][0]["overlay"] == "4" assert timer_step["home_penalty_targets"][0]["auto_hide_on_finish"] is False assert timer_step["game_vmix_selected_name"] == "Clock.Text" assert flag_step["condition"] == "home_empty_net" assert flag_step["function"] == "OverlayInput3In" def read_strict_json(path): def reject_constant(value): pytest.fail(f"Nonfinite JSON constant persisted: {value}") return json.loads(path.read_text(encoding="utf-8"), parse_constant=reject_constant) @pytest.mark.parametrize("delay,expected", DELAY_CASES) def test_manager_saves_finite_delay_for_penalty_slots_and_conditional_steps(tmp_path, delay, expected): manager = UIBuilderManager(tmp_path) assert_delays(manager.save(timer_config(delay)), expected) assert_delays(manager.load(), expected) assert_delays(read_strict_json(manager.settings_file), expected) def test_manager_defaults_old_targets_and_steps_to_zero_without_losing_legacy_fields(tmp_path): config = timer_config(2.0) timer_step, flag_step = config["shortcut_sequences"][0]["steps"] for step in (timer_step, flag_step): step.pop("scoreboard_delay_seconds") timer_step["home_penalty_targets"] = None timer_step["home_penalty_inputs"] = "PEN HOME" timer_step["home_penalty_selected_names"] = "Penalty.Text" timer_step["away_penalty_targets"][0].pop("scoreboard_delay_seconds") saved = UIBuilderManager(tmp_path).save(config) timer_step, flag_step = saved["shortcut_sequences"][0]["steps"] assert timer_step["scoreboard_delay_seconds"] == 0.0 assert flag_step["scoreboard_delay_seconds"] == 0.0 assert timer_step["home_penalty_targets"][0]["scoreboard_delay_seconds"] == 0.0 assert timer_step["away_penalty_targets"][0]["scoreboard_delay_seconds"] == 0.0 assert timer_step["home_penalty_targets"][0]["input"] == "PEN HOME" assert timer_step["home_penalty_targets"][0]["selected_name"] == "Penalty.Text" def test_actual_config_save_load_publish_and_restart_preserve_delays(tmp_path): draft = UIBuilderManager(tmp_path, filename="ui_builder_draft.json") published = UIBuilderManager(tmp_path, filename="ui_builder_published.json") app = FastAPI() app.include_router(create_ui_builder_router(draft, {}, publish_manager=published)) with TestClient(app) as client: response = client.post("/api/ui-builder/config", json=timer_config(2.3)) assert response.status_code == 200, response.text assert_delays(response.json()["config"], 2.3) assert_delays(client.get("/api/ui-builder/config").json(), 2.3) response = client.post("/api/ui-builder/publish") assert response.status_code == 200, response.text assert_delays(response.json()["config"], 2.3) assert_delays(published.load(), 2.3) restarted = UIBuilderManager(tmp_path, filename="ui_builder_published.json") assert restarted.load() == draft.load() assert_delays(read_strict_json(restarted.settings_file), 2.3) @pytest.mark.parametrize("delay,expected", DELAY_CASES) def test_team_state_settings_normalize_before_write_and_survive_restart(tmp_path, delay, expected): settings = HockeySettingsStore(tmp_path) states = {key: {"scoreboard_delay_seconds": delay} for key in DEFAULT_SCOREBOARD_TEAM_STATES} result = settings.update_public({"scoreboard_team_states": states}) persisted = read_strict_json(settings.timer_rules_file)["scoreboard_team_states"] restarted = HockeySettingsStore(tmp_path).public()["scoreboard_team_states"] for key in DEFAULT_SCOREBOARD_TEAM_STATES: assert result["scoreboard_team_states"][key]["scoreboard_delay_seconds"] == expected assert persisted[key]["scoreboard_delay_seconds"] == expected assert restarted[key]["scoreboard_delay_seconds"] == expected def test_old_client_update_preserves_delay_and_operator_team_state_configuration(tmp_path): settings = HockeySettingsStore(tmp_path) settings.update_public({"scoreboard_team_states": {"home_empty_net": { "ru": "Мой титр", "en": "My title", "input": "stable-key", "input_title": "CUSTOM EMPTY NET", "overlay": "7", "enabled": False, "scoreboard_delay_seconds": 3.2, }}}) # A client predating the delay field may update only the old properties. settings.update_public({"scoreboard_team_states": {"home_empty_net": {"ru": "Новая подпись"}}}) actual = HockeySettingsStore(tmp_path).public()["scoreboard_team_states"]["home_empty_net"] assert actual == { "ru": "Новая подпись", "en": "My title", "input": "stable-key", "input_title": "CUSTOM EMPTY NET", "overlay": "7", "enabled": False, "scoreboard_delay_seconds": 3.2, } def test_legacy_public_read_defaults_all_four_team_states_to_zero(tmp_path): settings = HockeySettingsStore(tmp_path) raw = read_strict_json(settings.timer_rules_file) for state in raw["scoreboard_team_states"].values(): state.pop("scoreboard_delay_seconds", None) settings.timer_rules_file.write_text(json.dumps(raw, ensure_ascii=False), encoding="utf-8") states = HockeySettingsStore(tmp_path).public()["scoreboard_team_states"] assert set(states) == set(DEFAULT_SCOREBOARD_TEAM_STATES) assert all(state["scoreboard_delay_seconds"] == 0.0 for state in states.values()) def test_actual_game_control_projects_all_four_team_state_delays(tmp_path): settings = HockeySettingsStore(tmp_path / "settings") delays = dict(zip(DEFAULT_SCOREBOARD_TEAM_STATES, (0.0, 0.1, 2.3, 60.0))) settings.update_public({"scoreboard_team_states": { key: {"scoreboard_delay_seconds": value, "input": f"input-{key}"} for key, value in delays.items() }}) database = LocalTestDatabase(tmp_path / "control.sqlite") database.create_all() try: with database.session() as session: session.add(Tournament(external_id="t", season_part="regular", level="khl")) session.add(Game(external_id="g", tournament_external_id="t")) service = HockeyDataService(base_dir=tmp_path, database=database, settings=settings) result = service.game_control("g") assert result is not None assert set(result["scoreboard_team_states"]) == set(delays) for key, expected in delays.items(): state = result["scoreboard_team_states"][key] assert state["scoreboard_delay_seconds"] == expected assert state["input"] == f"input-{key}" finally: database.engine.dispose()