From 0daa6f449160e79459fbbced1d98afc88c868c9e Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Sun, 1 Mar 2026 15:36:38 -0600 Subject: [PATCH] fix: key plays score text shows "tied at X" instead of "Team up X-X" (closes #48) The else branch in descriptive_text() caught both "away leading" and "tied" cases, always overwriting the tied text. Changed to if/elif/else so tied scores display correctly. Co-Authored-By: Claude Opus 4.6 --- models/play.py | 160 +++++++++++++++++++++++++------------- tests/test_models_play.py | 129 ++++++++++++++++++++++++++++++ 2 files changed, 233 insertions(+), 56 deletions(-) create mode 100644 tests/test_models_play.py diff --git a/models/play.py b/models/play.py index 047652a..74eccf3 100644 --- a/models/play.py +++ b/models/play.py @@ -7,6 +7,7 @@ This model matches the database schema at /database/app/routers_v3/stratplay.py. NOTE: ID fields have corresponding optional model object fields for API-populated nested data. Future enhancement could add validators to ensure consistency between ID and model fields. """ + from typing import Optional, Literal from pydantic import Field, field_validator from models.base import SBABaseModel @@ -28,9 +29,11 @@ class Play(SBABaseModel): game: Optional[Game] = Field(None, description="Game object (API-populated)") play_num: int = Field(..., description="Sequential play number in game") pitcher_id: Optional[int] = Field(None, description="Pitcher ID") - pitcher: Optional[Player] = Field(None, description="Pitcher object (API-populated)") + pitcher: Optional[Player] = Field( + None, description="Pitcher object (API-populated)" + ) on_base_code: str = Field(..., description="Base runners code (e.g., '100', '011')") - inning_half: Literal['top', 'bot'] = Field(..., description="Inning half") + inning_half: Literal["top", "bot"] = Field(..., description="Inning half") inning_num: int = Field(..., description="Inning number") batting_order: int = Field(..., description="Batting order position") starting_outs: int = Field(..., description="Outs at start of play") @@ -41,21 +44,37 @@ class Play(SBABaseModel): batter_id: Optional[int] = Field(None, description="Batter ID") batter: Optional[Player] = Field(None, description="Batter object (API-populated)") batter_team_id: Optional[int] = Field(None, description="Batter's team ID") - batter_team: Optional[Team] = Field(None, description="Batter's team object (API-populated)") + batter_team: Optional[Team] = Field( + None, description="Batter's team object (API-populated)" + ) pitcher_team_id: Optional[int] = Field(None, description="Pitcher's team ID") - pitcher_team: Optional[Team] = Field(None, description="Pitcher's team object (API-populated)") + pitcher_team: Optional[Team] = Field( + None, description="Pitcher's team object (API-populated)" + ) batter_pos: Optional[str] = Field(None, description="Batter's position") # Base runner information on_first_id: Optional[int] = Field(None, description="Runner on first ID") - on_first: Optional[Player] = Field(None, description="Runner on first object (API-populated)") - on_first_final: Optional[int] = Field(None, description="Runner on first final base") + on_first: Optional[Player] = Field( + None, description="Runner on first object (API-populated)" + ) + on_first_final: Optional[int] = Field( + None, description="Runner on first final base" + ) on_second_id: Optional[int] = Field(None, description="Runner on second ID") - on_second: Optional[Player] = Field(None, description="Runner on second object (API-populated)") - on_second_final: Optional[int] = Field(None, description="Runner on second final base") + on_second: Optional[Player] = Field( + None, description="Runner on second object (API-populated)" + ) + on_second_final: Optional[int] = Field( + None, description="Runner on second final base" + ) on_third_id: Optional[int] = Field(None, description="Runner on third ID") - on_third: Optional[Player] = Field(None, description="Runner on third object (API-populated)") - on_third_final: Optional[int] = Field(None, description="Runner on third final base") + on_third: Optional[Player] = Field( + None, description="Runner on third object (API-populated)" + ) + on_third_final: Optional[int] = Field( + None, description="Runner on third final base" + ) batter_final: Optional[int] = Field(None, description="Batter's final base") # Statistical fields (all default to 0) @@ -96,17 +115,27 @@ class Play(SBABaseModel): # Defensive players catcher_id: Optional[int] = Field(None, description="Catcher ID") - catcher: Optional[Player] = Field(None, description="Catcher object (API-populated)") + catcher: Optional[Player] = Field( + None, description="Catcher object (API-populated)" + ) catcher_team_id: Optional[int] = Field(None, description="Catcher's team ID") - catcher_team: Optional[Team] = Field(None, description="Catcher's team object (API-populated)") + catcher_team: Optional[Team] = Field( + None, description="Catcher's team object (API-populated)" + ) defender_id: Optional[int] = Field(None, description="Defender ID") - defender: Optional[Player] = Field(None, description="Defender object (API-populated)") + defender: Optional[Player] = Field( + None, description="Defender object (API-populated)" + ) defender_team_id: Optional[int] = Field(None, description="Defender's team ID") - defender_team: Optional[Team] = Field(None, description="Defender's team object (API-populated)") + defender_team: Optional[Team] = Field( + None, description="Defender's team object (API-populated)" + ) runner_id: Optional[int] = Field(None, description="Runner ID") runner: Optional[Player] = Field(None, description="Runner object (API-populated)") runner_team_id: Optional[int] = Field(None, description="Runner's team ID") - runner_team: Optional[Team] = Field(None, description="Runner's team object (API-populated)") + runner_team: Optional[Team] = Field( + None, description="Runner's team object (API-populated)" + ) # Defensive plays check_pos: Optional[str] = Field(None, description="Position checked") @@ -126,35 +155,35 @@ class Play(SBABaseModel): hand_pitching: Optional[str] = Field(None, description="Pitcher handedness (L/R)") # Validators from database model - @field_validator('on_first_final') + @field_validator("on_first_final") @classmethod def no_final_if_no_runner_one(cls, v, info): """Validate on_first_final is None if no runner on first.""" - if info.data.get('on_first_id') is None: + if info.data.get("on_first_id") is None: return None return v - @field_validator('on_second_final') + @field_validator("on_second_final") @classmethod def no_final_if_no_runner_two(cls, v, info): """Validate on_second_final is None if no runner on second.""" - if info.data.get('on_second_id') is None: + if info.data.get("on_second_id") is None: return None return v - @field_validator('on_third_final') + @field_validator("on_third_final") @classmethod def no_final_if_no_runner_three(cls, v, info): """Validate on_third_final is None if no runner on third.""" - if info.data.get('on_third_id') is None: + if info.data.get("on_third_id") is None: return None return v - @field_validator('batter_final') + @field_validator("batter_final") @classmethod def no_final_if_no_batter(cls, v, info): """Validate batter_final is None if no batter.""" - if info.data.get('batter_id') is None: + if info.data.get("batter_id") is None: return None return v @@ -170,25 +199,28 @@ class Play(SBABaseModel): Formatted string like: "Top 3: Player Name (NYY) homers in 2 runs" """ # Determine inning text - inning_text = f"{'Top' if self.inning_half == 'top' else 'Bot'} {self.inning_num}" + inning_text = ( + f"{'Top' if self.inning_half == 'top' else 'Bot'} {self.inning_num}" + ) # Determine team abbreviation based on inning half away_score = self.away_score home_score = self.home_score - if self.inning_half == 'top': + if self.inning_half == "top": away_score += self.rbi else: home_score += self.rbi - - score_text = f'tied at {home_score}' + if home_score > away_score: - score_text = f'{home_team.abbrev} up {home_score}-{away_score}' + score_text = f"{home_team.abbrev} up {home_score}-{away_score}" + elif away_score > home_score: + score_text = f"{away_team.abbrev} up {away_score}-{home_score}" else: - score_text = f'{away_team.abbrev} up {away_score}-{home_score}' + score_text = f"tied at {home_score}" # Build play description based on play type description_parts = [] - which_player = 'batter' + which_player = "batter" # Offensive plays if self.homerun > 0: @@ -199,63 +231,79 @@ class Play(SBABaseModel): elif self.triple > 0: description_parts.append("triples") if self.rbi > 0: - description_parts.append(f"scoring {self.rbi} run{'s' if self.rbi > 1 else ''}") + description_parts.append( + f"scoring {self.rbi} run{'s' if self.rbi > 1 else ''}" + ) elif self.double > 0: description_parts.append("doubles") if self.rbi > 0: - description_parts.append(f"scoring {self.rbi} run{'s' if self.rbi > 1 else ''}") + description_parts.append( + f"scoring {self.rbi} run{'s' if self.rbi > 1 else ''}" + ) elif self.hit > 0: description_parts.append("singles") if self.rbi > 0: - description_parts.append(f"scoring {self.rbi} run{'s' if self.rbi > 1 else ''}") + description_parts.append( + f"scoring {self.rbi} run{'s' if self.rbi > 1 else ''}" + ) elif self.bb > 0: if self.ibb > 0: description_parts.append("intentionally walked") else: description_parts.append("walks") if self.rbi > 0: - description_parts.append(f"scoring {self.rbi} run{'s' if self.rbi > 1 else ''}") + description_parts.append( + f"scoring {self.rbi} run{'s' if self.rbi > 1 else ''}" + ) elif self.hbp > 0: description_parts.append("hit by pitch") if self.rbi > 0: - description_parts.append(f"scoring {self.rbi} run{'s' if self.rbi > 1 else ''}") + description_parts.append( + f"scoring {self.rbi} run{'s' if self.rbi > 1 else ''}" + ) elif self.sac > 0: description_parts.append("sacrifice fly") if self.rbi > 0: - description_parts.append(f"scoring {self.rbi} run{'s' if self.rbi > 1 else ''}") + description_parts.append( + f"scoring {self.rbi} run{'s' if self.rbi > 1 else ''}" + ) elif self.sb > 0: description_parts.append("steals a base") elif self.cs > 0: - which_player = 'catcher' + which_player = "catcher" description_parts.append("guns down a baserunner") elif self.gidp > 0: description_parts.append("grounds into double play") elif self.so > 0: - which_player = 'pitcher' + which_player = "pitcher" description_parts.append(f"gets a strikeout") # Defensive plays elif self.error > 0: - which_player = 'defender' + which_player = "defender" description_parts.append("commits an error") if self.rbi > 0: - description_parts.append(f"allowing {self.rbi} run{'s' if self.rbi > 1 else ''}") + description_parts.append( + f"allowing {self.rbi} run{'s' if self.rbi > 1 else ''}" + ) elif self.wild_pitch > 0: - which_player = 'pitcher' + which_player = "pitcher" description_parts.append("uncorks a wild pitch") elif self.passed_ball > 0: - which_player = 'catcher' + which_player = "catcher" description_parts.append("passed ball") elif self.pick_off > 0: - which_player = 'runner' + which_player = "runner" description_parts.append("picked off") elif self.balk > 0: - which_player = 'pitcher' + which_player = "pitcher" description_parts.append("balk") else: # Generic out if self.outs > 0: - which_player = 'pitcher' - description_parts.append(f'records out number {self.starting_outs + self.outs}') + which_player = "pitcher" + description_parts.append( + f"records out number {self.starting_outs + self.outs}" + ) # Combine parts if description_parts: @@ -264,18 +312,18 @@ class Play(SBABaseModel): play_desc = "makes a play" player_dict = { - 'batter': self.batter, - 'pitcher': self.pitcher, - 'catcher': self.catcher, - 'runner': self.runner, - 'defender': self.defender + "batter": self.batter, + "pitcher": self.pitcher, + "catcher": self.catcher, + "runner": self.runner, + "defender": self.defender, } team_dict = { - 'batter': self.batter_team, - 'pitcher': self.pitcher_team, - 'catcher': self.catcher_team, - 'runner': self.runner_team, - 'defender': self.defender_team + "batter": self.batter_team, + "pitcher": self.pitcher_team, + "catcher": self.catcher_team, + "runner": self.runner_team, + "defender": self.defender_team, } # Format: "Top 3: Derek Jeter (NYY) homers in 2 runs, NYY up 2-0" diff --git a/tests/test_models_play.py b/tests/test_models_play.py new file mode 100644 index 0000000..af921ba --- /dev/null +++ b/tests/test_models_play.py @@ -0,0 +1,129 @@ +"""Tests for Play model descriptive_text method. + +Covers score text generation for key plays display, specifically +ensuring tied games show 'tied at X' instead of 'Team up X-X'. +""" + +from models.play import Play +from models.player import Player +from models.team import Team + + +def _make_team(abbrev: str) -> Team: + """Create a minimal Team for descriptive_text tests.""" + return Team.model_construct( + id=1, + abbrev=abbrev, + sname=abbrev, + lname=f"Team {abbrev}", + season=13, + ) + + +def _make_player(name: str, team: Team) -> Player: + """Create a minimal Player for descriptive_text tests.""" + return Player.model_construct(id=1, name=name, wara=0.0, season=13, team_id=team.id) + + +def _make_play(**overrides) -> Play: + """Create a Play with sensible defaults for descriptive_text tests.""" + tst_team = _make_team("TST") + opp_team = _make_team("OPP") + defaults = dict( + id=1, + game_id=1, + play_num=1, + on_base_code="000", + inning_half="top", + inning_num=7, + batting_order=1, + starting_outs=2, + away_score=0, + home_score=0, + outs=1, + batter_id=10, + batter=_make_player("Test Batter", tst_team), + batter_team=tst_team, + pitcher_id=20, + pitcher=_make_player("Test Pitcher", opp_team), + pitcher_team=opp_team, + ) + defaults.update(overrides) + return Play.model_construct(**defaults) + + +class TestDescriptiveTextScoreText: + """Tests for score text in Play.descriptive_text (issue #48).""" + + def test_tied_score_shows_tied_at(self): + """When scores are equal after the play, should show 'tied at X' not 'Team up X-X'.""" + away = _make_team("BSG") + home = _make_team("DEN") + # Top 7: away scores 1 RBI, making it 2-2 + play = _make_play( + inning_half="top", + inning_num=7, + away_score=1, + home_score=2, + rbi=1, + hit=1, + ) + result = play.descriptive_text(away, home) + assert "tied at 2" in result + assert "up" not in result + + def test_home_team_leading(self): + """When home team leads, should show 'HOME up X-Y'.""" + away = _make_team("BSG") + home = _make_team("DEN") + play = _make_play( + inning_half="top", + away_score=0, + home_score=3, + outs=1, + ) + result = play.descriptive_text(away, home) + assert "DEN up 3-0" in result + + def test_away_team_leading(self): + """When away team leads, should show 'AWAY up X-Y'.""" + away = _make_team("BSG") + home = _make_team("DEN") + play = _make_play( + inning_half="bot", + away_score=5, + home_score=2, + outs=1, + ) + result = play.descriptive_text(away, home) + assert "BSG up 5-2" in result + + def test_tied_at_zero(self): + """Tied at 0-0 should show 'tied at 0'.""" + away = _make_team("BSG") + home = _make_team("DEN") + play = _make_play( + inning_half="top", + away_score=0, + home_score=0, + outs=1, + ) + result = play.descriptive_text(away, home) + assert "tied at 0" in result + + def test_rbi_creates_tie_bottom_inning(self): + """Bottom inning RBI that ties the game should show 'tied at X'.""" + away = _make_team("BSG") + home = _make_team("DEN") + # Bot 5: home scores 2 RBI, tying at 4-4 + play = _make_play( + inning_half="bot", + inning_num=5, + away_score=4, + home_score=2, + rbi=2, + hit=1, + ) + result = play.descriptive_text(away, home) + assert "tied at 4" in result + assert "up" not in result