CLAUDE: Fix on_base_code encoding bug + add truth table & invariant tests
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 <noreply@anthropic.com>
This commit is contained in:
parent
7c54bfd26b
commit
2a70df74bf
@ -1309,7 +1309,7 @@ class GameEngine:
|
|||||||
state.current_pitcher = None
|
state.current_pitcher = None
|
||||||
state.current_catcher = 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()
|
state.current_on_base_code = state.calculate_on_base_code()
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|||||||
@ -481,7 +481,7 @@ class GameState(BaseModel):
|
|||||||
current_batter: Snapshot - LineupPlayerState for current batter (required)
|
current_batter: Snapshot - LineupPlayerState for current batter (required)
|
||||||
current_pitcher: Snapshot - LineupPlayerState for current pitcher (optional)
|
current_pitcher: Snapshot - LineupPlayerState for current pitcher (optional)
|
||||||
current_catcher: Snapshot - LineupPlayerState for current catcher (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')
|
pending_decision: Type of decision awaiting ('defensive', 'offensive', 'result_selection')
|
||||||
decisions_this_play: Accumulated decisions for current play
|
decisions_this_play: Accumulated decisions for current play
|
||||||
play_count: Total plays so far
|
play_count: Total plays so far
|
||||||
@ -548,7 +548,7 @@ class GameState(BaseModel):
|
|||||||
current_catcher: LineupPlayerState | None = None
|
current_catcher: LineupPlayerState | None = None
|
||||||
current_on_base_code: int = Field(
|
current_on_base_code: int = Field(
|
||||||
default=0, ge=0
|
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
|
# Decision tracking
|
||||||
pending_decision: str | None = None # 'defensive', 'offensive', 'result_selection'
|
pending_decision: str | None = None # 'defensive', 'offensive', 'result_selection'
|
||||||
@ -709,26 +709,35 @@ class GameState(BaseModel):
|
|||||||
"""
|
"""
|
||||||
Calculate on-base code from current runner positions.
|
Calculate on-base code from current runner positions.
|
||||||
|
|
||||||
Returns bit field where:
|
Returns sequential chart encoding matching the official rulebook charts:
|
||||||
- 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:
|
|
||||||
0 = empty bases
|
0 = empty bases
|
||||||
1 = runner on first only
|
1 = runner on 1st only
|
||||||
3 = runners on first and second
|
2 = runner on 2nd only
|
||||||
7 = bases loaded
|
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
|
r1 = self.on_first is not None
|
||||||
if self.on_first:
|
r2 = self.on_second is not None
|
||||||
code |= 1 # Bit 0
|
r3 = self.on_third is not None
|
||||||
if self.on_second:
|
|
||||||
code |= 2 # Bit 1
|
if r1 and r2 and r3:
|
||||||
if self.on_third:
|
return 7 # Loaded
|
||||||
code |= 4 # Bit 2
|
if r2 and r3:
|
||||||
return code
|
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:
|
def get_runner_at_base(self, base: int) -> LineupPlayerState | None:
|
||||||
"""Get runner at specified base (1, 2, or 3)"""
|
"""Get runner at specified base (1, 2, or 3)"""
|
||||||
|
|||||||
666
backend/tests/unit/core/test_play_resolver_invariants.py
Normal file
666
backend/tests/unit/core/test_play_resolver_invariants.py
Normal file
@ -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"
|
||||||
0
backend/tests/unit/core/truth_tables/__init__.py
Normal file
0
backend/tests/unit/core/truth_tables/__init__.py
Normal file
215
backend/tests/unit/core/truth_tables/conftest.py
Normal file
215
backend/tests/unit/core/truth_tables/conftest.py
Normal file
@ -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}"
|
||||||
|
)
|
||||||
493
backend/tests/unit/core/truth_tables/test_tt_groundballs.py
Normal file
493
backend/tests/unit/core/truth_tables/test_tt_groundballs.py
Normal file
@ -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}",
|
||||||
|
)
|
||||||
169
backend/tests/unit/core/truth_tables/test_tt_hits.py
Normal file
169
backend/tests/unit/core/truth_tables/test_tt_hits.py
Normal file
@ -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}"
|
||||||
|
)
|
||||||
182
backend/tests/unit/core/truth_tables/test_tt_simple_outs.py
Normal file
182
backend/tests/unit/core/truth_tables/test_tt_simple_outs.py
Normal file
@ -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}"
|
||||||
129
backend/tests/unit/core/truth_tables/test_tt_walks.py
Normal file
129
backend/tests/unit/core/truth_tables/test_tt_walks.py
Normal file
@ -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}"
|
||||||
|
)
|
||||||
Loading…
Reference in New Issue
Block a user