Files
lottosight/tests/test_predictor.py
2026-05-23 11:32:54 -04:00

193 lines
7.4 KiB
Python

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