strat-gameplay-webapp/backend/tests/unit/core/test_dice.py
Cal Corum beb939b32a CLAUDE: Fix all unit test failures and implement 100% test requirement
Test Fixes (609/609 passing):
- Fixed DiceSystem API to accept team_id/player_id parameters for audit trails
- Fixed dice roll history timing issue in test
- Fixed terminal client mock to match resolve_play signature (X-Check params)
- Fixed result chart test mocks with missing pitching fields
- Fixed flaky test by using groundball_a (exists in both batting/pitching)

Documentation Updates:
- Added Testing Policy section to backend/CLAUDE.md
- Added Testing Policy section to tests/CLAUDE.md
- Documented 100% unit test requirement before commits
- Added git hook setup instructions

Git Hook System:
- Created .git-hooks/pre-commit script (enforces 100% test pass)
- Created .git-hooks/install-hooks.sh (easy installation)
- Created .git-hooks/README.md (hook documentation)
- Hook automatically runs all unit tests before each commit
- Blocks commits if any test fails

All 609 unit tests now passing (100%)
Integration tests have known asyncpg connection issues (documented)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-04 19:35:21 -06:00

468 lines
15 KiB
Python

"""
Unit Tests for Dice System
Tests cryptographic dice rolling system with all roll types.
"""
import pytest
from uuid import uuid4
from app.core.dice import DiceSystem
from app.core.roll_types import RollType, AbRoll, JumpRoll, FieldingRoll, D20Roll
class TestDiceSystemBasic:
"""Test basic DiceSystem functionality"""
def test_dice_system_creation(self):
"""Test creating a DiceSystem"""
dice = DiceSystem()
assert dice is not None
assert len(dice._roll_history) == 0
def test_singleton_access(self):
"""Test singleton dice_system access"""
from app.core.dice import dice_system
assert dice_system is not None
class TestAbRolls:
"""Test at-bat rolls"""
def test_roll_ab_basic(self):
"""Test basic at-bat roll"""
dice = DiceSystem()
roll = dice.roll_ab(league_id="sba")
assert isinstance(roll, AbRoll)
assert roll.roll_type == RollType.AB
assert roll.league_id == "sba"
assert 1 <= roll.d6_one <= 6
assert 1 <= roll.d6_two_a <= 6
assert 1 <= roll.d6_two_b <= 6
assert 1 <= roll.chaos_d20 <= 20
assert 1 <= roll.resolution_d20 <= 20
assert roll.d6_two_total == roll.d6_two_a + roll.d6_two_b
def test_roll_ab_with_game_id(self):
"""Test at-bat roll with game_id"""
dice = DiceSystem()
game_id = uuid4()
roll = dice.roll_ab(league_id="pd", game_id=game_id)
assert roll.game_id == game_id
assert roll.league_id == "pd"
def test_roll_ab_adds_to_history(self):
"""Test that AB rolls are added to history"""
dice = DiceSystem()
initial_count = len(dice._roll_history)
dice.roll_ab(league_id="sba")
assert len(dice._roll_history) == initial_count + 1
assert isinstance(dice._roll_history[-1], AbRoll)
def test_roll_ab_unique_roll_ids(self):
"""Test that each roll gets unique ID"""
dice = DiceSystem()
roll1 = dice.roll_ab(league_id="sba")
roll2 = dice.roll_ab(league_id="sba")
assert roll1.roll_id != roll2.roll_id
def test_roll_ab_wild_pitch_check_distribution(self):
"""Test that wild pitch checks occur (roll 1 on chaos_d20)"""
dice = DiceSystem()
found_wp_check = False
for _ in range(100):
roll = dice.roll_ab(league_id="sba")
if roll.check_wild_pitch:
found_wp_check = True
assert roll.chaos_d20 == 1
assert 1 <= roll.resolution_d20 <= 20
break
# Should find at least one in 100 rolls (probability ~99.4%)
assert found_wp_check, "No wild pitch check found in 100 rolls"
def test_roll_ab_passed_ball_check_distribution(self):
"""Test that passed ball checks occur (roll 2 on chaos_d20)"""
dice = DiceSystem()
found_pb_check = False
for _ in range(100):
roll = dice.roll_ab(league_id="sba")
if roll.check_passed_ball:
found_pb_check = True
assert roll.chaos_d20 == 2
assert 1 <= roll.resolution_d20 <= 20
break
assert found_pb_check, "No passed ball check found in 100 rolls"
class TestJumpRolls:
"""Test jump rolls"""
def test_roll_jump_basic(self):
"""Test basic jump roll"""
dice = DiceSystem()
roll = dice.roll_jump(league_id="sba")
assert isinstance(roll, JumpRoll)
assert roll.roll_type == RollType.JUMP
assert roll.league_id == "sba"
assert 1 <= roll.check_roll <= 20
def test_roll_jump_normal(self):
"""Test normal jump (check_roll >= 3)"""
dice = DiceSystem()
found_normal = False
for _ in range(50):
roll = dice.roll_jump(league_id="sba")
if roll.check_roll >= 3:
found_normal = True
assert roll.jump_dice_a is not None
assert roll.jump_dice_b is not None
assert 1 <= roll.jump_dice_a <= 6
assert 1 <= roll.jump_dice_b <= 6
assert roll.jump_total == roll.jump_dice_a + roll.jump_dice_b
assert roll.resolution_roll is None
break
assert found_normal
def test_roll_jump_pickoff(self):
"""Test pickoff check (check_roll == 1)"""
dice = DiceSystem()
found_pickoff = False
for _ in range(100):
roll = dice.roll_jump(league_id="sba")
if roll.check_roll == 1:
found_pickoff = True
assert roll.is_pickoff_check
assert not roll.is_balk_check
assert roll.resolution_roll is not None
assert 1 <= roll.resolution_roll <= 20
assert roll.jump_dice_a is None
assert roll.jump_dice_b is None
break
assert found_pickoff
def test_roll_jump_balk(self):
"""Test balk check (check_roll == 2)"""
dice = DiceSystem()
found_balk = False
for _ in range(100):
roll = dice.roll_jump(league_id="sba")
if roll.check_roll == 2:
found_balk = True
assert roll.is_balk_check
assert not roll.is_pickoff_check
assert roll.resolution_roll is not None
assert roll.jump_dice_a is None
break
assert found_balk
def test_roll_jump_adds_to_history(self):
"""Test that jump rolls are added to history"""
dice = DiceSystem()
initial_count = len(dice._roll_history)
dice.roll_jump(league_id="pd")
assert len(dice._roll_history) == initial_count + 1
class TestFieldingRolls:
"""Test fielding rolls"""
def test_roll_fielding_basic(self):
"""Test basic fielding roll"""
dice = DiceSystem()
roll = dice.roll_fielding(position="SS", league_id="sba")
assert isinstance(roll, FieldingRoll)
assert roll.roll_type == RollType.FIELDING
assert roll.position == "SS"
assert roll.league_id == "sba"
assert 1 <= roll.d20 <= 20
assert 1 <= roll.d6_one <= 6
assert 1 <= roll.d6_two <= 6
assert 1 <= roll.d6_three <= 6
assert 1 <= roll.d100 <= 100
assert 3 <= roll.error_total <= 18
def test_roll_fielding_all_positions(self):
"""Test fielding roll for all valid positions"""
dice = DiceSystem()
positions = ['P', 'C', '1B', '2B', '3B', 'SS', 'LF', 'CF', 'RF']
for pos in positions:
roll = dice.roll_fielding(position=pos, league_id="sba")
assert roll.position == pos
def test_roll_fielding_invalid_position(self):
"""Test that invalid position raises error"""
dice = DiceSystem()
with pytest.raises(ValueError, match="Invalid position"):
dice.roll_fielding(position="DH", league_id="sba")
def test_roll_fielding_sba_rare_play(self):
"""Test SBA rare play detection (d100 == 1)"""
dice = DiceSystem()
found_rare = False
# Rare play is 1%, so test many times
for _ in range(500):
roll = dice.roll_fielding(position="CF", league_id="sba")
if roll.d100 == 1:
found_rare = True
assert roll.is_rare_play
break
# Note: Might occasionally fail due to randomness (0.6% chance)
# In production, we'd mock the dice for deterministic testing
def test_roll_fielding_pd_rare_play(self):
"""Test PD rare play detection (error_total == 5)"""
dice = DiceSystem()
found_rare = False
# error_total of 5 is about 2.7% chance (3d6: 1+1+3, 1+2+2)
for _ in range(500):
roll = dice.roll_fielding(position="1B", league_id="pd")
if roll.error_total == 5:
found_rare = True
assert roll.is_rare_play
break
def test_roll_fielding_adds_to_history(self):
"""Test that fielding rolls are added to history"""
dice = DiceSystem()
initial_count = len(dice._roll_history)
dice.roll_fielding(position="3B", league_id="pd")
assert len(dice._roll_history) == initial_count + 1
class TestD20Rolls:
"""Test generic d20 rolls"""
def test_roll_d20_basic(self):
"""Test basic d20 roll"""
dice = DiceSystem()
roll = dice.roll_d20(league_id="sba")
assert isinstance(roll, D20Roll)
assert roll.roll_type == RollType.D20
assert roll.league_id == "sba"
assert 1 <= roll.roll <= 20
def test_roll_d20_with_game_id(self):
"""Test d20 roll with game_id"""
dice = DiceSystem()
game_id = uuid4()
roll = dice.roll_d20(league_id="pd", game_id=game_id)
assert roll.game_id == game_id
def test_roll_d20_adds_to_history(self):
"""Test that d20 rolls are added to history"""
dice = DiceSystem()
initial_count = len(dice._roll_history)
dice.roll_d20(league_id="sba")
assert len(dice._roll_history) == initial_count + 1
def test_roll_d20_distribution(self):
"""Test d20 distribution is roughly uniform"""
dice = DiceSystem()
rolls = [dice.roll_d20(league_id="sba").roll for _ in range(1000)]
# Count occurrences of each value
counts = {i: rolls.count(i) for i in range(1, 21)}
# Each value should appear roughly 50 times (1000/20)
# Allow for variance - check all values appear at least 20 times
for value, count in counts.items():
assert count >= 20, f"Value {value} appeared only {count} times"
class TestRollHistory:
"""Test roll history management"""
def test_get_roll_history_all(self):
"""Test getting all roll history"""
dice = DiceSystem()
dice.clear_history()
dice.roll_ab(league_id="sba")
dice.roll_jump(league_id="sba")
dice.roll_fielding(position="SS", league_id="sba")
history = dice.get_roll_history()
assert len(history) == 3
def test_get_roll_history_by_type(self):
"""Test filtering history by roll type"""
dice = DiceSystem()
dice.clear_history()
dice.roll_ab(league_id="sba")
dice.roll_ab(league_id="sba")
dice.roll_jump(league_id="sba")
dice.roll_fielding(position="SS", league_id="sba")
ab_rolls = dice.get_roll_history(roll_type=RollType.AB)
assert len(ab_rolls) == 2
assert all(r.roll_type == RollType.AB for r in ab_rolls)
def test_get_roll_history_by_game(self):
"""Test filtering history by game_id"""
dice = DiceSystem()
dice.clear_history()
game1 = uuid4()
game2 = uuid4()
dice.roll_ab(league_id="sba", game_id=game1)
dice.roll_ab(league_id="sba", game_id=game1)
dice.roll_ab(league_id="sba", game_id=game2)
game1_rolls = dice.get_roll_history(game_id=game1)
assert len(game1_rolls) == 2
assert all(r.game_id == game1 for r in game1_rolls)
def test_get_roll_history_limit(self):
"""Test limit parameter"""
dice = DiceSystem()
dice.clear_history()
for _ in range(10):
dice.roll_d20(league_id="sba")
limited = dice.get_roll_history(limit=5)
assert len(limited) == 5
def test_get_rolls_since(self):
"""Test getting rolls since timestamp"""
dice = DiceSystem()
dice.clear_history()
game_id = uuid4()
import pendulum
import time
# Roll some dice
roll1 = dice.roll_ab(league_id="sba", game_id=game_id)
# Use roll1's timestamp as the cutoff point
timestamp = roll1.timestamp
# Sleep briefly to ensure roll2 has a later timestamp
time.sleep(0.01)
roll2 = dice.roll_jump(league_id="sba", game_id=game_id)
# Get rolls since timestamp (should only get roll2 because timestamp >= is used)
# We want rolls AFTER roll1, so add a tiny offset
recent = dice.get_rolls_since(game_id, timestamp.add(microseconds=1))
assert len(recent) == 1
assert recent[0].roll_type == RollType.JUMP
def test_verify_roll(self):
"""Test roll verification"""
dice = DiceSystem()
roll = dice.roll_d20(league_id="sba")
assert dice.verify_roll(roll.roll_id)
assert not dice.verify_roll("nonexistent_id")
def test_clear_history(self):
"""Test clearing roll history"""
dice = DiceSystem()
dice.roll_ab(league_id="sba")
dice.roll_jump(league_id="sba")
assert len(dice._roll_history) > 0
dice.clear_history()
assert len(dice._roll_history) == 0
class TestDistributionStats:
"""Test distribution statistics"""
def test_get_distribution_stats(self):
"""Test getting distribution statistics"""
dice = DiceSystem()
dice.clear_history()
dice.roll_ab(league_id="sba")
dice.roll_ab(league_id="sba")
dice.roll_jump(league_id="sba")
stats = dice.get_distribution_stats()
assert stats["total_rolls"] == 3
assert stats["by_type"]["ab"] == 2
assert stats["by_type"]["jump"] == 1
def test_get_distribution_stats_by_type(self):
"""Test getting stats for specific roll type"""
dice = DiceSystem()
dice.clear_history()
dice.roll_ab(league_id="sba")
dice.roll_ab(league_id="sba")
dice.roll_jump(league_id="sba")
ab_stats = dice.get_distribution_stats(roll_type=RollType.AB)
assert ab_stats["total_rolls"] == 2
def test_get_stats(self):
"""Test get_stats helper method"""
dice = DiceSystem()
dice.clear_history()
dice.roll_ab(league_id="sba")
dice.roll_fielding(position="SS", league_id="sba")
stats = dice.get_stats()
assert stats["total_rolls"] == 2
assert "by_type" in stats
class TestCryptographicRandomness:
"""Test that dice use cryptographic randomness"""
def test_unique_roll_ids(self):
"""Test that roll IDs are unique"""
dice = DiceSystem()
roll_ids = set()
for _ in range(100):
roll = dice.roll_d20(league_id="sba")
roll_ids.add(roll.roll_id)
# All 100 should be unique
assert len(roll_ids) == 100
def test_roll_id_format(self):
"""Test roll ID format (hex string)"""
dice = DiceSystem()
roll = dice.roll_d20(league_id="sba")
# Should be hex string (16 chars for 8 bytes)
assert len(roll.roll_id) == 16
assert all(c in '0123456789abcdef' for c in roll.roll_id)