from __future__ import annotations import json import tempfile import unittest from datetime import date, datetime from pathlib import Path from hockey_data.config import HockeySettingsStore from hockey_data.match_details_parser import ( parse_match_details_json, parse_players_xml, parse_referees_xml, ) from hockey_data.match_parser import ParsedGame, parse_date_value from hockey_data.models import Game, Tournament from hockey_data.service import HockeyDataService from support import LocalTestDatabase PLAYERS_XML = b""" """ REFEREES_XML = b""" """ MATCH_RU = """{ "game": { "idschedule": 900001, "teamA": "Команда А", "teamB": "Команда Б", "idclubA": 1, "idteamA": 11, "idclubB": 2, "idteamB": 22, "mref1": "Сидоров Алексей", "mref1_id": 301, "mref1_num": "18", "lref1": "Смирнов Олег", "lref1_id": 302, "lref1_num": "22", "status": "матч не начался" }, "teams": { "A": {"coach_fullname": "Тренер Первый", "shots": 31, "goals": 3, "fo": 50, "fow": 28, "fow_pct": "56.00", "hits": 14, "bls": 9, "pim": 6, "toa": "14:20"}, "B": {"coach_fullname": "Тренер Второй", "shots": 27, "goals": 2, "fo": 50, "fow": 22, "fow_pct": "44.00", "hits": 18, "bls": 12, "pim": 8, "toa": "11:05"} }, "teams_periods": { "A": {"1": {"shots": 10, "goals": 1, "fo": 18, "fow": 11, "pim": 2}, "2": {"shots": 12, "goals": 2, "fo": 17, "fow": 9, "pim": 4}}, "B": {"1": {"shots": 8, "goals": 0, "fo": 18, "fow": 7, "pim": 4}, "2": {"shots": 11, "goals": 1, "fo": 17, "fow": 8, "pim": 2}} }, "players": { "A": {"17": {"id": "101", "name": "Иванов Иван", "ps": "н", "pig": 1, "line": "1", "ca": "к", "goals": 1, "assists": 2, "pts": 3, "shots": 4, "hits": 2, "bls": 1, "fo": 10, "fow": 6, "toi": "17:20", "tie": "13:00", "tipp": "03:10", "tish": "01:10", "si": 21}}, "B": {"30": {"id": "202", "name": "Петров Пётр", "ps": "в", "pig": 1, "line": "", "sog": 31, "sv": 29, "ga": 2, "sv_pct": 93.55, "toi": "59:40"}} } }""" MATCH_EN = """{ "game": { "idschedule": 900001, "teamA": "Team A", "teamB": "Team B", "idclubA": 1, "idteamA": 11, "idclubB": 2, "idteamB": 22, "mref1": "Sidorov Alexei", "mref1_id": 301, "mref1_num": "18", "lref1": "Smirnov Oleg", "lref1_id": 302, "lref1_num": "22", "status": "scheduled" }, "teams": { "A": {"coach_fullname": "Coach One", "shots": 31, "goals": 3, "fo": 50, "fow": 28, "fow_pct": "56.00", "hits": 14, "bls": 9, "pim": 6, "toa": "14:20"}, "B": {"coach_fullname": "Coach Two", "shots": 27, "goals": 2, "fo": 50, "fow": 22, "fow_pct": "44.00", "hits": 18, "bls": 12, "pim": 8, "toa": "11:05"} }, "teams_periods": { "A": {"1": {"shots": 10, "goals": 1, "fo": 18, "fow": 11, "pim": 2}, "2": {"shots": 12, "goals": 2, "fo": 17, "fow": 9, "pim": 4}}, "B": {"1": {"shots": 8, "goals": 0, "fo": 18, "fow": 7, "pim": 4}, "2": {"shots": 11, "goals": 1, "fo": 17, "fow": 8, "pim": 2}} }, "players": { "A": {"17": {"id": "101", "name": "Ivanov Ivan", "ps": "f", "pig": 1, "line": "1", "ca": "c", "goals": 1, "assists": 2, "pts": 3, "shots": 4, "hits": 2, "bls": 1, "fo": 10, "fow": 6, "toi": "17:20", "tie": "13:00", "tipp": "03:10", "tish": "01:10", "si": 21}}, "B": {"30": {"id": "202", "name": "Petrov Pyotr", "ps": "g", "pig": 1, "line": "", "sog": 31, "sv": 29, "ga": 2, "sv_pct": 93.55, "toi": "59:40"}} } }""" class MatchDetailsParserTests(unittest.TestCase): def test_parses_localized_match_datetime_into_real_game_date(self) -> None: self.assertEqual( parse_date_value("16 августа 2026, Вс 13:00:00"), date(2026, 8, 16), ) self.assertEqual( parse_date_value("August 16, 2026, Sun 13:00:00"), date(2026, 8, 16), ) def test_details_date_is_used_only_as_schedule_fallback(self) -> None: with tempfile.TemporaryDirectory() as temp_dir: base_dir = Path(temp_dir) database = LocalTestDatabase(base_dir / "test.sqlite3") database.create_all() settings = HockeySettingsStore(base_dir / "settings") service = HockeyDataService( base_dir=base_dir, database=database, settings=settings, ) with database.session() as session: session.add(Tournament(external_id="1369")) session.add( Game( external_id="900001", tournament_external_id="1369", ) ) match_ru = MATCH_RU.replace( '"status": "матч не начался"', '"status": "матч не начался", "date": "16 августа 2026, Вс 13:00:00"', ) details = parse_match_details_json(match_ru, MATCH_EN) with database.session() as session: game = session.query(Game).filter_by(external_id="900001").one() service._apply_details_game_fields(game, details, now=datetime.utcnow()) with database.session() as session: game = session.query(Game).filter_by(external_id="900001").one() self.assertEqual(game.game_date, date(2026, 8, 16)) self.assertEqual(game.start_time, "13:00") self.assertEqual(game.start_datetime_raw, "16 августа 2026, Вс 13:00:00") # A later schedule refresh with an empty date must not erase # the date recovered from the selected game's detail card. service._upsert_game( session, ParsedGame( external_id="900001", tournament_external_id="1369", game_date=None, start_time="", start_datetime_raw="", ), endpoint="schedule-test", now=datetime.utcnow(), ) with database.session() as session: game = session.query(Game).filter_by(external_id="900001").one() self.assertEqual(game.game_date, date(2026, 8, 16)) self.assertEqual(game.start_time, "13:00") # If the schedule does have a real date, it stays primary. game.game_date = date(2026, 8, 17) session.flush() service._apply_details_game_fields(game, details, now=datetime.utcnow()) self.assertEqual(game.game_date, date(2026, 8, 17)) database.engine.dispose() def test_exact_khl_game_date_and_starttime_are_saved(self) -> None: with tempfile.TemporaryDirectory() as temp_dir: base_dir = Path(temp_dir) database = LocalTestDatabase(base_dir / "test.sqlite3") database.create_all() settings = HockeySettingsStore(base_dir / "settings") service = HockeyDataService( base_dir=base_dir, database=database, settings=settings, ) payload = json.dumps({ "game": { "idschedule": 902733, "number": 5, "date": "16 августа 2026, Вс", "starttime": "13:00:00", "teamA": "Спартак", "teamB": "Северсталь", "idclubA": 7, "idteamA": 15655, "idclubB": 56, "idteamB": 15652, "score": "6:4", "status": "матч завершен", "statusvalue": 90, "place": "ЛД «Легенды хоккея - Сокольники»" } }, ensure_ascii=False) details = parse_match_details_json(payload, expected_game_id="902733") with database.session() as session: game = Game(external_id="902733", tournament_external_id="1369") session.add(game) service._apply_details_game_fields(game, details, now=datetime.utcnow()) with database.session() as session: game = session.query(Game).filter_by(external_id="902733").one() self.assertEqual(game.game_date, date(2026, 8, 16)) self.assertEqual(game.start_time, "13:00") self.assertEqual(game.start_datetime_raw, "16 августа 2026, Вс 13:00:00") def test_schedule_raw_localized_datetime_recovers_game_date(self) -> None: with tempfile.TemporaryDirectory() as temp_dir: base_dir = Path(temp_dir) database = LocalTestDatabase(base_dir / "test.sqlite3") database.create_all() settings = HockeySettingsStore(base_dir / "settings") service = HockeyDataService( base_dir=base_dir, database=database, settings=settings, ) with database.session() as session: service._upsert_game( session, ParsedGame( external_id="902733", tournament_external_id="1369", game_date=None, start_time="", start_datetime_raw="16 августа 2026, Вс 13:00:00", ), endpoint="raw-date-test", now=datetime.utcnow(), ) with database.session() as session: game = session.query(Game).filter_by(external_id="902733").one() self.assertEqual(game.game_date, date(2026, 8, 16)) self.assertEqual(game.start_time, "13:00") def test_backfills_existing_null_game_date_from_raw_datetime(self) -> None: with tempfile.TemporaryDirectory() as temp_dir: base_dir = Path(temp_dir) database = LocalTestDatabase(base_dir / "test.sqlite3") database.create_all() settings = HockeySettingsStore(base_dir / "settings") service = HockeyDataService( base_dir=base_dir, database=database, settings=settings, ) with database.session() as session: session.add( Game( external_id="902733", tournament_external_id="1369", game_date=None, start_time="13:00", start_datetime_raw="16 августа 2026, Вс 13:00:00", ) ) with database.session() as session: repaired = service._backfill_missing_game_dates(session) self.assertEqual(repaired, 1) with database.session() as session: game = session.query(Game).filter_by(external_id="902733").one() self.assertEqual(game.game_date, date(2026, 8, 16)) self.assertEqual(game.start_time, "13:00") def test_parses_bilingual_rosters_and_officials(self) -> None: details = parse_match_details_json( MATCH_RU, MATCH_EN, expected_game_id="900001", ) self.assertEqual(details.game_external_id, "900001") self.assertEqual(len(details.rosters), 2) self.assertEqual(details.rosters[0].role, "forward") self.assertEqual(details.rosters[0].captain_role, "captain") self.assertEqual(details.rosters[1].role, "goalkeeper") self.assertEqual(len(details.officials), 2) self.assertEqual(details.officials[0].role, "head") def test_stores_and_serializes_match_details(self) -> None: with tempfile.TemporaryDirectory() as temp_dir: base_dir = Path(temp_dir) database = LocalTestDatabase(base_dir / "test.sqlite3") database.create_all() settings = HockeySettingsStore(base_dir / "settings") service = HockeyDataService( base_dir=base_dir, database=database, settings=settings, ) with database.session() as session: session.add(Tournament(external_id="1369")) with database.session() as session: session.add( Game( external_id="900001", tournament_external_id="1369", home_team_external_id="1", away_team_external_id="2", home_team_name_ru="Команда А", home_team_name_en="Team A", away_team_name_ru="Команда Б", away_team_name_en="Team B", ) ) details = parse_match_details_json(MATCH_RU, MATCH_EN) players = parse_players_xml(PLAYERS_XML) referees = parse_referees_xml(REFEREES_XML) with database.session() as session: game = session.query(Game).filter_by(external_id="900001").one() service._store_match_details( session, game=game, details=details, player_profiles=players, referee_profiles=referees, endpoint_ru="1369/json/900001.json", raw_ru=MATCH_RU, raw_en=MATCH_EN, now=datetime.utcnow(), ) payload_ru = service.game_details("900001", language="ru") payload_en = service.game_details("900001", language="en") self.assertIsNotNone(payload_ru) self.assertEqual(payload_ru["game"]["home"]["players"][0]["name"], "Иванов Иван") self.assertEqual(payload_en["game"]["away"]["players"][0]["name"], "Petrov Pyotr") self.assertEqual(len(payload_ru["referees"]), 2) self.assertEqual(payload_ru["details"]["home_players"], 1) self.assertEqual(payload_ru["team_statistics"]["segments"], ["total", "1", "2"]) self.assertEqual(payload_ru["team_statistics"]["total"]["home"]["shots"], 31) self.assertEqual(payload_ru["team_statistics"]["periods"]["1"]["away"]["goals"], 0) self.assertEqual(payload_ru["game"]["home"]["players"][0]["statistics"]["points"], 3) self.assertEqual(payload_ru["game"]["home"]["players"][0]["statistics"]["faceoff_pct"], 60.0) self.assertEqual(payload_ru["game"]["away"]["players"][0]["statistics"]["saves"], 29) self.assertEqual(payload_ru["game"]["away"]["players"][0]["statistics"]["save_pct"], 93.55) self.assertTrue(payload_ru["game"]["player_statistics"]["available"]) self.assertEqual(payload_ru["game"]["home"]["players"][0]["season_statistics"]["games"], 63) self.assertEqual(payload_ru["game"]["home"]["players"][0]["season_statistics"]["points"], 25) self.assertEqual(payload_ru["game"]["away"]["players"][0]["season_statistics"]["wins"], 25) self.assertEqual(payload_ru["game"]["away"]["players"][0]["season_statistics"]["save_pct"], 92) self.assertTrue(payload_ru["game"]["season_player_statistics"]["available"]) database.engine.dispose() if __name__ == "__main__": unittest.main()