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:
parent
f7a65706a1
commit
0daa6f4491
160
models/play.py
160
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"
|
||||
|
||||
129
tests/test_models_play.py
Normal file
129
tests/test_models_play.py
Normal 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
|
||||
Loading…
Reference in New Issue
Block a user