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