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>
468 lines
15 KiB
Python
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)
|