mantimon-tcg/backend/tests/core/test_rng.py
Cal Corum 3e82280efb Add game engine foundation: enums, config, and RNG modules
- Create core module structure with models and effects subdirectories
- Add enums module with CardType, EnergyType, TurnPhase, StatusCondition, etc.
- Add RulesConfig with Mantimon TCG defaults (40-card deck, 4 points to win)
- Add RandomProvider protocol with SeededRandom (testing) and SecureRandom (production)
- Include comprehensive tests for all modules (97 tests passing)

Defaults reflect GAME_RULES.md: Pokemon Pocket-style energy deck,
first turn can attack but not attach energy, 30-turn limit enabled.
2026-01-24 22:14:45 -06:00

512 lines
15 KiB
Python

"""Tests for the RandomProvider implementations.
These tests verify:
1. SeededRandom produces deterministic results with the same seed
2. SecureRandom produces varied results
3. Both implementations satisfy the RandomProvider protocol
4. Edge cases are handled correctly
"""
import pytest
from app.core.rng import RandomProvider, SecureRandom, SeededRandom, create_rng
class TestSeededRandom:
"""Tests for the SeededRandom implementation."""
def test_same_seed_produces_same_sequence(self) -> None:
"""
Verify that two SeededRandom instances with the same seed produce
identical sequences of random values.
This is critical for testing game logic that depends on randomness.
"""
rng1 = SeededRandom(seed=42)
rng2 = SeededRandom(seed=42)
# Generate sequences of values
seq1 = [rng1.randint(1, 100) for _ in range(10)]
seq2 = [rng2.randint(1, 100) for _ in range(10)]
assert seq1 == seq2
def test_different_seeds_produce_different_sequences(self) -> None:
"""
Verify that different seeds produce different sequences.
This validates that the seed actually affects the output.
"""
rng1 = SeededRandom(seed=1)
rng2 = SeededRandom(seed=2)
seq1 = [rng1.randint(1, 1000) for _ in range(10)]
seq2 = [rng2.randint(1, 1000) for _ in range(10)]
assert seq1 != seq2
def test_random_returns_float_in_range(self) -> None:
"""
Verify random() returns floats in [0.0, 1.0).
"""
rng = SeededRandom(seed=42)
for _ in range(100):
value = rng.random()
assert 0.0 <= value < 1.0
def test_randint_returns_value_in_range(self) -> None:
"""
Verify randint() returns integers in the specified inclusive range.
"""
rng = SeededRandom(seed=42)
for _ in range(100):
value = rng.randint(1, 6)
assert 1 <= value <= 6
def test_randint_with_equal_bounds(self) -> None:
"""
Verify randint() works when a == b.
"""
rng = SeededRandom(seed=42)
assert rng.randint(5, 5) == 5
def test_choice_selects_from_sequence(self) -> None:
"""
Verify choice() returns an element from the sequence.
"""
rng = SeededRandom(seed=42)
options = ["a", "b", "c", "d"]
for _ in range(100):
assert rng.choice(options) in options
def test_choice_deterministic(self) -> None:
"""
Verify choice() is deterministic with same seed.
"""
options = ["fire", "water", "grass"]
rng1 = SeededRandom(seed=123)
rng2 = SeededRandom(seed=123)
choices1 = [rng1.choice(options) for _ in range(10)]
choices2 = [rng2.choice(options) for _ in range(10)]
assert choices1 == choices2
def test_shuffle_is_deterministic(self) -> None:
"""
Verify shuffle() produces same order with same seed.
This is important for testing deck shuffling.
"""
rng1 = SeededRandom(seed=42)
rng2 = SeededRandom(seed=42)
list1 = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
list2 = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
rng1.shuffle(list1)
rng2.shuffle(list2)
assert list1 == list2
def test_shuffle_modifies_in_place(self) -> None:
"""
Verify shuffle() modifies the list in place and returns None.
"""
rng = SeededRandom(seed=42)
original = [1, 2, 3, 4, 5]
to_shuffle = original.copy()
result = rng.shuffle(to_shuffle)
assert result is None
# With high probability, shuffled list differs from original
# (1/120 chance they're the same for 5 elements)
# Use a seed known to produce a different order
assert to_shuffle != [1, 2, 3, 4, 5]
def test_coin_flip_is_deterministic(self) -> None:
"""
Verify coin_flip() produces same sequence with same seed.
This is critical for testing status condition removal.
"""
rng1 = SeededRandom(seed=42)
rng2 = SeededRandom(seed=42)
flips1 = [rng1.coin_flip() for _ in range(20)]
flips2 = [rng2.coin_flip() for _ in range(20)]
assert flips1 == flips2
def test_coin_flip_produces_both_outcomes(self) -> None:
"""
Verify coin_flip() can produce both True and False.
With 1000 flips, we should see both outcomes.
"""
rng = SeededRandom(seed=42)
flips = [rng.coin_flip() for _ in range(1000)]
assert True in flips
assert False in flips
def test_sample_returns_correct_count(self) -> None:
"""
Verify sample() returns exactly k elements.
"""
rng = SeededRandom(seed=42)
population = list(range(100))
result = rng.sample(population, 10)
assert len(result) == 10
def test_sample_returns_unique_elements(self) -> None:
"""
Verify sample() returns unique elements (no duplicates).
"""
rng = SeededRandom(seed=42)
population = list(range(100))
result = rng.sample(population, 10)
assert len(result) == len(set(result))
def test_sample_elements_from_population(self) -> None:
"""
Verify sample() only returns elements from the population.
"""
rng = SeededRandom(seed=42)
population = ["a", "b", "c", "d", "e"]
result = rng.sample(population, 3)
for item in result:
assert item in population
def test_sample_is_deterministic(self) -> None:
"""
Verify sample() is deterministic with same seed.
"""
population = list(range(50))
rng1 = SeededRandom(seed=42)
rng2 = SeededRandom(seed=42)
sample1 = rng1.sample(population, 5)
sample2 = rng2.sample(population, 5)
assert sample1 == sample2
def test_seed_property(self) -> None:
"""
Verify the seed property returns the initialization seed.
"""
rng = SeededRandom(seed=12345)
assert rng.seed == 12345
def test_none_seed_stored(self) -> None:
"""
Verify None seed is stored correctly.
"""
rng = SeededRandom(seed=None)
assert rng.seed is None
class TestSecureRandom:
"""Tests for the SecureRandom implementation."""
def test_random_returns_float_in_range(self) -> None:
"""
Verify random() returns floats in [0.0, 1.0).
"""
rng = SecureRandom()
for _ in range(100):
value = rng.random()
assert 0.0 <= value < 1.0
def test_randint_returns_value_in_range(self) -> None:
"""
Verify randint() returns integers in the specified inclusive range.
"""
rng = SecureRandom()
for _ in range(100):
value = rng.randint(1, 6)
assert 1 <= value <= 6
def test_randint_with_equal_bounds(self) -> None:
"""
Verify randint() works when a == b.
"""
rng = SecureRandom()
assert rng.randint(5, 5) == 5
def test_randint_invalid_range_raises(self) -> None:
"""
Verify randint() raises ValueError when a > b.
"""
rng = SecureRandom()
with pytest.raises(ValueError, match="must be <="):
rng.randint(10, 5)
def test_choice_selects_from_sequence(self) -> None:
"""
Verify choice() returns an element from the sequence.
"""
rng = SecureRandom()
options = ["a", "b", "c", "d"]
for _ in range(100):
assert rng.choice(options) in options
def test_choice_empty_sequence_raises(self) -> None:
"""
Verify choice() raises IndexError on empty sequence.
"""
rng = SecureRandom()
with pytest.raises(IndexError, match="empty sequence"):
rng.choice([])
def test_shuffle_changes_order(self) -> None:
"""
Verify shuffle() changes the order of elements.
Note: There's a tiny chance this fails if shuffle happens
to produce the original order, but it's negligible for
lists of reasonable size.
"""
rng = SecureRandom()
original = list(range(20))
shuffled = original.copy()
rng.shuffle(shuffled)
# Very unlikely to be in original order
assert shuffled != original
def test_shuffle_preserves_elements(self) -> None:
"""
Verify shuffle() preserves all elements.
"""
rng = SecureRandom()
original = [1, 2, 3, 4, 5]
shuffled = original.copy()
rng.shuffle(shuffled)
assert sorted(shuffled) == sorted(original)
def test_coin_flip_produces_both_outcomes(self) -> None:
"""
Verify coin_flip() produces both True and False.
"""
rng = SecureRandom()
flips = [rng.coin_flip() for _ in range(100)]
assert True in flips
assert False in flips
def test_sample_returns_correct_count(self) -> None:
"""
Verify sample() returns exactly k elements.
"""
rng = SecureRandom()
population = list(range(100))
result = rng.sample(population, 10)
assert len(result) == 10
def test_sample_returns_unique_elements(self) -> None:
"""
Verify sample() returns unique elements.
"""
rng = SecureRandom()
population = list(range(100))
result = rng.sample(population, 10)
assert len(result) == len(set(result))
def test_sample_too_large_raises(self) -> None:
"""
Verify sample() raises ValueError when k > population size.
"""
rng = SecureRandom()
with pytest.raises(ValueError, match="larger than population"):
rng.sample([1, 2, 3], 5)
def test_sample_negative_raises(self) -> None:
"""
Verify sample() raises ValueError for negative k.
"""
rng = SecureRandom()
with pytest.raises(ValueError, match="non-negative"):
rng.sample([1, 2, 3], -1)
def test_produces_varied_results(self) -> None:
"""
Verify SecureRandom produces varied results across calls.
This is a statistical test - we check that repeated calls
don't all return the same value.
"""
rng = SecureRandom()
# Generate many random values
values = [rng.randint(1, 1000000) for _ in range(100)]
# Should have many unique values
unique_values = set(values)
assert len(unique_values) > 90 # At least 90% unique
class TestRandomProviderProtocol:
"""Tests verifying both implementations satisfy the protocol."""
@pytest.mark.parametrize("rng_class", [SeededRandom, SecureRandom])
def test_implements_random_provider(self, rng_class: type) -> None:
"""
Verify both implementations can be used as RandomProvider.
This ensures the protocol is properly implemented.
"""
if rng_class == SeededRandom:
rng: RandomProvider = rng_class(seed=42)
else:
rng = rng_class()
# All protocol methods should be callable
assert callable(rng.random)
assert callable(rng.randint)
assert callable(rng.choice)
assert callable(rng.shuffle)
assert callable(rng.coin_flip)
assert callable(rng.sample)
@pytest.mark.parametrize("rng_class", [SeededRandom, SecureRandom])
def test_methods_return_correct_types(self, rng_class: type) -> None:
"""
Verify protocol methods return expected types.
"""
if rng_class == SeededRandom:
rng: RandomProvider = rng_class(seed=42)
else:
rng = rng_class()
assert isinstance(rng.random(), float)
assert isinstance(rng.randint(1, 10), int)
assert isinstance(rng.choice([1, 2, 3]), int)
assert isinstance(rng.coin_flip(), bool)
assert isinstance(rng.sample([1, 2, 3, 4, 5], 2), list)
class TestCreateRng:
"""Tests for the create_rng factory function."""
def test_with_seed_returns_seeded_random(self) -> None:
"""
Verify create_rng with seed returns SeededRandom.
"""
rng = create_rng(seed=42)
assert isinstance(rng, SeededRandom)
assert rng.seed == 42
def test_with_secure_returns_secure_random(self) -> None:
"""
Verify create_rng with secure=True returns SecureRandom.
"""
rng = create_rng(secure=True)
assert isinstance(rng, SecureRandom)
def test_default_returns_seeded_random(self) -> None:
"""
Verify create_rng with no args returns unseeded SeededRandom.
"""
rng = create_rng()
assert isinstance(rng, SeededRandom)
def test_seed_takes_precedence_over_secure(self) -> None:
"""
Verify that providing a seed returns SeededRandom even if secure=True.
The seed parameter takes precedence because if you're specifying a seed,
you clearly want reproducibility.
"""
rng = create_rng(seed=42, secure=True)
assert isinstance(rng, SeededRandom)
assert rng.seed == 42
class TestGameUseCases:
"""Tests simulating actual game engine use cases."""
def test_deck_shuffle_reproducibility(self) -> None:
"""
Verify deck shuffling is reproducible for testing.
This simulates initializing a game with the same seed
and verifying the deck order is identical.
"""
deck = list(range(1, 41)) # 40 card deck
rng1 = SeededRandom(seed=12345)
deck1 = deck.copy()
rng1.shuffle(deck1)
rng2 = SeededRandom(seed=12345)
deck2 = deck.copy()
rng2.shuffle(deck2)
assert deck1 == deck2
def test_coin_flip_for_status_removal(self) -> None:
"""
Verify coin flips work correctly for status condition removal.
With a known seed, we can test specific scenarios.
"""
rng = SeededRandom(seed=42)
# Simulate checking if burn is removed (flip at end of turn)
flips = [rng.coin_flip() for _ in range(10)]
# We don't care about the specific values, just that they're
# consistent and boolean
assert all(isinstance(f, bool) for f in flips)
def test_prize_card_selection(self) -> None:
"""
Verify random prize card selection works correctly.
When using classic prize card rules, a random card must be selected.
"""
rng = SeededRandom(seed=42)
prize_cards = ["card_1", "card_2", "card_3", "card_4", "card_5", "card_6"]
selected = rng.choice(prize_cards)
assert selected in prize_cards
def test_damage_calculation_with_randomness(self) -> None:
"""
Verify random damage modifiers work correctly.
Some attacks might have random damage (e.g., "flip a coin, if heads +20").
"""
rng = SeededRandom(seed=42)
base_damage = 30
# Simulate attack with coin flip bonus
total_damage = base_damage + 20 if rng.coin_flip() else base_damage
# Just verify the result is one of the two possibilities
assert total_damage in [30, 50]