Files
hockey_new/tests/test_match_details.py
2026-08-19 15:08:39 +03:00

358 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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"""<?xml version="1.0" encoding="UTF-8"?>
<PlayerStatsList tournamentId="1369">
<PlayerStats id="101" pos="\xd0\xbd" name="\xd0\x98\xd0\xb2\xd0\xb0\xd0\xbd\xd0\xbe\xd0\xb2 \xd0\x98\xd0\xb2\xd0\xb0\xd0\xbd" nameen="Ivanov Ivan"
lastname="\xd0\x98\xd0\xb2\xd0\xb0\xd0\xbd\xd0\xbe\xd0\xb2" firstname="\xd0\x98\xd0\xb2\xd0\xb0\xd0\xbd" lastnameen="Ivanov" firstnameen="Ivan" birthdate="2000-01-02"
gp="63" g="11" a="14" pts="25" pm="5" pim="24" sog="111" sog_pct="9.9" hits="22" bls="50" fo="100" fow="55" fo_pct="55.0" toi_avg="14:54" />
<PlayerStats id="202" pos="\xd0\xb2" name="\xd0\x9f\xd0\xb5\xd1\x82\xd1\x80\xd0\xbe\xd0\xb2 \xd0\x9f\xd1\x91\xd1\x82\xd1\x80" nameen="Petrov Pyotr"
lastname="\xd0\x9f\xd0\xb5\xd1\x82\xd1\x80\xd0\xbe\xd0\xb2" firstname="\xd0\x9f\xd1\x91\xd1\x82\xd1\x80" lastnameen="Petrov" firstnameen="Pyotr"
gp="40" w="25" l="10" n="0" sop="5" sog="1000" ga="80" sv="920" sv_pct="92.0" gaa="2.00" so="5" toi="2400:00" />
</PlayerStatsList>"""
REFEREES_XML = b"""<?xml version="1.0" encoding="UTF-8"?>
<Referees tournamentId="1369">
<Referee id="301" name="\xd0\xa1\xd0\xb8\xd0\xb4\xd0\xbe\xd1\x80\xd0\xbe\xd0\xb2 \xd0\x90\xd0\xbb\xd0\xb5\xd0\xba\xd1\x81\xd0\xb5\xd0\xb9" nameen="Sidorov Alexei"
lastname="\xd0\xa1\xd0\xb8\xd0\xb4\xd0\xbe\xd1\x80\xd0\xbe\xd0\xb2" firstname="\xd0\x90\xd0\xbb\xd0\xb5\xd0\xba\xd1\x81\xd0\xb5\xd0\xb9" lastnameen="Sidorov" firstnameen="Alexei"
pos="m" jn="18" isactive="Y" />
<Referee id="302" name="\xd0\xa1\xd0\xbc\xd0\xb8\xd1\x80\xd0\xbd\xd0\xbe\xd0\xb2 \xd0\x9e\xd0\xbb\xd0\xb5\xd0\xb3" nameen="Smirnov Oleg" pos="l" jn="22" isactive="Y" />
</Referees>"""
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()