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