from __future__ import annotations
import tempfile
import unittest
from datetime import datetime
from pathlib import Path
from sqlalchemy import func, select
from hockey_data.config import HockeySettingsStore
from hockey_data.match_details_parser import parse_players_xml
from hockey_data.models import (
Game,
GameTeamStatistic,
PlayerSeasonStatistic,
TeamSeasonStatistic,
Tournament,
TournamentStandingRow,
TournamentStatisticRow,
)
from hockey_data.service import HockeyDataService
from hockey_data.standings_parser import parse_standings_xml
from hockey_data.tournament_statistics_parser import parse_tournament_statistics_xml
from support import LocalTestDatabase
PLAYERS_XML = b'''
'''
STANDINGS_XML = '''
'''
POWERPLAY_XML = '''
'''
RANK_XML = '''
'''
class NormalizedStatisticsStorageTests(unittest.TestCase):
def setUp(self) -> None:
self.tmp = tempfile.TemporaryDirectory()
self.base_dir = Path(self.tmp.name)
self.database = LocalTestDatabase(self.base_dir / "test.sqlite3")
self.database.create_all()
self.settings = HockeySettingsStore(self.base_dir / "settings")
self.service = HockeyDataService(base_dir=self.base_dir, database=self.database, settings=self.settings)
with self.database.session() as session:
session.add(Tournament(external_id="1369"))
def tearDown(self) -> None:
self.database.engine.dispose()
self.tmp.cleanup()
def test_player_season_stats_are_normalized(self) -> None:
profiles = parse_players_xml(PLAYERS_XML)
now = datetime.utcnow()
with self.database.session() as session:
self.service._store_tournament_directory_profiles(
session,
tournament_external_id="1369",
player_profiles=profiles,
referee_profiles={},
coach_profiles={},
countries=None,
now=now,
)
with self.database.session() as session:
rows = list(session.scalars(select(PlayerSeasonStatistic).order_by(PlayerSeasonStatistic.player_external_id)))
self.assertEqual(len(rows), 2)
skater, goalie = rows
self.assertEqual(skater.games, 63)
self.assertEqual(skater.points, 25)
self.assertEqual(skater.faceoff_pct, 55.0)
self.assertEqual(goalie.role, "goalkeeper")
self.assertEqual(goalie.wins, 25)
self.assertEqual(goalie.saves, 920)
self.assertEqual(goalie.save_pct, 92.0)
def test_game_team_stats_are_stored_for_total_and_periods(self) -> None:
now = datetime.utcnow()
raw_teams = {
"A": {"shots": 31, "goals": 3, "fo": 50, "fow": 28, "fow_pct": "56.0", "hits": 14, "bls": 9, "pim": 6, "toa": "14:20"},
"B": {"shots": 27, "goals": 2, "fo": 50, "fow": 22, "fow_pct": "44.0", "hits": 18, "bls": 12, "pim": 8, "toa": "11:05"},
}
raw_periods = {
"A": {"1": {"shots": 10, "goals": 1}, "2": {"shots": 12, "goals": 2}},
"B": {"1": {"shots": 8, "goals": 0}, "2": {"shots": 11, "goals": 1}},
}
with self.database.session() as session:
game = Game(external_id="900001", tournament_external_id="1369", home_team_external_id="1", away_team_external_id="2")
session.add(game)
session.flush()
created = self.service._store_game_team_statistics(session, game=game, raw_teams=raw_teams, raw_periods=raw_periods, now=now)
self.assertEqual(created, 6)
with self.database.session() as session:
home_total = session.scalar(select(GameTeamStatistic).where(GameTeamStatistic.game_external_id == "900001", GameTeamStatistic.side == "home", GameTeamStatistic.segment_key == "total"))
self.assertIsNotNone(home_total)
self.assertEqual(home_total.shots, 31)
self.assertEqual(home_total.faceoffs_won, 28)
self.assertEqual(home_total.time_on_attack, "14:20")
def test_standings_powerplay_and_rank_are_normalized(self) -> None:
now = datetime.utcnow()
standings = parse_standings_xml(STANDINGS_XML, language="ru")
powerplay = parse_tournament_statistics_xml(POWERPLAY_XML, resource_type="powerplay", language="ru")
rank = parse_tournament_statistics_xml(RANK_XML, resource_type="rank", language="ru")
with self.database.session() as session:
self.service._store_normalized_standings(session, tournament_external_id="1369", parsed=standings, now=now)
self.service._store_normalized_tournament_statistic_rows(session, tournament_external_id="1369", resource_type="powerplay", parsed=powerplay, now=now)
self.service._store_normalized_tournament_statistic_rows(session, tournament_external_id="1369", resource_type="rank", parsed=rank, now=now)
with self.database.session() as session:
standing_count = session.scalar(select(func.count(TournamentStandingRow.id)))
pp_count = session.scalar(select(func.count(TournamentStatisticRow.id)).where(TournamentStatisticRow.resource_type == "powerplay"))
rank_count = session.scalar(select(func.count(TournamentStatisticRow.id)).where(TournamentStatisticRow.resource_type == "rank"))
team_a = session.scalar(select(TeamSeasonStatistic).where(TeamSeasonStatistic.tournament_external_id == "1369", TeamSeasonStatistic.team_entry_external_id == "14927"))
self.assertEqual(standing_count, 3)
self.assertEqual(pp_count, 2)
self.assertEqual(rank_count, 1)
self.assertIsNotNone(team_a)
self.assertEqual(team_a.points, 17)
self.assertEqual(team_a.power_play_goals, 8)
self.assertEqual(team_a.power_play_opportunities, 30)
self.assertAlmostEqual(team_a.power_play_pct, 26.7)
status = self.service.normalized_storage_status(tournament_external_id="1369")
self.assertEqual(status["tables"]["standings_rows"]["rows"], 3)
self.assertEqual(status["resource_rows"]["powerplay"], 2)
self.assertEqual(status["resource_rows"]["rank"], 1)
def test_repeated_powerplay_team_sections_do_not_duplicate_team_season_rows(self) -> None:
now = datetime.utcnow()
standings = parse_standings_xml(STANDINGS_XML, language="ru")
# Real Stat2TV powerplay resources can repeat the same team in several
# sections. The actual PP section uses pp/ppgf/pp_pct rather than ppo/ppg.
parsed = {
"sections": [
{
"id": "goals-for",
"names": {"ru": "Голы", "en": "Goals"},
"periods": {},
"rows": [
{"id": "1", "name": "Команда А", "names": {"ru": "Команда А", "en": "Team A"}, "team_names": {}, "team_id": "", "player_id": "", "rank": "1", "number": "", "values": {"teid": "14927", "gp": "10", "gf": "31"}, "source_tag": "Team"},
{"id": "2", "name": "Команда Б", "names": {"ru": "Команда Б", "en": "Team B"}, "team_names": {}, "team_id": "", "player_id": "", "rank": "2", "number": "", "values": {"teid": "14928", "gp": "10", "gf": "29"}, "source_tag": "Team"},
],
},
{
"id": "powerplay",
"names": {"ru": "Большинство", "en": "Power play"},
"periods": {},
"rows": [
{"id": "1", "name": "Команда А", "names": {"ru": "Команда А", "en": "Team A"}, "team_names": {}, "team_id": "", "player_id": "", "rank": "1", "number": "", "values": {"teid": "14927", "gp": "10", "pp": "30", "ppgf": "8", "pp_pct": "26.7"}, "source_tag": "Team"},
{"id": "2", "name": "Команда Б", "names": {"ru": "Команда Б", "en": "Team B"}, "team_names": {}, "team_id": "", "player_id": "", "rank": "2", "number": "", "values": {"teid": "14928", "gp": "10", "pp": "31", "ppgf": "6", "pp_pct": "19.4"}, "source_tag": "Team"},
],
},
]
}
with self.database.session() as session:
self.service._store_normalized_standings(session, tournament_external_id="1369", parsed=standings, now=now)
self.service._store_normalized_tournament_statistic_rows(session, tournament_external_id="1369", resource_type="powerplay", parsed=parsed, now=now)
# Rebuild again in the same Session to cover pending/deleted ORM rows.
self.service._rebuild_team_season_statistics(session, "1369", now=now)
with self.database.session() as session:
rows = list(session.scalars(select(TeamSeasonStatistic).where(TeamSeasonStatistic.tournament_external_id == "1369").order_by(TeamSeasonStatistic.team_key)))
self.assertEqual(len(rows), 2)
team_a = next(row for row in rows if row.team_entry_external_id == "14927")
self.assertEqual(team_a.power_play_opportunities, 30)
self.assertEqual(team_a.power_play_goals, 8)
self.assertAlmostEqual(team_a.power_play_pct, 26.7)
if __name__ == "__main__":
unittest.main()