Files
hockey_new/tests/test_build194_score_sync.py

197 lines
10 KiB
Python

"""Effective DB/custom/manual score stays consistent across control and Mapping."""
from __future__ import annotations
import asyncio
from datetime import datetime, timedelta
import json
import pytest
from sqlalchemy import event, select
from hockey_data.agent_bridge import VmixAgentHub
from hockey_data.mapping_context import MappingDataService
from hockey_data.models import Game, GameControlState, MappingSqlDataSource, MatchControlLease, VmixMappingField
from tests.test_stage1_match_access import env
def write_score(env, *, game_id="g", home=5, away=4, values=None, status="finished"):
with env.db.session() as session:
game = session.scalar(select(Game).where(Game.external_id == game_id))
game.home_score, game.away_score, game.status = home, away, status
game.score_raw = f"{home}:{away}"
row = session.scalar(select(GameControlState).where(GameControlState.game_external_id == game_id))
if row is None:
row = GameControlState(game_external_id=game_id)
session.add(row)
if values is not None:
row.runtime_values_json = json.dumps(values)
def old_snapshot(**changes):
return {"score.manual": "0", "score.external_valid": "1", "score.external_home": "1",
"score.external_away": "1", "score.external_source": "game", **changes}
def custom_snapshot(**changes):
return old_snapshot(**{"score.external_source": "mapping_sql", "score.external_home": "7",
"score.external_away": "6", "score.external_home_key": "custom.home",
"score.external_away_key": "custom.away", "score.external_home_source": "mapping_sql",
"score.external_away_source": "mapping_sql", **changes})
def configure(env, home="", away=""):
env.service.settings.update_public({"score_external_home_key": home, "score_external_away_key": away})
def control(env, game="g"):
response = env.client.get(f"/api/hockey/games/{game}/control", headers={"X-Test-User": "a"})
assert response.status_code == 200, response.text
return response.json()
def assert_consistent(env, expected, *, external=None, manual=False, game="g"):
payload = control(env, game)
score = payload["score"]
external = expected if external is None else external
assert (score["home"], score["away"]) == expected
assert (score["external_home"], score["external_away"]) == external
assert score["manual"] is manual
context, _ = env.hub.mapping_data.resolve_context(env.users["a"], {"game_id": game})
assert (context["score_home"], context["score_away"]) == tuple(map(str, expected))
assert (context["score_external_home"], context["score_external_away"]) == tuple(map(str, external))
catalog = env.hub.mapping_data.data_catalog(env.users["a"], {"game_id": game}, source_codes=set())
values = {item["key"]: item["value"] for item in catalog["items"] if item.get("source_code") == "live_control"}
assert (values["score.home"], values["score.away"]) == expected
assert (values["score.external_home"], values["score.external_away"]) == external
return score
@pytest.mark.parametrize("status", ["finished", "live"])
@pytest.mark.parametrize("db_score", [(5, 4), (0, 0)])
@pytest.mark.parametrize("snapshot_source", ["game", "mapping_sql"])
def test_sql_default_reads_current_game_not_historical_external_snapshot(env, status, db_score, snapshot_source):
write_score(env, home=db_score[0], away=db_score[1], status=status,
values=old_snapshot(**{"score.external_source": snapshot_source}))
assert_consistent(env, db_score)
# A later DB correction is visible without any Agent, command or new revision.
before_revision = control(env)["revision"]
write_score(env, home=3, away=2)
assert_consistent(env, (3, 2))
assert control(env)["revision"] == before_revision
@pytest.mark.parametrize("operator_score", [(2, 3), (0, 0)])
def test_manual_score_survives_database_and_source_configuration_changes(env, operator_score):
write_score(env, values=custom_snapshot(**{"score.manual": "1", "score.home": str(operator_score[0]),
"score.away": str(operator_score[1])}))
configure(env, "custom.home", "custom.away")
assert_consistent(env, operator_score, external=(7, 6), manual=True)
configure(env)
write_score(env, home=9, away=8)
assert_consistent(env, operator_score, external=(9, 8), manual=True)
@pytest.mark.parametrize("home_key,away_key,changes,expected", [
("custom.home", "custom.away", {}, (7, 6)),
("custom.home", "", {}, (7, 4)),
("", "custom.away", {}, (5, 6)),
("edited.home", "custom.away", {}, (5, 6)),
("custom.home", "edited.away", {}, (7, 4)),
("", "", {}, (5, 4)),
("custom.home", "custom.away", {"score.external_home_source": "game"}, (5, 6)),
("custom.home", "custom.away", {"score.external_away_key": ""}, (7, 4)),
("custom.home", "custom.away", {"score.external_home": "0", "score.external_away": "0"}, (0, 0)),
("custom.home", "custom.away", {"score.external_home": "", "score.external_away": "NaN"}, (5, 4)),
("custom.home", "custom.away", {"score.external_home": "Infinity", "score.external_away": "not-a-score"}, (5, 4)),
pytest.param("custom.home", "custom.away", {"score.external_valid": "0"}, (5, 4), id="external_valid_off"),
pytest.param("custom.home", "custom.away", {"score.external_home": ""}, (5, 6), id="invalid_home_valid_away"),
pytest.param("custom.home", "custom.away", {"score.external_home": "0", "score.external_away": "invalid"},
(0, 4), id="zero_home_invalid_away"),
pytest.param("custom.home", "custom.away", {"score.external_home_key": None}, (5, 6), id="missing_home_provenance"),
])
def test_custom_snapshot_is_bound_to_each_current_configured_key(env, home_key, away_key, changes, expected):
configure(env, home_key, away_key)
write_score(env, values=custom_snapshot(**changes))
assert_consistent(env, expected)
def test_operator_plus_starts_from_current_db_and_sync_accepts_latest_db(env):
write_score(env, values=old_snapshot())
assert env.claim().status_code == 200
response = env.client.put("/api/hockey/games/g/control/score", headers=env.headers(), json={"command": "home_plus"})
assert response.status_code == 200, response.text
assert_consistent(env, (6, 4), external=(5, 4), manual=True)
write_score(env, home=9, away=8)
assert_consistent(env, (6, 4), external=(9, 8), manual=True)
response = env.client.put("/api/hockey/games/g/control/score", headers=env.headers(), json={"command": "sync"})
assert response.status_code == 200, response.text
assert_consistent(env, (9, 8))
def test_default_control_get_reads_without_agent_or_source_queries_or_writes(env, monkeypatch):
write_score(env, values=old_snapshot())
def forbidden(*_args, **_kwargs):
raise AssertionError("Score GET must not dispatch a source query or Agent command")
monkeypatch.setattr(env.hub.mapping_data, "data_catalog", forbidden)
monkeypatch.setattr(env.hub, "apply_mapping_to_device", forbidden)
before = env.service.game_control("g")
statements = []
def record(_connection, _cursor, statement, _params, _context, _many):
statements.append(statement.strip().lower())
event.listen(env.db.engine, "before_cursor_execute", record)
try:
assert control(env)["score"]["home"] == 5
finally:
event.remove(env.db.engine, "before_cursor_execute", record)
assert statements and all(statement.startswith("select") for statement in statements)
assert env.service.game_control("g")["values"] == before["values"]
def test_score_is_scoped_to_each_match_of_same_account(env):
write_score(env, home=5, away=4, values=old_snapshot())
write_score(env, game_id="g2", home=0, away=0, values=old_snapshot(**{
"score.manual": "1", "score.home": "2", "score.away": "1"}))
assert_consistent(env, (5, 4))
assert_consistent(env, (2, 1), external=(0, 0), manual=True, game="g2")
@pytest.mark.parametrize("home_key,away_key,expected", [
("score.home", "score.away", (0, 2)),
("score.home", "", (0, 4)),
("", "score.away", (5, 2)),
])
def test_custom_sql_source_named_score_keeps_builtin_and_manual_values_consistent(env, home_key, away_key, expected):
from tests.test_build194_broadcast_readiness import seed
rig = seed(env)
# Restore real local catalog/persistence after the shared ACK fixture's fakes.
env.hub.mapping_data.data_catalog = MappingDataService.data_catalog.__get__(env.hub.mapping_data)
env.hub._persist_score_external_snapshot = VmixAgentHub._persist_score_external_snapshot.__get__(env.hub)
configure(env, home_key, away_key)
write_score(env, values=old_snapshot())
assert env.claim().status_code == 200
with env.db.session() as session:
authority = session.scalar(select(MatchControlLease).where(MatchControlLease.game_external_id == "g"))
authority.owner_device_id = rig.device_id
authority.expires_at = datetime.utcnow() + timedelta(minutes=1)
for field in session.scalars(select(VmixMappingField).where(VmixMappingField.profile_id == rig.profile_id)):
field.data_key = "score.home" if field.vmix_field == "Home.Text" else "score.away"
session.add(MappingSqlDataSource(code="score", name="Custom score", enabled=True,
sql_text="SELECT 0 AS home, 2 AS away"))
result = asyncio.run(env.hub.apply_mapping_to_device(rig.device_id, source_codes={"score"}))
assert result["ok"], result
assert_consistent(env, expected)
values = control(env)["values"]
assert values["score.external_home_source"] == ("mapping_sql" if home_key else "game")
assert values["score.external_away_source"] == ("mapping_sql" if away_key else "game")
# A disappeared custom source cannot turn its former snapshot into the
# fallback Game value or leave Mapping and the lower panel disagreeing.
with env.db.session() as session:
session.delete(session.scalar(select(MappingSqlDataSource).where(MappingSqlDataSource.code == "score")))
result = asyncio.run(env.hub.apply_mapping_to_device(rig.device_id, source_codes={"score"}))
assert result["ok"], result
assert_consistent(env, (5, 4))
last_commands = next(packet["commands"] for packet in reversed(rig.socket.sent) if packet.get("type") == "vmix.batch")
assert {item["SelectedName"]: item["Value"] for item in last_commands} == {"Home.Text": "5", "Away.Text": "4"}
configure(env)
assert_consistent(env, (5, 4))