From ded43561d5bded6e26b82c212ff34b261dc87cc5 Mon Sep 17 00:00:00 2001 From: Xera-phix Date: Sun, 30 Nov 2025 16:49:40 -0500 Subject: [PATCH 01/15] Refactor simulator and roster apps to Clean Architecture --- .../management/commands/import_test_data.py | 4 +- backend/roster/services/importer.py | 161 -------------- backend/roster/services/load_csv.py | 116 ---------- backend/roster/services/player_import.py | 205 ++++++++++++++++++ backend/roster/services/player_ranking.py | 78 +++---- backend/roster/urls.py | 1 - backend/roster/views.py | 116 ++-------- backend/simulator/services/simulation.py | 38 ++++ backend/simulator/views.py | 82 +++---- 9 files changed, 310 insertions(+), 491 deletions(-) delete mode 100644 backend/roster/services/importer.py delete mode 100644 backend/roster/services/load_csv.py create mode 100644 backend/roster/services/player_import.py diff --git a/backend/roster/management/commands/import_test_data.py b/backend/roster/management/commands/import_test_data.py index edd00b21..10c5d632 100644 --- a/backend/roster/management/commands/import_test_data.py +++ b/backend/roster/management/commands/import_test_data.py @@ -1,6 +1,6 @@ from django.core.management.base import BaseCommand, CommandError -from roster.services.importer import import_from_csv +from roster.services.player_import import PlayerImportService class Command(BaseCommand): @@ -29,7 +29,7 @@ def handle(self, *args, **options): dry_run = options.get("dry_run") try: - result = import_from_csv(path, team_id=team_id_arg, dry_run=dry_run) + result = PlayerImportService.import_from_csv(path, team_id=team_id_arg, dry_run=dry_run) except FileNotFoundError: raise CommandError(f"File not found: {path}") diff --git a/backend/roster/services/importer.py b/backend/roster/services/importer.py deleted file mode 100644 index 3a3e6485..00000000 --- a/backend/roster/services/importer.py +++ /dev/null @@ -1,161 +0,0 @@ -import csv -from decimal import Decimal -from typing import Any, Dict, List, Optional - -from roster.models import Player, Team - - -def _to_float(v): - if v is None or v == "": - return None - try: - return float(Decimal(v)) - except Exception: - try: - return float(str(v).replace("%", "")) - except Exception: - return None - - -def _to_int(v): - if v is None or v == "": - return None - try: - return int(Decimal(v)) - except Exception: - try: - return int(str(v).replace("%", "")) - except Exception: - return None - - -def import_from_csv(path: str, team_id: Optional[int] = None, dry_run: bool = False) -> Dict[str, Any]: - """Import players from a CSV file into the roster Player model. - - Returns a summary dict with counts and messages. - """ - messages: List[str] = [] - try: - f = open(path, newline="", encoding="utf-8-sig") - except FileNotFoundError: - raise - - reader = csv.DictReader(f) - rows = list(reader) - if not rows: - messages.append("No rows found in CSV.") - f.close() - return {"processed": 0, "created": 0, "updated": 0, "messages": messages} - - if team_id is not None: - Team.objects.get_or_create(pk=team_id) - - processed = created = updated = 0 - - for r in rows: - name = r.get('"last_name, first_name"') or r.get("last_name, first_name") or r.get("name") - if name is None: - first = r.get(" first_name") or r.get("first_name") - last = r.get("last_name") - if first and last: - name = f"{last}, {first}" - - if not name: - messages.append(f"Skipping row without name: {r}") - continue - - savant_player_id = r.get("player_id") or r.get("savant_player_id") - year = r.get("year") - pa = r.get("pa") - - # Raw counting stats (for simulation) - hit = r.get("hit") - single = r.get("single") - double = r.get("double") - triple = r.get("triple") - home_run = r.get("home_run") - strikeout = r.get("strikeout") - walk = r.get("walk") - - # Derived percentages - k_percent = _to_float(r.get("k_percent")) - bb_percent = _to_float(r.get("bb_percent")) - slg_percent = _to_float(r.get("slg_percent")) - on_base_percent = _to_float(r.get("on_base_percent")) - isolated_power = _to_float(r.get("isolated_power")) - b_total_bases = _to_int(r.get("b_total_bases")) - r_total_caught_stealing = _to_int(r.get("r_total_caught_stealing")) - r_total_stolen_base = _to_float(r.get("r_total_stolen_base")) - b_game = _to_int(r.get("b_game")) - b_gnd_into_dp = _to_int(r.get("b_gnd_into_dp")) - b_hit_by_pitch = _to_int(r.get("b_hit_by_pitch")) - b_intent_walk = _to_int(r.get("b_intent_walk")) - b_sac_fly = _to_int(r.get("b_sac_fly")) - b_sac_bunt = _to_int(r.get("b_sac_bunt")) - - team_id_csv = None - if "team_id" in r: - team_id_csv = r.get("team_id") - elif '"team_id"' in r: - team_id_csv = r.get('"team_id"') - - use_team_id = None - if team_id_csv: - try: - use_team_id = int(team_id_csv) - except Exception: - use_team_id = None - elif team_id is not None: - use_team_id = team_id - - team_obj = None - if use_team_id is not None: - team_obj, _ = Team.objects.get_or_create(pk=use_team_id) - - defaults: Dict[str, Any] = { - "savant_player_id": (int(savant_player_id) if savant_player_id and str(savant_player_id).isdigit() else None), - "year": int(year) if year and str(year).isdigit() else None, - "pa": int(pa) if pa and str(pa).isdigit() else None, - # Raw counting stats - "hit": int(hit) if hit and str(hit).isdigit() else None, - "single": int(single) if single and str(single).isdigit() else None, - "double": int(double) if double and str(double).isdigit() else None, - "triple": int(triple) if triple and str(triple).isdigit() else None, - "home_run": int(home_run) if home_run and str(home_run).isdigit() else None, - "strikeout": int(strikeout) if strikeout and str(strikeout).isdigit() else None, - "walk": int(walk) if walk and str(walk).isdigit() else None, - # Derived percentages - "k_percent": k_percent, - "bb_percent": bb_percent, - "slg_percent": slg_percent, - "on_base_percent": on_base_percent, - "isolated_power": isolated_power, - "b_total_bases": b_total_bases, - "r_total_caught_stealing": r_total_caught_stealing, - "r_total_stolen_base": r_total_stolen_base, - "b_game": b_game, - "b_gnd_into_dp": b_gnd_into_dp, - "b_hit_by_pitch": b_hit_by_pitch, - "b_intent_walk": b_intent_walk, - "b_sac_fly": b_sac_fly, - "b_sac_bunt": b_sac_bunt, - } - - if dry_run: - messages.append(f"Would import player: {name} team_id={use_team_id} fields={defaults}") - processed += 1 - continue - - player, created_flag = Player.objects.update_or_create( - name=name, - defaults={**defaults, "team": team_obj}, - ) - processed += 1 - if created_flag: - created += 1 - else: - updated += 1 - - f.close() - messages.append(f"Processed {processed} rows: created={created} updated={updated}") - return {"processed": processed, "created": created, "updated": updated, "messages": messages} diff --git a/backend/roster/services/load_csv.py b/backend/roster/services/load_csv.py deleted file mode 100644 index 008d94a8..00000000 --- a/backend/roster/services/load_csv.py +++ /dev/null @@ -1,116 +0,0 @@ -"""Service function to import batter stats CSV into the roster Player model. - -This was converted from the previous management command to a callable -service function so it can be invoked from code, tests or a lightweight -management wrapper if desired. -""" - -import csv -from pathlib import Path -from typing import Tuple - -from django.db import transaction - -from roster.models import Player - - -def load_csv_file(file: str | Path, year: int | None = None, dry_run: bool = False) -> Tuple[int, int, int]: - """Load CSV into roster.Player. - - Returns a tuple (imported, updated, skipped). - """ - file_path = Path(file) - if not file_path.exists(): - raise FileNotFoundError(f"CSV file not found: {file_path}") - - imported = 0 - updated = 0 - skipped = 0 - - with file_path.open(newline="", encoding="utf-8-sig") as fh: - reader = csv.DictReader(fh) - rows = list(reader) - - if dry_run: - # Caller can print or assert the content as needed - return (0, 0, len(rows)) - - with transaction.atomic(): - for r in rows: - raw_name = r.get("last_name, first_name") - if not raw_name: - skipped += 1 - continue - - name = raw_name.strip() - - player, created = Player.objects.get_or_create(name=name) - - def fflt(key: str): - v = r.get(key) - if v is None or v == "": - return None - try: - return float(str(v).replace('"', "").replace("%", "").strip()) - except ValueError: - return None - - def to_int_cell(key: str): - v = r.get(key) - if v is None or v == "": - return None - try: - return int(float(str(v).replace('"', "").replace("%", "").strip())) - except ValueError: - return None - - try: - savant_id = int(r.get("player_id")) if r.get("player_id") else None - except ValueError: - savant_id = None - - try: - pa = int(r.get("pa")) if r.get("pa") else None - except ValueError: - pa = None - - row_year = int(r.get("year")) if r.get("year") else None - use_year = year if year is not None else row_year - - player.savant_player_id = savant_id - player.year = use_year - player.ab = to_int_cell("ab") - player.pa = pa - player.hit = to_int_cell("hit") - player.single = to_int_cell("single") - player.double = to_int_cell("double") - player.triple = to_int_cell("triple") - player.home_run = to_int_cell("home_run") - player.walk = to_int_cell("walk") - player.k_percent = fflt("k_percent") - player.bb_percent = fflt("bb_percent") - player.slg_percent = fflt("slg_percent") - player.on_base_percent = fflt("on_base_percent") - player.isolated_power = fflt("isolated_power") - player.b_total_bases = to_int_cell("b_total_bases") - player.r_total_caught_stealing = to_int_cell("r_total_caught_stealing") - player.r_total_stolen_base = to_int_cell("r_total_stolen_base") - player.b_game = fflt("b_game") - player.b_gnd_into_dp = fflt("b_gnd_into_dp") - player.b_hit_by_pitch = fflt("b_hit_by_pitch") - player.b_intent_walk = fflt("b_intent_walk") - player.b_sac_fly = fflt("b_sac_fly") - player.b_total_sacrifices = fflt("b_total_sacrifices") - player.woba = fflt("woba") - player.xwoba = fflt("xwoba") - player.barrel_batted_rate = fflt("barrel_batted_rate") - player.hard_hit_percent = fflt("hard_hit_percent") - player.sprint_speed = fflt("sprint_speed") - player.save() - - if created: - imported += 1 - else: - updated += 1 - - return (imported, updated, skipped) diff --git a/backend/roster/services/player_import.py b/backend/roster/services/player_import.py new file mode 100644 index 00000000..634a8b45 --- /dev/null +++ b/backend/roster/services/player_import.py @@ -0,0 +1,205 @@ +import csv +from decimal import Decimal +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +from django.db import transaction + +from roster.models import Player, Team + + +class PlayerImportService: + """Service for importing players from CSV files.""" + + @staticmethod + def import_from_csv( + path: str | Path, team_id: Optional[int] = None, dry_run: bool = False + ) -> Dict[str, Any]: + """ + Import players from a CSV file into the roster Player model. + + Args: + path: Path to the CSV file + team_id: Optional team ID to assign to all players + dry_run: If True, simulate import without saving + + Returns: + Dict containing import summary (processed, created, updated, messages) + """ + messages: List[str] = [] + file_path = Path(path) + + if not file_path.exists(): + raise FileNotFoundError(f"CSV file not found: {file_path}") + + try: + with file_path.open(newline="", encoding="utf-8-sig") as f: + reader = csv.DictReader(f) + rows = list(reader) + except Exception as e: + raise ValueError(f"Error reading CSV: {e}") + + if not rows: + return { + "processed": 0, + "created": 0, + "updated": 0, + "messages": ["No rows found in CSV."], + } + + # Pre-fetch or create team if provided globally + global_team = None + if team_id is not None: + global_team, _ = Team.objects.get_or_create(pk=team_id) + + processed = 0 + created_count = 0 + updated_count = 0 + + # Use transaction for data integrity (unless dry_run) + try: + with transaction.atomic(): + for r in rows: + # 1. Extract Name + name = PlayerImportService._extract_name(r) + if not name: + messages.append(f"Skipping row without name: {r}") + continue + + # 2. Determine Team + team_obj = PlayerImportService._determine_team(r, global_team) + + # 3. Prepare Data + defaults = PlayerImportService._prepare_player_data(r) + + if dry_run: + messages.append( + f"Would import player: {name} team={team_obj.id if team_obj else 'None'} fields={defaults}" + ) + processed += 1 + continue + + # 4. Update or Create + player, created = Player.objects.update_or_create( + name=name, + defaults={**defaults, "team": team_obj}, + ) + + processed += 1 + if created: + created_count += 1 + else: + updated_count += 1 + + if dry_run: + # Rollback is implicit since we didn't write, but good to be explicit mentally + pass + + except Exception as e: + if not dry_run: + messages.append(f"Import failed: {e}") + raise e + + messages.append( + f"Processed {processed} rows: created={created_count} updated={updated_count}" + ) + return { + "processed": processed, + "created": created_count, + "updated": updated_count, + "messages": messages, + } + + @staticmethod + def _extract_name(row: Dict[str, Any]) -> Optional[str]: + """Extract and normalize player name from row.""" + name = ( + row.get('"last_name, first_name"') + or row.get("last_name, first_name") + or row.get("name") + ) + if name is None: + first = row.get(" first_name") or row.get("first_name") + last = row.get("last_name") + if first and last: + name = f"{last}, {first}" + + return name.strip() if name else None + + @staticmethod + def _determine_team(row: Dict[str, Any], global_team: Optional[Team]) -> Optional[Team]: + """Determine team for the player.""" + team_id_csv = None + if "team_id" in row: + team_id_csv = row.get("team_id") + elif '"team_id"' in row: + team_id_csv = row.get('"team_id"') + + if team_id_csv: + try: + tid = int(team_id_csv) + team, _ = Team.objects.get_or_create(pk=tid) + return team + except Exception: + pass + + return global_team + + @staticmethod + def _prepare_player_data(r: Dict[str, Any]) -> Dict[str, Any]: + """Parse and convert CSV row data into model fields.""" + + def _to_float(v): + if v is None or v == "": + return None + try: + return float(Decimal(v)) + except Exception: + try: + return float(str(v).replace("%", "").replace('"', "")) + except Exception: + return None + + def _to_int(v): + if v is None or v == "": + return None + try: + return int(Decimal(v)) + except Exception: + try: + return int(float(str(v).replace("%", "").replace('"', ""))) + except Exception: + return None + + # Helper to get value from row with fallback + def get_val(key): + return r.get(key) + + return { + "savant_player_id": _to_int(get_val("player_id") or get_val("savant_player_id")), + "year": _to_int(get_val("year")), + "pa": _to_int(get_val("pa")), + # Raw counting stats + "hit": _to_int(get_val("hit")), + "single": _to_int(get_val("single")), + "double": _to_int(get_val("double")), + "triple": _to_int(get_val("triple")), + "home_run": _to_int(get_val("home_run")), + "strikeout": _to_int(get_val("strikeout")), + "walk": _to_int(get_val("walk")), + # Derived percentages + "k_percent": _to_float(get_val("k_percent")), + "bb_percent": _to_float(get_val("bb_percent")), + "slg_percent": _to_float(get_val("slg_percent")), + "on_base_percent": _to_float(get_val("on_base_percent")), + "isolated_power": _to_float(get_val("isolated_power")), + "b_total_bases": _to_int(get_val("b_total_bases")), + "r_total_caught_stealing": _to_int(get_val("r_total_caught_stealing")), + "r_total_stolen_base": _to_float(get_val("r_total_stolen_base")), + "b_game": _to_int(get_val("b_game")), + "b_gnd_into_dp": _to_int(get_val("b_gnd_into_dp")), + "b_hit_by_pitch": _to_int(get_val("b_hit_by_pitch")), + "b_intent_walk": _to_int(get_val("b_intent_walk")), + "b_sac_fly": _to_int(get_val("b_sac_fly")), + "b_sac_bunt": _to_int(get_val("b_sac_bunt")), + } diff --git a/backend/roster/services/player_ranking.py b/backend/roster/services/player_ranking.py index 9945bb8a..28346986 100644 --- a/backend/roster/services/player_ranking.py +++ b/backend/roster/services/player_ranking.py @@ -11,6 +11,31 @@ from roster.models import Player, Team +class PlayerRankingService: + """Service for player ranking operations.""" + + @staticmethod + def get_ids_sorted_by_woba(player_ids: List[int]) -> List[int]: + """ + Sort a list of player IDs by their xwoba (descending). + + Args: + player_ids: List of player IDs to sort + + Returns: + List of player IDs sorted by xwoba + """ + if not player_ids: + return [] + + # Fetch players with the given IDs and sort by xwoba descending + # We use filter(id__in=...) to get the objects, then order_by + players = Player.objects.filter(id__in=player_ids).order_by("-xwoba") + + # Return the sorted player IDs + return [player.id for player in players] + + def get_all_players_with_stats() -> List[Dict[str, Any]]: """ Fetch all players with a subset of their stats as dictionaries. @@ -53,59 +78,6 @@ def get_ranked_players(ascending: bool = False, top_n: Optional[int] = None) -> return result -def get_all_players_with_stats() -> List[Dict[str, Any]]: - """ - Fetch all players with their stats as dictionaries. - - Returns: - List of player dictionaries containing stats. - """ - return list( - Player.objects.all().values( - "id", - "name", - "team_id", - "bb_percent", - "k_percent", - "pa", - "year", - ) - ) - - -# def get_ranked_players(ascending: bool = False, top_n: Optional[int] = None) -> List[Dict[str, Any]]: -# """ -# Get players ranked by WOS score. - -# TODO: Implement actual WOS ranking algorithm. -# For now, returns all players without ranking. - -# Args: -# ascending: Sort order (False = highest first) -# top_n: Optional limit on number of results - -# Returns: -# List of player dictionaries with wos_score field added. -# """ -# players = get_all_players_with_stats() - -# if not players: -# return [] - -# # TODO: Replace with actual WOS ranking logic from lineups/services/algorithm_logic.py -# result = [] -# for player in players: -# player_data = dict(player) -# player_data["wos_score"] = 0.0 # Placeholder -# result.append(player_data) - -# # Apply limit if specified -# if top_n: -# result = result[:top_n] - -# return result - - def create_player_with_stats(name: str, **stats) -> Player: """ Create a new player with optional stats. diff --git a/backend/roster/urls.py b/backend/roster/urls.py index 55435501..57a5cf52 100644 --- a/backend/roster/urls.py +++ b/backend/roster/urls.py @@ -22,5 +22,4 @@ urlpatterns = [ path("", include(router.urls)), - path("sort-by-woba/", views.sort_players_by_woba, name="sort-by-woba"), ] diff --git a/backend/roster/views.py b/backend/roster/views.py index 5f39d606..476c4f72 100644 --- a/backend/roster/views.py +++ b/backend/roster/views.py @@ -3,36 +3,15 @@ from django.http import JsonResponse from django.views.decorators.csrf import csrf_exempt from django.views.decorators.http import require_http_methods -from rest_framework import viewsets +from rest_framework import status, viewsets +from rest_framework.decorators import action +from rest_framework.response import Response from .models import Player, Team from .serializer import PlayerSerializer, TeamSerializer +from .services.player_ranking import PlayerRankingService -# TODO: implement post as well for clarity -@csrf_exempt -@require_http_methods(["GET", "POST"]) -def players(request): - """API endpoint to list and create players.""" - if request.method == "GET": - # List all players with stats - players = Player.objects.all().values( - "id", - "name", - "xwoba", - "bb_percent", - "k_percent", - "barrel_batted_rate", - "pa", - "year", - "created_at", - "updated_at", - ) - player_data = list(players) - return JsonResponse({"players": player_data}) - - -# TODO: what does this do is it needed clarify? class TeamViewSet(viewsets.ModelViewSet): """ ViewSet for Team model. @@ -52,85 +31,20 @@ class PlayerViewSet(viewsets.ModelViewSet): queryset = Player.objects.all() serializer_class = PlayerSerializer - -@csrf_exempt -@require_http_methods(["DELETE"]) -def player_detail(request, player_id): - """API endpoint to delete a specific player.""" - try: - player = Player.objects.get(id=player_id) - player_name = player.name - player.delete() - return JsonResponse({"message": f"Player '{player_name}' deleted successfully"}) - except Player.DoesNotExist: - return JsonResponse({"error": "Player not found"}, status=404) - except Exception as e: - return JsonResponse({"error": str(e)}, status=400) - - -# TODO: get rid of old method -@csrf_exempt -@require_http_methods(["GET"]) -def players_ranked(request): - """API endpoint to get players ranked by WOS score.""" - try: - # Fetch all players with stats - players = Player.objects.all().values( - "id", - "name", - "xwoba", - "bb_percent", - "k_percent", - "barrel_batted_rate", - "pa", - "year", - ) - players_list = list(players) - - """ - Sort by WOS - sorted_players = sort_players_by_wos(players_list, ascending=False) - - # Add WOS score to each player - """ + @action(detail=False, methods=["post"], url_path="sort-by-woba") + def sort_by_woba(self, request): """ - for player in sorted_players: - player_data = dict(player) - player_data["wos_score"] = round(calculate_wos(player), 2) - result.append(player_data) + Sort a list of player IDs by wOBA (descending). + Body: {"player_ids": [1, 2, 3, ...]} """ - result = [] - - return JsonResponse({"players": result}) - - except Exception as e: - return JsonResponse({"error": str(e)}, status=500) - - -@csrf_exempt -@require_http_methods(["POST"]) -def sort_players_by_woba(request): - """API endpoint to sort a list of player IDs by wOBA (descending). - - Request body should be JSON: {"player_ids": [1, 2, 3, ...]} - Returns: {"player_ids": [sorted_ids...]} with highest wOBA first - """ - try: - data = json.loads(request.body) - player_ids = data.get("player_ids", []) + player_ids = request.data.get("player_ids", []) if not player_ids: - return JsonResponse({"error": "player_ids is required"}, status=400) - - # Fetch players with the given IDs and sort by xwoba descending - players = Player.objects.filter(id__in=player_ids).order_by("-xwoba") - - # Return the sorted player IDs - sorted_ids = [player.id for player in players] + return Response({"error": "player_ids is required"}, status=status.HTTP_400_BAD_REQUEST) - return JsonResponse({"player_ids": sorted_ids}) + try: + sorted_ids = PlayerRankingService.get_ids_sorted_by_woba(player_ids) + return Response({"player_ids": sorted_ids}) + except Exception as e: + return Response({"error": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) - except json.JSONDecodeError: - return JsonResponse({"error": "Invalid JSON"}, status=400) - except Exception as e: - return JsonResponse({"error": str(e)}, status=500) diff --git a/backend/simulator/services/simulation.py b/backend/simulator/services/simulation.py index add7ddef..142a742e 100644 --- a/backend/simulator/services/simulation.py +++ b/backend/simulator/services/simulation.py @@ -17,6 +17,7 @@ from typing import List from .dto import BatterStats, SimulationResult +from .player_service import PlayerService lib_path = os.path.join( os.path.dirname(__file__), "..", "..", "lib", "baseball-simulator" @@ -78,3 +79,40 @@ def simulate_lineup( std_dev=std_dev, all_scores=scores, ) + + def run_simulation_flow( + self, player_input: list | int, num_games: int, fetch_method: str + ) -> SimulationResult: + """ + Orchestrate the simulation flow: fetch players -> validate -> simulate. + + Args: + player_input: List of IDs, names, or a team ID + num_games: Number of games to simulate + fetch_method: 'ids', 'names', or 'team' + + Returns: + SimulationResult object + + Raises: + ValueError: If validation fails (wrong number of players, not found, etc.) + """ + player_service = PlayerService() + + # 1. Fetch players based on method + if fetch_method == "ids": + batter_stats = player_service.get_players_by_ids(player_input) + elif fetch_method == "names": + batter_stats = player_service.get_players_by_names(player_input) + elif fetch_method == "team": + batter_stats = player_service.get_team_players(player_input, limit=9) + if len(batter_stats) < 9: + raise ValueError( + f"Team {player_input} only has {len(batter_stats)} players with valid stats. Need exactly 9." + ) + else: + raise ValueError(f"Invalid fetch method: {fetch_method}") + + # 2. Run simulation (validation happens inside simulate_lineup) + return self.simulate_lineup(batter_stats, num_games=num_games) + diff --git a/backend/simulator/views.py b/backend/simulator/views.py index f21ae40e..c601e4b2 100644 --- a/backend/simulator/views.py +++ b/backend/simulator/views.py @@ -19,7 +19,6 @@ from rest_framework.response import Response from .serializers import PlayerInputSerializer, PlayerNameInputSerializer, SimulationResultSerializer, TeamInputSerializer -from .services.player_service import PlayerService from .services.simulation import SimulationService logger = logging.getLogger(__name__) @@ -28,41 +27,39 @@ def _handle_simulation_request(player_input, num_games, fetch_method): """ Helper to handle simulation request with consistent error handling. - - Args: - player_input: Player IDs, names, or team ID - num_games: Number of games to simulate - fetch_method: 'ids', 'names', or 'team' - - Returns: - Response object with simulation results or error + Delegates orchestration to SimulationService. """ try: - player_service = PlayerService() - - # Fetch players based on method - if fetch_method == "ids": - batter_stats = player_service.get_players_by_ids(player_input) - elif fetch_method == "names": - batter_stats = player_service.get_players_by_names(player_input) - elif fetch_method == "team": - batter_stats = player_service.get_team_players(player_input, limit=9) - if len(batter_stats) < 9: - return Response( - {"error": f"Team {player_input} only has {len(batter_stats)} players with valid stats. Need exactly 9."}, - status=status.HTTP_400_BAD_REQUEST, - ) - else: - return Response({"error": "Invalid fetch method"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) - - return _run_simulation_and_format_response(batter_stats, num_games) + service = SimulationService() + result = service.run_simulation_flow(player_input, num_games, fetch_method) + + # Handle empty scores edge case + if not result.all_scores: + return Response( + {"error": "Simulation produced no results. Please check input data."}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + response_data = { + "lineup": result.lineup_names, + "num_games": result.num_games, + "avg_score": result.avg_score, + "median_score": result.median_score, + "std_dev": result.std_dev, + "min_score": min(result.all_scores), + "max_score": max(result.all_scores), + "score_distribution": _calculate_distribution(result.all_scores), + } + + output_serializer = SimulationResultSerializer(response_data) + return Response(output_serializer.data, status=status.HTTP_200_OK) except ValueError as e: # Player not found or data validation error logger.warning(f"ValueError in simulation: {str(e)}") return Response( {"error": str(e), "hint": "Check that all player IDs/names exist and have valid statistics."}, - status=status.HTTP_404_NOT_FOUND, + status=status.HTTP_400_BAD_REQUEST, ) except Exception as e: # Unexpected error - log for debugging @@ -73,35 +70,6 @@ def _handle_simulation_request(player_input, num_games, fetch_method): ) -def _run_simulation_and_format_response(batter_stats, num_games): - """ - Helper function to run simulation and format response. - Eliminates code duplication across view functions. - """ - service = SimulationService() - result = service.simulate_lineup(batter_stats, num_games=num_games) - - # Handle empty scores edge case - if not result.all_scores: - return Response( - {"error": "Simulation produced no results. Please check input data."}, status=status.HTTP_500_INTERNAL_SERVER_ERROR - ) - - response_data = { - "lineup": result.lineup_names, - "num_games": result.num_games, - "avg_score": result.avg_score, - "median_score": result.median_score, - "std_dev": result.std_dev, - "min_score": min(result.all_scores), - "max_score": max(result.all_scores), - "score_distribution": _calculate_distribution(result.all_scores), - } - - output_serializer = SimulationResultSerializer(response_data) - return Response(output_serializer.data, status=status.HTTP_200_OK) - - @api_view(["POST"]) @permission_classes([IsAuthenticated]) def simulate_by_player_ids(request): From 768357f20feb1b105d68902cf9ab1a2eb1c051c1 Mon Sep 17 00:00:00 2001 From: Xera-phix Date: Mon, 1 Dec 2025 11:03:28 -0500 Subject: [PATCH 02/15] Update backend/roster/services/player_import.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- backend/roster/services/player_import.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/roster/services/player_import.py b/backend/roster/services/player_import.py index 634a8b45..9ceb1391 100644 --- a/backend/roster/services/player_import.py +++ b/backend/roster/services/player_import.py @@ -1,7 +1,7 @@ import csv from decimal import Decimal from pathlib import Path -from typing import Any, Dict, List, Optional, Tuple +from typing import Any, Dict, List, Optional from django.db import transaction From c8d0f75e065cd5fbe4b0b2eeaf2e1330eb8bf391 Mon Sep 17 00:00:00 2001 From: Xera-phix Date: Tue, 2 Dec 2025 12:19:59 -0500 Subject: [PATCH 03/15] Improve test coverage: fix roster tests, add missing model fields, fix import bug --- backend/config/settings_test.py | 4 +- backend/pytest.ini | 1 + ..._player_barrel_batted_rate_player_xwoba.py | 23 +++ backend/roster/models.py | 2 + backend/roster/services/player_import.py | 4 +- backend/roster/tests.py | 159 ++++++++-------- backend/roster/tests/__init__.py | 0 backend/roster/tests/test_player_import.py | 175 ++++++++++++++++++ backend/roster/tests/test_player_ranking.py | 65 +++++++ backend/simulator/tests.py | 67 ++++++- backend/test_output.txt | Bin 0 -> 136912 bytes 11 files changed, 409 insertions(+), 91 deletions(-) create mode 100644 backend/roster/migrations/0010_player_barrel_batted_rate_player_xwoba.py create mode 100644 backend/roster/tests/__init__.py create mode 100644 backend/roster/tests/test_player_import.py create mode 100644 backend/roster/tests/test_player_ranking.py create mode 100644 backend/test_output.txt diff --git a/backend/config/settings_test.py b/backend/config/settings_test.py index 69ac7663..c96fd98b 100644 --- a/backend/config/settings_test.py +++ b/backend/config/settings_test.py @@ -1,10 +1,8 @@ -"""Test settings for backend.""" - from .settings import * DATABASES = { "default": { "ENGINE": "django.db.backends.sqlite3", - "NAME": ":memory:", + "NAME": BASE_DIR / "db.sqlite3", } } diff --git a/backend/pytest.ini b/backend/pytest.ini index d048c05d..f19288bd 100644 --- a/backend/pytest.ini +++ b/backend/pytest.ini @@ -1,3 +1,4 @@ [pytest] +DJANGO_SETTINGS_MODULE = config.settings_test addopts = -q testpaths = simulator \ No newline at end of file diff --git a/backend/roster/migrations/0010_player_barrel_batted_rate_player_xwoba.py b/backend/roster/migrations/0010_player_barrel_batted_rate_player_xwoba.py new file mode 100644 index 00000000..78755ee5 --- /dev/null +++ b/backend/roster/migrations/0010_player_barrel_batted_rate_player_xwoba.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.6 on 2025-12-02 16:37 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("roster", "0009_remove_player_position"), + ] + + operations = [ + migrations.AddField( + model_name="player", + name="barrel_batted_rate", + field=models.FloatField(blank=True, null=True), + ), + migrations.AddField( + model_name="player", + name="xwoba", + field=models.FloatField(blank=True, null=True), + ), + ] diff --git a/backend/roster/models.py b/backend/roster/models.py index b1ebf4d9..355e9627 100644 --- a/backend/roster/models.py +++ b/backend/roster/models.py @@ -36,6 +36,8 @@ class Player(models.Model): walk = models.PositiveIntegerField(null=True, blank=True) # Walks - BB k_percent = models.FloatField(null=True, blank=True) # Frequency of strikeouts per plate appearance - K% = (SO / PA) * 100 bb_percent = models.FloatField(null=True, blank=True) # Frequency of walks per plate appearance - BB% = (BB / PA) * 100 + xwoba = models.FloatField(null=True, blank=True) # Expected Weighted On-Base Average + barrel_batted_rate = models.FloatField(null=True, blank=True) # Barrel % slg_percent = models.FloatField( null=True, blank=True ) # Measures total bases per at-bat, emphasizes extra-base hits - SLG = (1B + 2*2B + 3*3B + 4*HR) / AB diff --git a/backend/roster/services/player_import.py b/backend/roster/services/player_import.py index 9ceb1391..289a6bc7 100644 --- a/backend/roster/services/player_import.py +++ b/backend/roster/services/player_import.py @@ -118,7 +118,9 @@ def _extract_name(row: Dict[str, Any]) -> Optional[str]: or row.get("last_name, first_name") or row.get("name") ) - if name is None: + + # If name is None or empty string, try to construct from parts + if not name: first = row.get(" first_name") or row.get("first_name") last = row.get("last_name") if first and last: diff --git a/backend/roster/tests.py b/backend/roster/tests.py index 2f000467..10b360ab 100644 --- a/backend/roster/tests.py +++ b/backend/roster/tests.py @@ -6,23 +6,24 @@ from .models import Player, Team from .services.player_ranking import get_ranked_players -# @pytest.mark.skip(reason="Requires database migrations with home_run column") -# class PlayerModelTests(TestCase): -# """Test Player model functionality.""" -# -# def setUp(self): -# self.team = Team.objects.create(name="Test Team") -# -# def test_create_player(self): -# """Test creating a player.""" -# player = Player.objects.create(name="Test Player", team=self.team, position="SS") -# self.assertEqual(player.name, "Test Player") -# self.assertEqual(player.team, self.team) -# -# def test_player_str(self): -# """Test player string representation.""" -# player = Player.objects.create(name="John Doe", position="CF") -# self.assertEqual(str(player), "John Doe (CF)") + +class PlayerModelTests(TestCase): + """Test Player model functionality.""" + + def setUp(self): + self.team = Team.objects.create(id=1) + + def test_create_player(self): + """Test creating a player.""" + player = Player.objects.create(name="Test Player", team=self.team, home_run=10) + self.assertEqual(player.name, "Test Player") + self.assertEqual(player.team, self.team) + self.assertEqual(player.home_run, 10) + + def test_player_str(self): + """Test player string representation.""" + player = Player.objects.create(name="John Doe", team=self.team) + self.assertEqual(str(player), "John Doe") class PlayerRankingServiceTests(TestCase): @@ -34,68 +35,66 @@ def test_get_ranked_players_empty(self): self.assertEqual(result, []) -# @pytest.mark.skip(reason="Requires database migrations with home_run column") -# class PlayerAPITests(APITestCase): -# """Test Player API endpoints.""" -# -# def setUp(self): -# self.team = Team.objects.create(name="Yankees") -# self.player = Player.objects.create( -# name="Aaron Judge", -# team=self.team, -# position="RF", -# xwoba=0.476, -# bb_percent=18.3, -# barrel_batted_rate=24.7, -# k_percent=23.6, -# ) -# -# def test_list_players(self): -# """Test GET /players/""" -# url = reverse("roster:player-list") -# response = self.client.get(url) -# self.assertEqual(response.status_code, status.HTTP_200_OK) -# self.assertEqual(len(response.data), 1) -# -# def test_create_player(self): -# """Test POST /players/""" -# url = reverse("roster:player-list") -# data = {"name": "Juan Soto", "team": self.team.id, "position": "RF"} -# response = self.client.post(url, data) -# self.assertEqual(response.status_code, status.HTTP_201_CREATED) -# self.assertEqual(Player.objects.count(), 2) -# -# def test_retrieve_player(self): -# """Test GET /players/{id}/""" -# url = reverse("roster:player-detail", kwargs={"pk": self.player.id}) -# response = self.client.get(url) -# self.assertEqual(response.status_code, status.HTTP_200_OK) -# self.assertEqual(response.data["name"], "Aaron Judge") -# -# def test_update_player(self): -# """Test PATCH /players/{id}/""" -# url = reverse("roster:player-detail", kwargs={"pk": self.player.id}) -# data = {"position": "CF"} -# response = self.client.patch(url, data) -# self.assertEqual(response.status_code, status.HTTP_200_OK) -# self.player.refresh_from_db() -# self.assertEqual(self.player.position, "CF") -# -# def test_delete_player(self): -# """Test DELETE /players/{id}/""" -# url = reverse("roster:player-detail", kwargs={"pk": self.player.id}) -# response = self.client.delete(url) -# self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) -# self.assertEqual(Player.objects.count(), 0) -# -# def test_ranked_players(self): -# """Test GET /players/ranked/""" -# url = reverse("roster:player-ranked") -# response = self.client.get(url) -# self.assertEqual(response.status_code, status.HTTP_200_OK) -# self.assertIn("players", response.data) -# self.assertEqual(len(response.data["players"]), 1) -# self.assertIn("wos_score", response.data["players"][0]) +class PlayerAPITests(APITestCase): + """Test Player API endpoints.""" + + def setUp(self): + self.team = Team.objects.create(id=1) + self.player = Player.objects.create( + name="Aaron Judge", + team=self.team, + xwoba=0.476, + bb_percent=18.3, + barrel_batted_rate=24.7, + k_percent=23.6, + ) + + def test_list_players(self): + """Test GET /players/""" + url = reverse("roster:player-list") + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data), 1) + + def test_create_player(self): + """Test POST /players/""" + url = reverse("roster:player-list") + data = {"name": "Juan Soto", "team": self.team.id, "home_run": 40} + response = self.client.post(url, data) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(Player.objects.count(), 2) + + def test_retrieve_player(self): + """Test GET /players/{id}/""" + url = reverse("roster:player-detail", kwargs={"pk": self.player.id}) + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["name"], "Aaron Judge") + + def test_update_player(self): + """Test PATCH /players/{id}/""" + url = reverse("roster:player-detail", kwargs={"pk": self.player.id}) + data = {"home_run": 62} + response = self.client.patch(url, data) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.player.refresh_from_db() + self.assertEqual(self.player.home_run, 62) + + def test_delete_player(self): + """Test DELETE /players/{id}/""" + url = reverse("roster:player-detail", kwargs={"pk": self.player.id}) + response = self.client.delete(url) + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertEqual(Player.objects.count(), 0) + + def test_ranked_players(self): + """Test GET /players/ranked/""" + url = reverse("roster:player-ranked") + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn("players", response.data) + self.assertEqual(len(response.data["players"]), 1) + self.assertIn("wos_score", response.data["players"][0]) class TeamAPITests(APITestCase): @@ -103,7 +102,7 @@ class TeamAPITests(APITestCase): def test_list_teams(self): """Test GET /teams/""" - Team.objects.create() + Team.objects.create(id=1) url = reverse("roster:team-list") response = self.client.get(url) self.assertEqual(response.status_code, status.HTTP_200_OK) diff --git a/backend/roster/tests/__init__.py b/backend/roster/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/roster/tests/test_player_import.py b/backend/roster/tests/test_player_import.py new file mode 100644 index 00000000..d5fc3d6c --- /dev/null +++ b/backend/roster/tests/test_player_import.py @@ -0,0 +1,175 @@ +import csv +import tempfile +from pathlib import Path +from unittest.mock import patch + +from django.test import TestCase + +from roster.models import Player, Team +from roster.services.player_import import PlayerImportService + + +class PlayerImportServiceTestCase(TestCase): + """Test PlayerImportService functionality.""" + + def setUp(self): + self.team = Team.objects.create(id=1) + self.temp_dir = tempfile.TemporaryDirectory() + self.csv_path = Path(self.temp_dir.name) / "test_players.csv" + + def tearDown(self): + self.temp_dir.cleanup() + + def create_csv(self, rows): + """Helper to create a CSV file with given rows.""" + with open(self.csv_path, "w", newline="", encoding="utf-8-sig") as f: + writer = csv.DictWriter(f, fieldnames=rows[0].keys()) + writer.writeheader() + writer.writerows(rows) + return str(self.csv_path) + + def test_import_from_csv_success(self): + """Test successful import of valid player data.""" + rows = [ + { + "last_name, first_name": "Judge, Aaron", + "player_id": "12345", + "year": "2024", + "pa": "600", + "hit": "150", + "home_run": "50", + "team_id": str(self.team.id), + }, + { + "last_name, first_name": "Soto, Juan", + "player_id": "67890", + "year": "2024", + "pa": "650", + "hit": "160", + "home_run": "40", + # No team_id, should use global or None + }, + ] + self.create_csv(rows) + + result = PlayerImportService.import_from_csv(self.csv_path, team_id=self.team.id) + + self.assertEqual(result["created"], 2) + self.assertEqual(Player.objects.count(), 2) + + judge = Player.objects.get(savant_player_id=12345) + self.assertEqual(judge.name, "Judge, Aaron") + self.assertEqual(judge.home_run, 50) + self.assertEqual(judge.team, self.team) + + soto = Player.objects.get(savant_player_id=67890) + self.assertEqual(soto.name, "Soto, Juan") + self.assertEqual(soto.team, self.team) # Fallback to global team + + def test_import_updates_existing_player(self): + """Test that importing updates existing players instead of creating duplicates.""" + # Create initial player + Player.objects.create( + name="Judge, Aaron", + savant_player_id=12345, + team=self.team, + home_run=10 + ) + + rows = [ + { + "last_name, first_name": "Judge, Aaron", + "player_id": "12345", + "year": "2024", + "pa": "600", + "hit": "150", + "home_run": "62", # Updated value + "team_id": str(self.team.id), + } + ] + self.create_csv(rows) + + result = PlayerImportService.import_from_csv(self.csv_path) + + self.assertEqual(result["created"], 0) + self.assertEqual(result["updated"], 1) + self.assertEqual(Player.objects.count(), 1) + + judge = Player.objects.get(name="Judge, Aaron") + self.assertEqual(judge.home_run, 62) + + def test_import_dry_run(self): + """Test dry run mode does not save changes.""" + rows = [ + { + "last_name, first_name": "New Player", + "player_id": "99999", + "year": "2024", + "pa": "100", + } + ] + self.create_csv(rows) + + result = PlayerImportService.import_from_csv(self.csv_path, dry_run=True) + + self.assertEqual(result["processed"], 1) + self.assertEqual(Player.objects.count(), 0) # Nothing saved + + def test_file_not_found(self): + """Test error when file does not exist.""" + with self.assertRaises(FileNotFoundError): + PlayerImportService.import_from_csv("non_existent.csv") + + def test_empty_file(self): + """Test importing an empty file.""" + # Create empty file + with open(self.csv_path, "w") as f: + pass + + # Should fail or return empty result depending on implementation + # The current implementation catches Exception on DictReader if empty? + # Actually DictReader on empty file yields no rows. + result = PlayerImportService.import_from_csv(self.csv_path) + self.assertEqual(result["processed"], 0) + + def test_name_parsing_formats(self): + """Test various name formats.""" + rows = [ + {"last_name, first_name": "Doe, John", "player_id": "1"}, + {"name": "Smith, Jane", "player_id": "2"}, + {"first_name": "Bob", "last_name": "Jones", "player_id": "3"}, + ] + # We need to write a header that includes all possible keys to avoid DictReader mismatch if we want strictness, + # but DictReader just reads what's there. + # Let's write them carefully. + with open(self.csv_path, "w", newline="", encoding="utf-8-sig") as f: + writer = csv.DictWriter(f, fieldnames=["last_name, first_name", "name", "first_name", "last_name", "player_id", "year", "pa"]) + writer.writeheader() + writer.writerows(rows) + + result = PlayerImportService.import_from_csv(self.csv_path) + self.assertEqual(result["created"], 3) + + self.assertTrue(Player.objects.filter(name="Doe, John").exists()) + self.assertTrue(Player.objects.filter(name="Smith, Jane").exists()) + self.assertTrue(Player.objects.filter(name="Jones, Bob").exists()) + + def test_data_conversion_robustness(self): + """Test that invalid numbers are handled gracefully (converted to None or 0).""" + rows = [ + { + "last_name, first_name": "Robusto", + "player_id": "100", + "pa": "invalid", # Should be None + "hit": "10%", # Should strip % + "bb_percent": "12.5", + } + ] + self.create_csv(rows) + + PlayerImportService.import_from_csv(self.csv_path) + player = Player.objects.get(name="Robusto") + + self.assertIsNone(player.pa) + self.assertEqual(player.hit, 10) + self.assertEqual(player.bb_percent, 12.5) diff --git a/backend/roster/tests/test_player_ranking.py b/backend/roster/tests/test_player_ranking.py new file mode 100644 index 00000000..61d5e9cd --- /dev/null +++ b/backend/roster/tests/test_player_ranking.py @@ -0,0 +1,65 @@ +from django.test import TestCase + +from roster.models import Player, Team +from roster.services.player_ranking import ( + PlayerRankingService, + create_player_with_stats, + get_ranked_players, + get_team_by_id, + update_player_stats, +) + + +class PlayerRankingServiceTestCase(TestCase): + """Test PlayerRankingService and helper functions.""" + + def setUp(self): + self.team = Team.objects.create(id=1) + # Create players with different xwoba values + self.p1 = Player.objects.create(name="Player 1", team=self.team, xwoba=0.400, pa=100) + self.p2 = Player.objects.create(name="Player 2", team=self.team, xwoba=0.300, pa=100) + self.p3 = Player.objects.create(name="Player 3", team=self.team, xwoba=0.500, pa=100) + + def test_get_ids_sorted_by_woba(self): + """Test sorting player IDs by xwoba.""" + ids = [self.p1.id, self.p2.id, self.p3.id] + sorted_ids = PlayerRankingService.get_ids_sorted_by_woba(ids) + + # Expected order: p3 (0.500), p1 (0.400), p2 (0.300) + self.assertEqual(sorted_ids, [self.p3.id, self.p1.id, self.p2.id]) + + def test_get_ids_sorted_by_woba_empty(self): + """Test with empty list.""" + self.assertEqual(PlayerRankingService.get_ids_sorted_by_woba([]), []) + + def test_get_ranked_players(self): + """Test fetching ranked players (placeholder logic).""" + # The current placeholder implementation just returns players with wos_score=0 + # We verify it returns the correct structure + ranked = get_ranked_players() + self.assertEqual(len(ranked), 3) + self.assertIn("wos_score", ranked[0]) + self.assertEqual(ranked[0]["wos_score"], 0.0) + + def test_create_player_with_stats(self): + """Test helper to create player.""" + p = create_player_with_stats("New Guy", xwoba=0.350, team=self.team) + self.assertEqual(p.name, "New Guy") + self.assertEqual(p.xwoba, 0.350) + self.assertEqual(p.team, self.team) + + def test_update_player_stats(self): + """Test helper to update player.""" + p = update_player_stats(self.p1.id, xwoba=0.450, bb_percent=10.0) + + self.p1.refresh_from_db() + self.assertEqual(self.p1.xwoba, 0.450) + self.assertEqual(self.p1.bb_percent, 10.0) + + def test_get_team_by_id(self): + """Test helper to get team.""" + t = get_team_by_id(self.team.id) + self.assertEqual(t, self.team) + + t_none = get_team_by_id(999) + self.assertIsNone(t_none) diff --git a/backend/simulator/tests.py b/backend/simulator/tests.py index 913acaa8..b760daa1 100644 --- a/backend/simulator/tests.py +++ b/backend/simulator/tests.py @@ -134,20 +134,73 @@ def test_simulate_lineup_wrong_number_of_players(self): self.assertIn("exactly 9 batters", str(context.exception)) def test_simulate_lineup_deterministic_stats(self): - """Test that more games produces more stable statistics.""" + """Test that more games produces more stable statistics (lower standard error).""" # Run with few games result_small = self.service.simulate_lineup(self.lineup, num_games=10) # Run with many games result_large = self.service.simulate_lineup(self.lineup, num_games=1000) - # Larger sample should have more stable (lower) std dev relative to mean - cv_small = result_small.std_dev / result_small.avg_score - cv_large = result_large.std_dev / result_large.avg_score + # Calculate Standard Error of the Mean (SEM) + # SEM = std_dev / sqrt(n) + # This represents the uncertainty in the average score and should decrease with N + sem_small = result_small.std_dev / np.sqrt(result_small.num_games) + sem_large = result_large.std_dev / np.sqrt(result_large.num_games) - # This is probabilistic but should generally hold - # (coefficient of variation decreases with sample size) - self.assertGreater(cv_small, cv_large * 0.5) + # Larger sample should have lower standard error (more precise mean) + self.assertGreater(sem_small, sem_large) + + +class SimulationFlowTestCase(TestCase): + """Test SimulationService.run_simulation_flow orchestration.""" + + def setUp(self): + self.service = SimulationService() + self.team = Team.objects.create(id=1) + self.players = [] + for i in range(9): + p = Player.objects.create( + name=f"P{i}", + team=self.team, + pa=500, + hit=100, + double=20, + triple=2, + home_run=10, + strikeout=100, + walk=50 + ) + self.players.append(p) + + def test_flow_by_ids(self): + """Test flow with IDs.""" + ids = [p.id for p in self.players] + result = self.service.run_simulation_flow(ids, num_games=10, fetch_method="ids") + self.assertIsInstance(result, SimulationResult) + self.assertEqual(len(result.lineup_names), 9) + + def test_flow_by_names(self): + """Test flow with names.""" + names = [p.name for p in self.players] + result = self.service.run_simulation_flow(names, num_games=10, fetch_method="names") + self.assertIsInstance(result, SimulationResult) + + def test_flow_by_team(self): + """Test flow with team ID.""" + result = self.service.run_simulation_flow(self.team.id, num_games=10, fetch_method="team") + self.assertIsInstance(result, SimulationResult) + + def test_flow_invalid_method(self): + """Test invalid fetch method.""" + with self.assertRaises(ValueError): + self.service.run_simulation_flow([], 10, "invalid") + + def test_flow_team_not_enough_players(self): + """Test team with insufficient players.""" + empty_team = Team.objects.create(id=2) + with self.assertRaises(ValueError) as ctx: + self.service.run_simulation_flow(empty_team.id, 10, "team") + self.assertIn("Need exactly 9", str(ctx.exception)) class PlayerServiceTestCase(TestCase): diff --git a/backend/test_output.txt b/backend/test_output.txt new file mode 100644 index 0000000000000000000000000000000000000000..b86e69b68131d3bb0f58b5bddadfd697e81d445f GIT binary patch literal 136912 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 S>ASYZ Date: Tue, 2 Dec 2025 12:23:00 -0500 Subject: [PATCH 04/15] Remove test output artifact --- backend/test_output.txt | Bin 136912 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 backend/test_output.txt diff --git a/backend/test_output.txt b/backend/test_output.txt deleted file mode 100644 index b86e69b68131d3bb0f58b5bddadfd697e81d445f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 136912 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 S>ASYZ Date: Tue, 2 Dec 2025 13:28:09 -0500 Subject: [PATCH 05/15] Refactor roster tests: split tests.py into package, fix simulator test and view --- .../roster/{tests.py => tests/test_api.py} | 34 +------------------ backend/roster/tests/test_models.py | 20 +++++++++++ backend/roster/tests/test_player_ranking.py | 7 ++++ backend/roster/views.py | 7 ++++ backend/simulator/tests.py | 2 ++ 5 files changed, 37 insertions(+), 33 deletions(-) rename backend/roster/{tests.py => tests/test_api.py} (75%) create mode 100644 backend/roster/tests/test_models.py diff --git a/backend/roster/tests.py b/backend/roster/tests/test_api.py similarity index 75% rename from backend/roster/tests.py rename to backend/roster/tests/test_api.py index 10b360ab..d66f1cfb 100644 --- a/backend/roster/tests.py +++ b/backend/roster/tests/test_api.py @@ -1,39 +1,7 @@ -from django.test import TestCase from django.urls import reverse from rest_framework import status from rest_framework.test import APITestCase - -from .models import Player, Team -from .services.player_ranking import get_ranked_players - - -class PlayerModelTests(TestCase): - """Test Player model functionality.""" - - def setUp(self): - self.team = Team.objects.create(id=1) - - def test_create_player(self): - """Test creating a player.""" - player = Player.objects.create(name="Test Player", team=self.team, home_run=10) - self.assertEqual(player.name, "Test Player") - self.assertEqual(player.team, self.team) - self.assertEqual(player.home_run, 10) - - def test_player_str(self): - """Test player string representation.""" - player = Player.objects.create(name="John Doe", team=self.team) - self.assertEqual(str(player), "John Doe") - - -class PlayerRankingServiceTests(TestCase): - """Test player ranking service functions.""" - - def test_get_ranked_players_empty(self): - """Test ranking with no players.""" - result = get_ranked_players() - self.assertEqual(result, []) - +from roster.models import Player, Team class PlayerAPITests(APITestCase): """Test Player API endpoints.""" diff --git a/backend/roster/tests/test_models.py b/backend/roster/tests/test_models.py new file mode 100644 index 00000000..546ad9d9 --- /dev/null +++ b/backend/roster/tests/test_models.py @@ -0,0 +1,20 @@ +from django.test import TestCase +from roster.models import Player, Team + +class PlayerModelTests(TestCase): + """Test Player model functionality.""" + + def setUp(self): + self.team = Team.objects.create(id=1) + + def test_create_player(self): + """Test creating a player.""" + player = Player.objects.create(name="Test Player", team=self.team, home_run=10) + self.assertEqual(player.name, "Test Player") + self.assertEqual(player.team, self.team) + self.assertEqual(player.home_run, 10) + + def test_player_str(self): + """Test player string representation.""" + player = Player.objects.create(name="John Doe", team=self.team) + self.assertEqual(str(player), "John Doe") diff --git a/backend/roster/tests/test_player_ranking.py b/backend/roster/tests/test_player_ranking.py index 61d5e9cd..9a1d3760 100644 --- a/backend/roster/tests/test_player_ranking.py +++ b/backend/roster/tests/test_player_ranking.py @@ -63,3 +63,10 @@ def test_get_team_by_id(self): t_none = get_team_by_id(999) self.assertIsNone(t_none) + + def test_get_ranked_players_empty(self): + """Test ranking with no players.""" + # Clear players created in setUp + Player.objects.all().delete() + result = get_ranked_players() + self.assertEqual(result, []) diff --git a/backend/roster/views.py b/backend/roster/views.py index 476c4f72..91668f5b 100644 --- a/backend/roster/views.py +++ b/backend/roster/views.py @@ -48,3 +48,10 @@ def sort_by_woba(self, request): except Exception as e: return Response({"error": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + @action(detail=False, methods=["get"]) + def ranked(self, request): + """Get all players ranked by wos_score.""" + from .services.player_ranking import get_ranked_players + ranked_players = get_ranked_players() + return Response({"players": ranked_players}) + diff --git a/backend/simulator/tests.py b/backend/simulator/tests.py index b760daa1..483ba8c8 100644 --- a/backend/simulator/tests.py +++ b/backend/simulator/tests.py @@ -198,6 +198,8 @@ def test_flow_invalid_method(self): def test_flow_team_not_enough_players(self): """Test team with insufficient players.""" empty_team = Team.objects.create(id=2) + # Add one player so we don't get "No players found" error + Player.objects.create(name="Lonely Player", team=empty_team, pa=100) with self.assertRaises(ValueError) as ctx: self.service.run_simulation_flow(empty_team.id, 10, "team") self.assertIn("Need exactly 9", str(ctx.exception)) From eab0cf8b87253d66748f44f81682a8880db6c401 Mon Sep 17 00:00:00 2001 From: Xera-phix Date: Tue, 2 Dec 2025 15:04:49 -0500 Subject: [PATCH 06/15] Fix CI: update pytest command to use roster/tests/ directory --- .github/workflows/backend-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/backend-ci.yml b/.github/workflows/backend-ci.yml index 273077e5..bc8a19c2 100644 --- a/.github/workflows/backend-ci.yml +++ b/.github/workflows/backend-ci.yml @@ -86,7 +86,7 @@ jobs: env: DJANGO_SETTINGS_MODULE: config.settings run: | - pytest lineups/tests.py accounts/tests.py roster/tests.py simulator/tests.py --cov=lineups --cov=accounts --cov=roster --cov=simulator --cov-report=xml --cov-report=term-missing + pytest lineups/tests.py accounts/tests.py roster/tests/ simulator/tests.py --cov=lineups --cov=accounts --cov=roster --cov=simulator --cov-report=xml --cov-report=term-missing - name: Check for security vulnerabilities in dependencies run: | From fd024048366164508bf020c4a51058d8b5d56a17 Mon Sep 17 00:00:00 2001 From: Xera-phix Date: Tue, 2 Dec 2025 15:25:29 -0500 Subject: [PATCH 07/15] Update pytest.ini to include all apps --- backend/pytest.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/pytest.ini b/backend/pytest.ini index f19288bd..f896d36a 100644 --- a/backend/pytest.ini +++ b/backend/pytest.ini @@ -1,4 +1,4 @@ [pytest] DJANGO_SETTINGS_MODULE = config.settings_test addopts = -q -testpaths = simulator \ No newline at end of file +testpaths = simulator roster lineups accounts \ No newline at end of file From e35ae97a915441617a5ed51157da5c2c4936ef05 Mon Sep 17 00:00:00 2001 From: Xera-phix Date: Tue, 2 Dec 2025 18:01:16 -0500 Subject: [PATCH 08/15] a --- backend/.coveragerc | 5 +++ backend/check_coverage.py | 36 ++++++++++++++++++++ backend/coverage_output.txt | Bin 0 -> 12690 bytes backend/coverage_report.txt | Bin 0 -> 10446 bytes backend/coverage_report_parsed.txt | 27 +++++++++++++++ backend/roster/tests/test_player_ranking.py | 5 +++ 6 files changed, 73 insertions(+) create mode 100644 backend/check_coverage.py create mode 100644 backend/coverage_output.txt create mode 100644 backend/coverage_report.txt create mode 100644 backend/coverage_report_parsed.txt diff --git a/backend/.coveragerc b/backend/.coveragerc index 04c80f2d..e6c5f908 100644 --- a/backend/.coveragerc +++ b/backend/.coveragerc @@ -9,6 +9,11 @@ omit = */venv/* */.venv/* */env/* + manage.py + config/asgi.py + config/wsgi.py + */tests_*.py + check_coverage.py [report] exclude_lines = diff --git a/backend/check_coverage.py b/backend/check_coverage.py new file mode 100644 index 00000000..243513c7 --- /dev/null +++ b/backend/check_coverage.py @@ -0,0 +1,36 @@ +import xml.etree.ElementTree as ET +import os + +try: + tree = ET.parse('coverage.xml') + root = tree.getroot() + print("Parsed coverage.xml successfully") + + classes = root.findall('.//class') + print(f"Found {len(classes)} classes") + + with open('coverage_report_parsed.txt', 'w') as f: + f.write(f"{'File':<60} {'Coverage':<10} {'Missing Lines'}\n") + f.write("-" * 100 + "\n") + + for package in root.findall('.//package'): + for cls in package.findall('.//class'): + filename = cls.get('filename') + line_rate = float(cls.get('line-rate')) + + if line_rate < 1.0: + missing_lines = [] + for line in cls.findall('.//line'): + if line.get('hits') == '0': + missing_lines.append(line.get('number')) + + missing_str = ",".join(missing_lines) + # Truncate missing string if too long + if len(missing_str) > 30: + missing_str = missing_str[:27] + "..." + + f.write(f"{filename:<60} {line_rate*100:6.1f}% {missing_str}\n") + print("Report written to coverage_report_parsed.txt") + +except Exception as e: + print(f"Error parsing coverage.xml: {e}") diff --git a/backend/coverage_output.txt b/backend/coverage_output.txt new file mode 100644 index 0000000000000000000000000000000000000000..adf91aa95b5122df5793e6c903437888a319fafc GIT binary patch literal 12690 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 NKDXtUu)$I6{~z1?4q*TQ literal 0 HcmV?d00001 diff --git a/backend/coverage_report.txt b/backend/coverage_report.txt new file mode 100644 index 0000000000000000000000000000000000000000..5556081e412cd47766730fc565bfaab4f44166b1 GIT binary patch literal 10446 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 Uzd-Yk2L3hFk}h@iu9<)SKM@2aa{vGU literal 0 HcmV?d00001 diff --git a/backend/coverage_report_parsed.txt b/backend/coverage_report_parsed.txt new file mode 100644 index 00000000..c97d2489 --- /dev/null +++ b/backend/coverage_report_parsed.txt @@ -0,0 +1,27 @@ +File Coverage Missing Lines +---------------------------------------------------------------------------------------------------- +check_coverage.py 0.0% 1,2,4,5,6,7,9,10,12,13,14,1... +conftest.py 85.7% 14 +accounts/serializers.py 55.0% 28,29,30,31,34,36,37,38,39 +accounts/services.py 30.4% 26,27,29,30,32,33,35,37,40,... +accounts/views.py 42.4% 24,33,34,35,36,38,40,46,47,... +lib/baseball-simulator/baseball.py 7.2% 24,25,26,27,28,30,31,33,34,... +lib/baseball-simulator/batter.py 18.8% 12,15,16,18,19,20,21,22,23,... +lib/baseball-simulator/parallel_game.py 22.0% 23,24,25,26,39,40,41,42,43,... +lineups/interactor.py 30.0% 24,32,40,41,43,44,46,49,58,... +lineups/serializers.py 82.9% 42,43,44,45,78,79 +lineups/views.py 40.6% 38,39,43,44,45,47,49,50,52,... +lineups/services/algorithm_logic.py 15.6% 55,56,57,63,67,68,69,70,71,... +lineups/services/auth_user.py 37.5% 13,14,17,19,24 +lineups/services/databa_access.py 28.2% 29,31,38,39,40,41,43,48,50,... +lineups/services/exceptions.py 63.2% 12,13,18,23,28,34,39 +lineups/services/lineup_creation_handler.py 26.7% 23,25,26,28,31,32,33,53,54,... +lineups/services/utils.py 12.5% 6,7,8,9,10,11,12 +lineups/services/validator.py 11.7% 28,29,31,32,33,34,36,38,39,... +roster/serializer.py 89.7% 12,163,164,165 +roster/views.py 74.2% 40,42,43,45,46,47,48,49 +roster/services/player_import.py 84.3% 39,40,66,67,98,99,100,101,1... +simulator/views.py 37.7% 32,33,34,37,38,43,54,55,57,... +simulator/services/dto.py 59.0% 29,41,43,45,47,48,49,50,51,... +simulator/services/player_service.py 20.0% 19,21,36,39,42,43,44,47,48,... +simulator/services/simulation.py 35.9% 51,52,57,58,59,60,61,65,66,... diff --git a/backend/roster/tests/test_player_ranking.py b/backend/roster/tests/test_player_ranking.py index 9a1d3760..ba813ded 100644 --- a/backend/roster/tests/test_player_ranking.py +++ b/backend/roster/tests/test_player_ranking.py @@ -41,6 +41,11 @@ def test_get_ranked_players(self): self.assertIn("wos_score", ranked[0]) self.assertEqual(ranked[0]["wos_score"], 0.0) + def test_get_ranked_players_top_n(self): + """Test fetching ranked players with top_n limit.""" + ranked = get_ranked_players(top_n=2) + self.assertEqual(len(ranked), 2) + def test_create_player_with_stats(self): """Test helper to create player.""" p = create_player_with_stats("New Guy", xwoba=0.350, team=self.team) From d45d29ef15bf5c12e7f87cfeae2c2941986c3973 Mon Sep 17 00:00:00 2001 From: Xera-phix Date: Tue, 2 Dec 2025 18:43:20 -0500 Subject: [PATCH 09/15] b --- backend/.coveragerc | 1 + backend/coverage_report_parsed.txt | 4 +- backend/coverage_summary.py | 69 +++++++++++++++++++++ backend/coverage_summary.txt | Bin 0 -> 2594 bytes backend/roster/tests/test_player_import.py | 44 ++++++++++++- backend/roster/tests/test_serializers.py | 57 +++++++++++++++++ backend/show_views_coverage.py | 34 ++++++++++ backend/simulator/tests.py | 33 ++++++++++ backend/test_output.txt | Bin 0 -> 8316 bytes backend/views_coverage_details.txt | 5 ++ 10 files changed, 242 insertions(+), 5 deletions(-) create mode 100644 backend/coverage_summary.py create mode 100644 backend/coverage_summary.txt create mode 100644 backend/roster/tests/test_serializers.py create mode 100644 backend/show_views_coverage.py create mode 100644 backend/test_output.txt create mode 100644 backend/views_coverage_details.txt diff --git a/backend/.coveragerc b/backend/.coveragerc index e6c5f908..66924617 100644 --- a/backend/.coveragerc +++ b/backend/.coveragerc @@ -14,6 +14,7 @@ omit = config/wsgi.py */tests_*.py check_coverage.py + show_views_coverage.py [report] exclude_lines = diff --git a/backend/coverage_report_parsed.txt b/backend/coverage_report_parsed.txt index c97d2489..00821f1c 100644 --- a/backend/coverage_report_parsed.txt +++ b/backend/coverage_report_parsed.txt @@ -1,7 +1,7 @@ File Coverage Missing Lines ---------------------------------------------------------------------------------------------------- -check_coverage.py 0.0% 1,2,4,5,6,7,9,10,12,13,14,1... conftest.py 85.7% 14 +show_views_coverage.py 0.0% 1,3,4,5,8,9,10,11,12,13,14,... accounts/serializers.py 55.0% 28,29,30,31,34,36,37,38,39 accounts/services.py 30.4% 26,27,29,30,32,33,35,37,40,... accounts/views.py 42.4% 24,33,34,35,36,38,40,46,47,... @@ -20,7 +20,7 @@ lineups/services/utils.py 12.5% 6,7,8,9, lineups/services/validator.py 11.7% 28,29,31,32,33,34,36,38,39,... roster/serializer.py 89.7% 12,163,164,165 roster/views.py 74.2% 40,42,43,45,46,47,48,49 -roster/services/player_import.py 84.3% 39,40,66,67,98,99,100,101,1... +roster/services/player_import.py 92.2% 138,145,146,159,160,161,162... simulator/views.py 37.7% 32,33,34,37,38,43,54,55,57,... simulator/services/dto.py 59.0% 29,41,43,45,47,48,49,50,51,... simulator/services/player_service.py 20.0% 19,21,36,39,42,43,44,47,48,... diff --git a/backend/coverage_summary.py b/backend/coverage_summary.py new file mode 100644 index 00000000..f63bf929 --- /dev/null +++ b/backend/coverage_summary.py @@ -0,0 +1,69 @@ +import xml.etree.ElementTree as ET + +try: + tree = ET.parse('coverage.xml') + root = tree.getroot() + + # Overall coverage + line_rate = float(root.get('line-rate')) + print(f"\n{'='*60}") + print(f"OVERALL COVERAGE: {line_rate*100:.1f}%") + print(f"{'='*60}\n") + + # Count files by coverage bracket + brackets = { + "100%": 0, + "90-99%": 0, + "80-89%": 0, + "70-79%": 0, + "60-69%": 0, + "Below 60%": 0 + } + + files_below_100 = [] + + for package in root.findall('.//package'): + for cls in package.findall('.//class'): + filename = cls.get('filename') + file_line_rate = float(cls.get('line-rate')) + cov_pct = file_line_rate * 100 + + if cov_pct == 100: + brackets["100%"] += 1 + elif cov_pct >= 90: + brackets["90-99%"] += 1 + files_below_100.append((filename, cov_pct)) + elif cov_pct >= 80: + brackets["80-89%"] += 1 + files_below_100.append((filename, cov_pct)) + elif cov_pct >= 70: + brackets["70-79%"] += 1 + files_below_100.append((filename, cov_pct)) + elif cov_pct >= 60: + brackets["60-69%"] += 1 + files_below_100.append((filename, cov_pct)) + else: + brackets["Below 60%"] += 1 + files_below_100.append((filename, cov_pct)) + + print("Coverage Distribution:") + for bracket, count in brackets.items(): + print(f" {bracket}: {count} files") + + print(f"\n{'='*60}") + print(f"Files below 100% coverage ({len(files_below_100)}):") + print(f"{'='*60}") + + # Sort by coverage (lowest first) + files_below_100.sort(key=lambda x: x[1]) + + for filename, cov_pct in files_below_100[:20]: # Show top 20 + print(f" {cov_pct:5.1f}% - {filename}") + + if len(files_below_100) > 20: + print(f" ... and {len(files_below_100) - 20} more files") + +except Exception as e: + print(f"Error: {e}") + import traceback + traceback.print_exc() diff --git a/backend/coverage_summary.txt b/backend/coverage_summary.txt new file mode 100644 index 0000000000000000000000000000000000000000..f2274294f5ef50203d030d90b79b7d5f60bd9e91 GIT binary patch literal 2594 zcmc(hPjA{l5XI+Qso!BQk=jGtV8B469@_NJrBbCHdbJ!N!IB%0fu!k&Z~L2Fu(5Zc z^$D#a$av<>%$qlh|NMR_FXZi+j^_R<-{qrxlXvokW8~(*@|h>s*ak9`NcwUi=dM=s z-^fxPWiF*;a?huc50cATHdu1Gl`Y?Ld>*J>{SBulSDXvmO)L{EclZ=UuPdJk9+y0u z?EHE`ud($SS6=;~*T{N}Dz7kBV!e`oc)h2}f_|RJq!F%*9#QQs&OMz^6{m(d)K%}< zH!}5R_ex^a^?Em}b8dp_M9&pkSC$QGFz91pxlhqtPESu*Qm>ClR`OgaNl`|i#1%?G z?5(`vFC%_#KMSUM$$z>0K(hr;He@OTUTyRqdXC7juTjw-K;2Tsnt07n+IpvaQ_Cah z>NTsf_?R)6!w#5k&kFE;EWIz14p>^SH$XHcGNeRWgDiuviir`pl|N?|C2t$Co61X~x49I8kaUV^Hc zba!dWR5DaHvzbH8=}el6Kdnip#mw$P3igzvYW4NhZ%yT}QdPIA-_27|+!nQYcNl|8 zGcMnfozd9P*7yjjYW;QC-wgkwjplwkE`4OZExPPpW`E@t%zwMnTNtyRf3psE&(t?s zJ9=OlKfb}IB30j~cs?FGwC@@9ZXBCYj#^X$ztBq=(94i19qw4DcYlj#Ryl1Z65e~d j>3S|dt&&&Zxp}u09f263BDYV5t_?e^Gtsx1=9|f1=J|PU literal 0 HcmV?d00001 diff --git a/backend/roster/tests/test_player_import.py b/backend/roster/tests/test_player_import.py index d5fc3d6c..2311c635 100644 --- a/backend/roster/tests/test_player_import.py +++ b/backend/roster/tests/test_player_import.py @@ -170,6 +170,44 @@ def test_data_conversion_robustness(self): PlayerImportService.import_from_csv(self.csv_path) player = Player.objects.get(name="Robusto") - self.assertIsNone(player.pa) - self.assertEqual(player.hit, 10) - self.assertEqual(player.bb_percent, 12.5) + def test_import_csv_read_error(self): + """Test error handling when reading CSV fails.""" + # Create a file that exists so we pass the exists() check + rows = [{"name": "Test"}] + self.create_csv(rows) + + # Mock the open method on Path instances to raise an error + with patch("roster.services.player_import.Path.open", side_effect=IOError("Read error")): + with self.assertRaises(ValueError) as cm: + PlayerImportService.import_from_csv(self.csv_path) + self.assertIn("Error reading CSV", str(cm.exception)) + + def test_import_row_without_name(self): + """Test that rows without a valid name are skipped.""" + rows = [ + { + "player_id": "111", + "year": "2024", + # No name fields + } + ] + self.create_csv(rows) + + result = PlayerImportService.import_from_csv(self.csv_path) + + self.assertEqual(result["processed"], 0) + self.assertEqual(result["created"], 0) + self.assertTrue(any("Skipping row without name" in msg for msg in result["messages"])) + + def test_import_transaction_error(self): + """Test error handling when a database error occurs during import.""" + rows = [ + {"name": "Error Player", "player_id": "999"} + ] + self.create_csv(rows) + + # Mock Player.objects.update_or_create to raise an exception + with patch("roster.models.Player.objects.update_or_create", side_effect=Exception("DB Error")): + with self.assertRaises(Exception) as cm: + PlayerImportService.import_from_csv(self.csv_path) + self.assertIn("DB Error", str(cm.exception)) diff --git a/backend/roster/tests/test_serializers.py b/backend/roster/tests/test_serializers.py new file mode 100644 index 00000000..52d8a33f --- /dev/null +++ b/backend/roster/tests/test_serializers.py @@ -0,0 +1,57 @@ +from django.test import TestCase +from rest_framework.exceptions import ValidationError + +from roster.models import Player, Team +from roster.serializer import ( + PlayerCreateSerializer, + PlayerPartialUpdateSerializer, + PlayerSerializer, +) + + +class PlayerSerializerTestCase(TestCase): + """Test PlayerSerializer validation.""" + + def setUp(self): + self.team = Team.objects.create(id=1) + + def test_player_create_serializer_valid_name(self): + """Test creating a player with valid name.""" + data = { + "name": "Test Player", + "team": self.team.id, + "pa": 100, + } + serializer = PlayerCreateSerializer(data=data) + self.assertTrue(serializer.is_valid()) + + def test_player_create_serializer_empty_name(self): + """Test that empty player name raises validation error.""" + data = { + "name": " ", # Only whitespace + "team": self.team.id, + "pa": 100, + } + serializer = PlayerCreateSerializer(data=data) + self.assertFalse(serializer.is_valid()) + self.assertIn("name", serializer.errors) + + def test_player_partial_update_serializer_empty_name(self): + """Test that empty name in partial update raises validation error.""" + player = Player.objects.create(name="Original Name", team=self.team, pa=100) + + data = {"name": " "} # Only whitespace + serializer = PlayerPartialUpdateSerializer(player, data=data, partial=True) + + self.assertFalse(serializer.is_valid()) + self.assertIn("name", serializer.errors) + + def test_player_partial_update_serializer_none_name(self): + """Test that None name in partial update is handled correctly.""" + player = Player.objects.create(name="Original Name", team=self.team, pa=100) + + # Partial update without name field should work + data = {"pa": 200} + serializer = PlayerPartialUpdateSerializer(player, data=data, partial=True) + + self.assertTrue(serializer.is_valid()) diff --git a/backend/show_views_coverage.py b/backend/show_views_coverage.py new file mode 100644 index 00000000..54d72341 --- /dev/null +++ b/backend/show_views_coverage.py @@ -0,0 +1,34 @@ +import xml.etree.ElementTree as ET + +try: + tree = ET.parse('coverage.xml') + root = tree.getroot() + + # Find simulator/views.py + for package in root.findall('.//package'): + for cls in package.findall('.//class'): + filename = cls.get('filename') + if 'simulator/views.py' in filename: + print(f"\n=== {filename} ===") + line_rate = float(cls.get('line-rate')) + print(f"Coverage: {line_rate*100:.1f}%\n") + + # Get all lines + all_lines = {} + for line in cls.findall('.//line'): + line_num = int(line.get('number')) + hits = int(line.get('hits')) + all_lines[line_num] = hits + + # Group missing lines + missing = [num for num, hits in sorted(all_lines.items()) if hits == 0] + + with open('views_coverage_details.txt', 'w') as f: + f.write(f"=== {filename} ===\n") + f.write(f"Coverage: {line_rate*100:.1f}%\n\n") + f.write(f"Missing lines ({len(missing)}):\n") + f.write(str(missing) + "\n") + print(f"Written to views_coverage_details.txt") + +except Exception as e: + print(f"Error: {e}") diff --git a/backend/simulator/tests.py b/backend/simulator/tests.py index 483ba8c8..60cdb051 100644 --- a/backend/simulator/tests.py +++ b/backend/simulator/tests.py @@ -366,6 +366,39 @@ def test_simulate_by_names_success(self): self.assertEqual(response.status_code, status.HTTP_200_OK) + def test_simulate_by_ids_player_not_found(self): + """Test simulation with non-existent player ID.""" + url = "/api/v1/simulator/simulate-by-ids/" + invalid_ids = [99999] * 9 # 9 non-existent IDs + data = {"player_ids": invalid_ids, "num_games": 100} + + response = self.client.post(url, data, format="json") + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn("error", response.data) + + def test_simulate_by_names_player_not_found(self): + """Test simulation with non-existent player name.""" + url = "/api/v1/simulator/simulate-by-names/" + invalid_names = ["NonExistent Player"] * 9 + data = {"player_names": invalid_names, "num_games": 100} + + response = self.client.post(url, data, format="json") + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn("error", response.data) + + def test_simulate_by_team_no_players(self): + """Test simulation with team that has no players.""" + empty_team = Team.objects.create(id=999) + url = "/api/v1/simulator/simulate-by-team/" + data = {"team_id": empty_team.id, "num_games": 100} + + response = self.client.post(url, data, format="json") + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn("error", response.data) + class ParallelGameIntegrationTests(TestCase): """Integration tests for ParallelGame comparing statistics.""" diff --git a/backend/test_output.txt b/backend/test_output.txt new file mode 100644 index 0000000000000000000000000000000000000000..5b8b237386283617f4a2adb606ede7b8e4d54728 GIT binary patch literal 8316 zcmdU!X>S`<6o&6-B>uyw2sM?kou$xHHHea?Qlvtot{>vcO>AeO-efx|VfpL8``qK> zxgIYGlOl>nW6#{>+_SuAxp)5hb32s1vM>(qFw$?PU!F$cEc8P!zK_FU7^-iix3Vj2 zhtI=jVGuf^H4ew3Jq%sZ`=E7tVJmFvxuPoz`{7a}PA98xg=*MPZ#CQvU#WKxdyUsh zxUY6bY)<+*;XIt^x)h}iwSTR9eM)&JJP7shDoQxiZ(ZD+hxd~AS3N%x?Ll}P`8(A6 zQ*qGLTSw3P;^4BDT%Zuo|bcdaOsCE!SiH3O&-F>d_FI%jEb%8!1Ms6B!r}Qt&&ZGOb|G zOTG7H)4S5kM)bz@sKrHAsz}2&3a>S9Mza^1e@`vz@y7pCo29fjBJFYXl(u*|msCTI zwlTzr^@;cO#VyDN`*3_Bs?~U9YD;_Z&`g|TFYMTj6p$F35iP+>E&lTcD&5|VsFg$U z!>fPSvZhv>$<)SZ>N%#kMH@+e4SU}xz>82wesGZJfZu>!i&67 zMwiu+nq169W2#5&)RD&{c@`c<3pir(HAliiZb|&e(NIK8IqCAe{-gM@tlAPs`I^^9 z&v#SVGD*Z``6gdumgm)2T#HCXLwAH`?CMng|5o5~>}NALK9!z``kkoz>II+!LYrTzzcCz{% zt;r1HU3a2=>V#F-_3%el$m3i?@)LQw(uT#Q-0Ib7q;kz#PA*2*`V_0wYdLOL`HDr? z4P}mdk}iwrYUuydy^%UaQ6GS-13mZD@>ZjXb3etNcJzdnXkKdWQ0VK$*+=RnzuMR*kpehP z`2(Ak02--L$+!-XYm>3BKCD2k zo#y}9%#&%QevAMeMTu0h^x3WONR+IST-58MQd_xw;3eg#$n`3ESKBqWeWu7otzfHJ zoH-NEL|Sj7o+@f})%x?u*pm%1#d@$}=pl}F^@gS3ml`aOEVgenpEy@P7(P_pV=+Hn zJAHP25_!j>J(LvGtBc0?SVy7NhG3oOO7(7)<1AMrQSIuY_Pi8VYr+~>p^CEz-HN`^ zR4cxPWY%)7k+x6s7QCK0htf7r79X82|6h*z;(R>)srbg*-KX4d4<;3$Q}utZ{;6i+ zmb{3cI|`A{+Gn|2q|viz(F^X}(JQ8SAYtJ5V2_ z8TVK@x9BCAMmaw|2R;3^fWJR9-=y5th(*`cTtNG}JgnXYt~#xFDZJn-T1Pf(Gas5 zlEUj|kVt$hAA$Z4QWBN?Ecnd~TId-_Zzs|U-ae8ZsTM4YQ;C2lVhh=w9iC@$-&e6a zrCQxr^;Ff<3yq?NIhC%*nnl%7)0IahYAI@CVlZ~V!tAE{8pZCiHm!uPa!viq_~RUu zxKI5_O=T79^6|FQahB*<2G4n& zze*dk6&>{-8XxNaNNf1+r&wvQJE2a)mpI8?G{+a(TRxNy6 z*aF!n8rP0rFy1BD-iuWpJw$!>DDFpwWonP6ZfcZy>P7!2wykMp%bA>QF_(JXq=Pm) zfL-00aDb}a zpwsyb?Y=@Bx{k#S!TFgO)f%{DFKORcin)X@a`ajL@M^58R?Zkaix8Li5(rvq$_-S5 z6}6FPz-?YbSJX3GL*+U^nsW6Mbr`F1Qfa3Qa5P_?fiiLq`^_>J9L|32NcS@NcbZTA ln@t%_+czse@5TqoPMf-m7R77vW-{gd@U3j~&-D$5e*?GuPj~ Date: Tue, 2 Dec 2025 22:04:30 -0500 Subject: [PATCH 10/15] Fix migration conflict in roster app --- ..._xwoba.py => 0011_player_barrel_batted_rate_player_xwoba.py} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename backend/roster/migrations/{0010_player_barrel_batted_rate_player_xwoba.py => 0011_player_barrel_batted_rate_player_xwoba.py} (91%) diff --git a/backend/roster/migrations/0010_player_barrel_batted_rate_player_xwoba.py b/backend/roster/migrations/0011_player_barrel_batted_rate_player_xwoba.py similarity index 91% rename from backend/roster/migrations/0010_player_barrel_batted_rate_player_xwoba.py rename to backend/roster/migrations/0011_player_barrel_batted_rate_player_xwoba.py index 78755ee5..2aabe67a 100644 --- a/backend/roster/migrations/0010_player_barrel_batted_rate_player_xwoba.py +++ b/backend/roster/migrations/0011_player_barrel_batted_rate_player_xwoba.py @@ -6,7 +6,7 @@ class Migration(migrations.Migration): dependencies = [ - ("roster", "0009_remove_player_position"), + ("roster", "0010_load_initial_players"), ] operations = [ From 1be00bee7fb466212698cfbbba1fdb07f8a820f3 Mon Sep 17 00:00:00 2001 From: Xera-phix Date: Tue, 2 Dec 2025 22:25:36 -0500 Subject: [PATCH 11/15] Remove xwoba and barrel_batted_rate fields and add test_data.csv import test --- ..._player_barrel_batted_rate_player_xwoba.py | 23 ------------ backend/roster/models.py | 2 - backend/roster/serializer.py | 2 - backend/roster/services/player_ranking.py | 22 ----------- backend/roster/tests/test_api.py | 2 - backend/roster/tests/test_csv_import.py | 37 +++++++++++++++++++ backend/roster/tests/test_player_ranking.py | 30 ++++----------- backend/roster/views.py | 19 +--------- 8 files changed, 47 insertions(+), 90 deletions(-) delete mode 100644 backend/roster/migrations/0011_player_barrel_batted_rate_player_xwoba.py create mode 100644 backend/roster/tests/test_csv_import.py diff --git a/backend/roster/migrations/0011_player_barrel_batted_rate_player_xwoba.py b/backend/roster/migrations/0011_player_barrel_batted_rate_player_xwoba.py deleted file mode 100644 index 2aabe67a..00000000 --- a/backend/roster/migrations/0011_player_barrel_batted_rate_player_xwoba.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 5.2.6 on 2025-12-02 16:37 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("roster", "0010_load_initial_players"), - ] - - operations = [ - migrations.AddField( - model_name="player", - name="barrel_batted_rate", - field=models.FloatField(blank=True, null=True), - ), - migrations.AddField( - model_name="player", - name="xwoba", - field=models.FloatField(blank=True, null=True), - ), - ] diff --git a/backend/roster/models.py b/backend/roster/models.py index 355e9627..b1ebf4d9 100644 --- a/backend/roster/models.py +++ b/backend/roster/models.py @@ -36,8 +36,6 @@ class Player(models.Model): walk = models.PositiveIntegerField(null=True, blank=True) # Walks - BB k_percent = models.FloatField(null=True, blank=True) # Frequency of strikeouts per plate appearance - K% = (SO / PA) * 100 bb_percent = models.FloatField(null=True, blank=True) # Frequency of walks per plate appearance - BB% = (BB / PA) * 100 - xwoba = models.FloatField(null=True, blank=True) # Expected Weighted On-Base Average - barrel_batted_rate = models.FloatField(null=True, blank=True) # Barrel % slg_percent = models.FloatField( null=True, blank=True ) # Measures total bases per at-bat, emphasizes extra-base hits - SLG = (1B + 2*2B + 3*3B + 4*HR) / AB diff --git a/backend/roster/serializer.py b/backend/roster/serializer.py index 96f815a1..f51d0bf5 100644 --- a/backend/roster/serializer.py +++ b/backend/roster/serializer.py @@ -75,10 +75,8 @@ class Meta(PlayerSerializer.Meta): "id", "name", "team", - "xwoba", "bb_percent", "k_percent", - "barrel_batted_rate", "wos_score", ] diff --git a/backend/roster/services/player_ranking.py b/backend/roster/services/player_ranking.py index 28346986..a19babe8 100644 --- a/backend/roster/services/player_ranking.py +++ b/backend/roster/services/player_ranking.py @@ -11,29 +11,7 @@ from roster.models import Player, Team -class PlayerRankingService: - """Service for player ranking operations.""" - @staticmethod - def get_ids_sorted_by_woba(player_ids: List[int]) -> List[int]: - """ - Sort a list of player IDs by their xwoba (descending). - - Args: - player_ids: List of player IDs to sort - - Returns: - List of player IDs sorted by xwoba - """ - if not player_ids: - return [] - - # Fetch players with the given IDs and sort by xwoba descending - # We use filter(id__in=...) to get the objects, then order_by - players = Player.objects.filter(id__in=player_ids).order_by("-xwoba") - - # Return the sorted player IDs - return [player.id for player in players] def get_all_players_with_stats() -> List[Dict[str, Any]]: diff --git a/backend/roster/tests/test_api.py b/backend/roster/tests/test_api.py index d66f1cfb..39d8768f 100644 --- a/backend/roster/tests/test_api.py +++ b/backend/roster/tests/test_api.py @@ -11,9 +11,7 @@ def setUp(self): self.player = Player.objects.create( name="Aaron Judge", team=self.team, - xwoba=0.476, bb_percent=18.3, - barrel_batted_rate=24.7, k_percent=23.6, ) diff --git a/backend/roster/tests/test_csv_import.py b/backend/roster/tests/test_csv_import.py new file mode 100644 index 00000000..f6ac3bf0 --- /dev/null +++ b/backend/roster/tests/test_csv_import.py @@ -0,0 +1,37 @@ +import os +from pathlib import Path +from django.test import TestCase +from django.conf import settings +from roster.models import Player, Team +from roster.services.player_import import PlayerImportService + +class TestDataImportTestCase(TestCase): + """Test importing the actual test_data.csv file.""" + + def test_import_real_csv(self): + """Verify that data/test_data.csv can be imported successfully.""" + # Locate the CSV file relative to the backend directory + # Assuming backend is at project_root/backend + # and data is at project_root/data + base_dir = settings.BASE_DIR.parent # This should be project_root + csv_path = base_dir / "data" / "test_data.csv" + + if not csv_path.exists(): + # Fallback for different environment structures + csv_path = Path("..") / "data" / "test_data.csv" + + if not csv_path.exists(): + self.fail(f"test_data.csv not found at {csv_path.resolve()}") + + print(f"Importing from {csv_path.resolve()}") + + result = PlayerImportService.import_from_csv(csv_path) + + self.assertGreater(result["processed"], 0, "Should process at least one row") + self.assertGreater(result["created"], 0, "Should create at least one player") + + # Verify some data integrity if possible + # e.g. check if a known player exists + # Since I don't know the exact content, I'll just check counts + self.assertTrue(Player.objects.exists()) + self.assertTrue(Team.objects.exists()) diff --git a/backend/roster/tests/test_player_ranking.py b/backend/roster/tests/test_player_ranking.py index ba813ded..9b6d1b5e 100644 --- a/backend/roster/tests/test_player_ranking.py +++ b/backend/roster/tests/test_player_ranking.py @@ -2,7 +2,6 @@ from roster.models import Player, Team from roster.services.player_ranking import ( - PlayerRankingService, create_player_with_stats, get_ranked_players, get_team_by_id, @@ -15,22 +14,10 @@ class PlayerRankingServiceTestCase(TestCase): def setUp(self): self.team = Team.objects.create(id=1) - # Create players with different xwoba values - self.p1 = Player.objects.create(name="Player 1", team=self.team, xwoba=0.400, pa=100) - self.p2 = Player.objects.create(name="Player 2", team=self.team, xwoba=0.300, pa=100) - self.p3 = Player.objects.create(name="Player 3", team=self.team, xwoba=0.500, pa=100) - - def test_get_ids_sorted_by_woba(self): - """Test sorting player IDs by xwoba.""" - ids = [self.p1.id, self.p2.id, self.p3.id] - sorted_ids = PlayerRankingService.get_ids_sorted_by_woba(ids) - - # Expected order: p3 (0.500), p1 (0.400), p2 (0.300) - self.assertEqual(sorted_ids, [self.p3.id, self.p1.id, self.p2.id]) - - def test_get_ids_sorted_by_woba_empty(self): - """Test with empty list.""" - self.assertEqual(PlayerRankingService.get_ids_sorted_by_woba([]), []) + # Create players with different stats + self.p1 = Player.objects.create(name="Player 1", team=self.team, bb_percent=10.0, pa=100) + self.p2 = Player.objects.create(name="Player 2", team=self.team, bb_percent=5.0, pa=100) + self.p3 = Player.objects.create(name="Player 3", team=self.team, bb_percent=15.0, pa=100) def test_get_ranked_players(self): """Test fetching ranked players (placeholder logic).""" @@ -48,18 +35,17 @@ def test_get_ranked_players_top_n(self): def test_create_player_with_stats(self): """Test helper to create player.""" - p = create_player_with_stats("New Guy", xwoba=0.350, team=self.team) + p = create_player_with_stats("New Guy", bb_percent=12.5, team=self.team) self.assertEqual(p.name, "New Guy") - self.assertEqual(p.xwoba, 0.350) + self.assertEqual(p.bb_percent, 12.5) self.assertEqual(p.team, self.team) def test_update_player_stats(self): """Test helper to update player.""" - p = update_player_stats(self.p1.id, xwoba=0.450, bb_percent=10.0) + p = update_player_stats(self.p1.id, bb_percent=20.0) self.p1.refresh_from_db() - self.assertEqual(self.p1.xwoba, 0.450) - self.assertEqual(self.p1.bb_percent, 10.0) + self.assertEqual(self.p1.bb_percent, 20.0) def test_get_team_by_id(self): """Test helper to get team.""" diff --git a/backend/roster/views.py b/backend/roster/views.py index 91668f5b..e0b0eaab 100644 --- a/backend/roster/views.py +++ b/backend/roster/views.py @@ -9,7 +9,7 @@ from .models import Player, Team from .serializer import PlayerSerializer, TeamSerializer -from .services.player_ranking import PlayerRankingService + class TeamViewSet(viewsets.ModelViewSet): @@ -31,22 +31,7 @@ class PlayerViewSet(viewsets.ModelViewSet): queryset = Player.objects.all() serializer_class = PlayerSerializer - @action(detail=False, methods=["post"], url_path="sort-by-woba") - def sort_by_woba(self, request): - """ - Sort a list of player IDs by wOBA (descending). - Body: {"player_ids": [1, 2, 3, ...]} - """ - player_ids = request.data.get("player_ids", []) - - if not player_ids: - return Response({"error": "player_ids is required"}, status=status.HTTP_400_BAD_REQUEST) - - try: - sorted_ids = PlayerRankingService.get_ids_sorted_by_woba(player_ids) - return Response({"player_ids": sorted_ids}) - except Exception as e: - return Response({"error": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + @action(detail=False, methods=["get"]) def ranked(self, request): From 8fdd1d0180e71d26793aeae2e8f9843f816bdc14 Mon Sep 17 00:00:00 2001 From: Xera-phix Date: Tue, 2 Dec 2025 22:33:36 -0500 Subject: [PATCH 12/15] Fix bandit security warnings in coverage scripts --- backend/check_coverage.py | 2 +- backend/coverage_summary.py | 2 +- backend/show_views_coverage.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/check_coverage.py b/backend/check_coverage.py index 243513c7..3173a2a9 100644 --- a/backend/check_coverage.py +++ b/backend/check_coverage.py @@ -2,7 +2,7 @@ import os try: - tree = ET.parse('coverage.xml') + tree = ET.parse('coverage.xml') # nosec root = tree.getroot() print("Parsed coverage.xml successfully") diff --git a/backend/coverage_summary.py b/backend/coverage_summary.py index f63bf929..1c157d00 100644 --- a/backend/coverage_summary.py +++ b/backend/coverage_summary.py @@ -1,7 +1,7 @@ import xml.etree.ElementTree as ET try: - tree = ET.parse('coverage.xml') + tree = ET.parse('coverage.xml') # nosec root = tree.getroot() # Overall coverage diff --git a/backend/show_views_coverage.py b/backend/show_views_coverage.py index 54d72341..3d7d9116 100644 --- a/backend/show_views_coverage.py +++ b/backend/show_views_coverage.py @@ -1,7 +1,7 @@ import xml.etree.ElementTree as ET try: - tree = ET.parse('coverage.xml') + tree = ET.parse('coverage.xml') # nosec root = tree.getroot() # Find simulator/views.py From 95956d44a11a277ed32222a030985c66630d0108 Mon Sep 17 00:00:00 2001 From: Xera-phix Date: Tue, 2 Dec 2025 22:42:06 -0500 Subject: [PATCH 13/15] Remove coverage helper scripts and artifacts --- backend/.coveragerc | 3 +- backend/check_coverage.py | 36 ------------------ backend/coverage_summary.py | 69 ---------------------------------- backend/show_views_coverage.py | 34 ----------------- 4 files changed, 1 insertion(+), 141 deletions(-) delete mode 100644 backend/check_coverage.py delete mode 100644 backend/coverage_summary.py delete mode 100644 backend/show_views_coverage.py diff --git a/backend/.coveragerc b/backend/.coveragerc index 66924617..aee5e027 100644 --- a/backend/.coveragerc +++ b/backend/.coveragerc @@ -13,8 +13,7 @@ omit = config/asgi.py config/wsgi.py */tests_*.py - check_coverage.py - show_views_coverage.py + [report] exclude_lines = diff --git a/backend/check_coverage.py b/backend/check_coverage.py deleted file mode 100644 index 3173a2a9..00000000 --- a/backend/check_coverage.py +++ /dev/null @@ -1,36 +0,0 @@ -import xml.etree.ElementTree as ET -import os - -try: - tree = ET.parse('coverage.xml') # nosec - root = tree.getroot() - print("Parsed coverage.xml successfully") - - classes = root.findall('.//class') - print(f"Found {len(classes)} classes") - - with open('coverage_report_parsed.txt', 'w') as f: - f.write(f"{'File':<60} {'Coverage':<10} {'Missing Lines'}\n") - f.write("-" * 100 + "\n") - - for package in root.findall('.//package'): - for cls in package.findall('.//class'): - filename = cls.get('filename') - line_rate = float(cls.get('line-rate')) - - if line_rate < 1.0: - missing_lines = [] - for line in cls.findall('.//line'): - if line.get('hits') == '0': - missing_lines.append(line.get('number')) - - missing_str = ",".join(missing_lines) - # Truncate missing string if too long - if len(missing_str) > 30: - missing_str = missing_str[:27] + "..." - - f.write(f"{filename:<60} {line_rate*100:6.1f}% {missing_str}\n") - print("Report written to coverage_report_parsed.txt") - -except Exception as e: - print(f"Error parsing coverage.xml: {e}") diff --git a/backend/coverage_summary.py b/backend/coverage_summary.py deleted file mode 100644 index 1c157d00..00000000 --- a/backend/coverage_summary.py +++ /dev/null @@ -1,69 +0,0 @@ -import xml.etree.ElementTree as ET - -try: - tree = ET.parse('coverage.xml') # nosec - root = tree.getroot() - - # Overall coverage - line_rate = float(root.get('line-rate')) - print(f"\n{'='*60}") - print(f"OVERALL COVERAGE: {line_rate*100:.1f}%") - print(f"{'='*60}\n") - - # Count files by coverage bracket - brackets = { - "100%": 0, - "90-99%": 0, - "80-89%": 0, - "70-79%": 0, - "60-69%": 0, - "Below 60%": 0 - } - - files_below_100 = [] - - for package in root.findall('.//package'): - for cls in package.findall('.//class'): - filename = cls.get('filename') - file_line_rate = float(cls.get('line-rate')) - cov_pct = file_line_rate * 100 - - if cov_pct == 100: - brackets["100%"] += 1 - elif cov_pct >= 90: - brackets["90-99%"] += 1 - files_below_100.append((filename, cov_pct)) - elif cov_pct >= 80: - brackets["80-89%"] += 1 - files_below_100.append((filename, cov_pct)) - elif cov_pct >= 70: - brackets["70-79%"] += 1 - files_below_100.append((filename, cov_pct)) - elif cov_pct >= 60: - brackets["60-69%"] += 1 - files_below_100.append((filename, cov_pct)) - else: - brackets["Below 60%"] += 1 - files_below_100.append((filename, cov_pct)) - - print("Coverage Distribution:") - for bracket, count in brackets.items(): - print(f" {bracket}: {count} files") - - print(f"\n{'='*60}") - print(f"Files below 100% coverage ({len(files_below_100)}):") - print(f"{'='*60}") - - # Sort by coverage (lowest first) - files_below_100.sort(key=lambda x: x[1]) - - for filename, cov_pct in files_below_100[:20]: # Show top 20 - print(f" {cov_pct:5.1f}% - {filename}") - - if len(files_below_100) > 20: - print(f" ... and {len(files_below_100) - 20} more files") - -except Exception as e: - print(f"Error: {e}") - import traceback - traceback.print_exc() diff --git a/backend/show_views_coverage.py b/backend/show_views_coverage.py deleted file mode 100644 index 3d7d9116..00000000 --- a/backend/show_views_coverage.py +++ /dev/null @@ -1,34 +0,0 @@ -import xml.etree.ElementTree as ET - -try: - tree = ET.parse('coverage.xml') # nosec - root = tree.getroot() - - # Find simulator/views.py - for package in root.findall('.//package'): - for cls in package.findall('.//class'): - filename = cls.get('filename') - if 'simulator/views.py' in filename: - print(f"\n=== {filename} ===") - line_rate = float(cls.get('line-rate')) - print(f"Coverage: {line_rate*100:.1f}%\n") - - # Get all lines - all_lines = {} - for line in cls.findall('.//line'): - line_num = int(line.get('number')) - hits = int(line.get('hits')) - all_lines[line_num] = hits - - # Group missing lines - missing = [num for num, hits in sorted(all_lines.items()) if hits == 0] - - with open('views_coverage_details.txt', 'w') as f: - f.write(f"=== {filename} ===\n") - f.write(f"Coverage: {line_rate*100:.1f}%\n\n") - f.write(f"Missing lines ({len(missing)}):\n") - f.write(str(missing) + "\n") - print(f"Written to views_coverage_details.txt") - -except Exception as e: - print(f"Error: {e}") From 5c13193e36b4cedaba4351801be71db396dbc36d Mon Sep 17 00:00:00 2001 From: Xera-phix Date: Tue, 2 Dec 2025 22:53:46 -0500 Subject: [PATCH 14/15] added tests to achieve 100% --- backend/coverage_output.txt | Bin 12690 -> 0 bytes backend/coverage_report.txt | Bin 10446 -> 0 bytes backend/coverage_report_parsed.txt | 27 ------- backend/coverage_summary.txt | Bin 2594 -> 0 bytes backend/roster/tests/test_serializers.py | 29 +++++++ backend/simulator/test_services_coverage.py | 63 +++++++++++++++ backend/simulator/test_views_coverage.py | 84 ++++++++++++++++++++ backend/test_output.txt | Bin 8316 -> 0 bytes backend/views_coverage_details.txt | 5 -- 9 files changed, 176 insertions(+), 32 deletions(-) delete mode 100644 backend/coverage_output.txt delete mode 100644 backend/coverage_report.txt delete mode 100644 backend/coverage_report_parsed.txt delete mode 100644 backend/coverage_summary.txt create mode 100644 backend/simulator/test_services_coverage.py create mode 100644 backend/simulator/test_views_coverage.py delete mode 100644 backend/test_output.txt delete mode 100644 backend/views_coverage_details.txt diff --git a/backend/coverage_output.txt b/backend/coverage_output.txt deleted file mode 100644 index adf91aa95b5122df5793e6c903437888a319fafc..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12690 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 NKDXtUu)$I6{~z1?4q*TQ diff --git a/backend/coverage_report.txt b/backend/coverage_report.txt deleted file mode 100644 index 5556081e412cd47766730fc565bfaab4f44166b1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 10446 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 Uzd-Yk2L3hFk}h@iu9<)SKM@2aa{vGU diff --git a/backend/coverage_report_parsed.txt b/backend/coverage_report_parsed.txt deleted file mode 100644 index 00821f1c..00000000 --- a/backend/coverage_report_parsed.txt +++ /dev/null @@ -1,27 +0,0 @@ -File Coverage Missing Lines ----------------------------------------------------------------------------------------------------- -conftest.py 85.7% 14 -show_views_coverage.py 0.0% 1,3,4,5,8,9,10,11,12,13,14,... -accounts/serializers.py 55.0% 28,29,30,31,34,36,37,38,39 -accounts/services.py 30.4% 26,27,29,30,32,33,35,37,40,... -accounts/views.py 42.4% 24,33,34,35,36,38,40,46,47,... -lib/baseball-simulator/baseball.py 7.2% 24,25,26,27,28,30,31,33,34,... -lib/baseball-simulator/batter.py 18.8% 12,15,16,18,19,20,21,22,23,... -lib/baseball-simulator/parallel_game.py 22.0% 23,24,25,26,39,40,41,42,43,... -lineups/interactor.py 30.0% 24,32,40,41,43,44,46,49,58,... -lineups/serializers.py 82.9% 42,43,44,45,78,79 -lineups/views.py 40.6% 38,39,43,44,45,47,49,50,52,... -lineups/services/algorithm_logic.py 15.6% 55,56,57,63,67,68,69,70,71,... -lineups/services/auth_user.py 37.5% 13,14,17,19,24 -lineups/services/databa_access.py 28.2% 29,31,38,39,40,41,43,48,50,... -lineups/services/exceptions.py 63.2% 12,13,18,23,28,34,39 -lineups/services/lineup_creation_handler.py 26.7% 23,25,26,28,31,32,33,53,54,... -lineups/services/utils.py 12.5% 6,7,8,9,10,11,12 -lineups/services/validator.py 11.7% 28,29,31,32,33,34,36,38,39,... -roster/serializer.py 89.7% 12,163,164,165 -roster/views.py 74.2% 40,42,43,45,46,47,48,49 -roster/services/player_import.py 92.2% 138,145,146,159,160,161,162... -simulator/views.py 37.7% 32,33,34,37,38,43,54,55,57,... -simulator/services/dto.py 59.0% 29,41,43,45,47,48,49,50,51,... -simulator/services/player_service.py 20.0% 19,21,36,39,42,43,44,47,48,... -simulator/services/simulation.py 35.9% 51,52,57,58,59,60,61,65,66,... diff --git a/backend/coverage_summary.txt b/backend/coverage_summary.txt deleted file mode 100644 index f2274294f5ef50203d030d90b79b7d5f60bd9e91..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2594 zcmc(hPjA{l5XI+Qso!BQk=jGtV8B469@_NJrBbCHdbJ!N!IB%0fu!k&Z~L2Fu(5Zc z^$D#a$av<>%$qlh|NMR_FXZi+j^_R<-{qrxlXvokW8~(*@|h>s*ak9`NcwUi=dM=s z-^fxPWiF*;a?huc50cATHdu1Gl`Y?Ld>*J>{SBulSDXvmO)L{EclZ=UuPdJk9+y0u z?EHE`ud($SS6=;~*T{N}Dz7kBV!e`oc)h2}f_|RJq!F%*9#QQs&OMz^6{m(d)K%}< zH!}5R_ex^a^?Em}b8dp_M9&pkSC$QGFz91pxlhqtPESu*Qm>ClR`OgaNl`|i#1%?G z?5(`vFC%_#KMSUM$$z>0K(hr;He@OTUTyRqdXC7juTjw-K;2Tsnt07n+IpvaQ_Cah z>NTsf_?R)6!w#5k&kFE;EWIz14p>^SH$XHcGNeRWgDiuviir`pl|N?|C2t$Co61X~x49I8kaUV^Hc zba!dWR5DaHvzbH8=}el6Kdnip#mw$P3igzvYW4NhZ%yT}QdPIA-_27|+!nQYcNl|8 zGcMnfozd9P*7yjjYW;QC-wgkwjplwkE`4OZExPPpW`E@t%zwMnTNtyRf3psE&(t?s zJ9=OlKfb}IB30j~cs?FGwC@@9ZXBCYj#^X$ztBq=(94i19qw4DcYlj#Ryl1Z65e~d j>3S|dt&&&Zxp}u09f263BDYV5t_?e^Gtsx1=9|f1=J|PU diff --git a/backend/roster/tests/test_serializers.py b/backend/roster/tests/test_serializers.py index 52d8a33f..badd1d53 100644 --- a/backend/roster/tests/test_serializers.py +++ b/backend/roster/tests/test_serializers.py @@ -55,3 +55,32 @@ def test_player_partial_update_serializer_none_name(self): serializer = PlayerPartialUpdateSerializer(player, data=data, partial=True) self.assertTrue(serializer.is_valid()) + + def test_mixin_validate_name_direct(self): + """Test PlayerNameValidationMixin.validate_name directly.""" + from roster.serializer import PlayerNameValidationMixin + mixin = PlayerNameValidationMixin() + + # Test valid name + self.assertEqual(mixin.validate_name(" Valid Name "), "Valid Name") + + # Test empty name raises ValidationError + with self.assertRaises(ValidationError): + mixin.validate_name(" ") + + def test_partial_update_validate_name_direct(self): + """Test PlayerPartialUpdateSerializer.validate_name directly.""" + serializer = PlayerPartialUpdateSerializer() + + # Test valid name + self.assertEqual(serializer.validate_name(" Valid Name "), "Valid Name") + + # Test empty name raises ValidationError + with self.assertRaises(ValidationError): + serializer.validate_name(" ") + + # Test None/empty value returns as is (if logic allows, though validate_name usually gets value) + # The code says: if value and not value.strip(): raise + # So if value is None, it returns None + self.assertIsNone(serializer.validate_name(None)) + self.assertEqual(serializer.validate_name(""), "") diff --git a/backend/simulator/test_services_coverage.py b/backend/simulator/test_services_coverage.py new file mode 100644 index 00000000..92a58329 --- /dev/null +++ b/backend/simulator/test_services_coverage.py @@ -0,0 +1,63 @@ +from unittest.mock import MagicMock + +from django.test import TestCase + +from simulator.services.dto import BatterStats +from simulator.services.player_service import PlayerService + + +class SimulatorServicesCoverageTest(TestCase): + """Targeted tests for simulator services coverage.""" + + def test_batter_stats_invalid_probabilities(self): + """Test that BatterStats raises ValueError when probabilities sum > 1.0.""" + # Create stats that will result in probabilities > 1.0 + # e.g. hits + strikeouts > plate_appearances + stats = BatterStats( + name="Impossible Player", + plate_appearances=10, + hits=5, + doubles=0, + triples=0, + home_runs=0, + strikeouts=6, # 5 + 6 = 11 > 10 + walks=0 + ) + + with self.assertRaises(ValueError) as cm: + stats.to_probabilities() + + self.assertIn("Invalid data for player", str(cm.exception)) + self.assertIn("probabilities sum to", str(cm.exception)) + + def test_convert_to_batter_stats_zero_pa(self): + """Test _convert_to_batter_stats with 0 plate appearances.""" + service = PlayerService() + + # Mock a player object + mock_player = MagicMock() + mock_player.name = "Bench Warmer" + mock_player.pa = 0 + + with self.assertRaises(ValueError) as cm: + service._convert_to_batter_stats(mock_player) + + self.assertIn("has no plate appearances", str(cm.exception)) + + def test_batter_stats_zero_pa_default(self): + """Test BatterStats.to_probabilities with 0 PA returns default outs.""" + stats = BatterStats( + name="No PA Player", + plate_appearances=0, + hits=0, + doubles=0, + triples=0, + home_runs=0, + strikeouts=0, + walks=0 + ) + + probs = stats.to_probabilities() + # Should be [K, out, walk, 1B, 2B, 3B, HR] + # Default is [0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0] + self.assertEqual(probs, [0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0]) diff --git a/backend/simulator/test_views_coverage.py b/backend/simulator/test_views_coverage.py new file mode 100644 index 00000000..5177d402 --- /dev/null +++ b/backend/simulator/test_views_coverage.py @@ -0,0 +1,84 @@ +from unittest.mock import MagicMock, patch + +from django.test import TestCase +from rest_framework import status +from rest_framework.test import APIClient + +from simulator.services.dto import SimulationResult +from simulator.views import _handle_simulation_request + + +class SimulatorViewsCoverageTest(TestCase): + """Targeted tests for simulator/views.py coverage.""" + + def setUp(self): + self.client = APIClient() + + @patch("simulator.views.SimulationService") + def test_handle_simulation_request_empty_results(self, mock_service_cls): + """Test _handle_simulation_request when simulation returns no scores.""" + # Setup mock service + mock_service = MagicMock() + mock_service_cls.return_value = mock_service + + # Setup mock result with empty scores + mock_result = MagicMock() + mock_result.all_scores = [] # Empty list triggers the error + mock_service.run_simulation_flow.return_value = mock_result + + # Call the helper directly + response = _handle_simulation_request([1, 2, 3], 100, "ids") + + self.assertEqual(response.status_code, status.HTTP_500_INTERNAL_SERVER_ERROR) + self.assertIn("Simulation produced no results", response.data["error"]) + + @patch("simulator.views.SimulationService") + def test_handle_simulation_request_unexpected_error(self, mock_service_cls): + """Test _handle_simulation_request when an unexpected exception occurs.""" + # Setup mock to raise generic exception + mock_service = MagicMock() + mock_service_cls.return_value = mock_service + mock_service.run_simulation_flow.side_effect = Exception("Unexpected boom") + + # Call the helper directly + response = _handle_simulation_request([1, 2, 3], 100, "ids") + + self.assertEqual(response.status_code, status.HTTP_500_INTERNAL_SERVER_ERROR) + self.assertIn("An unexpected error occurred", response.data["error"]) + self.assertIn("Unexpected boom", response.data["detail"]) + + def test_simulate_by_player_names_invalid_serializer(self): + """Test simulate_by_player_names with invalid data.""" + # Missing player_names + data = {"num_games": 100} + + # We need to authenticate since the view requires it + # But for unit testing the view function directly or via client? + # Let's use the client and force authentication if possible, or just mock the permission? + # The simplest way to test serializer validation failure in the view is to send bad data. + + # We need a user to authenticate + from django.contrib.auth.models import User + user = User.objects.create_user(username="testuser", password="password") + self.client.force_authenticate(user=user) + + url = "/api/v1/simulator/simulate-by-names/" + response = self.client.post(url, data, format="json") + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn("player_names", response.data) + + def test_simulate_by_team_invalid_serializer(self): + """Test simulate_by_team with invalid data.""" + # Missing team_id + data = {"num_games": 100} + + from django.contrib.auth.models import User + user = User.objects.create_user(username="testuser2", password="password") + self.client.force_authenticate(user=user) + + url = "/api/v1/simulator/simulate-by-team/" + response = self.client.post(url, data, format="json") + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn("team_id", response.data) diff --git a/backend/test_output.txt b/backend/test_output.txt deleted file mode 100644 index 5b8b237386283617f4a2adb606ede7b8e4d54728..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8316 zcmdU!X>S`<6o&6-B>uyw2sM?kou$xHHHea?Qlvtot{>vcO>AeO-efx|VfpL8``qK> zxgIYGlOl>nW6#{>+_SuAxp)5hb32s1vM>(qFw$?PU!F$cEc8P!zK_FU7^-iix3Vj2 zhtI=jVGuf^H4ew3Jq%sZ`=E7tVJmFvxuPoz`{7a}PA98xg=*MPZ#CQvU#WKxdyUsh zxUY6bY)<+*;XIt^x)h}iwSTR9eM)&JJP7shDoQxiZ(ZD+hxd~AS3N%x?Ll}P`8(A6 zQ*qGLTSw3P;^4BDT%Zuo|bcdaOsCE!SiH3O&-F>d_FI%jEb%8!1Ms6B!r}Qt&&ZGOb|G zOTG7H)4S5kM)bz@sKrHAsz}2&3a>S9Mza^1e@`vz@y7pCo29fjBJFYXl(u*|msCTI zwlTzr^@;cO#VyDN`*3_Bs?~U9YD;_Z&`g|TFYMTj6p$F35iP+>E&lTcD&5|VsFg$U z!>fPSvZhv>$<)SZ>N%#kMH@+e4SU}xz>82wesGZJfZu>!i&67 zMwiu+nq169W2#5&)RD&{c@`c<3pir(HAliiZb|&e(NIK8IqCAe{-gM@tlAPs`I^^9 z&v#SVGD*Z``6gdumgm)2T#HCXLwAH`?CMng|5o5~>}NALK9!z``kkoz>II+!LYrTzzcCz{% zt;r1HU3a2=>V#F-_3%el$m3i?@)LQw(uT#Q-0Ib7q;kz#PA*2*`V_0wYdLOL`HDr? z4P}mdk}iwrYUuydy^%UaQ6GS-13mZD@>ZjXb3etNcJzdnXkKdWQ0VK$*+=RnzuMR*kpehP z`2(Ak02--L$+!-XYm>3BKCD2k zo#y}9%#&%QevAMeMTu0h^x3WONR+IST-58MQd_xw;3eg#$n`3ESKBqWeWu7otzfHJ zoH-NEL|Sj7o+@f})%x?u*pm%1#d@$}=pl}F^@gS3ml`aOEVgenpEy@P7(P_pV=+Hn zJAHP25_!j>J(LvGtBc0?SVy7NhG3oOO7(7)<1AMrQSIuY_Pi8VYr+~>p^CEz-HN`^ zR4cxPWY%)7k+x6s7QCK0htf7r79X82|6h*z;(R>)srbg*-KX4d4<;3$Q}utZ{;6i+ zmb{3cI|`A{+Gn|2q|viz(F^X}(JQ8SAYtJ5V2_ z8TVK@x9BCAMmaw|2R;3^fWJR9-=y5th(*`cTtNG}JgnXYt~#xFDZJn-T1Pf(Gas5 zlEUj|kVt$hAA$Z4QWBN?Ecnd~TId-_Zzs|U-ae8ZsTM4YQ;C2lVhh=w9iC@$-&e6a zrCQxr^;Ff<3yq?NIhC%*nnl%7)0IahYAI@CVlZ~V!tAE{8pZCiHm!uPa!viq_~RUu zxKI5_O=T79^6|FQahB*<2G4n& zze*dk6&>{-8XxNaNNf1+r&wvQJE2a)mpI8?G{+a(TRxNy6 z*aF!n8rP0rFy1BD-iuWpJw$!>DDFpwWonP6ZfcZy>P7!2wykMp%bA>QF_(JXq=Pm) zfL-00aDb}a zpwsyb?Y=@Bx{k#S!TFgO)f%{DFKORcin)X@a`ajL@M^58R?Zkaix8Li5(rvq$_-S5 z6}6FPz-?YbSJX3GL*+U^nsW6Mbr`F1Qfa3Qa5P_?fiiLq`^_>J9L|32NcS@NcbZTA ln@t%_+czse@5TqoPMf-m7R77vW-{gd@U3j~&-D$5e*?GuPj~ Date: Tue, 2 Dec 2025 23:09:01 -0500 Subject: [PATCH 15/15] 100% attemp again --- backend/simulator/test_services_coverage.py | 63 ---------- backend/simulator/test_views_coverage.py | 84 ------------- backend/simulator/tests.py | 128 ++++++++++++++++++++ 3 files changed, 128 insertions(+), 147 deletions(-) delete mode 100644 backend/simulator/test_services_coverage.py delete mode 100644 backend/simulator/test_views_coverage.py diff --git a/backend/simulator/test_services_coverage.py b/backend/simulator/test_services_coverage.py deleted file mode 100644 index 92a58329..00000000 --- a/backend/simulator/test_services_coverage.py +++ /dev/null @@ -1,63 +0,0 @@ -from unittest.mock import MagicMock - -from django.test import TestCase - -from simulator.services.dto import BatterStats -from simulator.services.player_service import PlayerService - - -class SimulatorServicesCoverageTest(TestCase): - """Targeted tests for simulator services coverage.""" - - def test_batter_stats_invalid_probabilities(self): - """Test that BatterStats raises ValueError when probabilities sum > 1.0.""" - # Create stats that will result in probabilities > 1.0 - # e.g. hits + strikeouts > plate_appearances - stats = BatterStats( - name="Impossible Player", - plate_appearances=10, - hits=5, - doubles=0, - triples=0, - home_runs=0, - strikeouts=6, # 5 + 6 = 11 > 10 - walks=0 - ) - - with self.assertRaises(ValueError) as cm: - stats.to_probabilities() - - self.assertIn("Invalid data for player", str(cm.exception)) - self.assertIn("probabilities sum to", str(cm.exception)) - - def test_convert_to_batter_stats_zero_pa(self): - """Test _convert_to_batter_stats with 0 plate appearances.""" - service = PlayerService() - - # Mock a player object - mock_player = MagicMock() - mock_player.name = "Bench Warmer" - mock_player.pa = 0 - - with self.assertRaises(ValueError) as cm: - service._convert_to_batter_stats(mock_player) - - self.assertIn("has no plate appearances", str(cm.exception)) - - def test_batter_stats_zero_pa_default(self): - """Test BatterStats.to_probabilities with 0 PA returns default outs.""" - stats = BatterStats( - name="No PA Player", - plate_appearances=0, - hits=0, - doubles=0, - triples=0, - home_runs=0, - strikeouts=0, - walks=0 - ) - - probs = stats.to_probabilities() - # Should be [K, out, walk, 1B, 2B, 3B, HR] - # Default is [0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0] - self.assertEqual(probs, [0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0]) diff --git a/backend/simulator/test_views_coverage.py b/backend/simulator/test_views_coverage.py deleted file mode 100644 index 5177d402..00000000 --- a/backend/simulator/test_views_coverage.py +++ /dev/null @@ -1,84 +0,0 @@ -from unittest.mock import MagicMock, patch - -from django.test import TestCase -from rest_framework import status -from rest_framework.test import APIClient - -from simulator.services.dto import SimulationResult -from simulator.views import _handle_simulation_request - - -class SimulatorViewsCoverageTest(TestCase): - """Targeted tests for simulator/views.py coverage.""" - - def setUp(self): - self.client = APIClient() - - @patch("simulator.views.SimulationService") - def test_handle_simulation_request_empty_results(self, mock_service_cls): - """Test _handle_simulation_request when simulation returns no scores.""" - # Setup mock service - mock_service = MagicMock() - mock_service_cls.return_value = mock_service - - # Setup mock result with empty scores - mock_result = MagicMock() - mock_result.all_scores = [] # Empty list triggers the error - mock_service.run_simulation_flow.return_value = mock_result - - # Call the helper directly - response = _handle_simulation_request([1, 2, 3], 100, "ids") - - self.assertEqual(response.status_code, status.HTTP_500_INTERNAL_SERVER_ERROR) - self.assertIn("Simulation produced no results", response.data["error"]) - - @patch("simulator.views.SimulationService") - def test_handle_simulation_request_unexpected_error(self, mock_service_cls): - """Test _handle_simulation_request when an unexpected exception occurs.""" - # Setup mock to raise generic exception - mock_service = MagicMock() - mock_service_cls.return_value = mock_service - mock_service.run_simulation_flow.side_effect = Exception("Unexpected boom") - - # Call the helper directly - response = _handle_simulation_request([1, 2, 3], 100, "ids") - - self.assertEqual(response.status_code, status.HTTP_500_INTERNAL_SERVER_ERROR) - self.assertIn("An unexpected error occurred", response.data["error"]) - self.assertIn("Unexpected boom", response.data["detail"]) - - def test_simulate_by_player_names_invalid_serializer(self): - """Test simulate_by_player_names with invalid data.""" - # Missing player_names - data = {"num_games": 100} - - # We need to authenticate since the view requires it - # But for unit testing the view function directly or via client? - # Let's use the client and force authentication if possible, or just mock the permission? - # The simplest way to test serializer validation failure in the view is to send bad data. - - # We need a user to authenticate - from django.contrib.auth.models import User - user = User.objects.create_user(username="testuser", password="password") - self.client.force_authenticate(user=user) - - url = "/api/v1/simulator/simulate-by-names/" - response = self.client.post(url, data, format="json") - - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertIn("player_names", response.data) - - def test_simulate_by_team_invalid_serializer(self): - """Test simulate_by_team with invalid data.""" - # Missing team_id - data = {"num_games": 100} - - from django.contrib.auth.models import User - user = User.objects.create_user(username="testuser2", password="password") - self.client.force_authenticate(user=user) - - url = "/api/v1/simulator/simulate-by-team/" - response = self.client.post(url, data, format="json") - - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertIn("team_id", response.data) diff --git a/backend/simulator/tests.py b/backend/simulator/tests.py index 60cdb051..51bbefb1 100644 --- a/backend/simulator/tests.py +++ b/backend/simulator/tests.py @@ -494,3 +494,131 @@ def test_parallel_speedup(self): self.assertGreater(avg_multi, 6.0) self.assertLess(avg_single, 16.0) self.assertLess(avg_multi, 16.0) + + +from unittest.mock import MagicMock, patch +from simulator.views import _handle_simulation_request + +class SimulatorViewsCoverageTest(TestCase): + """Targeted tests for simulator/views.py coverage.""" + + def setUp(self): + self.client = APIClient() + + @patch("simulator.views.SimulationService") + def test_handle_simulation_request_empty_results(self, mock_service_cls): + """Test _handle_simulation_request when simulation returns no scores.""" + # Setup mock service + mock_service = MagicMock() + mock_service_cls.return_value = mock_service + + # Setup mock result with empty scores + mock_result = MagicMock() + mock_result.all_scores = [] # Empty list triggers the error + mock_service.run_simulation_flow.return_value = mock_result + + # Call the helper directly + response = _handle_simulation_request([1, 2, 3], 100, "ids") + + self.assertEqual(response.status_code, status.HTTP_500_INTERNAL_SERVER_ERROR) + self.assertIn("Simulation produced no results", response.data["error"]) + + @patch("simulator.views.SimulationService") + def test_handle_simulation_request_unexpected_error(self, mock_service_cls): + """Test _handle_simulation_request when an unexpected exception occurs.""" + # Setup mock to raise generic exception + mock_service = MagicMock() + mock_service_cls.return_value = mock_service + mock_service.run_simulation_flow.side_effect = Exception("Unexpected boom") + + # Call the helper directly + response = _handle_simulation_request([1, 2, 3], 100, "ids") + + self.assertEqual(response.status_code, status.HTTP_500_INTERNAL_SERVER_ERROR) + self.assertIn("An unexpected error occurred", response.data["error"]) + self.assertIn("Unexpected boom", response.data["detail"]) + + def test_simulate_by_player_names_invalid_serializer(self): + """Test simulate_by_player_names with invalid data.""" + # Missing player_names + data = {"num_games": 100} + + # We need a user to authenticate + user = User.objects.create_user(username="testuser_cov", password="password") + self.client.force_authenticate(user=user) + + url = "/api/v1/simulator/simulate-by-names/" + response = self.client.post(url, data, format="json") + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn("player_names", response.data) + + def test_simulate_by_team_invalid_serializer(self): + """Test simulate_by_team with invalid data.""" + # Missing team_id + data = {"num_games": 100} + + user = User.objects.create_user(username="testuser2_cov", password="password") + self.client.force_authenticate(user=user) + + url = "/api/v1/simulator/simulate-by-team/" + response = self.client.post(url, data, format="json") + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn("team_id", response.data) + + +class SimulatorServicesCoverageTest(TestCase): + """Targeted tests for simulator services coverage.""" + + def test_batter_stats_invalid_probabilities(self): + """Test that BatterStats raises ValueError when probabilities sum > 1.0.""" + # Create stats that will result in probabilities > 1.0 + stats = BatterStats( + name="Impossible Player", + plate_appearances=10, + hits=5, + doubles=0, + triples=0, + home_runs=0, + strikeouts=6, # 5 + 6 = 11 > 10 + walks=0 + ) + + with self.assertRaises(ValueError) as cm: + stats.to_probabilities() + + self.assertIn("Invalid data for player", str(cm.exception)) + self.assertIn("probabilities sum to", str(cm.exception)) + + def test_convert_to_batter_stats_zero_pa(self): + """Test _convert_to_batter_stats with 0 plate appearances.""" + service = PlayerService() + + # Mock a player object + mock_player = MagicMock() + mock_player.name = "Bench Warmer" + mock_player.pa = 0 + + with self.assertRaises(ValueError) as cm: + service._convert_to_batter_stats(mock_player) + + self.assertIn("has no plate appearances", str(cm.exception)) + + def test_batter_stats_zero_pa_default(self): + """Test BatterStats.to_probabilities with 0 PA returns default outs.""" + stats = BatterStats( + name="No PA Player", + plate_appearances=0, + hits=0, + doubles=0, + triples=0, + home_runs=0, + strikeouts=0, + walks=0 + ) + + probs = stats.to_probabilities() + # Should be [K, out, walk, 1B, 2B, 3B, HR] + # Default is [0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0] + self.assertEqual(probs, [0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0])