import pytest from sqlmodel import Session, select, func from command_logic.logic_gameplay import advance_runners, doubles, gb_result_1, get_obc, get_re24, get_wpa, complete_play, log_run_scored, strikeouts, steals, xchecks, walks, popouts, hit_by_pitch, homeruns, singles, triples, bunts, chaos, safe_wpa_lookup, create_pinch_runner_entry_play from in_game.gameplay_models import Lineup, Play, Card, Player from tests.factory import session_fixture, Game def test_advance_runners(session: Session): this_game = session.get(Game, 3) play_1 = this_game.initialize_play(session) assert play_1.starting_outs == 0 assert play_1.on_first_id is None assert play_1.on_second_id is None assert play_1.on_third_id is None play_1.pa, play_1.ab, play_1.hit, play_1.double, play_1.batter_final = 1, 1, 1, 1, 2 play_2 = complete_play(session, play_1) assert play_2.inning_half == play_1.inning_half assert play_2.on_second_id == play_1.batter_id play_2.pa, play_2.ab, play_2.hit, play_2.batter_final = 1, 1, 1, 1 play_2 = advance_runners(session, play_2, 1) session.add(play_2) session.commit() assert play_2.on_second_final == 3 assert play_2.batter_final == 1 play_3 = complete_play(session, play_2) assert play_3.on_third is not None assert play_3.on_second is None assert play_3.on_first is not None play_3.pa, play_3.ab, play_3.hit, play_3.batter_final = 1, 1, 1, 2 play_3 = advance_runners(session, play_3, 3, earned_bases=1) session.add(play_3) session.commit() assert play_3.rbi == 1 assert play_2.run == 1 assert play_2.e_run == 0 assert play_1.run == 1 assert play_1.e_run == 1 def test_get_obc(): assert get_obc() == 0 assert get_obc(on_first=True) == 1 assert get_obc(on_second=True) == 2 assert get_obc(on_third=True) == 3 assert get_obc(on_first=True, on_second=True) == 4 assert get_obc(on_first=True, on_third=True) == 5 assert get_obc(on_second=True, on_third=True) == 6 assert get_obc(on_first=True, on_second=True, on_third=True) == 7 def test_get_re24(session: Session): game_1 = session.get(Game, 1) this_play = game_1.current_play_or_none(session) assert this_play is not None assert get_re24(this_play, runs_scored=0, new_obc=0, new_starting_outs=2) == -.154 assert get_re24(this_play, runs_scored=1, new_obc=0, new_starting_outs=1) == 1 assert get_re24(this_play, runs_scored=0, new_obc=2, new_starting_outs=1) == 0.365 assert get_re24(this_play, runs_scored=0, new_obc=6, new_starting_outs=2) == 0.217 def test_get_wpa(session: Session): game_1 = session.get(Game, 1) next_play = game_1.current_play_or_none(session) # Starting wpa: 0.564 this_play = game_1.plays[0] # Starting wpa: 0.500 assert this_play != next_play assert get_wpa(this_play, next_play) == -0.064 next_play.starting_outs = 2 next_play.away_score = 3 # Starting wpa: 0.347 assert get_wpa(this_play, next_play) == 0.153 def test_safe_wpa_lookup(): """Test safe_wpa_lookup handles both valid and missing keys gracefully.""" # Test 1: Valid key should return actual value valid_result = safe_wpa_lookup('bot', 1, 0, 0, 0) assert isinstance(valid_result, float) assert 0.0 <= valid_result <= 1.0 assert valid_result == 0.589 # Known value from wpa_data.csv # Test 2: Missing key with bases loaded should fallback to bases empty # This simulates the error case: top_1_0_out_6_obc_-5_home_run_diff doesn't exist fallback_result = safe_wpa_lookup('top', 1, 0, 6, -5) assert isinstance(fallback_result, float) assert 0.0 <= fallback_result <= 1.0 # Should use fallback to bases empty state if available # Test 3: If even bases empty fallback doesn't exist, should return 0.0 # (This is an edge case that shouldn't happen with complete data) extreme_fallback = safe_wpa_lookup('top', 1, 0, 7, -10) # Invalid obc and extreme run diff assert extreme_fallback == 0.0 # Test 4: Another valid key valid_result_2 = safe_wpa_lookup('top', 9, 2, 3, 1) assert isinstance(valid_result_2, float) assert 0.0 <= valid_result_2 <= 1.0 def test_complete_play(session: Session): game_1 = session.get(Game, 1) this_play = game_1.current_play_or_none(session) assert this_play.inning_half == 'top' assert this_play.inning_num == 1 assert this_play.starting_outs == 1 this_play.pa, this_play.ab, this_play.so, this_play.outs = 1, 1, 1, 1 next_play = complete_play(session, this_play) assert next_play.inning_half == 'top' assert this_play.inning_num == 1 assert next_play.starting_outs == 2 assert next_play.is_tied assert next_play.managerai == this_play.managerai assert this_play.re24 == -0.154 next_play.pa, next_play.ab, next_play.double, next_play.batter_final = 1, 1, 1, 2 third_play = complete_play(session, next_play) assert third_play.on_base_code == 2 assert next_play.re24 == 0.182 assert third_play.on_second == next_play.batter third_play.pa, third_play.ab, third_play.hit def test_complete_play_scratch(session: Session): this_game = session.get(Game, 3) assert len(this_game.plays) == 0 play_1 = this_game.initialize_play(session) assert play_1.starting_outs == 0 assert play_1.on_first_id is None assert play_1.on_second_id is None assert play_1.on_third_id is None play_1.hit = 1 assert play_1.hit == 1 play_1.pa, play_1.ab, play_1.hit, play_1.double, play_1.batter_final = 1, 1, 1, 1, 2 print(f'play_1: {play_1}') play_2 = complete_play(session, play_1) assert play_2.on_second is not None def test_log_run_scored(session: Session): game_1 = session.get(Game, 1) lineup_1 = session.get(Lineup, 1) play_1 = session.get(Play, 1) assert play_1.run == 0 log_run_scored(session, lineup_1, play_1) assert play_1.run == 1 play_2 = session.get(Play, 2) play_2.pa, play_2.ab, play_2.double, play_2.batter_final = 1, 1, 1, 2 play_3 = complete_play(session, play_2) assert play_3.on_second is not None assert play_3.on_second.game == game_1 play_3.pa, play_3.ab, play_3.so, play_3.outs = 1, 1, 1, 1 play_3 = advance_runners(session, play_3, num_bases=0) assert play_3.on_second_final == 2 play_4 = complete_play(session, play_3) assert play_4.starting_outs == 2 assert play_4.on_second is not None play_4.pa, play_4.ab, play_4.error = 1, 1, 1 log_run_scored(session, play_4.on_second, play_4) runs = session.exec(select(func.sum(Play.run)).where(Play.game == game_1)).one() e_runs = session.exec(select(func.sum(Play.e_run)).where(Play.game == game_1)).one() assert runs == 2 assert e_runs == 1 async def test_strikeouts(session: Session): game_1 = session.get(Game, 1) play_1 = session.get(Play, 1) play_1 = await strikeouts(session, None, play_1) assert play_1.so == 1 assert play_1.outs == 1 assert play_1.batter_final is None async def test_doubles(session: Session): game_1 = session.get(Game, 1) play_1 = session.get(Play, 1) play_1.hit, play_1.batter_final = 1, 1 play_2 = complete_play(session, play_1) assert play_2.play_num == 2 play_2_ghost_1 = await doubles(session, None, play_2, double_type='***') assert play_2_ghost_1.double == 1 assert play_2_ghost_1.on_first_final == 4 assert play_2_ghost_1.rbi == 1 play_2_ghost_2 = await doubles(session, None, play_2, double_type='**') assert play_2_ghost_2.double == 1 assert play_2_ghost_2.rbi == 0 assert play_2_ghost_2.on_first_final == 3 async def test_stealing(session: Session): game_1 = session.get(Game, 1) play_1 = session.get(Play, 1) play_1.hit, play_1.batter_final = 1, 1 play_2 = complete_play(session, play_1) assert play_2.play_num == 2 play_2 = await steals(session, None, play_2, 'stolen-base', to_base=2) assert play_2.on_first_final == 2 assert play_2.sb == 1 assert play_2.runner == play_2.on_first async def test_walks(session: Session): """Test walk functionality - both intentional and unintentional walks.""" this_game = session.get(Game, 3) play_1 = this_game.initialize_play(session) # Test unintentional walk play_1 = await walks(session, None, play_1, 'unintentional') assert play_1.ab == 0 # No at-bat for walk assert play_1.bb == 1 # Base on balls assert play_1.ibb == 0 # Not intentional assert play_1.batter_final == 1 # Batter reaches first # Set up for intentional walk test play_2 = complete_play(session, play_1) play_2 = await walks(session, None, play_2, 'intentional') assert play_2.ab == 0 assert play_2.bb == 1 assert play_2.ibb == 1 # Intentional walk assert play_2.batter_final == 1 async def test_strikeouts(session: Session): """Test strikeout functionality.""" this_game = session.get(Game, 3) play_1 = this_game.initialize_play(session) play_1 = await strikeouts(session, None, play_1) assert play_1.so == 1 # Strikeout recorded assert play_1.outs == 1 # One out recorded # Note: batter_final is not set by strikeouts function, handled by advance_runners async def test_popouts(session: Session): """Test popout functionality.""" this_game = session.get(Game, 3) play_1 = this_game.initialize_play(session) play_1 = await popouts(session, None, play_1) assert play_1.outs == 1 # One out recorded # Note: batter_final is not set by popouts function, handled by advance_runners async def test_hit_by_pitch(session: Session): """Test hit by pitch functionality.""" this_game = session.get(Game, 3) play_1 = this_game.initialize_play(session) play_1 = await hit_by_pitch(session, None, play_1) assert play_1.ab == 0 # No at-bat for HBP assert play_1.hbp == 1 # Hit by pitch recorded assert play_1.batter_final == 1 # Batter reaches first async def test_homeruns(session: Session): """Test homerun functionality - both ballpark and no-doubt homers.""" this_game = session.get(Game, 3) play_1 = this_game.initialize_play(session) # Test ballpark homerun play_1 = await homeruns(session, None, play_1, 'ballpark') assert play_1.hit == 1 assert play_1.homerun == 1 assert play_1.batter_final == 4 # Batter scores assert play_1.run == 1 # Batter scores a run assert play_1.rbi >= 1 # At least 1 RBI (for the batter themselves) assert play_1.bphr == 1 # Ballpark homerun # Test no-doubt homerun play_2 = complete_play(session, play_1) play_2 = await homeruns(session, None, play_2, 'no-doubt') assert play_2.hit == 1 assert play_2.homerun == 1 assert play_2.batter_final == 4 assert play_2.run == 1 assert play_2.rbi >= 1 assert play_2.bphr == 0 # Not a ballpark homerun async def test_singles(session: Session): """Test single functionality with different types.""" this_game = session.get(Game, 3) play_1 = this_game.initialize_play(session) # Test regular single (*) play_1 = await singles(session, None, play_1, '*') assert play_1.hit == 1 assert play_1.batter_final == 1 # Batter reaches first assert play_1.bp1b == 0 # Not a ballpark single # Test ballpark single play_2 = complete_play(session, play_1) play_2 = await singles(session, None, play_2, 'ballpark') assert play_2.hit == 1 assert play_2.batter_final == 1 assert play_2.bp1b == 1 # Ballpark single # Test double-advance single (**) play_3 = complete_play(session, play_2) play_3 = await singles(session, None, play_3, '**') assert play_3.hit == 1 assert play_3.batter_final == 1 async def test_doubles(session: Session): """Test double functionality with different types.""" this_game = session.get(Game, 3) play_1 = this_game.initialize_play(session) # Test regular double (**) play_1 = await doubles(session, None, play_1, '**') assert play_1.hit == 1 assert play_1.double == 1 assert play_1.batter_final == 2 # Batter reaches second # Test triple-advance double (***) play_2 = complete_play(session, play_1) play_2 = await doubles(session, None, play_2, '***') assert play_2.hit == 1 assert play_2.double == 1 assert play_2.batter_final == 2 async def test_triples(session: Session): """Test triple functionality.""" this_game = session.get(Game, 3) play_1 = this_game.initialize_play(session) play_1 = await triples(session, None, play_1) assert play_1.hit == 1 assert play_1.triple == 1 assert play_1.batter_final == 3 # Batter reaches third async def test_bunts(session: Session): """Test bunt functionality with different types.""" this_game = session.get(Game, 3) # Test sacrifice bunt play_1 = this_game.initialize_play(session) play_1 = await bunts(session, None, play_1, 'sacrifice') assert play_1.ab == 0 # No at-bat for sacrifice assert play_1.sac == 1 # Sacrifice recorded assert play_1.outs == 1 # Out recorded # Test bad bunt (batter reaches first safely) play_2 = complete_play(session, play_1) play_2 = await bunts(session, None, play_2, 'bad') assert play_2.ab == 1 # At-bat recorded assert play_2.sac == 0 # No sacrifice assert play_2.outs == 1 # Out recorded assert play_2.batter_final == 1 # Batter reaches first # Test popout bunt play_3 = complete_play(session, play_2) play_3 = await bunts(session, None, play_3, 'popout') assert play_3.ab == 1 # At-bat recorded assert play_3.sac == 0 # No sacrifice assert play_3.outs == 1 # Out recorded # Test double-play bunt play_4 = complete_play(session, play_3) play_4 = await bunts(session, None, play_4, 'double-play') assert play_4.ab == 1 # At-bat recorded assert play_4.sac == 0 # No sacrifice # Double play outs depend on starting_outs async def test_chaos(session: Session): """Test chaos events - wild pitch, passed ball, balk, pickoff.""" this_game = session.get(Game, 3) # Test wild pitch play_1 = this_game.initialize_play(session) play_1 = await chaos(session, None, play_1, 'wild-pitch') assert play_1.pa == 0 # No plate appearance assert play_1.ab == 0 # No at-bat assert play_1.wild_pitch == 1 # Wild pitch recorded assert play_1.rbi == 0 # No RBI on wild pitch # Test passed ball play_2 = complete_play(session, play_1) play_2 = await chaos(session, None, play_2, 'passed-ball') assert play_2.pa == 0 assert play_2.ab == 0 assert play_2.passed_ball == 1 # Passed ball recorded assert play_2.rbi == 0 # Test balk play_3 = complete_play(session, play_2) play_3 = await chaos(session, None, play_3, 'balk') assert play_3.pa == 0 assert play_3.ab == 0 assert play_3.balk == 1 # Balk recorded assert play_3.rbi == 0 # Test pickoff play_4 = complete_play(session, play_3) play_4 = await chaos(session, None, play_4, 'pickoff') assert play_4.pa == 0 assert play_4.ab == 0 assert play_4.pick_off == 1 # Pickoff recorded assert play_4.outs == 1 # Out recorded def test_pinch_runner_entry_and_scoring(session: Session): """ Test that pinch runners get an entry Play record when they substitute for a runner on base, and that the run is properly credited when they score. """ this_game = session.get(Game, 3) # Step 1: Batter A gets a single play_1 = this_game.initialize_play(session) play_1.pa, play_1.ab, play_1.hit, play_1.batter_final = 1, 1, 1, 1 # Complete the play - batter A is now on first play_2 = complete_play(session, play_1) assert play_2.on_first == play_1.batter # Original batter on first assert play_2.on_first.player_id == play_1.batter.player_id # Step 2: Create a pinch runner lineup (simulating /substitute batter) # Get a different player/card for the pinch runner pinch_runner_card = session.get(Card, 2) # Different card than the one on base pinch_runner_lineup = Lineup( team=play_2.on_first.team, player=pinch_runner_card.player, card=pinch_runner_card, position='PR', batting_order=play_2.on_first.batting_order, game=this_game, after_play=play_2.play_num - 1, replacing_id=play_2.on_first.id ) session.add(pinch_runner_lineup) session.commit() session.refresh(pinch_runner_lineup) # Step 3: Update the current play to reference the pinch runner play_2.on_first = pinch_runner_lineup session.add(play_2) session.commit() # Step 4: Create the pinch runner entry Play entry_play = create_pinch_runner_entry_play( session=session, game=this_game, current_play=play_2, pinch_runner_lineup=pinch_runner_lineup ) # Verify the entry Play was created correctly assert entry_play is not None assert entry_play.batter == pinch_runner_lineup assert entry_play.pa == 0 # NOT a plate appearance assert entry_play.ab == 0 # NOT an at-bat assert entry_play.run == 0 # Not scored yet assert entry_play.complete == True # Entry is complete assert entry_play.game == this_game assert entry_play.pitcher == play_2.pitcher assert entry_play.inning_half == play_2.inning_half assert entry_play.inning_num == play_2.inning_num # Step 5: Advance the pinch runner to third play_2.hit = 1 play_2.batter_final = 1 play_2 = advance_runners(session, play_2, num_bases=2) # Advance runner 2 bases session.add(play_2) session.commit() assert play_2.on_first_final == 3 # Pinch runner advanced to third # Complete the play - pinch runner is now on third play_3 = complete_play(session, play_2) assert play_3.on_third == pinch_runner_lineup # Step 6: Score the pinch runner on a subsequent hit play_3.pa, play_3.ab, play_3.hit, play_3.batter_final = 1, 1, 1, 1 play_3 = advance_runners(session, play_3, num_bases=1) # Score from third session.add(play_3) session.commit() assert play_3.on_third_final == 4 # Runner scored assert play_3.rbi >= 1 # RBI for the batter # Step 7: Verify the run was credited to the pinch runner's entry Play session.refresh(entry_play) assert entry_play.run == 1 # Pinch runner's entry Play has run=1 assert entry_play.pa == 0 # Still not a plate appearance assert entry_play.ab == 0 # Still not an at-bat # Step 8: Verify pitcher stats are not affected by the entry Play # The pitcher allowed a hit (play_1) but the entry Play (PA=0, AB=0) should not count pitcher_plays = session.exec( select(Play).where( Play.game == this_game, Play.pitcher == play_2.pitcher, Play.pa > 0 ) ).all() # Entry play should NOT be in this list (PA=0) assert entry_play not in pitcher_plays async def test_cs_end_of_inning_preserves_batter(session: Session): """ Test that when a half-inning ends on a Caught Stealing, the batter who was at the plate (but did not complete a plate appearance) leads off the next time their team bats. Scenario: Top 1 - Away team batting: Batter 1 (bo=1): strikeout → 1 out Batter 2 (bo=2): strikeout → 2 outs Batter 3 (bo=3): single, reaches first Batter 4 (bo=4): at the plate, runner caught stealing → 3 outs, side switch Bot 1 - Home team batting: Batter 1 (bo=1): strikeout → 1 out Batter 2 (bo=2): strikeout → 2 outs Batter 3 (bo=3): strikeout → 3 outs, side switch Top 2 - Away team batting: The next batter should be Batter 4 (bo=4), NOT Batter 5 (bo=5), because Batter 4 never completed a plate appearance. """ this_game = session.get(Game, 3) play = this_game.initialize_play(session) # --- TOP 1 --- # Batter 1 (bo=1): strikeout → 1 out assert play.batting_order == 1 assert play.inning_half == 'top' play = await strikeouts(session, None, play) play = complete_play(session, play) # Batter 2 (bo=2): strikeout → 2 outs assert play.batting_order == 2 assert play.starting_outs == 1 play = await strikeouts(session, None, play) play = complete_play(session, play) # Batter 3 (bo=3): single → runner on first assert play.batting_order == 3 assert play.starting_outs == 2 play = await singles(session, None, play, '*') play = complete_play(session, play) # Batter 4 (bo=4) is at the plate with runner on first assert play.batting_order == 4 assert play.starting_outs == 2 assert play.on_first is not None assert play.inning_half == 'top' # Runner on first is caught stealing → 3rd out, side switch play = await steals(session, None, play, 'caught-stealing', to_base=2) assert play.pa == 0 # CS is not a plate appearance assert play.outs == 1 # CS records one out play = complete_play(session, play) # Should now be bottom of inning 1 assert play.inning_half == 'bot' assert play.inning_num == 1 assert play.is_new_inning is True # --- BOT 1 --- # Home batter 1 (bo=1): strikeout → 1 out assert play.batting_order == 1 play = await strikeouts(session, None, play) play = complete_play(session, play) # Home batter 2 (bo=2): strikeout → 2 outs assert play.batting_order == 2 play = await strikeouts(session, None, play) play = complete_play(session, play) # Home batter 3 (bo=3): strikeout → 3 outs, side switch assert play.batting_order == 3 play = await strikeouts(session, None, play) play = complete_play(session, play) # --- TOP 2 --- # Should be top of inning 2, and batter 4 (bo=4) should lead off # because they never completed a PA in the previous half-inning assert play.inning_half == 'top' assert play.inning_num == 2 assert play.is_new_inning is True assert play.batting_order == 4, ( f"Expected batter 4 to lead off (never completed PA), " f"but got batter {play.batting_order}" )