- 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.
512 lines
15 KiB
Python
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]
|