feat: Uncapped hit decision tree, x-check workflow, baserunner UI #8

Merged
cal merged 11 commits from feature/uncapped-hit-decision-tree into main 2026-02-12 15:37:34 +00:00
9 changed files with 1884 additions and 21 deletions
Showing only changes of commit 2a70df74bf - Show all commits

View File

@ -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(

View File

@ -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)"""

View 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"

View 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}"
)

View 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, R12nd
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/2BResult 3, 3BResult 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}",
)

View 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, R23rd, R12nd
SINGLE_2: R3 scores, R2 scores, R13rd
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}"
)

View 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}"

View 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}"
)