194 lines
9.2 KiB
Python
194 lines
9.2 KiB
Python
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()
|