"""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))