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