From 2a70df74bf4da4dc6d13f794aac25929af2102ec Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Sun, 8 Feb 2026 23:33:10 -0600 Subject: [PATCH] CLAUDE: Fix on_base_code encoding bug + add truth table & invariant tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix critical encoding mismatch where calculate_on_base_code() returned bit-field encoding (3=R1+R2, 4=R3) but runner_advancement.py charts expected sequential encoding (3=R3, 4=R1+R2). Values 3 and 4 were swapped, causing wrong groundball results for R1+R2 and R3-only scenarios. Add comprehensive test coverage: - 1184 invariant tests (structural correctness across all outcomes × base codes) - 49 hit truth table tests (SINGLE_1/2, DOUBLE_2/3, TRIPLE, HOMERUN) - 33 walk truth table tests (WALK, HBP with stat flag verification) - 42 simple out truth table tests (STRIKEOUT, LINEOUT, POPOUT, WP, PB) - 88 groundball truth table tests (GB_A/B/C × infield back/in/corners_in × locations) Total: 2401 unit tests passing. Co-Authored-By: Claude Opus 4.6 --- backend/app/core/game_engine.py | 2 +- backend/app/models/game_models.py | 49 +- .../core/test_play_resolver_invariants.py | 666 ++++++++++++++++++ .../tests/unit/core/truth_tables/__init__.py | 0 .../tests/unit/core/truth_tables/conftest.py | 215 ++++++ .../core/truth_tables/test_tt_groundballs.py | 493 +++++++++++++ .../unit/core/truth_tables/test_tt_hits.py | 169 +++++ .../core/truth_tables/test_tt_simple_outs.py | 182 +++++ .../unit/core/truth_tables/test_tt_walks.py | 129 ++++ 9 files changed, 1884 insertions(+), 21 deletions(-) create mode 100644 backend/tests/unit/core/test_play_resolver_invariants.py create mode 100644 backend/tests/unit/core/truth_tables/__init__.py create mode 100644 backend/tests/unit/core/truth_tables/conftest.py create mode 100644 backend/tests/unit/core/truth_tables/test_tt_groundballs.py create mode 100644 backend/tests/unit/core/truth_tables/test_tt_hits.py create mode 100644 backend/tests/unit/core/truth_tables/test_tt_simple_outs.py create mode 100644 backend/tests/unit/core/truth_tables/test_tt_walks.py diff --git a/backend/app/core/game_engine.py b/backend/app/core/game_engine.py index 4e416e1..3bccfa7 100644 --- a/backend/app/core/game_engine.py +++ b/backend/app/core/game_engine.py @@ -1309,7 +1309,7 @@ class GameEngine: state.current_pitcher = None state.current_catcher = None - # Calculate on_base_code from current runners (bit field) + # Calculate on_base_code from current runners (sequential chart encoding) state.current_on_base_code = state.calculate_on_base_code() logger.info( diff --git a/backend/app/models/game_models.py b/backend/app/models/game_models.py index 2f7397a..6ca2125 100644 --- a/backend/app/models/game_models.py +++ b/backend/app/models/game_models.py @@ -481,7 +481,7 @@ class GameState(BaseModel): current_batter: Snapshot - LineupPlayerState for current batter (required) current_pitcher: Snapshot - LineupPlayerState for current pitcher (optional) current_catcher: Snapshot - LineupPlayerState for current catcher (optional) - current_on_base_code: Snapshot - bit field of occupied bases (1=1st, 2=2nd, 4=3rd) + current_on_base_code: Snapshot - sequential chart encoding (0=empty, 1=R1, 2=R2, 3=R3, 4=R1+R2, 5=R1+R3, 6=R2+R3, 7=loaded) pending_decision: Type of decision awaiting ('defensive', 'offensive', 'result_selection') decisions_this_play: Accumulated decisions for current play play_count: Total plays so far @@ -548,7 +548,7 @@ class GameState(BaseModel): current_catcher: LineupPlayerState | None = None current_on_base_code: int = Field( default=0, ge=0 - ) # Bit field: 1=1st, 2=2nd, 4=3rd, 7=loaded + ) # Sequential chart encoding: 0=empty, 1=R1, 2=R2, 3=R3, 4=R1+R2, 5=R1+R3, 6=R2+R3, 7=loaded # Decision tracking pending_decision: str | None = None # 'defensive', 'offensive', 'result_selection' @@ -709,26 +709,35 @@ class GameState(BaseModel): """ Calculate on-base code from current runner positions. - Returns bit field where: - - Bit 0 (value 1): runner on first - - Bit 1 (value 2): runner on second - - Bit 2 (value 4): runner on third - - Value 7: bases loaded (1 + 2 + 4) - - Examples: + Returns sequential chart encoding matching the official rulebook charts: 0 = empty bases - 1 = runner on first only - 3 = runners on first and second - 7 = bases loaded + 1 = runner on 1st only + 2 = runner on 2nd only + 3 = runner on 3rd only + 4 = runners on 1st and 2nd + 5 = runners on 1st and 3rd + 6 = runners on 2nd and 3rd + 7 = bases loaded (1st, 2nd, and 3rd) """ - code = 0 - if self.on_first: - code |= 1 # Bit 0 - if self.on_second: - code |= 2 # Bit 1 - if self.on_third: - code |= 4 # Bit 2 - return code + r1 = self.on_first is not None + r2 = self.on_second is not None + r3 = self.on_third is not None + + if r1 and r2 and r3: + return 7 # Loaded + if r2 and r3: + return 6 # R2+R3 + if r1 and r3: + return 5 # R1+R3 + if r1 and r2: + return 4 # R1+R2 + if r3: + return 3 # R3 only + if r2: + return 2 # R2 only + if r1: + return 1 # R1 only + return 0 # Empty def get_runner_at_base(self, base: int) -> LineupPlayerState | None: """Get runner at specified base (1, 2, or 3)""" diff --git a/backend/tests/unit/core/test_play_resolver_invariants.py b/backend/tests/unit/core/test_play_resolver_invariants.py new file mode 100644 index 0000000..2dda6e3 --- /dev/null +++ b/backend/tests/unit/core/test_play_resolver_invariants.py @@ -0,0 +1,666 @@ +""" +Invariant Tests for Play Resolver Advancement + +Structural invariant tests that verify correctness properties that must hold +across ALL combinations of play outcomes and on-base situations. These catch +bugs like lost runners, base collisions, backward movement, and incorrect +run counting without needing to specify every individual expected result. + +These tests use @pytest.mark.parametrize to exhaustively cover the full matrix +of (outcome, on_base_code) combinations handled by play_resolver.resolve_outcome(). + +Invariants tested: + 1. Conservation of players - every runner and batter must be accounted for + 2. No base collisions - no two runners end up on the same base (1-3) + 3. Runners never go backward - to_base >= from_base for non-outs + 4. Batter reaches minimum base for hit type + 5. Runs scored equals count of runners reaching base 4 + 6. Walk/HBP: only forced (consecutive from 1st) runners advance + 7. Outs recorded is non-negative and bounded + 8. Hit flag correctness - hits are hits, outs are outs + +Author: Claude +Date: 2025-02-08 +""" + +import pytest +from itertools import product +from uuid import uuid4 + +import pendulum + +from app.config import PlayOutcome +from app.core.play_resolver import PlayResolver, PlayResult, RunnerAdvancementData +from app.core.roll_types import AbRoll, RollType +from app.models.game_models import ( + DefensiveDecision, + GameState, + LineupPlayerState, + OffensiveDecision, +) + + +# ============================================================================= +# Test Fixtures & Helpers +# ============================================================================= + +def make_player(lineup_id: int, batting_order: int = 1) -> LineupPlayerState: + """Create a LineupPlayerState with unique IDs for testing.""" + return LineupPlayerState( + lineup_id=lineup_id, + card_id=lineup_id * 100, + position="CF", + batting_order=batting_order, + ) + + +def make_ab_roll(game_id=None) -> AbRoll: + """Create a mock AbRoll for testing.""" + return AbRoll( + roll_type=RollType.AB, + roll_id="test_invariant", + timestamp=pendulum.now("UTC"), + league_id="sba", + game_id=game_id, + d6_one=3, + d6_two_a=2, + d6_two_b=4, + chaos_d20=10, + resolution_d20=10, + ) + + +def make_state_with_runners(on_base_code: int) -> GameState: + """ + Create a GameState with runners placed according to on_base_code. + + On-base code is a bit field: + bit 0 (value 1) = runner on 1st (lineup_id=10) + bit 1 (value 2) = runner on 2nd (lineup_id=20) + bit 2 (value 4) = runner on 3rd (lineup_id=30) + + The batter always has lineup_id=1. + """ + return GameState( + game_id=uuid4(), + league_id="sba", + home_team_id=1, + away_team_id=2, + current_batter=make_player(1, batting_order=1), + on_first=make_player(10, batting_order=2) if on_base_code & 1 else None, + on_second=make_player(20, batting_order=3) if on_base_code & 2 else None, + on_third=make_player(30, batting_order=4) if on_base_code & 4 else None, + ) + + +def count_initial_runners(on_base_code: int) -> int: + """Count how many runners are on base for a given on_base_code.""" + return bin(on_base_code).count("1") + + +# ============================================================================= +# Outcome Categories +# ============================================================================= + +# Outcomes handled directly by play_resolver.resolve_outcome() with simple advancement +# (no hit_location required, no delegation to runner_advancement.py) +HIT_OUTCOMES = [ + PlayOutcome.SINGLE_1, + PlayOutcome.SINGLE_2, + PlayOutcome.DOUBLE_2, + PlayOutcome.DOUBLE_3, + PlayOutcome.TRIPLE, + PlayOutcome.HOMERUN, +] + +WALK_OUTCOMES = [ + PlayOutcome.WALK, + PlayOutcome.HIT_BY_PITCH, +] + +SIMPLE_OUT_OUTCOMES = [ + PlayOutcome.STRIKEOUT, + PlayOutcome.LINEOUT, + PlayOutcome.POPOUT, +] + +# Interrupt plays (runners advance 1 base, batter stays) +INTERRUPT_ADVANCE_OUTCOMES = [ + PlayOutcome.WILD_PITCH, + PlayOutcome.PASSED_BALL, +] + +# All outcomes that can be tested without hit_location or special setup +# Groundballs and flyballs are excluded because they delegate to runner_advancement.py +# and require hit_location logic - they have their own exhaustive tests. +SIMPLE_OUTCOMES = HIT_OUTCOMES + WALK_OUTCOMES + SIMPLE_OUT_OUTCOMES + INTERRUPT_ADVANCE_OUTCOMES + +# All 8 possible on-base codes +ALL_ON_BASE_CODES = list(range(8)) + +# Minimum base the batter must reach for each hit type +BATTER_MINIMUM_BASE = { + PlayOutcome.SINGLE_1: 1, + PlayOutcome.SINGLE_2: 1, + PlayOutcome.DOUBLE_2: 2, + PlayOutcome.DOUBLE_3: 2, + PlayOutcome.TRIPLE: 3, + PlayOutcome.HOMERUN: 4, +} + + +def resolve(outcome: PlayOutcome, on_base_code: int) -> PlayResult: + """ + Helper to resolve a play outcome with a given on-base situation. + + Creates all necessary objects and calls resolve_outcome() directly. + Returns the PlayResult for invariant checking. + """ + resolver = PlayResolver(league_id="sba", auto_mode=False) + state = make_state_with_runners(on_base_code) + ab_roll = make_ab_roll(state.game_id) + + return resolver.resolve_outcome( + outcome=outcome, + hit_location=None, + state=state, + defensive_decision=DefensiveDecision(), + offensive_decision=OffensiveDecision(), + ab_roll=ab_roll, + ) + + +# Generate the full test matrix: (outcome, on_base_code) +FULL_MATRIX = list(product(SIMPLE_OUTCOMES, ALL_ON_BASE_CODES)) + +# Human-readable IDs for parametrize +MATRIX_IDS = [f"{outcome.value}__obc{obc}" for outcome, obc in FULL_MATRIX] + + +# ============================================================================= +# Invariant 1: Conservation of Players +# ============================================================================= + +class TestConservationOfPlayers: + """ + Every runner who started on base must be accounted for in the result. + + After a play resolves, every initial runner must appear in exactly one of: + - runners_advanced (moved to a new base or scored) + - still implicitly on their original base (if not in runners_advanced) + - recorded as out (is_out=True in runners_advanced) + + For hits and walks, no runners should be lost. + For outs where only the batter is out, runners should still be tracked. + """ + + @pytest.mark.parametrize("outcome, on_base_code", FULL_MATRIX, ids=MATRIX_IDS) + def test_runs_scored_not_negative(self, outcome, on_base_code): + """Runs scored must never be negative.""" + result = resolve(outcome, on_base_code) + assert result.runs_scored >= 0, ( + f"Negative runs scored: {result.runs_scored}" + ) + + @pytest.mark.parametrize("outcome, on_base_code", FULL_MATRIX, ids=MATRIX_IDS) + def test_outs_recorded_not_negative(self, outcome, on_base_code): + """Outs recorded must never be negative.""" + result = resolve(outcome, on_base_code) + assert result.outs_recorded >= 0, ( + f"Negative outs recorded: {result.outs_recorded}" + ) + + @pytest.mark.parametrize("outcome, on_base_code", FULL_MATRIX, ids=MATRIX_IDS) + def test_runs_scored_bounded_by_runners_plus_batter(self, outcome, on_base_code): + """ + Runs scored cannot exceed the number of runners on base + batter. + + Maximum possible: 3 runners + 1 batter = 4 (grand slam). + """ + n_runners = count_initial_runners(on_base_code) + result = resolve(outcome, on_base_code) + + max_possible = n_runners + 1 # runners + batter + assert result.runs_scored <= max_possible, ( + f"Runs scored ({result.runs_scored}) exceeds max possible " + f"({max_possible}) for on_base_code={on_base_code}" + ) + + @pytest.mark.parametrize("outcome, on_base_code", FULL_MATRIX, ids=MATRIX_IDS) + def test_runs_scored_matches_runners_reaching_home(self, outcome, on_base_code): + """ + Runs scored must equal the count of runner advancements to base 4, + plus 1 if the batter also reaches base 4 (home run). + """ + result = resolve(outcome, on_base_code) + + runners_scoring = sum( + 1 for adv in result.runners_advanced if adv.to_base == 4 and not adv.is_out + ) + batter_scores = 1 if result.batter_result == 4 else 0 + + assert result.runs_scored == runners_scoring + batter_scores, ( + f"runs_scored ({result.runs_scored}) != runners reaching home " + f"({runners_scoring}) + batter scoring ({batter_scores}). " + f"Advances: {[(a.from_base, a.to_base, a.is_out) for a in result.runners_advanced]}, " + f"batter_result={result.batter_result}" + ) + + +# ============================================================================= +# Invariant 2: No Base Collisions +# ============================================================================= + +class TestNoBaseCollisions: + """ + After a play resolves, no two runners (including batter) should occupy + the same base (1, 2, or 3). Base 4 (home) and base 0 (out) can have + multiple entries. + """ + + @pytest.mark.parametrize("outcome, on_base_code", FULL_MATRIX, ids=MATRIX_IDS) + def test_no_two_runners_on_same_base(self, outcome, on_base_code): + """No two runners should end up on the same base (1-3).""" + result = resolve(outcome, on_base_code) + + # Collect all final base positions (excluding home=4 and out=0) + final_bases = [] + + # Batter's final position + if result.batter_result is not None and 1 <= result.batter_result <= 3: + final_bases.append(("batter", result.batter_result)) + + # Runners who moved + moved_from_bases = set() + for adv in result.runners_advanced: + moved_from_bases.add(adv.from_base) + if not adv.is_out and 1 <= adv.to_base <= 3: + final_bases.append((f"runner_from_{adv.from_base}", adv.to_base)) + + # Runners who didn't move (still on their original base) + initial_runners = [] + if on_base_code & 1: + initial_runners.append((1, 10)) + if on_base_code & 2: + initial_runners.append((2, 20)) + if on_base_code & 4: + initial_runners.append((3, 30)) + + for base, _lid in initial_runners: + if base not in moved_from_bases: + final_bases.append((f"unmoved_on_{base}", base)) + + # Check for duplicates + occupied = {} + for label, base in final_bases: + if base in occupied: + pytest.fail( + f"Base collision on base {base}: " + f"{occupied[base]} and {label} both occupy it. " + f"Outcome={outcome.value}, on_base_code={on_base_code}, " + f"batter_result={result.batter_result}, " + f"advances={[(a.from_base, a.to_base, a.is_out) for a in result.runners_advanced]}" + ) + occupied[base] = label + + +# ============================================================================= +# Invariant 3: Runners Never Go Backward +# ============================================================================= + +class TestRunnersNeverGoBackward: + """ + A runner's destination base must be >= their starting base, unless + they are recorded as out (to_base=0). + """ + + @pytest.mark.parametrize("outcome, on_base_code", FULL_MATRIX, ids=MATRIX_IDS) + def test_runners_advance_forward_or_out(self, outcome, on_base_code): + """Every runner movement must go forward (higher base) or be an out.""" + result = resolve(outcome, on_base_code) + + for adv in result.runners_advanced: + if adv.is_out: + # Outs can go to base 0 (removed) + continue + assert adv.to_base >= adv.from_base, ( + f"Runner went backward: base {adv.from_base} → {adv.to_base}. " + f"Outcome={outcome.value}, on_base_code={on_base_code}" + ) + + +# ============================================================================= +# Invariant 4: Batter Reaches Minimum Base for Hit Type +# ============================================================================= + +class TestBatterMinimumBase: + """ + On hits, the batter must reach at least the base corresponding to the + hit type (single=1, double=2, triple=3, homerun=4). + """ + + HIT_MATRIX = list(product(HIT_OUTCOMES, ALL_ON_BASE_CODES)) + HIT_IDS = [f"{o.value}__obc{c}" for o, c in HIT_MATRIX] + + @pytest.mark.parametrize("outcome, on_base_code", HIT_MATRIX, ids=HIT_IDS) + def test_batter_reaches_minimum_base(self, outcome, on_base_code): + """Batter must reach at least the minimum base for their hit type.""" + result = resolve(outcome, on_base_code) + min_base = BATTER_MINIMUM_BASE[outcome] + + assert result.batter_result is not None, ( + f"Batter result is None for hit {outcome.value}" + ) + assert result.batter_result >= min_base, ( + f"Batter reached base {result.batter_result} but minimum for " + f"{outcome.value} is {min_base}" + ) + + +# ============================================================================= +# Invariant 5: Walk/HBP Forced Advancement Rules +# ============================================================================= + +class TestWalkForcedAdvancement: + """ + On a walk or HBP, the batter goes to 1st and only FORCED runners advance. + + A runner is forced if all bases between them and 1st (inclusive) are occupied. + Examples: + - R1 is always forced (batter takes 1st) + - R2 is forced only if R1 is also on base + - R3 is forced only if R1 AND R2 are on base (bases loaded) + """ + + WALK_MATRIX = list(product(WALK_OUTCOMES, ALL_ON_BASE_CODES)) + WALK_IDS = [f"{o.value}__obc{c}" for o, c in WALK_MATRIX] + + @pytest.mark.parametrize("outcome, on_base_code", WALK_MATRIX, ids=WALK_IDS) + def test_batter_to_first(self, outcome, on_base_code): + """On walk/HBP, batter always reaches 1st base.""" + result = resolve(outcome, on_base_code) + assert result.batter_result == 1, ( + f"Batter should reach 1st on {outcome.value}, got {result.batter_result}" + ) + + @pytest.mark.parametrize("outcome, on_base_code", WALK_MATRIX, ids=WALK_IDS) + def test_no_outs_on_walk(self, outcome, on_base_code): + """Walks/HBP never record outs.""" + result = resolve(outcome, on_base_code) + assert result.outs_recorded == 0, ( + f"Walk/HBP should record 0 outs, got {result.outs_recorded}" + ) + + @pytest.mark.parametrize("outcome, on_base_code", WALK_MATRIX, ids=WALK_IDS) + def test_only_forced_runners_advance(self, outcome, on_base_code): + """ + Only runners forced by the batter taking 1st should advance. + + Forced chain: 1st must be occupied for anyone to be forced. + If 1st is empty, no runners advance at all. + """ + result = resolve(outcome, on_base_code) + has_r1 = bool(on_base_code & 1) + has_r2 = bool(on_base_code & 2) + has_r3 = bool(on_base_code & 4) + + if not has_r1: + # No runner on 1st → no one is forced → no advancements + assert len(result.runners_advanced) == 0, ( + f"No runner on 1st, but runners advanced: " + f"{[(a.from_base, a.to_base) for a in result.runners_advanced]}" + ) + else: + # R1 is forced to 2nd + r1_advanced = any(a.from_base == 1 for a in result.runners_advanced) + assert r1_advanced, "R1 should be forced to advance on walk" + + if has_r2: + # R2 is forced to 3rd (since R1 pushes to 2nd) + r2_advanced = any(a.from_base == 2 for a in result.runners_advanced) + assert r2_advanced, "R2 should be forced to advance (R1 and R2 both on)" + + if has_r3: + # R3 is forced home (bases loaded) + r3_advanced = any(a.from_base == 3 for a in result.runners_advanced) + assert r3_advanced, "R3 should be forced home (bases loaded)" + assert result.runs_scored == 1, ( + f"Bases loaded walk should score 1 run, got {result.runs_scored}" + ) + else: + # R2 not on base → not forced, should not advance + r2_advanced = any(a.from_base == 2 for a in result.runners_advanced) + assert not r2_advanced, ( + "R2 should NOT advance (not forced - no consecutive chain)" + ) + + @pytest.mark.parametrize("outcome, on_base_code", WALK_MATRIX, ids=WALK_IDS) + def test_forced_runners_advance_exactly_one_base(self, outcome, on_base_code): + """Forced runners on a walk advance exactly 1 base.""" + result = resolve(outcome, on_base_code) + + for adv in result.runners_advanced: + expected_to = adv.from_base + 1 + assert adv.to_base == expected_to, ( + f"Forced runner from base {adv.from_base} should go to " + f"{expected_to}, went to {adv.to_base}" + ) + + +# ============================================================================= +# Invariant 6: Simple Outs Record Exactly 1 Out, 0 Runs +# ============================================================================= + +class TestSimpleOuts: + """ + Strikeouts, lineouts, and popouts always record exactly 1 out, + 0 runs, and no runner advancement. + """ + + OUT_MATRIX = list(product(SIMPLE_OUT_OUTCOMES, ALL_ON_BASE_CODES)) + OUT_IDS = [f"{o.value}__obc{c}" for o, c in OUT_MATRIX] + + @pytest.mark.parametrize("outcome, on_base_code", OUT_MATRIX, ids=OUT_IDS) + def test_one_out_recorded(self, outcome, on_base_code): + """Simple outs always record exactly 1 out.""" + result = resolve(outcome, on_base_code) + assert result.outs_recorded == 1, ( + f"{outcome.value} should record 1 out, got {result.outs_recorded}" + ) + + @pytest.mark.parametrize("outcome, on_base_code", OUT_MATRIX, ids=OUT_IDS) + def test_zero_runs(self, outcome, on_base_code): + """Simple outs never score runs.""" + result = resolve(outcome, on_base_code) + assert result.runs_scored == 0, ( + f"{outcome.value} should score 0 runs, got {result.runs_scored}" + ) + + @pytest.mark.parametrize("outcome, on_base_code", OUT_MATRIX, ids=OUT_IDS) + def test_batter_is_out(self, outcome, on_base_code): + """Batter result should be None (out) for simple outs.""" + result = resolve(outcome, on_base_code) + assert result.batter_result is None, ( + f"Batter should be out for {outcome.value}, got base {result.batter_result}" + ) + + @pytest.mark.parametrize("outcome, on_base_code", OUT_MATRIX, ids=OUT_IDS) + def test_no_runner_advancement(self, outcome, on_base_code): + """Simple outs should not advance any runners.""" + result = resolve(outcome, on_base_code) + assert len(result.runners_advanced) == 0, ( + f"No runners should advance on {outcome.value}, " + f"got {[(a.from_base, a.to_base) for a in result.runners_advanced]}" + ) + + +# ============================================================================= +# Invariant 7: Hit Flag Correctness +# ============================================================================= + +class TestHitFlagCorrectness: + """ + The is_hit, is_out, and is_walk flags on PlayResult must match the + outcome type. + """ + + @pytest.mark.parametrize("outcome, on_base_code", FULL_MATRIX, ids=MATRIX_IDS) + def test_is_hit_flag(self, outcome, on_base_code): + """is_hit should be True for hit outcomes, False otherwise.""" + result = resolve(outcome, on_base_code) + + if outcome in HIT_OUTCOMES: + assert result.is_hit is True, ( + f"{outcome.value} should have is_hit=True" + ) + else: + assert result.is_hit is False, ( + f"{outcome.value} should have is_hit=False" + ) + + @pytest.mark.parametrize("outcome, on_base_code", FULL_MATRIX, ids=MATRIX_IDS) + def test_is_out_flag(self, outcome, on_base_code): + """is_out should be True for out outcomes, False for hits/walks.""" + result = resolve(outcome, on_base_code) + + if outcome in SIMPLE_OUT_OUTCOMES: + assert result.is_out is True, ( + f"{outcome.value} should have is_out=True" + ) + + WALK_MATRIX_LOCAL = list(product(WALK_OUTCOMES, ALL_ON_BASE_CODES)) + WALK_IDS_LOCAL = [f"{o.value}__obc{c}" for o, c in WALK_MATRIX_LOCAL] + + @pytest.mark.parametrize("outcome, on_base_code", WALK_MATRIX_LOCAL, ids=WALK_IDS_LOCAL) + def test_walk_is_walk_flag(self, outcome, on_base_code): + """ + WALK should have is_walk=True. + HBP should have is_walk=False (different stat category). + """ + result = resolve(outcome, on_base_code) + + if outcome == PlayOutcome.WALK: + assert result.is_walk is True, "WALK should have is_walk=True" + elif outcome == PlayOutcome.HIT_BY_PITCH: + assert result.is_walk is False, "HBP should have is_walk=False" + + +# ============================================================================= +# Invariant 8: Interrupt Plays (WP/PB) +# ============================================================================= + +class TestInterruptPlays: + """ + Wild pitches and passed balls advance all runners exactly 1 base. + The batter does NOT advance (stays at plate). + """ + + INT_MATRIX = list(product(INTERRUPT_ADVANCE_OUTCOMES, ALL_ON_BASE_CODES)) + INT_IDS = [f"{o.value}__obc{c}" for o, c in INT_MATRIX] + + @pytest.mark.parametrize("outcome, on_base_code", INT_MATRIX, ids=INT_IDS) + def test_batter_stays_at_plate(self, outcome, on_base_code): + """On WP/PB, batter stays at plate (result is None).""" + result = resolve(outcome, on_base_code) + assert result.batter_result is None, ( + f"Batter should stay at plate on {outcome.value}, " + f"got base {result.batter_result}" + ) + + @pytest.mark.parametrize("outcome, on_base_code", INT_MATRIX, ids=INT_IDS) + def test_no_outs_recorded(self, outcome, on_base_code): + """WP/PB never record outs.""" + result = resolve(outcome, on_base_code) + assert result.outs_recorded == 0 + + @pytest.mark.parametrize("outcome, on_base_code", INT_MATRIX, ids=INT_IDS) + def test_all_runners_advance_one_base(self, outcome, on_base_code): + """Every runner on base advances exactly 1 base.""" + result = resolve(outcome, on_base_code) + n_runners = count_initial_runners(on_base_code) + + assert len(result.runners_advanced) == n_runners, ( + f"Expected {n_runners} runner movements, got {len(result.runners_advanced)}" + ) + + for adv in result.runners_advanced: + expected_to = min(adv.from_base + 1, 4) + assert adv.to_base == expected_to, ( + f"Runner from base {adv.from_base} should advance to " + f"{expected_to}, went to {adv.to_base}" + ) + + @pytest.mark.parametrize("outcome, on_base_code", INT_MATRIX, ids=INT_IDS) + def test_runner_on_third_scores(self, outcome, on_base_code): + """If runner on 3rd, they score (base 4) on WP/PB.""" + result = resolve(outcome, on_base_code) + has_r3 = bool(on_base_code & 4) + + if has_r3: + r3_scores = any( + a.from_base == 3 and a.to_base == 4 + for a in result.runners_advanced + ) + assert r3_scores, "Runner on 3rd should score on WP/PB" + + +# ============================================================================= +# Invariant 9: Hits Never Record Outs +# ============================================================================= + +class TestHitsNeverRecordOuts: + """ + Hit outcomes (singles, doubles, triples, homers) should never + record any outs. + """ + + HIT_MATRIX = list(product(HIT_OUTCOMES, ALL_ON_BASE_CODES)) + HIT_IDS = [f"{o.value}__obc{c}" for o, c in HIT_MATRIX] + + @pytest.mark.parametrize("outcome, on_base_code", HIT_MATRIX, ids=HIT_IDS) + def test_zero_outs_on_hits(self, outcome, on_base_code): + """Hits never record outs.""" + result = resolve(outcome, on_base_code) + assert result.outs_recorded == 0, ( + f"{outcome.value} should record 0 outs, got {result.outs_recorded}" + ) + + +# ============================================================================= +# Invariant 10: Home Run - Everyone Scores +# ============================================================================= + +class TestHomeRunEveryoneScores: + """On a home run, every runner on base scores plus the batter.""" + + @pytest.mark.parametrize("on_base_code", ALL_ON_BASE_CODES) + def test_homerun_all_score(self, on_base_code): + """Home run: runs = runners on base + 1 (batter).""" + result = resolve(PlayOutcome.HOMERUN, on_base_code) + n_runners = count_initial_runners(on_base_code) + + assert result.runs_scored == n_runners + 1, ( + f"HR with {n_runners} runners should score {n_runners + 1}, " + f"got {result.runs_scored}" + ) + assert result.batter_result == 4, "Batter should reach home (4) on HR" + + +# ============================================================================= +# Invariant 11: Triple - All Runners Score +# ============================================================================= + +class TestTripleAllRunnersScore: + """On a triple, all runners on base score. Batter reaches 3rd.""" + + @pytest.mark.parametrize("on_base_code", ALL_ON_BASE_CODES) + def test_triple_runners_all_score(self, on_base_code): + """Triple: all runners score, batter to 3rd.""" + result = resolve(PlayOutcome.TRIPLE, on_base_code) + n_runners = count_initial_runners(on_base_code) + + assert result.runs_scored == n_runners, ( + f"Triple with {n_runners} runners should score {n_runners}, " + f"got {result.runs_scored}" + ) + assert result.batter_result == 3, "Batter should reach 3rd on triple" diff --git a/backend/tests/unit/core/truth_tables/__init__.py b/backend/tests/unit/core/truth_tables/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/unit/core/truth_tables/conftest.py b/backend/tests/unit/core/truth_tables/conftest.py new file mode 100644 index 0000000..aa7c5c6 --- /dev/null +++ b/backend/tests/unit/core/truth_tables/conftest.py @@ -0,0 +1,215 @@ +""" +Shared fixtures and helpers for truth table tests. + +Provides factory functions for GameState, AbRoll, and a resolve() helper +that all truth table test files use. +""" + +import pytest +from uuid import uuid4 + +import pendulum + +from app.config import PlayOutcome +from app.core.play_resolver import PlayResolver, PlayResult +from app.core.roll_types import AbRoll, RollType +from app.models.game_models import ( + DefensiveDecision, + GameState, + LineupPlayerState, + OffensiveDecision, +) + + +# ============================================================================= +# Player & State Factories +# ============================================================================= + +# Lineup IDs by base position (consistent across all truth table tests) +BATTER_LID = 1 +R1_LID = 10 +R2_LID = 20 +R3_LID = 30 + + +def make_player(lineup_id: int, batting_order: int = 1) -> LineupPlayerState: + """Create a LineupPlayerState with unique IDs for testing.""" + return LineupPlayerState( + lineup_id=lineup_id, + card_id=lineup_id * 100, + position="CF", + batting_order=batting_order, + ) + + +def make_ab_roll(game_id=None) -> AbRoll: + """Create a mock AbRoll for testing.""" + return AbRoll( + roll_type=RollType.AB, + roll_id="test_truth_table", + timestamp=pendulum.now("UTC"), + league_id="sba", + game_id=game_id, + d6_one=3, + d6_two_a=2, + d6_two_b=4, + chaos_d20=10, + resolution_d20=10, + ) + + +def make_state(on_base_code: int, outs: int = 0) -> GameState: + """ + Create a GameState with runners placed according to on_base_code. + + Uses sequential chart encoding (matching official rulebook charts): + 0 = empty 4 = R1+R2 + 1 = R1 5 = R1+R3 + 2 = R2 6 = R2+R3 + 3 = R3 7 = R1+R2+R3 (loaded) + """ + # Sequential encoding → which bases are occupied + r1_on = on_base_code in (1, 4, 5, 7) + r2_on = on_base_code in (2, 4, 6, 7) + r3_on = on_base_code in (3, 5, 6, 7) + + state = GameState( + game_id=uuid4(), + league_id="sba", + home_team_id=1, + away_team_id=2, + outs=outs, + current_batter=make_player(BATTER_LID, batting_order=1), + on_first=make_player(R1_LID, batting_order=2) if r1_on else None, + on_second=make_player(R2_LID, batting_order=3) if r2_on else None, + on_third=make_player(R3_LID, batting_order=4) if r3_on else None, + ) + # Set current_on_base_code for chart-based lookups (groundballs, x-checks) + state.current_on_base_code = on_base_code + return state + + +# ============================================================================= +# On-Base Code Reference (Sequential Chart Encoding) +# ============================================================================= +# 0 = empty 4 = R1+R2 +# 1 = R1 5 = R1+R3 +# 2 = R2 6 = R2+R3 +# 3 = R3 7 = R1+R2+R3 (loaded) + +OBC_LABELS = { + 0: "empty", + 1: "R1", + 2: "R2", + 3: "R3", + 4: "R1_R2", + 5: "R1_R3", + 6: "R2_R3", + 7: "loaded", +} + + +# ============================================================================= +# Resolve Helpers +# ============================================================================= + +def resolve_simple(outcome: PlayOutcome, on_base_code: int) -> PlayResult: + """ + Resolve a play that doesn't need hit_location or special defensive setup. + + Used for: hits, walks, HBP, strikeouts, lineouts, popouts, WP, PB. + """ + resolver = PlayResolver(league_id="sba", auto_mode=False) + state = make_state(on_base_code) + ab_roll = make_ab_roll(state.game_id) + + return resolver.resolve_outcome( + outcome=outcome, + hit_location=None, + state=state, + defensive_decision=DefensiveDecision(), + offensive_decision=OffensiveDecision(), + ab_roll=ab_roll, + ) + + +def resolve_with_location( + outcome: PlayOutcome, + on_base_code: int, + hit_location: str, + infield_depth: str = "normal", + outs: int = 0, +) -> PlayResult: + """ + Resolve a play that requires hit_location and/or defensive positioning. + + Used for: groundballs, flyballs. + """ + resolver = PlayResolver(league_id="sba", auto_mode=False) + state = make_state(on_base_code, outs=outs) + ab_roll = make_ab_roll(state.game_id) + + return resolver.resolve_outcome( + outcome=outcome, + hit_location=hit_location, + state=state, + defensive_decision=DefensiveDecision(infield_depth=infield_depth), + offensive_decision=OffensiveDecision(), + ab_roll=ab_roll, + ) + + +# ============================================================================= +# Assertion Helper +# ============================================================================= + +def assert_play_result( + result: PlayResult, + expected_batter: int | None, + expected_movements: list[tuple[int, int]], + expected_runs: int, + expected_outs: int, + context: str = "", +): + """ + Assert that a PlayResult matches expected values. + + Args: + result: The actual PlayResult from resolve_outcome() + expected_batter: Expected batter_result (None=out, 1-4=base reached) + expected_movements: Expected runner movements as [(from_base, to_base), ...] + Only includes runners that MOVED. Order doesn't matter. + Movements with to_base=0 indicate runner is out. + expected_runs: Expected runs scored + expected_outs: Expected outs recorded + context: Description for error messages (e.g. "SINGLE_1 obc=3") + """ + prefix = f"[{context}] " if context else "" + + # Check batter result + assert result.batter_result == expected_batter, ( + f"{prefix}batter_result: expected {expected_batter}, got {result.batter_result}" + ) + + # Check runner movements (order-independent, excluding "hold" movements + # where from_base == to_base, which are informational only) + actual_movements = { + (a.from_base, a.to_base) for a in result.runners_advanced + if a.from_base != a.to_base + } + expected_set = set(expected_movements) + + assert actual_movements == expected_set, ( + f"{prefix}runner movements: expected {sorted(expected_set)}, " + f"got {sorted(actual_movements)}" + ) + + # Check runs scored + assert result.runs_scored == expected_runs, ( + f"{prefix}runs_scored: expected {expected_runs}, got {result.runs_scored}" + ) + + # Check outs recorded + assert result.outs_recorded == expected_outs, ( + f"{prefix}outs_recorded: expected {expected_outs}, got {result.outs_recorded}" + ) diff --git a/backend/tests/unit/core/truth_tables/test_tt_groundballs.py b/backend/tests/unit/core/truth_tables/test_tt_groundballs.py new file mode 100644 index 0000000..cb8eb89 --- /dev/null +++ b/backend/tests/unit/core/truth_tables/test_tt_groundballs.py @@ -0,0 +1,493 @@ +""" +Truth Table Tests: Groundball Outcomes + +Verifies exact runner advancement for every (groundball_type, on_base_code, +infield_depth, hit_location) combination. + +Groundball types: GROUNDBALL_A, GROUNDBALL_B, GROUNDBALL_C +Infield depth: "normal" (infield back), "infield_in", "corners_in" +Hit locations: 1B, 2B, SS, 3B, P, C (all infield positions) + +Chart routing: + - 2 outs: Always Result 1 (batter out, runners hold) regardless of other factors + - Infield In: Applied when infield_in AND obc in {3,5,6,7} (runner on 3rd) + - Corners In: Applied when corners_in AND obc in {3,5,6,7} AND hit to corner (1B,3B,P,C) + - Infield Back: Default for all other scenarios + +Result types (from runner_advancement.py): + 1: Batter out, runners hold + 2: Double play at 2nd and 1st (R1 out, batter out, others advance) + 3: Batter out, all runners advance 1 base + 4: Batter safe at 1st, R1 forced out at 2nd, others advance + 5: Conditional on middle infield (2B/SS=Result 3, else=Result 1) + 6: Conditional on right side (1B/2B=Result 3, else=Result 1) + 7: Batter out, forced runners only advance + 8: Same as Result 7 + 9: Batter out, R3 holds, R1→2nd + 10: Double play at home and 1st (R3 out, batter out, others advance) + 11: Batter safe at 1st, lead runner out, others advance + 12: DECIDE opportunity (conservative default: batter out, runners hold; + 1B/2B→Result 3, 3B→Result 1) + +On-base codes (sequential chart encoding): + 0=empty, 1=R1, 2=R2, 3=R3, 4=R1+R2, 5=R1+R3, 6=R2+R3, 7=loaded + +Author: Claude +Date: 2025-02-08 +""" + +import pytest + +from app.config import PlayOutcome + +from .conftest import OBC_LABELS, assert_play_result, resolve_with_location + +# ============================================================================= +# Infield Back Chart (Normal Defense) +# ============================================================================= +# Reference: runner_advancement.py _apply_infield_back_chart() +# +# obc 0 (empty): A=1, B=1, C=1 +# obc 1 (R1): A=2, B=4, C=3 +# obc 2 (R2): A=6, B=6, C=3 +# obc 3 (R3): A=5, B=5, C=3 +# obc 4 (R1+R2): A=2, B=4, C=3 +# obc 5 (R1+R3): A=2, B=4, C=3 +# obc 6 (R2+R3): A=5, B=5, C=3 +# obc 7 (loaded): A=2, B=4, C=3 +# +# Results 5 and 6 are conditional on hit location: +# Result 5: 2B/SS → all advance (Result 3), else → hold (Result 1) +# Result 6: 1B/2B → all advance (Result 3), else → hold (Result 1) + +# ============================================================================= +# Truth Table: Infield Back (Normal Defense) - 0 outs +# ============================================================================= +# Each entry: (outcome, obc, hit_location, depth, exp_batter, exp_moves, exp_runs, exp_outs) + +INFIELD_BACK_TRUTH_TABLE = [ + # ========================================================================= + # OBC 0 (Empty): All groundballs → Result 1 (batter out, runners hold) + # ========================================================================= + (PlayOutcome.GROUNDBALL_A, 0, "SS", "normal", None, [], 0, 1), + (PlayOutcome.GROUNDBALL_B, 0, "SS", "normal", None, [], 0, 1), + (PlayOutcome.GROUNDBALL_C, 0, "SS", "normal", None, [], 0, 1), + + # ========================================================================= + # OBC 1 (R1): A=2 (DP), B=4 (FC at 2nd), C=3 (advance) + # ========================================================================= + # Result 2: R1 out at 2nd, batter out (DP). 0 outs → can turn DP. + (PlayOutcome.GROUNDBALL_A, 1, "SS", "normal", None, [(1, 0)], 0, 2), + # Result 4: R1 forced out at 2nd, batter safe at 1st + (PlayOutcome.GROUNDBALL_B, 1, "SS", "normal", 1, [(1, 0)], 0, 1), + # Result 3: Batter out, R1→2nd + (PlayOutcome.GROUNDBALL_C, 1, "SS", "normal", None, [(1, 2)], 0, 1), + + # ========================================================================= + # OBC 2 (R2): A=6 (conditional right), B=6, C=3 (advance) + # ========================================================================= + # Result 6: 1B/2B → advance (Result 3), else → hold (Result 1) + # Hit to SS (not right side) → Result 1: batter out, R2 holds + (PlayOutcome.GROUNDBALL_A, 2, "SS", "normal", None, [], 0, 1), + (PlayOutcome.GROUNDBALL_B, 2, "SS", "normal", None, [], 0, 1), + # Hit to 2B (right side) → Result 3: batter out, R2→3rd + (PlayOutcome.GROUNDBALL_A, 2, "2B", "normal", None, [(2, 3)], 0, 1), + (PlayOutcome.GROUNDBALL_B, 2, "2B", "normal", None, [(2, 3)], 0, 1), + # Hit to 1B (right side) → Result 3: batter out, R2→3rd + (PlayOutcome.GROUNDBALL_A, 2, "1B", "normal", None, [(2, 3)], 0, 1), + (PlayOutcome.GROUNDBALL_B, 2, "1B", "normal", None, [(2, 3)], 0, 1), + # Result 3: Batter out, R2→3rd (unconditional) + (PlayOutcome.GROUNDBALL_C, 2, "SS", "normal", None, [(2, 3)], 0, 1), + + # ========================================================================= + # OBC 3 (R3): A=5 (conditional MIF), B=5, C=3 (advance) + # ========================================================================= + # Result 5: 2B/SS → advance (Result 3), else → hold (Result 1) + # Hit to SS (MIF) → Result 3: batter out, R3 scores + (PlayOutcome.GROUNDBALL_A, 3, "SS", "normal", None, [(3, 4)], 1, 1), + (PlayOutcome.GROUNDBALL_B, 3, "SS", "normal", None, [(3, 4)], 1, 1), + # Hit to 2B (MIF) → Result 3: batter out, R3 scores + (PlayOutcome.GROUNDBALL_A, 3, "2B", "normal", None, [(3, 4)], 1, 1), + (PlayOutcome.GROUNDBALL_B, 3, "2B", "normal", None, [(3, 4)], 1, 1), + # Hit to 1B (not MIF) → Result 1: batter out, R3 holds + (PlayOutcome.GROUNDBALL_A, 3, "1B", "normal", None, [], 0, 1), + (PlayOutcome.GROUNDBALL_B, 3, "1B", "normal", None, [], 0, 1), + # Hit to 3B (not MIF) → Result 1: batter out, R3 holds + (PlayOutcome.GROUNDBALL_A, 3, "3B", "normal", None, [], 0, 1), + (PlayOutcome.GROUNDBALL_B, 3, "3B", "normal", None, [], 0, 1), + # Result 3: Batter out, R3 scores (unconditional) + (PlayOutcome.GROUNDBALL_C, 3, "SS", "normal", None, [(3, 4)], 1, 1), + + # ========================================================================= + # OBC 4 (R1+R2): A=2 (DP), B=4 (FC at 2nd), C=3 (advance) + # ========================================================================= + # Result 2: DP at 2nd+1st. R1 out, batter out, R2 scores (per chart rules) + (PlayOutcome.GROUNDBALL_A, 4, "SS", "normal", None, [(1, 0), (2, 4)], 1, 2), + # Result 4: R1 forced out at 2nd, batter safe at 1st, R2→3rd + (PlayOutcome.GROUNDBALL_B, 4, "SS", "normal", 1, [(1, 0), (2, 3)], 0, 1), + # Result 3: Batter out, R1→2nd, R2→3rd + (PlayOutcome.GROUNDBALL_C, 4, "SS", "normal", None, [(1, 2), (2, 3)], 0, 1), + + # ========================================================================= + # OBC 5 (R1+R3): A=2 (DP), B=4 (FC at 2nd), C=3 (advance) + # ========================================================================= + # Result 2: DP at 2nd+1st. R1 out, batter out, R3 scores + (PlayOutcome.GROUNDBALL_A, 5, "SS", "normal", None, [(1, 0), (3, 4)], 1, 2), + # Result 4: R1 forced out at 2nd, batter safe at 1st, R3 scores + (PlayOutcome.GROUNDBALL_B, 5, "SS", "normal", 1, [(1, 0), (3, 4)], 1, 1), + # Result 3: Batter out, R1→2nd, R3 scores + (PlayOutcome.GROUNDBALL_C, 5, "SS", "normal", None, [(1, 2), (3, 4)], 1, 1), + + # ========================================================================= + # OBC 6 (R2+R3): A=5 (conditional MIF), B=5, C=3 (advance) + # ========================================================================= + # Result 5: 2B/SS → advance (Result 3), else → hold (Result 1) + # Hit to SS (MIF) → Result 3: batter out, R2→3rd, R3 scores + (PlayOutcome.GROUNDBALL_A, 6, "SS", "normal", None, [(2, 3), (3, 4)], 1, 1), + (PlayOutcome.GROUNDBALL_B, 6, "SS", "normal", None, [(2, 3), (3, 4)], 1, 1), + # Hit to 1B (not MIF) → Result 1: batter out, R2+R3 hold + (PlayOutcome.GROUNDBALL_A, 6, "1B", "normal", None, [], 0, 1), + (PlayOutcome.GROUNDBALL_B, 6, "1B", "normal", None, [], 0, 1), + # Result 3: Batter out, R2→3rd, R3 scores (unconditional) + (PlayOutcome.GROUNDBALL_C, 6, "SS", "normal", None, [(2, 3), (3, 4)], 1, 1), + + # ========================================================================= + # OBC 7 (Loaded): A=2 (DP), B=4 (FC at 2nd), C=3 (advance) + # ========================================================================= + # Result 2: DP at 2nd+1st. R1 out, batter out, R2→3rd (actually R2 scores from 2nd), R3 scores + (PlayOutcome.GROUNDBALL_A, 7, "SS", "normal", None, [(1, 0), (2, 4), (3, 4)], 2, 2), + # Result 4: R1 forced out at 2nd, batter safe at 1st, R2→3rd, R3 scores + (PlayOutcome.GROUNDBALL_B, 7, "SS", "normal", 1, [(1, 0), (2, 3), (3, 4)], 1, 1), + # Result 3: Batter out, R1→2nd, R2→3rd, R3 scores + (PlayOutcome.GROUNDBALL_C, 7, "SS", "normal", None, [(1, 2), (2, 3), (3, 4)], 1, 1), +] + +INFIELD_BACK_IDS = [ + f"{outcome.value}__{OBC_LABELS[obc]}__{loc}__{depth}" + for outcome, obc, loc, depth, *_ in INFIELD_BACK_TRUTH_TABLE +] + + +# ============================================================================= +# Truth Table: Infield In - 0 outs +# ============================================================================= +# Only applies when obc in {3, 5, 6, 7} (runner on 3rd) +# Reference: runner_advancement.py _apply_infield_in_chart() +# +# obc 3 (R3): A=7, B=1, C varies by location +# obc 5 (R1+R3): A=7, B=9, C=12(SS/P/C) or 8(else) +# obc 6 (R2+R3): A=7, B=1, C=8 +# obc 7 (loaded): A=10, B=11, C=11 +# +# Result 7: Batter out, forced runners only advance +# Result 8: Same as Result 7 +# Result 9: Batter out, R3 holds, R1→2nd +# Result 10: DP at home+1st (R3 out, batter out, R2→3rd, R1→2nd) +# Result 11: Batter safe at 1st, lead runner out, others advance +# Result 12: DECIDE (conservative default: SS/P/C → batter out runners hold, +# 1B/2B → Result 3, 3B → Result 1) + +INFIELD_IN_TRUTH_TABLE = [ + # ========================================================================= + # OBC 3 (R3) with Infield In: A=7, B=1, C=12 (DECIDE) + # ========================================================================= + # Result 7: Batter out, forced only. R3 NOT forced (no R1+R2), so R3 holds. + (PlayOutcome.GROUNDBALL_A, 3, "SS", "infield_in", None, [], 0, 1), + # Result 1: Batter out, runners hold + (PlayOutcome.GROUNDBALL_B, 3, "SS", "infield_in", None, [], 0, 1), + # Result 12 (DECIDE): SS → conservative (batter out, R3 holds) + (PlayOutcome.GROUNDBALL_C, 3, "SS", "infield_in", None, [], 0, 1), + # Result 12 (DECIDE): P → conservative (batter out, R3 holds) + (PlayOutcome.GROUNDBALL_C, 3, "P", "infield_in", None, [], 0, 1), + # Result 12 (DECIDE): 1B → Result 3 (batter out, R3 scores) + (PlayOutcome.GROUNDBALL_C, 3, "1B", "infield_in", None, [(3, 4)], 1, 1), + # Result 12 (DECIDE): 2B → Result 3 (batter out, R3 scores) + (PlayOutcome.GROUNDBALL_C, 3, "2B", "infield_in", None, [(3, 4)], 1, 1), + # Result 12 (DECIDE): 3B → Result 1 (batter out, R3 holds) + (PlayOutcome.GROUNDBALL_C, 3, "3B", "infield_in", None, [], 0, 1), + # Result 12 (DECIDE): C → conservative (batter out, R3 holds) + (PlayOutcome.GROUNDBALL_C, 3, "C", "infield_in", None, [], 0, 1), + + # ========================================================================= + # OBC 5 (R1+R3) with Infield In: A=7, B=9, C varies + # ========================================================================= + # Result 7: Batter out, forced only. R1 forced→2nd, R3 NOT forced→holds. + (PlayOutcome.GROUNDBALL_A, 5, "SS", "infield_in", None, [(1, 2)], 0, 1), + # Result 9: Batter out, R3 holds, R1→2nd + (PlayOutcome.GROUNDBALL_B, 5, "SS", "infield_in", None, [(1, 2)], 0, 1), + # Result 12 (DECIDE): SS → conservative (batter out, R1+R3 hold) + (PlayOutcome.GROUNDBALL_C, 5, "SS", "infield_in", None, [], 0, 1), + # Result 12 (DECIDE): P → conservative (batter out, R1+R3 hold) + (PlayOutcome.GROUNDBALL_C, 5, "P", "infield_in", None, [], 0, 1), + # Result 12 (DECIDE): C → conservative (batter out, R1+R3 hold) + (PlayOutcome.GROUNDBALL_C, 5, "C", "infield_in", None, [], 0, 1), + # Result 8: 1B → batter out, forced only. R1 forced→2nd, R3 NOT forced→holds. + (PlayOutcome.GROUNDBALL_C, 5, "1B", "infield_in", None, [(1, 2)], 0, 1), + # Result 8: 2B → same as above + (PlayOutcome.GROUNDBALL_C, 5, "2B", "infield_in", None, [(1, 2)], 0, 1), + # Result 8: 3B → same as above + (PlayOutcome.GROUNDBALL_C, 5, "3B", "infield_in", None, [(1, 2)], 0, 1), + + # ========================================================================= + # OBC 6 (R2+R3) with Infield In: A=7, B=1, C=8 + # ========================================================================= + # Result 7: Batter out, forced only. R2 NOT forced (no R1), R3 NOT forced. Both hold. + (PlayOutcome.GROUNDBALL_A, 6, "SS", "infield_in", None, [], 0, 1), + # Result 1: Batter out, runners hold + (PlayOutcome.GROUNDBALL_B, 6, "SS", "infield_in", None, [], 0, 1), + # Result 8: Same as Result 7. Batter out, no forced runners. R2+R3 hold. + (PlayOutcome.GROUNDBALL_C, 6, "SS", "infield_in", None, [], 0, 1), + + # ========================================================================= + # OBC 7 (Loaded) with Infield In: A=10, B=11, C=11 + # ========================================================================= + # Result 10: DP at home+1st. R3 out at home, batter out. R2→3rd, R1→2nd. 0 runs (R3 out). + (PlayOutcome.GROUNDBALL_A, 7, "SS", "infield_in", None, [(3, 0), (2, 3), (1, 2)], 0, 2), + # Result 11: Batter safe at 1st, lead runner (R3) out. R2→3rd, R1→2nd. + (PlayOutcome.GROUNDBALL_B, 7, "SS", "infield_in", 1, [(3, 0), (2, 3), (1, 2)], 0, 1), + # Result 11: Same as above + (PlayOutcome.GROUNDBALL_C, 7, "SS", "infield_in", 1, [(3, 0), (2, 3), (1, 2)], 0, 1), +] + +INFIELD_IN_IDS = [ + f"{outcome.value}__{OBC_LABELS[obc]}__{loc}__{depth}" + for outcome, obc, loc, depth, *_ in INFIELD_IN_TRUTH_TABLE +] + + +# ============================================================================= +# Truth Table: Corners In +# ============================================================================= +# Corners In uses Infield In rules when hit to corner positions (1B, 3B, P, C) +# and Infield Back rules when hit to middle infield (2B, SS). +# Only applies when obc in {3, 5, 6, 7} (runner on 3rd). + +CORNERS_IN_TRUTH_TABLE = [ + # ========================================================================= + # OBC 3 (R3) with Corners In + # ========================================================================= + # Hit to 1B (corner) → Infield In chart: A=7, B=1, C=12 + # Result 7: Batter out, forced only. R3 NOT forced→holds. + (PlayOutcome.GROUNDBALL_A, 3, "1B", "corners_in", None, [], 0, 1), + # Result 1: Batter out, runners hold + (PlayOutcome.GROUNDBALL_B, 3, "1B", "corners_in", None, [], 0, 1), + # Result 12 (DECIDE): 1B → Result 3 (batter out, R3 scores) + (PlayOutcome.GROUNDBALL_C, 3, "1B", "corners_in", None, [(3, 4)], 1, 1), + + # Hit to 3B (corner) → Infield In chart + (PlayOutcome.GROUNDBALL_A, 3, "3B", "corners_in", None, [], 0, 1), + (PlayOutcome.GROUNDBALL_B, 3, "3B", "corners_in", None, [], 0, 1), + # Result 12 (DECIDE): 3B → Result 1 (batter out, R3 holds) + (PlayOutcome.GROUNDBALL_C, 3, "3B", "corners_in", None, [], 0, 1), + + # Hit to P (corner) → Infield In chart + (PlayOutcome.GROUNDBALL_A, 3, "P", "corners_in", None, [], 0, 1), + (PlayOutcome.GROUNDBALL_B, 3, "P", "corners_in", None, [], 0, 1), + # Result 12 (DECIDE): P → conservative (batter out, R3 holds) + (PlayOutcome.GROUNDBALL_C, 3, "P", "corners_in", None, [], 0, 1), + + # Hit to SS (middle) → Infield Back chart: A=5, B=5, C=3 + # Result 5: SS is MIF → Result 3 (batter out, R3 scores) + (PlayOutcome.GROUNDBALL_A, 3, "SS", "corners_in", None, [(3, 4)], 1, 1), + (PlayOutcome.GROUNDBALL_B, 3, "SS", "corners_in", None, [(3, 4)], 1, 1), + # Result 3: Batter out, R3 scores + (PlayOutcome.GROUNDBALL_C, 3, "SS", "corners_in", None, [(3, 4)], 1, 1), + + # Hit to 2B (middle) → Infield Back chart: A=5, B=5, C=3 + # Result 5: 2B is MIF → Result 3 (batter out, R3 scores) + (PlayOutcome.GROUNDBALL_A, 3, "2B", "corners_in", None, [(3, 4)], 1, 1), + (PlayOutcome.GROUNDBALL_B, 3, "2B", "corners_in", None, [(3, 4)], 1, 1), + (PlayOutcome.GROUNDBALL_C, 3, "2B", "corners_in", None, [(3, 4)], 1, 1), + + # ========================================================================= + # OBC 7 (Loaded) with Corners In - corner hit + # ========================================================================= + # Hit to 3B (corner) → Infield In chart: A=10, B=11, C=11 + # Result 10: DP at home+1st + (PlayOutcome.GROUNDBALL_A, 7, "3B", "corners_in", None, [(3, 0), (2, 3), (1, 2)], 0, 2), + # Result 11: Batter safe, lead runner (R3) out + (PlayOutcome.GROUNDBALL_B, 7, "3B", "corners_in", 1, [(3, 0), (2, 3), (1, 2)], 0, 1), + (PlayOutcome.GROUNDBALL_C, 7, "3B", "corners_in", 1, [(3, 0), (2, 3), (1, 2)], 0, 1), + + # Hit to SS (middle) → Infield Back chart: A=2, B=4, C=3 + # Result 2: DP at 2nd+1st, R1 out, batter out, R2+R3 advance/score + (PlayOutcome.GROUNDBALL_A, 7, "SS", "corners_in", None, [(1, 0), (2, 4), (3, 4)], 2, 2), + # Result 4: R1 out at 2nd, batter safe, R2→3rd, R3 scores + (PlayOutcome.GROUNDBALL_B, 7, "SS", "corners_in", 1, [(1, 0), (2, 3), (3, 4)], 1, 1), + # Result 3: Batter out, R1→2nd, R2→3rd, R3 scores + (PlayOutcome.GROUNDBALL_C, 7, "SS", "corners_in", None, [(1, 2), (2, 3), (3, 4)], 1, 1), +] + +CORNERS_IN_IDS = [ + f"{outcome.value}__{OBC_LABELS[obc]}__{loc}__{depth}" + for outcome, obc, loc, depth, *_ in CORNERS_IN_TRUTH_TABLE +] + + +# ============================================================================= +# Truth Table: 2 Outs Override +# ============================================================================= +# With 2 outs, ALL groundballs → Result 1 (batter out, runners hold) +# regardless of groundball type, defense, or hit location. + +TWO_OUTS_TRUTH_TABLE = [ + # Sample across different obc/depth/location combos to verify override + (PlayOutcome.GROUNDBALL_A, 0, "SS", "normal", None, [], 0, 1), + (PlayOutcome.GROUNDBALL_A, 1, "SS", "normal", None, [], 0, 1), + (PlayOutcome.GROUNDBALL_A, 5, "SS", "infield_in", None, [], 0, 1), + (PlayOutcome.GROUNDBALL_A, 7, "SS", "infield_in", None, [], 0, 1), + (PlayOutcome.GROUNDBALL_B, 3, "1B", "corners_in", None, [], 0, 1), + (PlayOutcome.GROUNDBALL_C, 7, "3B", "infield_in", None, [], 0, 1), +] + +TWO_OUTS_IDS = [ + f"2outs_{outcome.value}__{OBC_LABELS[obc]}__{loc}__{depth}" + for outcome, obc, loc, depth, *_ in TWO_OUTS_TRUTH_TABLE +] + + +# ============================================================================= +# Truth Table: Result 2 DP behavior with 1 out +# ============================================================================= +# Result 2 with 1 out: Still a valid DP (0+2=2 outs, inning not over, runners advance) +# This tests the DP at 1 out specifically. + +ONE_OUT_DP_TRUTH_TABLE = [ + # R1 only, 1 out: DP at 2nd+1st. Total outs = 1+2 = 3 → inning over. + # With 3 outs, runners do NOT advance (inning ends). + (PlayOutcome.GROUNDBALL_A, 1, "SS", "normal", None, [(1, 0)], 0, 2), + # R1+R3, 1 out: DP at 2nd+1st. Total outs = 3 → inning over, R3 does NOT score. + (PlayOutcome.GROUNDBALL_A, 5, "SS", "normal", None, [(1, 0)], 0, 2), + # Loaded, 1 out: DP at 2nd+1st. Total outs = 3 → inning over, no runs score. + (PlayOutcome.GROUNDBALL_A, 7, "SS", "normal", None, [(1, 0)], 0, 2), +] + +ONE_OUT_DP_IDS = [ + f"1out_{outcome.value}__{OBC_LABELS[obc]}__{loc}" + for outcome, obc, loc, depth, *_ in ONE_OUT_DP_TRUTH_TABLE +] + + +# ============================================================================= +# Tests: Infield Back (Normal Defense) +# ============================================================================= + +class TestInfieldBackTruthTable: + """Verify groundball advancement with normal (infield back) defense at 0 outs.""" + + @pytest.mark.parametrize( + "outcome, obc, hit_location, depth, exp_batter, exp_moves, exp_runs, exp_outs", + INFIELD_BACK_TRUTH_TABLE, + ids=INFIELD_BACK_IDS, + ) + def test_infield_back(self, outcome, obc, hit_location, depth, exp_batter, exp_moves, exp_runs, exp_outs): + """Verify groundball with infield back produces correct advancement.""" + result = resolve_with_location(outcome, obc, hit_location, infield_depth=depth, outs=0) + assert_play_result( + result, + expected_batter=exp_batter, + expected_movements=exp_moves, + expected_runs=exp_runs, + expected_outs=exp_outs, + context=f"{outcome.value} obc={obc}({OBC_LABELS[obc]}) loc={hit_location} depth={depth}", + ) + + +# ============================================================================= +# Tests: Infield In +# ============================================================================= + +class TestInfieldInTruthTable: + """Verify groundball advancement with infield in defense at 0 outs.""" + + @pytest.mark.parametrize( + "outcome, obc, hit_location, depth, exp_batter, exp_moves, exp_runs, exp_outs", + INFIELD_IN_TRUTH_TABLE, + ids=INFIELD_IN_IDS, + ) + def test_infield_in(self, outcome, obc, hit_location, depth, exp_batter, exp_moves, exp_runs, exp_outs): + """Verify groundball with infield in produces correct advancement.""" + result = resolve_with_location(outcome, obc, hit_location, infield_depth=depth, outs=0) + assert_play_result( + result, + expected_batter=exp_batter, + expected_movements=exp_moves, + expected_runs=exp_runs, + expected_outs=exp_outs, + context=f"{outcome.value} obc={obc}({OBC_LABELS[obc]}) loc={hit_location} depth={depth}", + ) + + +# ============================================================================= +# Tests: Corners In +# ============================================================================= + +class TestCornersInTruthTable: + """Verify groundball advancement with corners in defense at 0 outs.""" + + @pytest.mark.parametrize( + "outcome, obc, hit_location, depth, exp_batter, exp_moves, exp_runs, exp_outs", + CORNERS_IN_TRUTH_TABLE, + ids=CORNERS_IN_IDS, + ) + def test_corners_in(self, outcome, obc, hit_location, depth, exp_batter, exp_moves, exp_runs, exp_outs): + """Verify groundball with corners in produces correct advancement.""" + result = resolve_with_location(outcome, obc, hit_location, infield_depth=depth, outs=0) + assert_play_result( + result, + expected_batter=exp_batter, + expected_movements=exp_moves, + expected_runs=exp_runs, + expected_outs=exp_outs, + context=f"{outcome.value} obc={obc}({OBC_LABELS[obc]}) loc={hit_location} depth={depth}", + ) + + +# ============================================================================= +# Tests: 2 Outs Override +# ============================================================================= + +class TestTwoOutsOverride: + """Verify all groundballs produce Result 1 with 2 outs regardless of other factors.""" + + @pytest.mark.parametrize( + "outcome, obc, hit_location, depth, exp_batter, exp_moves, exp_runs, exp_outs", + TWO_OUTS_TRUTH_TABLE, + ids=TWO_OUTS_IDS, + ) + def test_two_outs_always_result_1(self, outcome, obc, hit_location, depth, exp_batter, exp_moves, exp_runs, exp_outs): + """With 2 outs, all groundballs should produce batter out, runners hold.""" + result = resolve_with_location(outcome, obc, hit_location, infield_depth=depth, outs=2) + assert_play_result( + result, + expected_batter=exp_batter, + expected_movements=exp_moves, + expected_runs=exp_runs, + expected_outs=exp_outs, + context=f"2outs {outcome.value} obc={obc}({OBC_LABELS[obc]}) loc={hit_location} depth={depth}", + ) + + +# ============================================================================= +# Tests: 1 Out Double Play +# ============================================================================= + +class TestOneOutDoublePlay: + """Verify DP behavior at 1 out (inning ends, runners don't advance).""" + + @pytest.mark.parametrize( + "outcome, obc, hit_location, depth, exp_batter, exp_moves, exp_runs, exp_outs", + ONE_OUT_DP_TRUTH_TABLE, + ids=ONE_OUT_DP_IDS, + ) + def test_one_out_dp(self, outcome, obc, hit_location, depth, exp_batter, exp_moves, exp_runs, exp_outs): + """DP with 1 out ends the inning; no runners should advance or score.""" + result = resolve_with_location(outcome, obc, hit_location, infield_depth=depth, outs=1) + assert_play_result( + result, + expected_batter=exp_batter, + expected_movements=exp_moves, + expected_runs=exp_runs, + expected_outs=exp_outs, + context=f"1out {outcome.value} obc={obc}({OBC_LABELS[obc]}) loc={hit_location}", + ) diff --git a/backend/tests/unit/core/truth_tables/test_tt_hits.py b/backend/tests/unit/core/truth_tables/test_tt_hits.py new file mode 100644 index 0000000..6c0c346 --- /dev/null +++ b/backend/tests/unit/core/truth_tables/test_tt_hits.py @@ -0,0 +1,169 @@ +""" +Truth Table Tests: Hit Outcomes + +Verifies exact runner advancement for every (hit_type, on_base_code) combination. + +Hit types tested: + SINGLE_1: R3 scores, R2→3rd, R1→2nd + SINGLE_2: R3 scores, R2 scores, R1→3rd + DOUBLE_2: All runners advance exactly 2 bases (capped at home) + DOUBLE_3: All runners advance exactly 3 bases (all score from any base) + TRIPLE: All runners score, batter to 3rd + HOMERUN: All runners score, batter scores + +On-base codes (sequential chart encoding): + 0=empty, 1=R1, 2=R2, 3=R3, 4=R1+R2, 5=R1+R3, 6=R2+R3, 7=loaded + +Each entry: (outcome, obc, batter_result, runner_movements, runs, outs) + +Author: Claude +Date: 2025-02-08 +""" + +import pytest + +from app.config import PlayOutcome + +from .conftest import OBC_LABELS, assert_play_result, resolve_simple + +# ============================================================================= +# Truth Table +# ============================================================================= +# Columns: (outcome, obc, batter_base, [(from, to), ...], runs, outs) + +HITS_TRUTH_TABLE = [ + # ========================================================================= + # SINGLE_1: R3 scores, R2→3rd, R1→2nd + # ========================================================================= + (PlayOutcome.SINGLE_1, 0, 1, [], 0, 0), # Empty + (PlayOutcome.SINGLE_1, 1, 1, [(1, 2)], 0, 0), # R1→2nd + (PlayOutcome.SINGLE_1, 2, 1, [(2, 3)], 0, 0), # R2→3rd + (PlayOutcome.SINGLE_1, 3, 1, [(3, 4)], 1, 0), # R3 scores + (PlayOutcome.SINGLE_1, 4, 1, [(1, 2), (2, 3)], 0, 0), # R1→2nd, R2→3rd + (PlayOutcome.SINGLE_1, 5, 1, [(1, 2), (3, 4)], 1, 0), # R1→2nd, R3 scores + (PlayOutcome.SINGLE_1, 6, 1, [(2, 3), (3, 4)], 1, 0), # R2→3rd, R3 scores + (PlayOutcome.SINGLE_1, 7, 1, [(1, 2), (2, 3), (3, 4)], 1, 0), # R1→2nd, R2→3rd, R3 scores + + # ========================================================================= + # SINGLE_2: R3 scores, R2 scores, R1→3rd + # ========================================================================= + (PlayOutcome.SINGLE_2, 0, 1, [], 0, 0), # Empty + (PlayOutcome.SINGLE_2, 1, 1, [(1, 3)], 0, 0), # R1→3rd + (PlayOutcome.SINGLE_2, 2, 1, [(2, 4)], 1, 0), # R2 scores + (PlayOutcome.SINGLE_2, 3, 1, [(3, 4)], 1, 0), # R3 scores + (PlayOutcome.SINGLE_2, 4, 1, [(1, 3), (2, 4)], 1, 0), # R1→3rd, R2 scores + (PlayOutcome.SINGLE_2, 5, 1, [(1, 3), (3, 4)], 1, 0), # R1→3rd, R3 scores + (PlayOutcome.SINGLE_2, 6, 1, [(2, 4), (3, 4)], 2, 0), # R2+R3 score + (PlayOutcome.SINGLE_2, 7, 1, [(1, 3), (2, 4), (3, 4)], 2, 0), # R1→3rd, R2+R3 score + + # ========================================================================= + # DOUBLE_2: All runners advance exactly 2 bases (capped at 4=home) + # R1→3rd (1+2), R2→home (2+2), R3→home (3+2→4) + # ========================================================================= + (PlayOutcome.DOUBLE_2, 0, 2, [], 0, 0), # Empty + (PlayOutcome.DOUBLE_2, 1, 2, [(1, 3)], 0, 0), # R1→3rd + (PlayOutcome.DOUBLE_2, 2, 2, [(2, 4)], 1, 0), # R2 scores + (PlayOutcome.DOUBLE_2, 3, 2, [(3, 4)], 1, 0), # R3 scores + (PlayOutcome.DOUBLE_2, 4, 2, [(1, 3), (2, 4)], 1, 0), # R1→3rd, R2 scores + (PlayOutcome.DOUBLE_2, 5, 2, [(1, 3), (3, 4)], 1, 0), # R1→3rd, R3 scores + (PlayOutcome.DOUBLE_2, 6, 2, [(2, 4), (3, 4)], 2, 0), # R2+R3 score + (PlayOutcome.DOUBLE_2, 7, 2, [(1, 3), (2, 4), (3, 4)], 2, 0), # R1→3rd, R2+R3 score + + # ========================================================================= + # DOUBLE_3: All runners advance exactly 3 bases (all score from any base) + # R1→home (1+3), R2→home (2+3→4), R3→home (3+3→4) + # ========================================================================= + (PlayOutcome.DOUBLE_3, 0, 2, [], 0, 0), # Empty + (PlayOutcome.DOUBLE_3, 1, 2, [(1, 4)], 1, 0), # R1 scores + (PlayOutcome.DOUBLE_3, 2, 2, [(2, 4)], 1, 0), # R2 scores + (PlayOutcome.DOUBLE_3, 3, 2, [(3, 4)], 1, 0), # R3 scores + (PlayOutcome.DOUBLE_3, 4, 2, [(1, 4), (2, 4)], 2, 0), # R1+R2 score + (PlayOutcome.DOUBLE_3, 5, 2, [(1, 4), (3, 4)], 2, 0), # R1+R3 score + (PlayOutcome.DOUBLE_3, 6, 2, [(2, 4), (3, 4)], 2, 0), # R2+R3 score + (PlayOutcome.DOUBLE_3, 7, 2, [(1, 4), (2, 4), (3, 4)], 3, 0), # All score + + # ========================================================================= + # TRIPLE: All runners score, batter to 3rd + # ========================================================================= + (PlayOutcome.TRIPLE, 0, 3, [], 0, 0), # Empty + (PlayOutcome.TRIPLE, 1, 3, [(1, 4)], 1, 0), # R1 scores + (PlayOutcome.TRIPLE, 2, 3, [(2, 4)], 1, 0), # R2 scores + (PlayOutcome.TRIPLE, 3, 3, [(3, 4)], 1, 0), # R3 scores + (PlayOutcome.TRIPLE, 4, 3, [(1, 4), (2, 4)], 2, 0), # R1+R2 score + (PlayOutcome.TRIPLE, 5, 3, [(1, 4), (3, 4)], 2, 0), # R1+R3 score + (PlayOutcome.TRIPLE, 6, 3, [(2, 4), (3, 4)], 2, 0), # R2+R3 score + (PlayOutcome.TRIPLE, 7, 3, [(1, 4), (2, 4), (3, 4)], 3, 0), # All score + + # ========================================================================= + # HOMERUN: All runners score + batter scores (batter_result=4) + # runs = number of runners + 1 (batter) + # ========================================================================= + (PlayOutcome.HOMERUN, 0, 4, [], 1, 0), # Solo HR + (PlayOutcome.HOMERUN, 1, 4, [(1, 4)], 2, 0), # 2-run HR + (PlayOutcome.HOMERUN, 2, 4, [(2, 4)], 2, 0), # 2-run HR + (PlayOutcome.HOMERUN, 3, 4, [(3, 4)], 2, 0), # 2-run HR + (PlayOutcome.HOMERUN, 4, 4, [(1, 4), (2, 4)], 3, 0), # 3-run HR + (PlayOutcome.HOMERUN, 5, 4, [(1, 4), (3, 4)], 3, 0), # 3-run HR + (PlayOutcome.HOMERUN, 6, 4, [(2, 4), (3, 4)], 3, 0), # 3-run HR + (PlayOutcome.HOMERUN, 7, 4, [(1, 4), (2, 4), (3, 4)], 4, 0), # Grand slam +] + +# Generate human-readable test IDs +HITS_IDS = [ + f"{outcome.value}__{OBC_LABELS[obc]}" + for outcome, obc, *_ in HITS_TRUTH_TABLE +] + + +# ============================================================================= +# Tests +# ============================================================================= + +class TestHitsTruthTable: + """Verify every hit outcome × on-base code produces the exact expected result.""" + + @pytest.mark.parametrize( + "outcome, obc, exp_batter, exp_moves, exp_runs, exp_outs", + HITS_TRUTH_TABLE, + ids=HITS_IDS, + ) + def test_hit_advancement(self, outcome, obc, exp_batter, exp_moves, exp_runs, exp_outs): + """ + Verify that a hit outcome with a given on-base situation produces + the exact expected batter result, runner movements, runs, and outs. + """ + result = resolve_simple(outcome, obc) + assert_play_result( + result, + expected_batter=exp_batter, + expected_movements=exp_moves, + expected_runs=exp_runs, + expected_outs=exp_outs, + context=f"{outcome.value} obc={obc} ({OBC_LABELS[obc]})", + ) + + +class TestHitsTruthTableCompleteness: + """Verify the truth table covers every hit outcome × on-base code.""" + + def test_all_hit_outcomes_covered(self): + """Every hit outcome must have exactly 8 entries (one per on-base code).""" + hit_outcomes = { + PlayOutcome.SINGLE_1, + PlayOutcome.SINGLE_2, + PlayOutcome.DOUBLE_2, + PlayOutcome.DOUBLE_3, + PlayOutcome.TRIPLE, + PlayOutcome.HOMERUN, + } + + for outcome in hit_outcomes: + entries = [row for row in HITS_TRUTH_TABLE if row[0] == outcome] + obcs = {row[1] for row in entries} + + assert len(entries) == 8, ( + f"{outcome.value} has {len(entries)} entries, expected 8" + ) + assert obcs == set(range(8)), ( + f"{outcome.value} missing on-base codes: {set(range(8)) - obcs}" + ) diff --git a/backend/tests/unit/core/truth_tables/test_tt_simple_outs.py b/backend/tests/unit/core/truth_tables/test_tt_simple_outs.py new file mode 100644 index 0000000..1db185c --- /dev/null +++ b/backend/tests/unit/core/truth_tables/test_tt_simple_outs.py @@ -0,0 +1,182 @@ +""" +Truth Table Tests: Simple Outs & Interrupt Plays + +Simple outs: STRIKEOUT, LINEOUT, POPOUT + - Always 1 out, 0 runs, no runner movement, batter is out + +Interrupt plays: WILD_PITCH, PASSED_BALL + - All runners advance exactly 1 base, batter stays at plate (not a PA) + - 0 outs recorded + +On-base codes (sequential chart encoding): + 0=empty, 1=R1, 2=R2, 3=R3, 4=R1+R2, 5=R1+R3, 6=R2+R3, 7=loaded + +Author: Claude +Date: 2025-02-08 +""" + +import pytest + +from app.config import PlayOutcome + +from .conftest import OBC_LABELS, assert_play_result, resolve_simple + +# ============================================================================= +# Truth Table: Simple Outs +# ============================================================================= +# These are trivial (same result for all on-base codes) but included for +# completeness and to catch regressions if someone adds runner movement logic. + +SIMPLE_OUTS_TRUTH_TABLE = [ + # ========================================================================= + # STRIKEOUT: 1 out, 0 runs, no movement (all 8 on-base codes) + # ========================================================================= + (PlayOutcome.STRIKEOUT, 0, None, [], 0, 1), + (PlayOutcome.STRIKEOUT, 1, None, [], 0, 1), + (PlayOutcome.STRIKEOUT, 2, None, [], 0, 1), + (PlayOutcome.STRIKEOUT, 3, None, [], 0, 1), + (PlayOutcome.STRIKEOUT, 4, None, [], 0, 1), + (PlayOutcome.STRIKEOUT, 5, None, [], 0, 1), + (PlayOutcome.STRIKEOUT, 6, None, [], 0, 1), + (PlayOutcome.STRIKEOUT, 7, None, [], 0, 1), + + # ========================================================================= + # LINEOUT: 1 out, 0 runs, no movement + # ========================================================================= + (PlayOutcome.LINEOUT, 0, None, [], 0, 1), + (PlayOutcome.LINEOUT, 1, None, [], 0, 1), + (PlayOutcome.LINEOUT, 2, None, [], 0, 1), + (PlayOutcome.LINEOUT, 3, None, [], 0, 1), + (PlayOutcome.LINEOUT, 4, None, [], 0, 1), + (PlayOutcome.LINEOUT, 5, None, [], 0, 1), + (PlayOutcome.LINEOUT, 6, None, [], 0, 1), + (PlayOutcome.LINEOUT, 7, None, [], 0, 1), + + # ========================================================================= + # POPOUT: 1 out, 0 runs, no movement + # ========================================================================= + (PlayOutcome.POPOUT, 0, None, [], 0, 1), + (PlayOutcome.POPOUT, 1, None, [], 0, 1), + (PlayOutcome.POPOUT, 2, None, [], 0, 1), + (PlayOutcome.POPOUT, 3, None, [], 0, 1), + (PlayOutcome.POPOUT, 4, None, [], 0, 1), + (PlayOutcome.POPOUT, 5, None, [], 0, 1), + (PlayOutcome.POPOUT, 6, None, [], 0, 1), + (PlayOutcome.POPOUT, 7, None, [], 0, 1), +] + +SIMPLE_OUTS_IDS = [ + f"{outcome.value}__{OBC_LABELS[obc]}" + for outcome, obc, *_ in SIMPLE_OUTS_TRUTH_TABLE +] + + +# ============================================================================= +# Truth Table: Interrupt Plays (WP/PB) +# ============================================================================= +# All runners advance exactly 1 base (capped at 4=home). Batter stays at plate. + +INTERRUPTS_TRUTH_TABLE = [ + # ========================================================================= + # WILD_PITCH: All runners advance 1 base, batter stays (None) + # ========================================================================= + (PlayOutcome.WILD_PITCH, 0, None, [], 0, 0), # Empty + (PlayOutcome.WILD_PITCH, 1, None, [(1, 2)], 0, 0), # R1→2nd + (PlayOutcome.WILD_PITCH, 2, None, [(2, 3)], 0, 0), # R2→3rd + (PlayOutcome.WILD_PITCH, 3, None, [(3, 4)], 1, 0), # R3 scores + (PlayOutcome.WILD_PITCH, 4, None, [(1, 2), (2, 3)], 0, 0), # R1→2nd, R2→3rd + (PlayOutcome.WILD_PITCH, 5, None, [(1, 2), (3, 4)], 1, 0), # R1→2nd, R3 scores + (PlayOutcome.WILD_PITCH, 6, None, [(2, 3), (3, 4)], 1, 0), # R2→3rd, R3 scores + (PlayOutcome.WILD_PITCH, 7, None, [(1, 2), (2, 3), (3, 4)], 1, 0), # All advance, R3 scores + + # ========================================================================= + # PASSED_BALL: Same advancement as wild pitch + # ========================================================================= + (PlayOutcome.PASSED_BALL, 0, None, [], 0, 0), + (PlayOutcome.PASSED_BALL, 1, None, [(1, 2)], 0, 0), + (PlayOutcome.PASSED_BALL, 2, None, [(2, 3)], 0, 0), + (PlayOutcome.PASSED_BALL, 3, None, [(3, 4)], 1, 0), + (PlayOutcome.PASSED_BALL, 4, None, [(1, 2), (2, 3)], 0, 0), + (PlayOutcome.PASSED_BALL, 5, None, [(1, 2), (3, 4)], 1, 0), + (PlayOutcome.PASSED_BALL, 6, None, [(2, 3), (3, 4)], 1, 0), + (PlayOutcome.PASSED_BALL, 7, None, [(1, 2), (2, 3), (3, 4)], 1, 0), +] + +INTERRUPTS_IDS = [ + f"{outcome.value}__{OBC_LABELS[obc]}" + for outcome, obc, *_ in INTERRUPTS_TRUTH_TABLE +] + + +# ============================================================================= +# Tests: Simple Outs +# ============================================================================= + +class TestSimpleOutsTruthTable: + """Verify every simple out × on-base code produces exact expected result.""" + + @pytest.mark.parametrize( + "outcome, obc, exp_batter, exp_moves, exp_runs, exp_outs", + SIMPLE_OUTS_TRUTH_TABLE, + ids=SIMPLE_OUTS_IDS, + ) + def test_simple_out(self, outcome, obc, exp_batter, exp_moves, exp_runs, exp_outs): + """Simple outs: 1 out, 0 runs, no runner movement regardless of base situation.""" + result = resolve_simple(outcome, obc) + assert_play_result( + result, + expected_batter=exp_batter, + expected_movements=exp_moves, + expected_runs=exp_runs, + expected_outs=exp_outs, + context=f"{outcome.value} obc={obc} ({OBC_LABELS[obc]})", + ) + + +# ============================================================================= +# Tests: Interrupt Plays +# ============================================================================= + +class TestInterruptsTruthTable: + """Verify every WP/PB × on-base code produces exact expected result.""" + + @pytest.mark.parametrize( + "outcome, obc, exp_batter, exp_moves, exp_runs, exp_outs", + INTERRUPTS_TRUTH_TABLE, + ids=INTERRUPTS_IDS, + ) + def test_interrupt_advancement(self, outcome, obc, exp_batter, exp_moves, exp_runs, exp_outs): + """WP/PB: all runners advance 1 base, batter stays at plate.""" + result = resolve_simple(outcome, obc) + assert_play_result( + result, + expected_batter=exp_batter, + expected_movements=exp_moves, + expected_runs=exp_runs, + expected_outs=exp_outs, + context=f"{outcome.value} obc={obc} ({OBC_LABELS[obc]})", + ) + + +# ============================================================================= +# Completeness +# ============================================================================= + +class TestSimpleOutsCompleteness: + """Verify truth tables cover all outcomes × all on-base codes.""" + + def test_simple_outs_complete(self): + """Every simple out outcome must have exactly 8 entries.""" + for outcome in [PlayOutcome.STRIKEOUT, PlayOutcome.LINEOUT, PlayOutcome.POPOUT]: + entries = [r for r in SIMPLE_OUTS_TRUTH_TABLE if r[0] == outcome] + obcs = {r[1] for r in entries} + assert len(entries) == 8, f"{outcome.value} has {len(entries)} entries" + assert obcs == set(range(8)), f"{outcome.value} missing obcs: {set(range(8)) - obcs}" + + def test_interrupts_complete(self): + """Every interrupt outcome must have exactly 8 entries.""" + for outcome in [PlayOutcome.WILD_PITCH, PlayOutcome.PASSED_BALL]: + entries = [r for r in INTERRUPTS_TRUTH_TABLE if r[0] == outcome] + obcs = {r[1] for r in entries} + assert len(entries) == 8, f"{outcome.value} has {len(entries)} entries" + assert obcs == set(range(8)), f"{outcome.value} missing obcs: {set(range(8)) - obcs}" diff --git a/backend/tests/unit/core/truth_tables/test_tt_walks.py b/backend/tests/unit/core/truth_tables/test_tt_walks.py new file mode 100644 index 0000000..bd950a8 --- /dev/null +++ b/backend/tests/unit/core/truth_tables/test_tt_walks.py @@ -0,0 +1,129 @@ +""" +Truth Table Tests: Walk & HBP Outcomes + +Verifies exact runner advancement for every (walk_type, on_base_code) combination. + +Walk advancement rule: Batter goes to 1st. Only FORCED runners advance. +A runner is forced when all bases between them and 1st (inclusive) are occupied. + + WALK: batter to 1st, forced runners advance, is_walk=True + HIT_BY_PITCH: batter to 1st, forced runners advance, is_walk=False + +On-base codes (sequential chart encoding): + 0=empty, 1=R1, 2=R2, 3=R3, 4=R1+R2, 5=R1+R3, 6=R2+R3, 7=loaded + +Author: Claude +Date: 2025-02-08 +""" + +import pytest + +from app.config import PlayOutcome + +from .conftest import OBC_LABELS, assert_play_result, resolve_simple + +# ============================================================================= +# Truth Table +# ============================================================================= +# Columns: (outcome, obc, batter_base, [(from, to), ...], runs, outs) + +WALKS_TRUTH_TABLE = [ + # ========================================================================= + # WALK: Batter to 1st, forced runners advance 1 base + # + # Forced chain: R1 always forced. R2 forced only if R1 present. + # R3 forced only if R1 AND R2 present (bases loaded). + # ========================================================================= + (PlayOutcome.WALK, 0, 1, [], 0, 0), # Empty - just batter + (PlayOutcome.WALK, 1, 1, [(1, 2)], 0, 0), # R1 forced→2nd + (PlayOutcome.WALK, 2, 1, [], 0, 0), # R2 NOT forced (no R1) + (PlayOutcome.WALK, 3, 1, [], 0, 0), # R3 NOT forced (no R1) + (PlayOutcome.WALK, 4, 1, [(1, 2), (2, 3)], 0, 0), # R1→2nd, R2 forced→3rd + (PlayOutcome.WALK, 5, 1, [(1, 2)], 0, 0), # R1 forced→2nd, R3 NOT forced + (PlayOutcome.WALK, 6, 1, [], 0, 0), # R2+R3 NOT forced (no R1) + (PlayOutcome.WALK, 7, 1, [(1, 2), (2, 3), (3, 4)], 1, 0), # Loaded: all forced, R3 scores + + # ========================================================================= + # HIT_BY_PITCH: Same advancement as walk, different stat classification + # ========================================================================= + (PlayOutcome.HIT_BY_PITCH, 0, 1, [], 0, 0), + (PlayOutcome.HIT_BY_PITCH, 1, 1, [(1, 2)], 0, 0), + (PlayOutcome.HIT_BY_PITCH, 2, 1, [], 0, 0), + (PlayOutcome.HIT_BY_PITCH, 3, 1, [], 0, 0), + (PlayOutcome.HIT_BY_PITCH, 4, 1, [(1, 2), (2, 3)], 0, 0), + (PlayOutcome.HIT_BY_PITCH, 5, 1, [(1, 2)], 0, 0), + (PlayOutcome.HIT_BY_PITCH, 6, 1, [], 0, 0), + (PlayOutcome.HIT_BY_PITCH, 7, 1, [(1, 2), (2, 3), (3, 4)], 1, 0), +] + +WALKS_IDS = [ + f"{outcome.value}__{OBC_LABELS[obc]}" + for outcome, obc, *_ in WALKS_TRUTH_TABLE +] + + +# ============================================================================= +# Tests +# ============================================================================= + +class TestWalksTruthTable: + """Verify every walk/HBP × on-base code produces the exact expected result.""" + + @pytest.mark.parametrize( + "outcome, obc, exp_batter, exp_moves, exp_runs, exp_outs", + WALKS_TRUTH_TABLE, + ids=WALKS_IDS, + ) + def test_walk_advancement(self, outcome, obc, exp_batter, exp_moves, exp_runs, exp_outs): + """ + Verify that a walk/HBP with a given on-base situation produces + the exact expected batter result, runner movements, runs, and outs. + """ + result = resolve_simple(outcome, obc) + assert_play_result( + result, + expected_batter=exp_batter, + expected_movements=exp_moves, + expected_runs=exp_runs, + expected_outs=exp_outs, + context=f"{outcome.value} obc={obc} ({OBC_LABELS[obc]})", + ) + + @pytest.mark.parametrize( + "outcome, obc, exp_batter, exp_moves, exp_runs, exp_outs", + WALKS_TRUTH_TABLE, + ids=WALKS_IDS, + ) + def test_walk_stat_flags(self, outcome, obc, exp_batter, exp_moves, exp_runs, exp_outs): + """ + Verify stat classification: WALK has is_walk=True, HBP has is_walk=False. + Neither is counted as a hit. + """ + result = resolve_simple(outcome, obc) + + assert result.is_hit is False, f"{outcome.value} should not be a hit" + assert result.is_out is False, f"{outcome.value} should not be an out" + + if outcome == PlayOutcome.WALK: + assert result.is_walk is True, "WALK should have is_walk=True" + else: + assert result.is_walk is False, "HBP should have is_walk=False" + + +class TestWalksTruthTableCompleteness: + """Verify the truth table covers every walk outcome × on-base code.""" + + def test_all_walk_outcomes_covered(self): + """Every walk outcome must have exactly 8 entries.""" + walk_outcomes = {PlayOutcome.WALK, PlayOutcome.HIT_BY_PITCH} + + for outcome in walk_outcomes: + entries = [row for row in WALKS_TRUTH_TABLE if row[0] == outcome] + obcs = {row[1] for row in entries} + + assert len(entries) == 8, ( + f"{outcome.value} has {len(entries)} entries, expected 8" + ) + assert obcs == set(range(8)), ( + f"{outcome.value} missing on-base codes: {set(range(8)) - obcs}" + )