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 <noreply@anthropic.com>
This commit is contained in:
Cal Corum 2026-03-01 15:36:38 -06:00
parent f7a65706a1
commit 0daa6f4491
2 changed files with 233 additions and 56 deletions

View File

@ -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"

129
tests/test_models_play.py Normal file
View File

@ -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