""" tests/test_predictor.py ------------------------ Tests for core/predictor.py. Verifies that every strategy: • returns exactly main_count unique numbers • all numbers within 1..main_max • numbers are sorted • bonus within 1..bonus_max (or None when bonus_count == 0) • works on an empty DB (random fallback) • works on a populated DB """ import pytest from db.models import get_game_by_name, insert_draw from core.predictor import ( hot_numbers, due_numbers, weighted_random, monte_carlo, positional_pick, ) # ── Shared helpers ──────────────────────────────────────────────────────────── _DRAWS = [ ("2024-01-01", [1, 13, 36, 61, 69], 7), ("2024-01-03", [1, 2, 13, 45, 69], 15), ("2024-01-05", [2, 13, 22, 36, 55], 3), ("2024-01-08", [5, 18, 33, 50, 65], 22), ("2024-01-10", [7, 14, 28, 42, 60], 11), ] @pytest.fixture def pb_game(tmp_db): game = get_game_by_name("Powerball") for date, nums, bonus in _DRAWS: insert_draw(game["id"], date, nums, bonus=bonus, source="test") return game def _assert_valid(result, game): """Shared validity assertions for any strategy output.""" nums = result["numbers"] bonus = result["bonus"] assert isinstance(nums, list), "numbers must be a list" assert len(nums) == game["main_count"], f"expected {game['main_count']} numbers, got {len(nums)}" assert nums == sorted(nums), "numbers must be sorted" assert len(set(nums)) == len(nums), "numbers must be unique" assert all(1 <= n <= game["main_max"] for n in nums), "all numbers must be in 1..main_max" if game["bonus_count"] > 0: assert bonus is not None, "bonus must not be None" assert 1 <= bonus <= game["bonus_max"], "bonus out of range" else: assert bonus is None, "bonus should be None when bonus_count == 0" # ── Hot Numbers ─────────────────────────────────────────────────────────────── def test_hot_numbers_valid(pb_game): result = hot_numbers(pb_game["id"]) _assert_valid(result, get_game_by_name("Powerball")) def test_hot_numbers_picks_most_frequent(pb_game): result = hot_numbers(pb_game["id"]) # 13 appears in all 5 draws — must be included assert 13 in result["numbers"] def test_hot_numbers_last_n_respected(pb_game): # last_n=1 → only draw 5: [7,14,28,42,60] result = hot_numbers(pb_game["id"], last_n=1) assert set(result["numbers"]) == {7, 14, 28, 42, 60} def test_hot_numbers_empty_db_fallback(tmp_db): game = get_game_by_name("Powerball") result = hot_numbers(game["id"]) _assert_valid(result, game) # ── Due Numbers ─────────────────────────────────────────────────────────────── def test_due_numbers_valid(pb_game): result = due_numbers(pb_game["id"]) _assert_valid(result, get_game_by_name("Powerball")) def test_due_numbers_picks_high_gap(pb_game): result = due_numbers(pb_game["id"]) # Numbers that never appeared have gap = total draws (5) # and should be favoured; at minimum, recently appearing numbers # (gap=0) should NOT all dominate the ticket. # Verify the ticket is valid (structure is the key assertion here). assert len(result["numbers"]) == 5 def test_due_numbers_empty_db_fallback(tmp_db): game = get_game_by_name("Powerball") result = due_numbers(game["id"]) _assert_valid(result, game) # ── Weighted Random ─────────────────────────────────────────────────────────── def test_weighted_random_valid(pb_game): result = weighted_random(pb_game["id"]) _assert_valid(result, get_game_by_name("Powerball")) def test_weighted_random_empty_db_still_valid(tmp_db): # No history → all weights equal to 1; should still produce valid ticket game = get_game_by_name("Powerball") result = weighted_random(game["id"]) _assert_valid(result, game) def test_weighted_random_different_runs(pb_game): # Two runs are almost certainly different (1-in-C(69,5) ≈ 1-in-11M chance of collision) r1 = weighted_random(pb_game["id"]) r2 = weighted_random(pb_game["id"]) # Validate both; don't assert inequality (astronomically unlikely to collide) _assert_valid(r1, get_game_by_name("Powerball")) _assert_valid(r2, get_game_by_name("Powerball")) # ── Monte Carlo ─────────────────────────────────────────────────────────────── def test_monte_carlo_valid(pb_game): result = monte_carlo(pb_game["id"], simulations=200) _assert_valid(result, get_game_by_name("Powerball")) def test_monte_carlo_empty_db_still_valid(tmp_db): game = get_game_by_name("Powerball") result = monte_carlo(game["id"], simulations=100) _assert_valid(result, game) def test_monte_carlo_favours_frequent_numbers(pb_game): # 13 appears in 4/5 draws — over many simulations it should be selected often. # Run with enough simulations to make this deterministic. result = monte_carlo(pb_game["id"], simulations=5000) assert 13 in result["numbers"], "Monte Carlo should pick 13 (appears in 4/5 draws)" # ── Positional Pick ─────────────────────────────────────────────────────────── def test_positional_pick_valid(pb_game): result = positional_pick(pb_game["id"]) _assert_valid(result, get_game_by_name("Powerball")) def test_positional_pick_no_duplicates(pb_game): # Each position contributes a unique number even if the same number # is the most frequent at multiple positions. result = positional_pick(pb_game["id"]) assert len(set(result["numbers"])) == len(result["numbers"]) def test_positional_pick_empty_db_fallback(tmp_db): game = get_game_by_name("Powerball") result = positional_pick(game["id"]) _assert_valid(result, game) # ── Mega Millions (no-bonus_count check is N/A; both games have bonus) ──────── def test_all_strategies_valid_for_megamillions(tmp_db): mm = get_game_by_name("Mega Millions") for date, nums, bonus in _DRAWS: insert_draw(mm["id"], date, nums, bonus=bonus, source="test") for fn in (hot_numbers, due_numbers, weighted_random, lambda gid: monte_carlo(gid, simulations=100), positional_pick): result = fn(mm["id"]) _assert_valid(result, mm) # ── Multiple tickets ────────────────────────────────────────────────────────── def test_generate_multiple_tickets(pb_game): game = get_game_by_name("Powerball") tickets = [hot_numbers(pb_game["id"]) for _ in range(5)] assert len(tickets) == 5 for t in tickets: _assert_valid(t, game)