feat: Uncapped hit decision tree, x-check workflow, baserunner UI #8
@ -1309,7 +1309,7 @@ class GameEngine:
|
||||
state.current_pitcher = None
|
||||
state.current_catcher = None
|
||||
|
||||
# Calculate on_base_code from current runners (bit field)
|
||||
# Calculate on_base_code from current runners (sequential chart encoding)
|
||||
state.current_on_base_code = state.calculate_on_base_code()
|
||||
|
||||
logger.info(
|
||||
|
||||
@ -481,7 +481,7 @@ class GameState(BaseModel):
|
||||
current_batter: Snapshot - LineupPlayerState for current batter (required)
|
||||
current_pitcher: Snapshot - LineupPlayerState for current pitcher (optional)
|
||||
current_catcher: Snapshot - LineupPlayerState for current catcher (optional)
|
||||
current_on_base_code: Snapshot - bit field of occupied bases (1=1st, 2=2nd, 4=3rd)
|
||||
current_on_base_code: Snapshot - sequential chart encoding (0=empty, 1=R1, 2=R2, 3=R3, 4=R1+R2, 5=R1+R3, 6=R2+R3, 7=loaded)
|
||||
pending_decision: Type of decision awaiting ('defensive', 'offensive', 'result_selection')
|
||||
decisions_this_play: Accumulated decisions for current play
|
||||
play_count: Total plays so far
|
||||
@ -548,7 +548,7 @@ class GameState(BaseModel):
|
||||
current_catcher: LineupPlayerState | None = None
|
||||
current_on_base_code: int = Field(
|
||||
default=0, ge=0
|
||||
) # Bit field: 1=1st, 2=2nd, 4=3rd, 7=loaded
|
||||
) # Sequential chart encoding: 0=empty, 1=R1, 2=R2, 3=R3, 4=R1+R2, 5=R1+R3, 6=R2+R3, 7=loaded
|
||||
|
||||
# Decision tracking
|
||||
pending_decision: str | None = None # 'defensive', 'offensive', 'result_selection'
|
||||
@ -709,26 +709,35 @@ class GameState(BaseModel):
|
||||
"""
|
||||
Calculate on-base code from current runner positions.
|
||||
|
||||
Returns bit field where:
|
||||
- Bit 0 (value 1): runner on first
|
||||
- Bit 1 (value 2): runner on second
|
||||
- Bit 2 (value 4): runner on third
|
||||
- Value 7: bases loaded (1 + 2 + 4)
|
||||
|
||||
Examples:
|
||||
Returns sequential chart encoding matching the official rulebook charts:
|
||||
0 = empty bases
|
||||
1 = runner on first only
|
||||
3 = runners on first and second
|
||||
7 = bases loaded
|
||||
1 = runner on 1st only
|
||||
2 = runner on 2nd only
|
||||
3 = runner on 3rd only
|
||||
4 = runners on 1st and 2nd
|
||||
5 = runners on 1st and 3rd
|
||||
6 = runners on 2nd and 3rd
|
||||
7 = bases loaded (1st, 2nd, and 3rd)
|
||||
"""
|
||||
code = 0
|
||||
if self.on_first:
|
||||
code |= 1 # Bit 0
|
||||
if self.on_second:
|
||||
code |= 2 # Bit 1
|
||||
if self.on_third:
|
||||
code |= 4 # Bit 2
|
||||
return code
|
||||
r1 = self.on_first is not None
|
||||
r2 = self.on_second is not None
|
||||
r3 = self.on_third is not None
|
||||
|
||||
if r1 and r2 and r3:
|
||||
return 7 # Loaded
|
||||
if r2 and r3:
|
||||
return 6 # R2+R3
|
||||
if r1 and r3:
|
||||
return 5 # R1+R3
|
||||
if r1 and r2:
|
||||
return 4 # R1+R2
|
||||
if r3:
|
||||
return 3 # R3 only
|
||||
if r2:
|
||||
return 2 # R2 only
|
||||
if r1:
|
||||
return 1 # R1 only
|
||||
return 0 # Empty
|
||||
|
||||
def get_runner_at_base(self, base: int) -> LineupPlayerState | None:
|
||||
"""Get runner at specified base (1, 2, or 3)"""
|
||||
|
||||
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