Merge pull request 'fix: key plays score text shows "tied at X" correctly (closes #48)' (#52) from fix/key-plays-tied-score into next-release

Reviewed-on: #52
This commit is contained in:
cal 2026-03-01 21:38:33 +00:00
commit 98d47a1262
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. 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. Future enhancement could add validators to ensure consistency between ID and model fields.
""" """
from typing import Optional, Literal from typing import Optional, Literal
from pydantic import Field, field_validator from pydantic import Field, field_validator
from models.base import SBABaseModel from models.base import SBABaseModel
@ -28,9 +29,11 @@ class Play(SBABaseModel):
game: Optional[Game] = Field(None, description="Game object (API-populated)") game: Optional[Game] = Field(None, description="Game object (API-populated)")
play_num: int = Field(..., description="Sequential play number in game") play_num: int = Field(..., description="Sequential play number in game")
pitcher_id: Optional[int] = Field(None, description="Pitcher ID") 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')") 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") inning_num: int = Field(..., description="Inning number")
batting_order: int = Field(..., description="Batting order position") batting_order: int = Field(..., description="Batting order position")
starting_outs: int = Field(..., description="Outs at start of play") 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_id: Optional[int] = Field(None, description="Batter ID")
batter: Optional[Player] = Field(None, description="Batter object (API-populated)") batter: Optional[Player] = Field(None, description="Batter object (API-populated)")
batter_team_id: Optional[int] = Field(None, description="Batter's team ID") 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_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") batter_pos: Optional[str] = Field(None, description="Batter's position")
# Base runner information # Base runner information
on_first_id: Optional[int] = Field(None, description="Runner on first ID") 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: Optional[Player] = Field(
on_first_final: Optional[int] = Field(None, description="Runner on first final base") 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_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: Optional[Player] = Field(
on_second_final: Optional[int] = Field(None, description="Runner on second final base") 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_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: Optional[Player] = Field(
on_third_final: Optional[int] = Field(None, description="Runner on third final base") 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") batter_final: Optional[int] = Field(None, description="Batter's final base")
# Statistical fields (all default to 0) # Statistical fields (all default to 0)
@ -96,17 +115,27 @@ class Play(SBABaseModel):
# Defensive players # Defensive players
catcher_id: Optional[int] = Field(None, description="Catcher ID") 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_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_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_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_id: Optional[int] = Field(None, description="Runner ID")
runner: Optional[Player] = Field(None, description="Runner object (API-populated)") runner: Optional[Player] = Field(None, description="Runner object (API-populated)")
runner_team_id: Optional[int] = Field(None, description="Runner's team ID") 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 # Defensive plays
check_pos: Optional[str] = Field(None, description="Position checked") 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)") hand_pitching: Optional[str] = Field(None, description="Pitcher handedness (L/R)")
# Validators from database model # Validators from database model
@field_validator('on_first_final') @field_validator("on_first_final")
@classmethod @classmethod
def no_final_if_no_runner_one(cls, v, info): def no_final_if_no_runner_one(cls, v, info):
"""Validate on_first_final is None if no runner on first.""" """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 None
return v return v
@field_validator('on_second_final') @field_validator("on_second_final")
@classmethod @classmethod
def no_final_if_no_runner_two(cls, v, info): def no_final_if_no_runner_two(cls, v, info):
"""Validate on_second_final is None if no runner on second.""" """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 None
return v return v
@field_validator('on_third_final') @field_validator("on_third_final")
@classmethod @classmethod
def no_final_if_no_runner_three(cls, v, info): def no_final_if_no_runner_three(cls, v, info):
"""Validate on_third_final is None if no runner on third.""" """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 None
return v return v
@field_validator('batter_final') @field_validator("batter_final")
@classmethod @classmethod
def no_final_if_no_batter(cls, v, info): def no_final_if_no_batter(cls, v, info):
"""Validate batter_final is None if no batter.""" """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 None
return v return v
@ -170,25 +199,28 @@ class Play(SBABaseModel):
Formatted string like: "Top 3: Player Name (NYY) homers in 2 runs" Formatted string like: "Top 3: Player Name (NYY) homers in 2 runs"
""" """
# Determine inning text # 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 # Determine team abbreviation based on inning half
away_score = self.away_score away_score = self.away_score
home_score = self.home_score home_score = self.home_score
if self.inning_half == 'top': if self.inning_half == "top":
away_score += self.rbi away_score += self.rbi
else: else:
home_score += self.rbi home_score += self.rbi
score_text = f'tied at {home_score}'
if home_score > away_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: 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 # Build play description based on play type
description_parts = [] description_parts = []
which_player = 'batter' which_player = "batter"
# Offensive plays # Offensive plays
if self.homerun > 0: if self.homerun > 0:
@ -199,63 +231,79 @@ class Play(SBABaseModel):
elif self.triple > 0: elif self.triple > 0:
description_parts.append("triples") description_parts.append("triples")
if self.rbi > 0: 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: elif self.double > 0:
description_parts.append("doubles") description_parts.append("doubles")
if self.rbi > 0: 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: elif self.hit > 0:
description_parts.append("singles") description_parts.append("singles")
if self.rbi > 0: 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: elif self.bb > 0:
if self.ibb > 0: if self.ibb > 0:
description_parts.append("intentionally walked") description_parts.append("intentionally walked")
else: else:
description_parts.append("walks") description_parts.append("walks")
if self.rbi > 0: 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: elif self.hbp > 0:
description_parts.append("hit by pitch") description_parts.append("hit by pitch")
if self.rbi > 0: 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: elif self.sac > 0:
description_parts.append("sacrifice fly") description_parts.append("sacrifice fly")
if self.rbi > 0: 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: elif self.sb > 0:
description_parts.append("steals a base") description_parts.append("steals a base")
elif self.cs > 0: elif self.cs > 0:
which_player = 'catcher' which_player = "catcher"
description_parts.append("guns down a baserunner") description_parts.append("guns down a baserunner")
elif self.gidp > 0: elif self.gidp > 0:
description_parts.append("grounds into double play") description_parts.append("grounds into double play")
elif self.so > 0: elif self.so > 0:
which_player = 'pitcher' which_player = "pitcher"
description_parts.append(f"gets a strikeout") description_parts.append(f"gets a strikeout")
# Defensive plays # Defensive plays
elif self.error > 0: elif self.error > 0:
which_player = 'defender' which_player = "defender"
description_parts.append("commits an error") description_parts.append("commits an error")
if self.rbi > 0: 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: elif self.wild_pitch > 0:
which_player = 'pitcher' which_player = "pitcher"
description_parts.append("uncorks a wild pitch") description_parts.append("uncorks a wild pitch")
elif self.passed_ball > 0: elif self.passed_ball > 0:
which_player = 'catcher' which_player = "catcher"
description_parts.append("passed ball") description_parts.append("passed ball")
elif self.pick_off > 0: elif self.pick_off > 0:
which_player = 'runner' which_player = "runner"
description_parts.append("picked off") description_parts.append("picked off")
elif self.balk > 0: elif self.balk > 0:
which_player = 'pitcher' which_player = "pitcher"
description_parts.append("balk") description_parts.append("balk")
else: else:
# Generic out # Generic out
if self.outs > 0: if self.outs > 0:
which_player = 'pitcher' which_player = "pitcher"
description_parts.append(f'records out number {self.starting_outs + self.outs}') description_parts.append(
f"records out number {self.starting_outs + self.outs}"
)
# Combine parts # Combine parts
if description_parts: if description_parts:
@ -264,18 +312,18 @@ class Play(SBABaseModel):
play_desc = "makes a play" play_desc = "makes a play"
player_dict = { player_dict = {
'batter': self.batter, "batter": self.batter,
'pitcher': self.pitcher, "pitcher": self.pitcher,
'catcher': self.catcher, "catcher": self.catcher,
'runner': self.runner, "runner": self.runner,
'defender': self.defender "defender": self.defender,
} }
team_dict = { team_dict = {
'batter': self.batter_team, "batter": self.batter_team,
'pitcher': self.pitcher_team, "pitcher": self.pitcher_team,
'catcher': self.catcher_team, "catcher": self.catcher_team,
'runner': self.runner_team, "runner": self.runner_team,
'defender': self.defender_team "defender": self.defender_team,
} }
# Format: "Top 3: Derek Jeter (NYY) homers in 2 runs, NYY up 2-0" # 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