from datetime import date from decimal import Decimal from django.core.management import call_command from django.test import TestCase from django.urls import reverse from .models import Competition, FavoritePlayer, Player, PlayerSeason, PlayerSeasonStats, Role, Season, Specialty, Team class ScoutingSearchViewsTests(TestCase): @classmethod def setUpTestData(cls): cls.role_playmaker = Role.objects.create(name="playmaker", slug="playmaker") cls.role_3d = Role.objects.create(name="3-and-D", slug="3-and-d") cls.specialty_defense = Specialty.objects.create(name="defense", slug="defense") cls.specialty_offball = Specialty.objects.create(name="off ball", slug="off-ball") cls.comp_a = Competition.objects.create(name="League A") cls.comp_b = Competition.objects.create(name="League B") cls.team_a = Team.objects.create(name="Team A", country="IT") cls.team_b = Team.objects.create(name="Team B", country="IT") cls.season_2025 = Season.objects.create(name="2025-2026", start_year=2025, end_year=2026) cls.season_2024 = Season.objects.create(name="2024-2025", start_year=2024, end_year=2025) cls.player_pg = Player.objects.create( full_name="Marco Guard", birth_date=date(2002, 1, 1), position="PG", height_cm=Decimal("188.00"), weight_kg=Decimal("82.00"), nationality="IT", ) cls.player_pg.roles.add(cls.role_playmaker) cls.player_pg.specialties.add(cls.specialty_defense) cls.player_wing = Player.objects.create( full_name="Luca Wing", birth_date=date(1998, 2, 2), position="SF", height_cm=Decimal("201.00"), weight_kg=Decimal("95.00"), ) cls.player_wing.roles.add(cls.role_3d) cls.player_wing.specialties.add(cls.specialty_offball) cls.ctx_pg_good = PlayerSeason.objects.create( player=cls.player_pg, season=cls.season_2025, team=cls.team_a, competition=cls.comp_a, ) PlayerSeasonStats.objects.create( player_season=cls.ctx_pg_good, points=Decimal("16.00"), assists=Decimal("7.50"), steals=Decimal("1.80"), turnovers=Decimal("2.10"), blocks=Decimal("0.30"), efg_pct=Decimal("53.20"), ts_pct=Decimal("58.10"), plus_minus=Decimal("4.20"), offensive_rating=Decimal("112.00"), defensive_rating=Decimal("104.00"), ) cls.ctx_pg_other = PlayerSeason.objects.create( player=cls.player_pg, season=cls.season_2024, team=cls.team_b, competition=cls.comp_b, ) PlayerSeasonStats.objects.create( player_season=cls.ctx_pg_other, points=Decimal("10.00"), assists=Decimal("4.00"), steals=Decimal("1.00"), turnovers=Decimal("3.50"), blocks=Decimal("0.20"), efg_pct=Decimal("48.00"), ts_pct=Decimal("50.00"), plus_minus=Decimal("-2.00"), offensive_rating=Decimal("101.00"), defensive_rating=Decimal("112.00"), ) cls.ctx_wing = PlayerSeason.objects.create( player=cls.player_wing, season=cls.season_2025, team=cls.team_b, competition=cls.comp_a, ) PlayerSeasonStats.objects.create( player_season=cls.ctx_wing, points=Decimal("14.00"), assists=Decimal("2.00"), steals=Decimal("1.20"), turnovers=Decimal("1.90"), blocks=Decimal("0.70"), efg_pct=Decimal("55.00"), ts_pct=Decimal("60.00"), plus_minus=Decimal("1.50"), offensive_rating=Decimal("108.00"), defensive_rating=Decimal("106.00"), ) def test_player_list_page_loads(self): response = self.client.get(reverse("scouting:player_list")) self.assertEqual(response.status_code, 200) self.assertContains(response, "Scout Search") def test_player_detail_page_loads(self): response = self.client.get(reverse("scouting:player_detail", args=[self.player_pg.id])) self.assertEqual(response.status_code, 200) self.assertContains(response, self.player_pg.full_name) def test_filter_by_player_level_fields(self): response = self.client.get( reverse("scouting:player_list"), {"position": "PG", "role": self.role_playmaker.id, "specialty": self.specialty_defense.id}, ) self.assertContains(response, self.player_pg.full_name) self.assertNotContains(response, self.player_wing.full_name) def test_filter_by_context_fields_and_stats(self): response = self.client.get( reverse("scouting:player_list"), { "competition": self.comp_a.id, "season": self.season_2025.id, "team": self.team_a.id, "min_ts_pct": "57", }, ) self.assertContains(response, self.player_pg.full_name) self.assertNotContains(response, self.player_wing.full_name) def test_result_row_shows_matching_context_for_filtered_search(self): response = self.client.get( reverse("scouting:player_list"), { "competition": self.comp_a.id, "season": self.season_2025.id, "team": self.team_a.id, "min_ts_pct": "57", }, ) player = next(player for player in response.context["players"] if player.id == self.player_pg.id) self.assertEqual(player.matching_context.id, self.ctx_pg_good.id) self.assertContains(response, "Match context:") self.assertContains(response, self.season_2025.name) self.assertContains(response, "PTS 16.00") def test_result_row_does_not_show_non_matching_context(self): response = self.client.get( reverse("scouting:player_list"), { "competition": self.comp_a.id, "season": self.season_2025.id, "team": self.team_a.id, "min_ts_pct": "57", }, ) player = next(player for player in response.context["players"] if player.id == self.player_pg.id) self.assertEqual(player.matching_context.id, self.ctx_pg_good.id) self.assertNotContains(response, "PTS 10.00") self.assertNotContains(response, "AST 4.00") def test_no_false_positive_from_different_context_rows(self): response = self.client.get( reverse("scouting:player_list"), { "team": self.team_b.id, "season": self.season_2024.id, "min_ts_pct": "57", }, ) self.assertNotContains(response, self.player_pg.full_name) def test_combined_pg_under_age_with_assists(self): response = self.client.get( reverse("scouting:player_list"), { "position": "PG", "max_age": "25", "min_assists": "7", }, ) self.assertContains(response, self.player_pg.full_name) self.assertNotContains(response, self.player_wing.full_name) def test_matching_context_selection_is_deterministic_when_multiple_contexts_match(self): second_matching_context = PlayerSeason.objects.create( player=self.player_pg, season=self.season_2024, team=self.team_a, competition=self.comp_a, ) PlayerSeasonStats.objects.create( player_season=second_matching_context, points=Decimal("18.00"), assists=Decimal("6.20"), steals=Decimal("1.50"), turnovers=Decimal("2.00"), blocks=Decimal("0.20"), efg_pct=Decimal("52.00"), ts_pct=Decimal("57.50"), plus_minus=Decimal("3.50"), offensive_rating=Decimal("110.00"), defensive_rating=Decimal("105.00"), ) response = self.client.get( reverse("scouting:player_list"), { "competition": self.comp_a.id, "team": self.team_a.id, }, ) player = next(player for player in response.context["players"] if player.id == self.player_pg.id) self.assertEqual(player.matching_context.id, self.ctx_pg_good.id) self.assertContains(response, self.season_2025.name) self.assertNotContains(response, "PTS 18.00") def test_player_detail_page_still_loads_after_related_loading_cleanup(self): response = self.client.get(reverse("scouting:player_detail", args=[self.player_pg.id])) self.assertEqual(response.status_code, 200) self.assertContains(response, "PTS 16.00") def test_combined_team_and_defensive_rating_quality_filter(self): response = self.client.get( reverse("scouting:player_list"), { "team": self.team_a.id, "max_defensive_rating": "105", }, ) self.assertContains(response, self.player_pg.full_name) self.assertNotContains(response, self.player_wing.full_name) def test_sorting_by_player_level_field(self): taller_player = Player.objects.create( full_name="Big Wing", birth_date=date(2001, 5, 5), position="SF", height_cm=Decimal("208.00"), weight_kg=Decimal("101.00"), ) response = self.client.get(reverse("scouting:player_list"), {"sort": "height_desc"}) player_names = [player.full_name for player in response.context["players"]] self.assertEqual(player_names[:3], [taller_player.full_name, self.player_wing.full_name, self.player_pg.full_name]) def test_sorting_by_matching_context_stat_field(self): response = self.client.get( reverse("scouting:player_list"), {"competition": self.comp_a.id, "sort": "assists_desc"}, ) player_names = [player.full_name for player in response.context["players"]] self.assertEqual(player_names[:2], [self.player_pg.full_name, self.player_wing.full_name]) def test_context_sorting_preserves_matching_context_semantics(self): second_context = PlayerSeason.objects.create( player=self.player_pg, season=self.season_2024, team=self.team_b, competition=self.comp_a, ) PlayerSeasonStats.objects.create( player_season=second_context, points=Decimal("12.00"), assists=Decimal("9.00"), steals=Decimal("1.00"), turnovers=Decimal("2.80"), blocks=Decimal("0.20"), efg_pct=Decimal("49.00"), ts_pct=Decimal("52.00"), plus_minus=Decimal("1.00"), offensive_rating=Decimal("107.00"), defensive_rating=Decimal("109.00"), ) response = self.client.get( reverse("scouting:player_list"), {"competition": self.comp_a.id, "team": self.team_a.id, "sort": "assists_desc"}, ) player = next(player for player in response.context["players"] if player.id == self.player_pg.id) self.assertEqual(player.matching_context.id, self.ctx_pg_good.id) self.assertContains(response, "AST 7.50") self.assertNotContains(response, "AST 9.00") def test_pagination_works_on_player_list(self): for index in range(25): Player.objects.create( full_name=f"Depth Player {index:02d}", birth_date=date(2000, 1, 1), position="SG", height_cm=Decimal("190.00"), weight_kg=Decimal("84.00"), ) response = self.client.get(reverse("scouting:player_list"), {"page": 2}) self.assertEqual(response.status_code, 200) self.assertEqual(response.context["page_obj"].number, 2) self.assertContains(response, "Page 2 of 2") self.assertContains(response, "Depth Player 20") self.assertContains(response, "Depth Player 24") self.assertNotContains(response, "Depth Player 00") def test_pagination_preserves_filters_and_sort_order(self): for index in range(25): Player.objects.create( full_name=f"Guard Prospect {index:02d}", birth_date=date(2001, 1, 1), position="PG", height_cm=Decimal("185.00"), weight_kg=Decimal("80.00"), ) response = self.client.get( reverse("scouting:player_list"), {"position": "PG", "sort": "name_desc"}, ) self.assertContains(response, "position=PG") self.assertContains(response, "sort=name_desc") self.assertContains(response, "page=2") def test_combined_filters_sort_and_pagination(self): for index in range(25): player = Player.objects.create( full_name=f"Playmaker Prospect {index:02d}", birth_date=date(2003, 1, 1), position="PG", height_cm=Decimal("186.00"), weight_kg=Decimal("79.00"), ) context = PlayerSeason.objects.create( player=player, season=self.season_2025, team=self.team_a, competition=self.comp_a, ) PlayerSeasonStats.objects.create( player_season=context, points=Decimal("10.00"), assists=Decimal(str(30 - index)), steals=Decimal("1.00"), turnovers=Decimal("2.00"), blocks=Decimal("0.10"), efg_pct=Decimal("50.00"), ts_pct=Decimal("56.00"), plus_minus=Decimal("1.00"), offensive_rating=Decimal("109.00"), defensive_rating=Decimal("108.00"), ) response = self.client.get( reverse("scouting:player_list"), { "position": "PG", "competition": self.comp_a.id, "sort": "assists_desc", "page": 2, }, ) self.assertEqual(response.context["page_obj"].number, 2) player_names = [player.full_name for player in response.context["players"]] self.assertNotIn(self.player_wing.full_name, player_names) self.assertEqual(player_names[0], "Playmaker Prospect 20") self.assertIn("Marco Guard", player_names) self.assertContains(response, "sort=assists_desc") self.assertContains(response, "competition=%s" % self.comp_a.id) class SeedScoutingDataCommandTests(TestCase): def test_seed_command_creates_expected_core_objects(self): call_command("seed_scouting_data") self.assertGreaterEqual(Competition.objects.count(), 3) self.assertGreaterEqual(Team.objects.count(), 4) self.assertGreaterEqual(Season.objects.count(), 3) self.assertGreaterEqual(Player.objects.count(), 5) self.assertGreaterEqual(PlayerSeason.objects.count(), 6) self.assertEqual(PlayerSeason.objects.count(), PlayerSeasonStats.objects.count()) player = Player.objects.get(full_name="Marco Guard") self.assertEqual(player.position, Player.Position.PG) self.assertTrue(player.roles.filter(slug="playmaker").exists()) self.assertTrue(player.specialties.filter(slug="ball-handling").exists()) def test_seed_command_is_idempotent_for_repeat_runs(self): call_command("seed_scouting_data") first_counts = { "players": Player.objects.count(), "contexts": PlayerSeason.objects.count(), "stats": PlayerSeasonStats.objects.count(), "roles": Role.objects.count(), "specialties": Specialty.objects.count(), } call_command("seed_scouting_data") self.assertEqual(Player.objects.count(), first_counts["players"]) self.assertEqual(PlayerSeason.objects.count(), first_counts["contexts"]) self.assertEqual(PlayerSeasonStats.objects.count(), first_counts["stats"]) self.assertEqual(Role.objects.count(), first_counts["roles"]) self.assertEqual(Specialty.objects.count(), first_counts["specialties"]) class FavoritePlayerViewsTests(TestCase): @classmethod def setUpTestData(cls): cls.player = Player.objects.create( full_name="Favorite Prospect", birth_date=date(2001, 4, 4), position="PG", height_cm=Decimal("187.00"), weight_kg=Decimal("81.00"), ) cls.other_player = Player.objects.create( full_name="Other Prospect", birth_date=date(2000, 6, 6), position="SF", height_cm=Decimal("202.00"), weight_kg=Decimal("94.00"), ) def test_adding_player_to_favorites(self): response = self.client.post( reverse("scouting:add_favorite", args=[self.player.id]), {"next": reverse("scouting:player_detail", args=[self.player.id])}, ) self.assertRedirects(response, reverse("scouting:player_detail", args=[self.player.id])) self.assertTrue(FavoritePlayer.objects.filter(player=self.player).exists()) def test_removing_player_from_favorites(self): FavoritePlayer.objects.create(player=self.player) response = self.client.post( reverse("scouting:remove_favorite", args=[self.player.id]), {"next": reverse("scouting:favorites_list")}, ) self.assertRedirects(response, reverse("scouting:favorites_list")) self.assertFalse(FavoritePlayer.objects.filter(player=self.player).exists()) def test_favorites_list_page_loads(self): FavoritePlayer.objects.create(player=self.player) response = self.client.get(reverse("scouting:favorites_list")) self.assertEqual(response.status_code, 200) self.assertContains(response, "Favorite Prospect") def test_favorite_state_is_visible_on_detail_and_search_pages(self): FavoritePlayer.objects.create(player=self.player) detail_response = self.client.get(reverse("scouting:player_detail", args=[self.player.id])) list_response = self.client.get(reverse("scouting:player_list")) self.assertContains(detail_response, "On the shared development shortlist.") self.assertContains(detail_response, "Remove from shortlist") self.assertContains(list_response, "Shortlisted") def test_search_and_detail_pages_still_load_after_favorites_integration(self): search_response = self.client.get(reverse("scouting:player_list")) detail_response = self.client.get(reverse("scouting:player_detail", args=[self.player.id])) self.assertEqual(search_response.status_code, 200) self.assertEqual(detail_response.status_code, 200)