05/23 Phase 14
This commit is contained in:
@@ -13,7 +13,8 @@
|
||||
"Bash(python -m PyInstaller lottosight.spec --clean)",
|
||||
"Bash(python -c \"from ui.dashboard import DashboardScreen\")",
|
||||
"Bash(python -m pytest tests/test_analysis_charts.py -v --tb=short)",
|
||||
"Bash(python -m pytest tests/ -q)"
|
||||
"Bash(python -m pytest tests/ -q)",
|
||||
"Bash(python -c ' *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -325,6 +325,41 @@ All actions are logged to console and optionally to a log file:
|
||||
|
||||
---
|
||||
|
||||
### ✅ Phase 13 — Ticket Checker
|
||||
- [x] Write `core/checker.py`
|
||||
- [x] `prize_tier(main_matches, bonus_match)` — maps match counts to Jackpot/Match 5/Match 4+Bonus/… tier labels
|
||||
- [x] `check_ticket(game_id, numbers, bonus)` — compares ticket against all draws, returns matches sorted by quality desc
|
||||
- [x] `parse_numbers(raw)` — parses space- or comma-separated user input, raises ValueError on bad input
|
||||
- [x] Add "Check Ticket" tab to `ui/predictor_ui.py` (3rd tab in Notebook)
|
||||
- [x] Game dropdown, Numbers entry, Bonus entry, Check / Clear buttons
|
||||
- [x] Hint label with accepted input format
|
||||
- [x] Results treeview: Draw Date, Draw Numbers, Bonus, Main Hits, Bonus Hit, Prize Tier
|
||||
- [x] Row colours: purple=Jackpot, green=≥3 matches or bonus hit, grey=low match
|
||||
- [x] Summary bar: "N draws matched • Best: <tier>"
|
||||
- [x] Input validation: number count, range check, bonus range
|
||||
- [x] 27 tests in `tests/test_checker.py` (254/254 total passing)
|
||||
|
||||
---
|
||||
|
||||
### ✅ Phase 14 — Custom Game Management
|
||||
- [x] Add `_BUILTIN_GAMES = {"Powerball", "Mega Millions"}` constant to `db/models.py`
|
||||
- [x] Add `add_game(name, main_count, main_max, bonus_count, bonus_max) -> int`
|
||||
- [x] Rejects blank names, duplicate names, and builtin game names (ValueError)
|
||||
- [x] Inserts with `active=1`, returns new row id
|
||||
- [x] Add `delete_game(game_id) -> bool`
|
||||
- [x] Refuses builtin games → returns False
|
||||
- [x] Refuses games with existing draw records → returns False
|
||||
- [x] Cascades: deletes predictions for game first, then removes game → returns True
|
||||
- [x] Update `ui/settings.py`
|
||||
- [x] Delete button on each non-builtin game row; warns if draws exist, asks confirm
|
||||
- [x] "+ Add Custom Game" button at bottom of Games section
|
||||
- [x] `_AddGameDialog` modal (tk.Toplevel): name + main_count/max + bonus_count/max spinboxes
|
||||
- [x] Error label in dialog for inline validation feedback
|
||||
- [x] Refresh games list after add or delete
|
||||
- [x] Write `tests/test_custom_games.py` — 20 tests (274/274 total passing)
|
||||
|
||||
---
|
||||
|
||||
### ✅ Phase 12 — Number Search + Next Draw Schedule
|
||||
- [x] Number search in History screen
|
||||
- [x] Add `number` param to `get_draws_with_game()` — SQL: `',' || numbers || ',' LIKE ?` for exact boundary matching
|
||||
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
"""
|
||||
core/checker.py
|
||||
---------------
|
||||
Ticket checker — compare a user-supplied ticket against all historical draws.
|
||||
Returns every draw where at least one number (main or bonus) matched,
|
||||
sorted by match quality descending.
|
||||
|
||||
Prize tiers follow Powerball / Mega Millions conventions:
|
||||
Jackpot 5 main + bonus
|
||||
Match 5 5 main, no bonus
|
||||
Match 4 + Bonus 4 main + bonus
|
||||
Match 4 4 main
|
||||
Match 3 + Bonus 3 main + bonus
|
||||
Match 3 3 main
|
||||
Match 2 + Bonus 2 main + bonus
|
||||
Match 1 + Bonus 1 main + bonus
|
||||
Bonus Only 0 main + bonus
|
||||
"""
|
||||
|
||||
from db.models import get_all_draws_numbers, get_game_by_id
|
||||
|
||||
# (required_main_matches, requires_bonus_match, tier_label)
|
||||
_TIERS = [
|
||||
(5, True, "Jackpot"),
|
||||
(5, False, "Match 5"),
|
||||
(4, True, "Match 4 + Bonus"),
|
||||
(4, False, "Match 4"),
|
||||
(3, True, "Match 3 + Bonus"),
|
||||
(3, False, "Match 3"),
|
||||
(2, True, "Match 2 + Bonus"),
|
||||
(1, True, "Match 1 + Bonus"),
|
||||
(0, True, "Bonus Only"),
|
||||
]
|
||||
|
||||
|
||||
def prize_tier(main_matches: int, bonus_match: bool) -> str:
|
||||
"""Return the prize tier label for a given match result."""
|
||||
for req_main, req_bonus, label in _TIERS:
|
||||
if main_matches == req_main:
|
||||
if req_bonus and not bonus_match:
|
||||
continue
|
||||
return label
|
||||
return "No Prize"
|
||||
|
||||
|
||||
def check_ticket(game_id: int, numbers: list[int],
|
||||
bonus: int | None = None) -> list[dict]:
|
||||
"""
|
||||
Compare *numbers* (and optional *bonus*) against all draws for *game_id*.
|
||||
|
||||
Returns a list of dicts for draws with ≥ 1 matched number (main or bonus),
|
||||
sorted by (main_matches DESC, bonus_match DESC, draw_date DESC).
|
||||
|
||||
Each dict contains:
|
||||
draw_date str
|
||||
draw_numbers list[int]
|
||||
draw_bonus int | None
|
||||
main_matches int
|
||||
bonus_match bool
|
||||
prize_tier str
|
||||
"""
|
||||
draws = get_all_draws_numbers(game_id)
|
||||
ticket_set = set(numbers)
|
||||
results = []
|
||||
|
||||
for draw in draws:
|
||||
draw_set = set(draw["numbers"])
|
||||
main_matches = len(ticket_set & draw_set)
|
||||
bonus_match = (
|
||||
bonus is not None
|
||||
and draw["bonus"] is not None
|
||||
and bonus == draw["bonus"]
|
||||
)
|
||||
|
||||
if main_matches == 0 and not bonus_match:
|
||||
continue
|
||||
|
||||
results.append({
|
||||
"draw_date": draw["draw_date"],
|
||||
"draw_numbers": draw["numbers"],
|
||||
"draw_bonus": draw["bonus"],
|
||||
"main_matches": main_matches,
|
||||
"bonus_match": bonus_match,
|
||||
"prize_tier": prize_tier(main_matches, bonus_match),
|
||||
})
|
||||
|
||||
results.sort(
|
||||
key=lambda x: (x["main_matches"], x["bonus_match"], x["draw_date"]),
|
||||
reverse=True,
|
||||
)
|
||||
return results
|
||||
|
||||
|
||||
def parse_numbers(raw: str) -> list[int]:
|
||||
"""
|
||||
Parse a user-typed string of lottery numbers into a sorted list of ints.
|
||||
Accepts space- or comma-separated input: '7 14 32 56 68' or '7,14,32,56,68'.
|
||||
Raises ValueError if any token is not a positive integer.
|
||||
"""
|
||||
tokens = raw.replace(",", " ").split()
|
||||
if not tokens:
|
||||
raise ValueError("No numbers entered.")
|
||||
nums = []
|
||||
for t in tokens:
|
||||
if not t.isdigit():
|
||||
raise ValueError(f"'{t}' is not a valid number.")
|
||||
nums.append(int(t))
|
||||
return sorted(nums)
|
||||
Binary file not shown.
@@ -19,6 +19,73 @@ logger = logging.getLogger(__name__)
|
||||
# GAMES
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
_BUILTIN_GAMES = {"Powerball", "Mega Millions"}
|
||||
|
||||
|
||||
def add_game(name: str, main_count: int, main_max: int,
|
||||
bonus_count: int = 0, bonus_max: int = 0) -> int:
|
||||
"""
|
||||
Insert a new game row. Returns the new game id.
|
||||
Raises ValueError if name is blank or already exists.
|
||||
"""
|
||||
name = name.strip()
|
||||
if not name:
|
||||
raise ValueError("Game name cannot be blank.")
|
||||
conn = get_connection()
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"SELECT id FROM games WHERE name = ?", (name,)
|
||||
)
|
||||
if cursor.fetchone():
|
||||
raise ValueError(f"A game named '{name}' already exists.")
|
||||
cursor.execute(
|
||||
"""INSERT INTO games (name, main_count, main_max, bonus_count, bonus_max, active)
|
||||
VALUES (?, ?, ?, ?, ?, 1)""",
|
||||
(name, main_count, main_max, bonus_count, bonus_max),
|
||||
)
|
||||
conn.commit()
|
||||
new_id = cursor.lastrowid
|
||||
logger.info("[DB] Added custom game id=%d name='%s'", new_id, name)
|
||||
return new_id
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def delete_game(game_id: int) -> bool:
|
||||
"""
|
||||
Delete a game by id. Returns True on success, False if the game has draws
|
||||
(to protect data integrity) or is a built-in game.
|
||||
"""
|
||||
conn = get_connection()
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT name FROM games WHERE id = ?", (game_id,))
|
||||
row = cursor.fetchone()
|
||||
if row is None:
|
||||
return False
|
||||
if row["name"] in _BUILTIN_GAMES:
|
||||
logger.warning("[DB] Refused to delete built-in game '%s'", row["name"])
|
||||
return False
|
||||
cursor.execute("SELECT COUNT(*) as cnt FROM draws WHERE game_id = ?", (game_id,))
|
||||
if cursor.fetchone()["cnt"] > 0:
|
||||
logger.warning("[DB] Refused to delete game id=%d — has draws", game_id)
|
||||
return False
|
||||
cursor.execute("DELETE FROM predictions WHERE game_id = ?", (game_id,))
|
||||
cursor.execute("DELETE FROM games WHERE id = ?", (game_id,))
|
||||
conn.commit()
|
||||
logger.info("[DB] Deleted custom game id=%d", game_id)
|
||||
return True
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_all_games(active_only=False):
|
||||
"""
|
||||
Return list of all games as sqlite3.Row objects.
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
"""
|
||||
tests/test_checker.py
|
||||
----------------------
|
||||
Tests for core/checker.py — ticket checking and prize tier logic.
|
||||
|
||||
Draw data (Powerball):
|
||||
Draw 1 2024-01-01: [1, 13, 36, 61, 69] bonus=7
|
||||
Draw 2 2024-01-03: [1, 2, 13, 45, 69] bonus=15
|
||||
Draw 3 2024-01-05: [2, 13, 22, 36, 55] bonus=3
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from db.models import get_game_by_name, insert_draw
|
||||
from core.checker import check_ticket, prize_tier, parse_numbers
|
||||
|
||||
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),
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def pb(tmp_db):
|
||||
game = get_game_by_name("Powerball")
|
||||
for date, nums, bonus in DRAWS:
|
||||
insert_draw(game["id"], date, nums, bonus=bonus)
|
||||
return game
|
||||
|
||||
|
||||
# ── prize_tier ────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_jackpot(tmp_db):
|
||||
assert prize_tier(5, True) == "Jackpot"
|
||||
|
||||
def test_match_5(tmp_db):
|
||||
assert prize_tier(5, False) == "Match 5"
|
||||
|
||||
def test_match_4_bonus(tmp_db):
|
||||
assert prize_tier(4, True) == "Match 4 + Bonus"
|
||||
|
||||
def test_match_4(tmp_db):
|
||||
assert prize_tier(4, False) == "Match 4"
|
||||
|
||||
def test_match_3_bonus(tmp_db):
|
||||
assert prize_tier(3, True) == "Match 3 + Bonus"
|
||||
|
||||
def test_match_3(tmp_db):
|
||||
assert prize_tier(3, False) == "Match 3"
|
||||
|
||||
def test_match_2_bonus(tmp_db):
|
||||
assert prize_tier(2, True) == "Match 2 + Bonus"
|
||||
|
||||
def test_match_1_bonus(tmp_db):
|
||||
assert prize_tier(1, True) == "Match 1 + Bonus"
|
||||
|
||||
def test_bonus_only(tmp_db):
|
||||
assert prize_tier(0, True) == "Bonus Only"
|
||||
|
||||
def test_no_prize(tmp_db):
|
||||
assert prize_tier(0, False) == "No Prize"
|
||||
|
||||
def test_no_prize_2_no_bonus(tmp_db):
|
||||
assert prize_tier(2, False) == "No Prize"
|
||||
|
||||
def test_no_prize_1_no_bonus(tmp_db):
|
||||
assert prize_tier(1, False) == "No Prize"
|
||||
|
||||
|
||||
# ── check_ticket — exact match ────────────────────────────────────────────────
|
||||
|
||||
def test_jackpot_ticket_found(pb):
|
||||
results = check_ticket(pb["id"], [1, 13, 36, 61, 69], bonus=7)
|
||||
assert len(results) >= 1
|
||||
top = results[0]
|
||||
assert top["main_matches"] == 5
|
||||
assert top["bonus_match"] is True
|
||||
assert top["prize_tier"] == "Jackpot"
|
||||
assert top["draw_date"] == "2024-01-01"
|
||||
|
||||
|
||||
def test_match_5_no_bonus(pb):
|
||||
results = check_ticket(pb["id"], [1, 13, 36, 61, 69], bonus=99)
|
||||
top = results[0]
|
||||
assert top["main_matches"] == 5
|
||||
assert top["bonus_match"] is False
|
||||
assert top["prize_tier"] == "Match 5"
|
||||
|
||||
|
||||
def test_partial_match_3(pb):
|
||||
# Numbers 1, 13, 69 appear in draw 1 and draw 2
|
||||
results = check_ticket(pb["id"], [1, 13, 69, 4, 8])
|
||||
dates_matched = {r["draw_date"] for r in results}
|
||||
assert "2024-01-01" in dates_matched
|
||||
assert "2024-01-03" in dates_matched
|
||||
|
||||
|
||||
def test_bonus_only_match(pb):
|
||||
# No main number matches but bonus=7 matches draw 1
|
||||
results = check_ticket(pb["id"], [3, 4, 5, 6, 7], bonus=7)
|
||||
bonus_only = [r for r in results if r["draw_date"] == "2024-01-01"]
|
||||
assert len(bonus_only) == 1
|
||||
assert bonus_only[0]["main_matches"] == 0
|
||||
assert bonus_only[0]["bonus_match"] is True
|
||||
assert bonus_only[0]["prize_tier"] == "Bonus Only"
|
||||
|
||||
|
||||
def test_no_match_returns_empty(pb):
|
||||
# Numbers that don't appear in any draw
|
||||
results = check_ticket(pb["id"], [3, 4, 5, 6, 7])
|
||||
assert results == []
|
||||
|
||||
|
||||
def test_no_bonus_in_ticket(pb):
|
||||
results = check_ticket(pb["id"], [1, 13, 36, 61, 69], bonus=None)
|
||||
top = results[0]
|
||||
assert top["bonus_match"] is False
|
||||
assert top["prize_tier"] == "Match 5"
|
||||
|
||||
|
||||
def test_results_sorted_by_matches_desc(pb):
|
||||
# Mix of matches: use numbers that appear in multiple draws with varying counts
|
||||
results = check_ticket(pb["id"], [1, 13, 36, 61, 69])
|
||||
if len(results) > 1:
|
||||
for i in range(len(results) - 1):
|
||||
assert results[i]["main_matches"] >= results[i + 1]["main_matches"]
|
||||
|
||||
|
||||
def test_result_contains_required_keys(pb):
|
||||
results = check_ticket(pb["id"], [1, 13, 36, 61, 69])
|
||||
for key in ("draw_date", "draw_numbers", "draw_bonus",
|
||||
"main_matches", "bonus_match", "prize_tier"):
|
||||
assert key in results[0]
|
||||
|
||||
|
||||
def test_empty_db_returns_empty(tmp_db):
|
||||
pb = get_game_by_name("Powerball")
|
||||
assert check_ticket(pb["id"], [1, 2, 3, 4, 5]) == []
|
||||
|
||||
|
||||
# ── parse_numbers ─────────────────────────────────────────────────────────────
|
||||
|
||||
def test_parse_space_separated(tmp_db):
|
||||
assert parse_numbers("7 14 32 56 68") == [7, 14, 32, 56, 68]
|
||||
|
||||
def test_parse_comma_separated(tmp_db):
|
||||
assert parse_numbers("7,14,32,56,68") == [7, 14, 32, 56, 68]
|
||||
|
||||
def test_parse_mixed_delimiters(tmp_db):
|
||||
assert parse_numbers("7, 14, 32") == [7, 14, 32]
|
||||
|
||||
def test_parse_returns_sorted(tmp_db):
|
||||
assert parse_numbers("68 7 32") == [7, 32, 68]
|
||||
|
||||
def test_parse_empty_raises(tmp_db):
|
||||
with pytest.raises(ValueError, match="No numbers"):
|
||||
parse_numbers(" ")
|
||||
|
||||
def test_parse_non_digit_raises(tmp_db):
|
||||
with pytest.raises(ValueError):
|
||||
parse_numbers("7 14 abc")
|
||||
@@ -0,0 +1,192 @@
|
||||
"""
|
||||
tests/test_custom_games.py
|
||||
---------------------------
|
||||
Tests for add_game() and delete_game() in db/models.py,
|
||||
covering custom game management.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from db.models import (
|
||||
add_game, delete_game, get_all_games, get_game_by_name,
|
||||
get_game_by_id, insert_draw, _BUILTIN_GAMES,
|
||||
)
|
||||
|
||||
|
||||
# ── add_game ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_add_game_creates_row(tmp_db):
|
||||
gid = add_game("EuroMillions", 5, 50, bonus_count=2, bonus_max=12)
|
||||
game = get_game_by_id(gid)
|
||||
assert game is not None
|
||||
assert game["name"] == "EuroMillions"
|
||||
|
||||
|
||||
def test_add_game_returns_id(tmp_db):
|
||||
gid = add_game("UK Lotto", 6, 59)
|
||||
assert isinstance(gid, int)
|
||||
assert gid > 0
|
||||
|
||||
|
||||
def test_add_game_default_active(tmp_db):
|
||||
gid = add_game("Test Game", 5, 45)
|
||||
game = get_game_by_id(gid)
|
||||
assert game["active"] == 1
|
||||
|
||||
|
||||
def test_add_game_appears_in_get_all_games(tmp_db):
|
||||
add_game("EuroMillions", 5, 50, bonus_count=2, bonus_max=12)
|
||||
names = [g["name"] for g in get_all_games()]
|
||||
assert "EuroMillions" in names
|
||||
|
||||
|
||||
def test_add_game_parameters_stored_correctly(tmp_db):
|
||||
gid = add_game("Custom", 6, 45, bonus_count=1, bonus_max=20)
|
||||
game = get_game_by_id(gid)
|
||||
assert game["main_count"] == 6
|
||||
assert game["main_max"] == 45
|
||||
assert game["bonus_count"] == 1
|
||||
assert game["bonus_max"] == 20
|
||||
|
||||
|
||||
def test_add_game_duplicate_name_raises(tmp_db):
|
||||
add_game("Test Game", 5, 45)
|
||||
with pytest.raises(ValueError, match="already exists"):
|
||||
add_game("Test Game", 6, 50)
|
||||
|
||||
|
||||
def test_add_game_blank_name_raises(tmp_db):
|
||||
with pytest.raises(ValueError, match="blank"):
|
||||
add_game(" ", 5, 45)
|
||||
|
||||
|
||||
def test_add_game_no_bonus(tmp_db):
|
||||
gid = add_game("No Bonus Game", 6, 49, bonus_count=0, bonus_max=0)
|
||||
game = get_game_by_id(gid)
|
||||
assert game["bonus_count"] == 0
|
||||
assert game["bonus_max"] == 0
|
||||
|
||||
|
||||
def test_add_game_builtin_name_raises(tmp_db):
|
||||
with pytest.raises(ValueError):
|
||||
add_game("Powerball", 5, 69, 1, 26)
|
||||
|
||||
|
||||
# ── delete_game ───────────────────────────────────────────────────────────────
|
||||
|
||||
def test_delete_custom_game_no_draws(tmp_db):
|
||||
gid = add_game("Temp Game", 5, 45)
|
||||
result = delete_game(gid)
|
||||
assert result is True
|
||||
assert get_game_by_id(gid) is None
|
||||
|
||||
|
||||
def test_delete_custom_game_removed_from_list(tmp_db):
|
||||
gid = add_game("Gone Game", 5, 45)
|
||||
delete_game(gid)
|
||||
names = [g["name"] for g in get_all_games()]
|
||||
assert "Gone Game" not in names
|
||||
|
||||
|
||||
def test_delete_game_with_draws_returns_false(tmp_db):
|
||||
gid = add_game("Active Game", 5, 45)
|
||||
insert_draw(gid, "2024-01-01", [1, 2, 3, 4, 5])
|
||||
result = delete_game(gid)
|
||||
assert result is False
|
||||
assert get_game_by_id(gid) is not None
|
||||
|
||||
|
||||
def test_delete_builtin_powerball_returns_false(tmp_db):
|
||||
pb = get_game_by_name("Powerball")
|
||||
result = delete_game(pb["id"])
|
||||
assert result is False
|
||||
assert get_game_by_name("Powerball") is not None
|
||||
|
||||
|
||||
def test_delete_builtin_mega_millions_returns_false(tmp_db):
|
||||
mm = get_game_by_name("Mega Millions")
|
||||
result = delete_game(mm["id"])
|
||||
assert result is False
|
||||
|
||||
|
||||
def test_delete_nonexistent_game_returns_false(tmp_db):
|
||||
result = delete_game(99999)
|
||||
assert result is False
|
||||
|
||||
|
||||
def test_delete_game_clears_predictions(tmp_db):
|
||||
from db.models import insert_prediction, get_predictions
|
||||
gid = add_game("Prediction Game", 5, 45)
|
||||
insert_prediction(gid, "Hot Numbers", [1, 2, 3, 4, 5])
|
||||
delete_game(gid)
|
||||
preds = get_predictions(game_id=gid, limit=100)
|
||||
assert preds == []
|
||||
|
||||
|
||||
# ── integration: custom game works with existing features ─────────────────────
|
||||
|
||||
def test_custom_game_works_with_frequency_analysis(tmp_db):
|
||||
from core.analyzer import frequency_analysis
|
||||
gid = add_game("My Lottery", 6, 49, bonus_count=1, bonus_max=10)
|
||||
insert_draw(gid, "2024-01-01", [1, 7, 14, 22, 35, 49])
|
||||
insert_draw(gid, "2024-01-03", [1, 7, 15, 23, 36, 48])
|
||||
freq = frequency_analysis(gid)
|
||||
assert freq[1] == 2
|
||||
assert freq[7] == 2
|
||||
|
||||
|
||||
def test_custom_game_works_with_predictor(tmp_db):
|
||||
from core.predictor import hot_numbers
|
||||
gid = add_game("My Lottery", 6, 49, bonus_count=1, bonus_max=10)
|
||||
insert_draw(gid, "2024-01-01", [1, 7, 14, 22, 35, 49], bonus=3)
|
||||
insert_draw(gid, "2024-01-03", [1, 7, 15, 23, 36, 48], bonus=5)
|
||||
result = hot_numbers(gid)
|
||||
assert len(result["numbers"]) == 6
|
||||
assert all(1 <= n <= 49 for n in result["numbers"])
|
||||
|
||||
|
||||
# ── UI smoke tests ────────────────────────────────────────────────────────────
|
||||
|
||||
def _has_display():
|
||||
try:
|
||||
import tkinter as tk
|
||||
r = tk.Tk(); r.withdraw(); r.destroy()
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _has_display(), reason="no display available")
|
||||
def test_settings_shows_add_button(tmp_db):
|
||||
import tkinter as tk
|
||||
from ui.settings import SettingsScreen
|
||||
|
||||
try:
|
||||
root = tk.Tk()
|
||||
root.withdraw()
|
||||
except Exception:
|
||||
pytest.skip("Tkinter init failed")
|
||||
try:
|
||||
screen = SettingsScreen(root)
|
||||
screen.refresh()
|
||||
assert screen.winfo_exists()
|
||||
finally:
|
||||
root.destroy()
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _has_display(), reason="no display available")
|
||||
def test_settings_custom_game_shows_delete_button(tmp_db):
|
||||
import tkinter as tk
|
||||
from ui.settings import SettingsScreen
|
||||
|
||||
add_game("My Custom Lottery", 5, 50)
|
||||
try:
|
||||
root = tk.Tk()
|
||||
root.withdraw()
|
||||
except Exception:
|
||||
pytest.skip("Tkinter init failed")
|
||||
try:
|
||||
screen = SettingsScreen(root)
|
||||
screen.refresh()
|
||||
assert screen.winfo_exists()
|
||||
finally:
|
||||
root.destroy()
|
||||
@@ -21,6 +21,7 @@ from db.models import (
|
||||
from core.predictor import (
|
||||
hot_numbers, due_numbers, weighted_random, monte_carlo, positional_pick,
|
||||
)
|
||||
from core.checker import check_ticket, parse_numbers
|
||||
from core.exporter import export_predictions_excel, export_predictions_csv, ensure_exports_dir
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -73,11 +74,14 @@ class PredictorScreen(ttk.Frame):
|
||||
|
||||
gen_frame = ttk.Frame(nb)
|
||||
saved_frame = ttk.Frame(nb)
|
||||
check_frame = ttk.Frame(nb)
|
||||
nb.add(gen_frame, text="Generate")
|
||||
nb.add(saved_frame, text="Saved")
|
||||
nb.add(check_frame, text="Check Ticket")
|
||||
|
||||
self._build_generate_tab(gen_frame)
|
||||
self._build_saved_tab(saved_frame)
|
||||
self._build_check_tab(check_frame)
|
||||
|
||||
# ── Generate tab ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -224,6 +228,7 @@ class PredictorScreen(ttk.Frame):
|
||||
|
||||
def refresh(self):
|
||||
self._load_games()
|
||||
self._load_check_games()
|
||||
self._refresh_saved()
|
||||
|
||||
def _load_games(self):
|
||||
@@ -409,3 +414,172 @@ class PredictorScreen(ttk.Frame):
|
||||
return
|
||||
delete_all_predictions(game_id=game_id)
|
||||
self._refresh_saved()
|
||||
|
||||
# ── Check Ticket tab ──────────────────────────────────────────────────────
|
||||
|
||||
def _build_check_tab(self, parent):
|
||||
# Input bar
|
||||
bar = ttk.Frame(parent, padding=(6, 8, 6, 4))
|
||||
bar.pack(fill="x")
|
||||
|
||||
ttk.Label(bar, text="Game:").pack(side="left")
|
||||
self._chk_game_var = tk.StringVar()
|
||||
self._chk_game_cb = ttk.Combobox(
|
||||
bar, textvariable=self._chk_game_var, state="readonly", width=15
|
||||
)
|
||||
self._chk_game_cb.pack(side="left", padx=(4, 14))
|
||||
|
||||
ttk.Label(bar, text="Numbers:").pack(side="left")
|
||||
self._chk_nums_var = tk.StringVar()
|
||||
chk_nums_entry = ttk.Entry(bar, textvariable=self._chk_nums_var, width=22)
|
||||
chk_nums_entry.pack(side="left", padx=(4, 4))
|
||||
chk_nums_entry.bind("<Return>", lambda e: self._run_check())
|
||||
|
||||
ttk.Label(bar, text="Bonus:").pack(side="left")
|
||||
self._chk_bonus_var = tk.StringVar()
|
||||
chk_bonus_entry = ttk.Entry(bar, textvariable=self._chk_bonus_var, width=5)
|
||||
chk_bonus_entry.pack(side="left", padx=(4, 14))
|
||||
chk_bonus_entry.bind("<Return>", lambda e: self._run_check())
|
||||
|
||||
ttk.Button(bar, text="Check", command=self._run_check).pack(side="left", padx=(0, 4))
|
||||
ttk.Button(bar, text="Clear", command=self._clear_check).pack(side="left")
|
||||
|
||||
self._chk_status_var = tk.StringVar()
|
||||
ttk.Label(bar, textvariable=self._chk_status_var,
|
||||
foreground="#c0392b").pack(side="left", padx=(12, 0))
|
||||
|
||||
# Hint text below bar
|
||||
hint = ttk.Frame(parent, padding=(6, 0, 6, 4))
|
||||
hint.pack(fill="x")
|
||||
ttk.Label(hint,
|
||||
text="Enter space- or comma-separated main numbers, and optionally a bonus number.",
|
||||
foreground="#888888", font=("TkDefaultFont", 8)).pack(anchor="w")
|
||||
|
||||
# Results treeview
|
||||
tree_frame = ttk.Frame(parent)
|
||||
tree_frame.pack(fill="both", expand=True, padx=6, pady=(0, 4))
|
||||
|
||||
c_cols = ("date", "draw_numbers", "bonus", "main", "bonus_hit", "tier")
|
||||
self._chk_tree = ttk.Treeview(
|
||||
tree_frame, columns=c_cols, show="headings", selectmode="browse"
|
||||
)
|
||||
self._chk_tree.heading("date", text="Draw Date")
|
||||
self._chk_tree.heading("draw_numbers", text="Draw Numbers")
|
||||
self._chk_tree.heading("bonus", text="Bonus")
|
||||
self._chk_tree.heading("main", text="Main Hits")
|
||||
self._chk_tree.heading("bonus_hit", text="Bonus Hit")
|
||||
self._chk_tree.heading("tier", text="Prize Tier")
|
||||
|
||||
self._chk_tree.column("date", width=100, anchor="center", stretch=False)
|
||||
self._chk_tree.column("draw_numbers", width=190, anchor="w")
|
||||
self._chk_tree.column("bonus", width=55, anchor="center", stretch=False)
|
||||
self._chk_tree.column("main", width=70, anchor="center", stretch=False)
|
||||
self._chk_tree.column("bonus_hit", width=70, anchor="center", stretch=False)
|
||||
self._chk_tree.column("tier", width=160, anchor="w", stretch=False)
|
||||
|
||||
vsb = ttk.Scrollbar(tree_frame, orient="vertical", command=self._chk_tree.yview)
|
||||
hsb = ttk.Scrollbar(tree_frame, orient="horizontal", command=self._chk_tree.xview)
|
||||
self._chk_tree.configure(yscrollcommand=vsb.set, xscrollcommand=hsb.set)
|
||||
self._chk_tree.grid(row=0, column=0, sticky="nsew")
|
||||
vsb.grid(row=0, column=1, sticky="ns")
|
||||
hsb.grid(row=1, column=0, sticky="ew")
|
||||
tree_frame.rowconfigure(0, weight=1)
|
||||
tree_frame.columnconfigure(0, weight=1)
|
||||
|
||||
# Row colour tags
|
||||
self._chk_tree.tag_configure("jackpot", foreground="#8e44ad", font=("TkDefaultFont", 9, "bold"))
|
||||
self._chk_tree.tag_configure("high", foreground="#1e8449")
|
||||
self._chk_tree.tag_configure("low", foreground="#555555")
|
||||
|
||||
# Summary label
|
||||
self._chk_summary_var = tk.StringVar()
|
||||
ttk.Label(parent, textvariable=self._chk_summary_var,
|
||||
anchor="e", padding=(6, 2)).pack(fill="x")
|
||||
|
||||
def _run_check(self):
|
||||
self._chk_status_var.set("")
|
||||
self._chk_tree.delete(*self._chk_tree.get_children())
|
||||
self._chk_summary_var.set("")
|
||||
|
||||
# Resolve game
|
||||
game = get_game_by_name(self._chk_game_var.get())
|
||||
if game is None:
|
||||
self._chk_status_var.set("Select a game first.")
|
||||
return
|
||||
|
||||
# Parse main numbers
|
||||
try:
|
||||
numbers = parse_numbers(self._chk_nums_var.get())
|
||||
except ValueError as e:
|
||||
self._chk_status_var.set(str(e))
|
||||
return
|
||||
|
||||
if len(numbers) != game["main_count"]:
|
||||
self._chk_status_var.set(
|
||||
f"{game['name']} requires {game['main_count']} main numbers "
|
||||
f"(got {len(numbers)})."
|
||||
)
|
||||
return
|
||||
|
||||
# Validate range
|
||||
invalid = [n for n in numbers if not (1 <= n <= game["main_max"])]
|
||||
if invalid:
|
||||
self._chk_status_var.set(
|
||||
f"Numbers out of range 1–{game['main_max']}: {invalid}"
|
||||
)
|
||||
return
|
||||
|
||||
# Parse optional bonus
|
||||
bonus = None
|
||||
raw_bonus = self._chk_bonus_var.get().strip()
|
||||
if raw_bonus:
|
||||
if not raw_bonus.isdigit():
|
||||
self._chk_status_var.set("Bonus must be a number.")
|
||||
return
|
||||
bonus = int(raw_bonus)
|
||||
if game["bonus_max"] and not (1 <= bonus <= game["bonus_max"]):
|
||||
self._chk_status_var.set(
|
||||
f"Bonus out of range 1–{game['bonus_max']}."
|
||||
)
|
||||
return
|
||||
|
||||
results = check_ticket(game["id"], numbers, bonus=bonus)
|
||||
|
||||
for r in results:
|
||||
nums_fmt = " ".join(f"{n:02d}" for n in r["draw_numbers"])
|
||||
bonus_str = str(r["draw_bonus"]) if r["draw_bonus"] is not None else "—"
|
||||
bonus_hit = "✓" if r["bonus_match"] else "—"
|
||||
tier = r["prize_tier"]
|
||||
|
||||
if tier == "Jackpot":
|
||||
tag = "jackpot"
|
||||
elif r["main_matches"] >= 3 or r["bonus_match"]:
|
||||
tag = "high"
|
||||
else:
|
||||
tag = "low"
|
||||
|
||||
self._chk_tree.insert("", "end", values=(
|
||||
r["draw_date"], nums_fmt, bonus_str,
|
||||
r["main_matches"], bonus_hit, tier,
|
||||
), tags=(tag,))
|
||||
|
||||
total = len(results)
|
||||
best = results[0]["prize_tier"] if results else "—"
|
||||
self._chk_summary_var.set(
|
||||
f"{total} draw{'s' if total != 1 else ''} matched • Best: {best}"
|
||||
)
|
||||
|
||||
def _clear_check(self):
|
||||
self._chk_nums_var.set("")
|
||||
self._chk_bonus_var.set("")
|
||||
self._chk_status_var.set("")
|
||||
self._chk_tree.delete(*self._chk_tree.get_children())
|
||||
self._chk_summary_var.set("")
|
||||
|
||||
def _load_check_games(self):
|
||||
games = get_all_games(active_only=True)
|
||||
names = [g["name"] for g in games]
|
||||
self._chk_game_cb["values"] = names
|
||||
if not self._chk_game_var.get() or self._chk_game_var.get() not in names:
|
||||
if names:
|
||||
self._chk_game_var.set(names[0])
|
||||
|
||||
+112
-1
@@ -7,13 +7,14 @@ on_fetch: callable injected by main.py to trigger the shared fetch thread.
|
||||
"""
|
||||
|
||||
import tkinter as tk
|
||||
from tkinter import ttk
|
||||
from tkinter import ttk, messagebox
|
||||
import logging
|
||||
|
||||
from db.database import get_db_stats
|
||||
from db.models import (
|
||||
get_all_games, get_draw_count, set_game_active,
|
||||
get_last_fetch_per_source, get_predictions,
|
||||
add_game, delete_game, _BUILTIN_GAMES,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -131,6 +132,17 @@ class SettingsScreen(ttk.Frame):
|
||||
foreground="#777777",
|
||||
).pack(side="left", padx=(16, 0))
|
||||
|
||||
if game["name"] not in _BUILTIN_GAMES:
|
||||
ttk.Button(
|
||||
row, text="Delete",
|
||||
command=lambda gid=game["id"], gname=game["name"]: self._delete_game(gid, gname),
|
||||
).pack(side="left", padx=(12, 0))
|
||||
|
||||
ttk.Button(
|
||||
self._games_body, text="+ Add Custom Game",
|
||||
command=self._open_add_game_dialog,
|
||||
).pack(anchor="w", pady=(8, 0))
|
||||
|
||||
def _refresh_sources(self):
|
||||
for w in self._sources_body.winfo_children():
|
||||
w.destroy()
|
||||
@@ -190,3 +202,102 @@ class SettingsScreen(ttk.Frame):
|
||||
self._on_fetch()
|
||||
else:
|
||||
self._fetch_msg_var.set("Fetch not available.")
|
||||
|
||||
def _open_add_game_dialog(self):
|
||||
_AddGameDialog(self, on_save=self._refresh_games)
|
||||
|
||||
def _delete_game(self, game_id: int, game_name: str):
|
||||
count = get_draw_count(game_id)
|
||||
if count > 0:
|
||||
messagebox.showwarning(
|
||||
"Cannot Delete",
|
||||
f"'{game_name}' has {count:,} draw records.\n"
|
||||
"Delete all draws for this game before removing it.",
|
||||
)
|
||||
return
|
||||
if not messagebox.askyesno("Delete Game",
|
||||
f"Permanently delete '{game_name}'?"):
|
||||
return
|
||||
if delete_game(game_id):
|
||||
self._refresh_games()
|
||||
else:
|
||||
messagebox.showerror("Error", f"Could not delete '{game_name}'.")
|
||||
|
||||
|
||||
class _AddGameDialog(tk.Toplevel):
|
||||
"""Modal dialog for adding a custom lottery game."""
|
||||
|
||||
def __init__(self, parent, on_save):
|
||||
super().__init__(parent)
|
||||
self.title("Add Custom Game")
|
||||
self.resizable(False, False)
|
||||
self.grab_set() # modal
|
||||
self._on_save = on_save
|
||||
self._build()
|
||||
self.transient(parent)
|
||||
self.wait_visibility()
|
||||
self.focus_set()
|
||||
|
||||
def _build(self):
|
||||
pad = {"padx": 10, "pady": 4}
|
||||
|
||||
# Name
|
||||
r = ttk.Frame(self, padding=(14, 14, 14, 4))
|
||||
r.pack(fill="x")
|
||||
ttk.Label(r, text="Game name:", width=18, anchor="w").pack(side="left")
|
||||
self._name_var = tk.StringVar()
|
||||
ttk.Entry(r, textvariable=self._name_var, width=22).pack(side="left")
|
||||
|
||||
# Main balls
|
||||
r2 = ttk.Frame(self, padding=(14, 4, 14, 4))
|
||||
r2.pack(fill="x")
|
||||
ttk.Label(r2, text="Main balls:", width=18, anchor="w").pack(side="left")
|
||||
self._main_count = tk.IntVar(value=5)
|
||||
ttk.Spinbox(r2, from_=1, to=10, textvariable=self._main_count,
|
||||
width=5).pack(side="left")
|
||||
ttk.Label(r2, text=" out of 1–", foreground="#555").pack(side="left")
|
||||
self._main_max = tk.IntVar(value=69)
|
||||
ttk.Spinbox(r2, from_=1, to=99, textvariable=self._main_max,
|
||||
width=5).pack(side="left")
|
||||
|
||||
# Bonus balls
|
||||
r3 = ttk.Frame(self, padding=(14, 4, 14, 4))
|
||||
r3.pack(fill="x")
|
||||
ttk.Label(r3, text="Bonus balls:", width=18, anchor="w").pack(side="left")
|
||||
self._bonus_count = tk.IntVar(value=1)
|
||||
ttk.Spinbox(r3, from_=0, to=5, textvariable=self._bonus_count,
|
||||
width=5).pack(side="left")
|
||||
ttk.Label(r3, text=" out of 1–", foreground="#555").pack(side="left")
|
||||
self._bonus_max = tk.IntVar(value=26)
|
||||
ttk.Spinbox(r3, from_=0, to=99, textvariable=self._bonus_max,
|
||||
width=5).pack(side="left")
|
||||
|
||||
# Error label
|
||||
self._err_var = tk.StringVar()
|
||||
ttk.Label(self, textvariable=self._err_var,
|
||||
foreground="#c0392b",
|
||||
padding=(14, 2)).pack(fill="x")
|
||||
|
||||
# Buttons
|
||||
btn_row = ttk.Frame(self, padding=(14, 4, 14, 14))
|
||||
btn_row.pack(fill="x")
|
||||
ttk.Button(btn_row, text="Add Game", command=self._save).pack(side="right")
|
||||
ttk.Button(btn_row, text="Cancel", command=self.destroy).pack(side="right", padx=(0, 6))
|
||||
|
||||
def _save(self):
|
||||
name = self._name_var.get().strip()
|
||||
if not name:
|
||||
self._err_var.set("Game name is required.")
|
||||
return
|
||||
try:
|
||||
add_game(
|
||||
name=name,
|
||||
main_count=int(self._main_count.get()),
|
||||
main_max=int(self._main_max.get()),
|
||||
bonus_count=int(self._bonus_count.get()),
|
||||
bonus_max=int(self._bonus_max.get()),
|
||||
)
|
||||
self._on_save()
|
||||
self.destroy()
|
||||
except ValueError as e:
|
||||
self._err_var.set(str(e))
|
||||
|
||||
Reference in New Issue
Block a user