05/23 Phase 20

This commit is contained in:
2026-05-23 16:38:20 -04:00
parent 2553406254
commit ea30c7d838
6 changed files with 423 additions and 5 deletions
+2 -1
View File
@@ -16,7 +16,8 @@
"Bash(python -m pytest tests/ -q)",
"Bash(python -c ' *)",
"Bash(python -m pytest tests/test_backup.py -v)",
"Bash(python -m pytest --tb=short -q)"
"Bash(python -m pytest --tb=short -q)",
"Bash(python -m pytest tests/test_wheeling.py -v)"
]
}
}
+16
View File
@@ -341,6 +341,22 @@ All actions are logged to console and optionally to a log file:
---
### ✅ Phase 20 — Number Wheeling System
- [x] Write `core/wheeling.py`
- [x] `wheel_count(numbers, k) -> int` — C(n, k) preview, deduplicates input
- [x] `wheel_full(numbers, k) -> list[list[int]]` — all combinations, each sorted ascending
- [x] Raises `ValueError` for invalid k, k > pool size, or count > `MAX_TICKETS` (200)
- [x] Update `ui/predictor_ui.py`
- [x] Add "Wheel" as 4th tab in ttk.Notebook
- [x] Game dropdown (synced on refresh), Numbers entry, Pick spinbox (defaults to game's main_count)
- [x] Live preview label: "Will generate X tickets (C(n,k))" updates as user types
- [x] Range validation against game's main_max before generating
- [x] Results treeview (#, Numbers) + BallsBar detail strip on row select
- [x] Save to DB (strategy="Wheel") + Copy + Clear buttons
- [x] Write `tests/test_wheeling.py` — 18 tests (379/379 total passing)
---
### ✅ Phase 19 — Dashboard Overdue Alert + DB Backup
- [x] Add `backup_db(dest_dir=None) -> str` to `db/database.py` — copies live DB to timestamped file, creates dest dir if needed
- [x] Add `restore_db(source_path: str) -> None` to `db/database.py` — overwrites live DB with backup file, raises FileNotFoundError if missing
+49
View File
@@ -0,0 +1,49 @@
"""
core/wheeling.py
----------------
Full-cover number wheeling.
wheel_count(numbers, k) — C(n, k) ticket count preview
wheel_full(numbers, k) — all C(n, k) sorted combinations
Raises ValueError for invalid inputs or if result exceeds MAX_TICKETS.
"""
from itertools import combinations
from math import comb
MAX_TICKETS = 200
def wheel_count(numbers: list | set, k: int) -> int:
"""Return how many tickets a full wheel would produce (C(n, k))."""
n = len(set(numbers))
if k < 1 or k > n:
return 0
return comb(n, k)
def wheel_full(numbers: list | set, k: int) -> list[list[int]]:
"""
Generate all C(n, k) combinations from *numbers*, each sorted ascending.
Duplicates in input are removed before wheeling.
Raises ValueError if k is out of range or count > MAX_TICKETS.
"""
pool = sorted(set(numbers))
n = len(pool)
if k < 1:
raise ValueError("Pick count must be at least 1.")
if k > n:
raise ValueError(
f"Pick count ({k}) exceeds the number pool size ({n})."
)
count = comb(n, k)
if count > MAX_TICKETS:
raise ValueError(
f"Wheel would produce {count:,} tickets (max {MAX_TICKETS:,}). "
f"Reduce your pool or pick count."
)
return [sorted(combo) for combo in combinations(pool, k)]
Binary file not shown.
+98
View File
@@ -0,0 +1,98 @@
"""
tests/test_wheeling.py
-----------------------
Tests for core/wheeling.py — full-cover wheel generation.
"""
import pytest
from core.wheeling import wheel_full, wheel_count, MAX_TICKETS
# ── wheel_count ───────────────────────────────────────────────────────────────
def test_wheel_count_basic():
assert wheel_count([1, 2, 3, 4, 5, 6], 5) == 6 # C(6,5)
def test_wheel_count_exact_pick():
assert wheel_count([1, 2, 3, 4, 5], 5) == 1 # C(5,5)
def test_wheel_count_larger():
assert wheel_count(list(range(1, 10)), 5) == 126 # C(9,5)
def test_wheel_count_k_zero_returns_zero():
assert wheel_count([1, 2, 3, 4, 5], 0) == 0
def test_wheel_count_k_exceeds_n_returns_zero():
assert wheel_count([1, 2, 3], 5) == 0
def test_wheel_count_deduplicates_input():
assert wheel_count([1, 1, 2, 3, 4, 5], 5) == 1 # C(5,5) after dedup
# ── wheel_full — valid cases ──────────────────────────────────────────────────
def test_wheel_full_ticket_count():
tickets = wheel_full([1, 2, 3, 4, 5, 6], 5)
assert len(tickets) == 6
def test_wheel_full_single_ticket():
tickets = wheel_full([5, 14, 22, 36, 69], 5)
assert len(tickets) == 1
assert tickets[0] == [5, 14, 22, 36, 69]
def test_wheel_full_each_ticket_sorted():
tickets = wheel_full([10, 3, 7, 1, 5, 2], 4)
for t in tickets:
assert t == sorted(t)
def test_wheel_full_no_duplicate_tickets():
tickets = wheel_full(list(range(1, 9)), 5) # C(8,5) = 56
as_tuples = [tuple(t) for t in tickets]
assert len(as_tuples) == len(set(as_tuples))
def test_wheel_full_all_numbers_in_pool():
pool = [5, 14, 22, 36, 55, 69]
tickets = wheel_full(pool, 4)
for t in tickets:
for n in t:
assert n in pool
def test_wheel_full_deduplicates_input():
# [1,1,2,3,4,5] → pool [1,2,3,4,5] → C(5,5) = 1
tickets = wheel_full([1, 1, 2, 3, 4, 5], 5)
assert len(tickets) == 1
def test_wheel_full_at_cap(monkeypatch):
import core.wheeling as wm
monkeypatch.setattr(wm, "MAX_TICKETS", 56)
# C(8,5) = 56 exactly at cap — should pass
tickets = wm.wheel_full(list(range(1, 9)), 5)
assert len(tickets) == 56
def test_wheel_full_returns_list_of_lists():
tickets = wheel_full([1, 2, 3, 4, 5, 6], 5)
assert isinstance(tickets, list)
for t in tickets:
assert isinstance(t, list)
# ── wheel_full — error cases ──────────────────────────────────────────────────
def test_wheel_full_k_zero_raises():
with pytest.raises(ValueError, match="at least 1"):
wheel_full([1, 2, 3, 4, 5], 0)
def test_wheel_full_k_exceeds_n_raises():
with pytest.raises(ValueError, match="exceeds"):
wheel_full([1, 2, 3], 5)
def test_wheel_full_exceeds_cap_raises():
# C(10,5) = 252 > MAX_TICKETS (200)
with pytest.raises(ValueError, match="252"):
wheel_full(list(range(1, 11)), 5)
def test_wheel_full_error_message_includes_count():
with pytest.raises(ValueError) as exc_info:
wheel_full(list(range(1, 11)), 5)
assert "252" in str(exc_info.value)
assert str(MAX_TICKETS) in str(exc_info.value)
+257 -3
View File
@@ -1,11 +1,12 @@
"""
ui/predictor_ui.py
------------------
Prediction generator screen — two tabs inside a ttk.Notebook.
Prediction generator screen — four tabs inside a ttk.Notebook.
Generate tab — pick strategy + game + count → generate tickets → save to DB
Saved tab — browse all saved predictions, see match count vs last real
draw, delete individual rows or clear all
Saved tab — browse all saved predictions, see match count vs last draw
Check Ticket tab — compare a user ticket against all historical draws
Wheel tab — full-cover wheeling: pick N numbers → all C(N,k) tickets
"""
import os
@@ -22,6 +23,7 @@ from core.predictor import (
hot_numbers, due_numbers, weighted_random, monte_carlo, positional_pick, quick_pick,
)
from core.checker import check_ticket, parse_numbers
from core.wheeling import wheel_full, wheel_count, MAX_TICKETS
from core.exporter import export_predictions_excel, export_predictions_csv, ensure_exports_dir
from ui.widgets import BallsBar
@@ -48,6 +50,17 @@ _DESCRIPTIONS = {
_TICKET_COUNTS = [str(n) for n in range(1, 11)]
def _parse_raw_numbers(raw: str) -> list[int]:
"""Parse space- or comma-separated integers from a string, ignoring non-numeric tokens."""
result = []
for tok in raw.replace(",", " ").split():
try:
result.append(int(tok))
except ValueError:
pass
return result
def _count_matches(pred_numbers: str, last_draw) -> str:
"""Compare prediction numbers to last draw, return 'X/5' string."""
if last_draw is None:
@@ -67,6 +80,7 @@ class PredictorScreen(ttk.Frame):
self._game_id: int | None = None
self._tickets: list[dict] = []
self._strategy_name: str = "Hot Numbers"
self._wheel_tickets: list[list[int]] = []
self._build_ui()
# ── UI construction ───────────────────────────────────────────────────────
@@ -78,13 +92,16 @@ class PredictorScreen(ttk.Frame):
gen_frame = ttk.Frame(nb)
saved_frame = ttk.Frame(nb)
check_frame = ttk.Frame(nb)
wheel_frame = ttk.Frame(nb)
nb.add(gen_frame, text="Generate")
nb.add(saved_frame, text="Saved")
nb.add(check_frame, text="Check Ticket")
nb.add(wheel_frame, text="Wheel")
self._build_generate_tab(gen_frame)
self._build_saved_tab(saved_frame)
self._build_check_tab(check_frame)
self._build_wheel_tab(wheel_frame)
# ── Generate tab ──────────────────────────────────────────────────────────
@@ -248,6 +265,7 @@ class PredictorScreen(ttk.Frame):
def refresh(self):
self._load_games()
self._load_check_games()
self._load_wheel_games()
self._refresh_saved()
def _load_games(self):
@@ -705,6 +723,242 @@ class PredictorScreen(ttk.Frame):
w.destroy()
self._chk_summary_var.set("")
# ── Wheel tab ─────────────────────────────────────────────────────────────
def _build_wheel_tab(self, parent):
bar = ttk.Frame(parent, padding=(6, 8, 6, 4))
bar.pack(fill="x")
ttk.Label(bar, text="Game:").pack(side="left")
self._whl_game_var = tk.StringVar()
self._whl_game_cb = ttk.Combobox(
bar, textvariable=self._whl_game_var, state="readonly", width=15
)
self._whl_game_cb.pack(side="left", padx=(4, 14))
self._whl_game_cb.bind("<<ComboboxSelected>>", lambda _: self._on_wheel_game_change())
ttk.Label(bar, text="Numbers:").pack(side="left")
self._whl_nums_var = tk.StringVar()
whl_entry = ttk.Entry(bar, textvariable=self._whl_nums_var, width=28)
whl_entry.pack(side="left", padx=(4, 4))
whl_entry.bind("<Return>", lambda e: self._run_wheel())
self._whl_nums_var.trace_add("write", lambda *_: self._update_wheel_preview())
ttk.Label(bar, text="Pick:").pack(side="left")
self._whl_pick_var = tk.IntVar(value=5)
self._whl_pick_sb = ttk.Spinbox(
bar, from_=1, to=10, textvariable=self._whl_pick_var, width=4,
command=self._update_wheel_preview,
)
self._whl_pick_sb.pack(side="left", padx=(4, 14))
self._whl_pick_var.trace_add("write", lambda *_: self._update_wheel_preview())
self._whl_btn = ttk.Button(bar, text="Generate Wheel", command=self._run_wheel)
self._whl_btn.pack(side="left", padx=(0, 4))
ttk.Button(bar, text="Clear", command=self._clear_wheel).pack(side="left")
# Preview + hint
hint = ttk.Frame(parent, padding=(6, 0, 6, 4))
hint.pack(fill="x")
self._whl_preview_var = tk.StringVar(
value=f"Enter more than pick count numbers. Max {MAX_TICKETS} tickets."
)
ttk.Label(hint, textvariable=self._whl_preview_var,
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))
w_cols = ("#", "numbers")
self._whl_tree = ttk.Treeview(
tree_frame, columns=w_cols, show="headings", selectmode="browse"
)
self._whl_tree.heading("#", text="#")
self._whl_tree.heading("numbers", text="Numbers")
self._whl_tree.column("#", width=50, anchor="center", stretch=False)
self._whl_tree.column("numbers", width=300, anchor="w")
vsb = ttk.Scrollbar(tree_frame, orient="vertical", command=self._whl_tree.yview)
self._whl_tree.configure(yscrollcommand=vsb.set)
self._whl_tree.pack(side="left", fill="both", expand=True)
vsb.pack(side="right", fill="y")
self._whl_tree.bind("<<TreeviewSelect>>", self._on_wheel_row_select)
# Ball detail strip
ttk.Separator(parent, orient="horizontal").pack(fill="x", padx=6)
self._whl_detail = ttk.Frame(parent, padding=(8, 3))
self._whl_detail.pack(fill="x")
# Bottom bar
bottom = ttk.Frame(parent, padding=(6, 4))
bottom.pack(fill="x")
self._whl_status_var = tk.StringVar()
ttk.Label(bottom, textvariable=self._whl_status_var,
foreground="#27ae60").pack(side="left", fill="x", expand=True)
self._whl_save_btn = ttk.Button(
bottom, text="Save to DB", command=self._save_wheel, state="disabled"
)
self._whl_save_btn.pack(side="right")
self._whl_copy_btn = ttk.Button(
bottom, text="Copy", command=self._copy_wheel, state="disabled"
)
self._whl_copy_btn.pack(side="right", padx=(0, 4))
def _load_wheel_games(self):
games = get_all_games(active_only=True)
names = [g["name"] for g in games]
self._whl_game_cb["values"] = names
if not self._whl_game_var.get() or self._whl_game_var.get() not in names:
if names:
self._whl_game_var.set(names[0])
self._on_wheel_game_change()
def _on_wheel_game_change(self):
game = get_game_by_name(self._whl_game_var.get())
if game:
self._whl_pick_var.set(game["main_count"])
self._clear_wheel()
self._update_wheel_preview()
def _update_wheel_preview(self):
raw = self._whl_nums_var.get().strip()
nums = _parse_raw_numbers(raw)
try:
k = int(self._whl_pick_var.get())
except (ValueError, tk.TclError):
self._whl_preview_var.set("Invalid pick count.")
return
if not nums:
self._whl_preview_var.set(
f"Enter more than pick count numbers. Max {MAX_TICKETS} tickets."
)
return
n = len(set(nums))
if k < 1 or k > n:
self._whl_preview_var.set(f"Pick count must be between 1 and {n}.")
return
from math import comb
count = comb(n, k)
if count > MAX_TICKETS:
self._whl_preview_var.set(
f"Would generate {count:,} tickets — exceeds limit of {MAX_TICKETS}. "
f"Reduce pool or pick count."
)
else:
self._whl_preview_var.set(
f"Will generate {count} ticket{'s' if count != 1 else ''} (C({n},{k}))."
)
def _run_wheel(self):
self._whl_status_var.set("")
raw = self._whl_nums_var.get().strip()
nums = _parse_raw_numbers(raw)
if not nums:
self._whl_status_var.set("Enter numbers first.")
return
game = get_game_by_name(self._whl_game_var.get())
if game is None:
self._whl_status_var.set("Select a game first.")
return
try:
k = int(self._whl_pick_var.get())
except (ValueError, tk.TclError):
self._whl_status_var.set("Invalid pick count.")
return
# Validate number ranges
bad = [n for n in nums if not (1 <= n <= game["main_max"])]
if bad:
self._whl_status_var.set(
f"Numbers out of range 1{game['main_max']}: {sorted(set(bad))}"
)
return
try:
tickets = wheel_full(nums, k)
except ValueError as e:
self._whl_status_var.set(str(e))
return
self._wheel_tickets = tickets
self._whl_tree.delete(*self._whl_tree.get_children())
for w in self._whl_detail.winfo_children():
w.destroy()
for i, combo in enumerate(tickets, 1):
nums_str = " ".join(f"{n:02d}" for n in combo)
self._whl_tree.insert("", "end", values=(i, nums_str))
self._whl_save_btn.config(state="normal")
self._whl_copy_btn.config(state="normal")
n_pool = len(set(nums))
self._whl_status_var.set(
f"{len(tickets)} ticket{'s' if len(tickets) != 1 else ''}"
f"full cover of {n_pool} numbers pick {k}."
)
logger.info("[WHEEL] Generated %d tickets from %d-number pool", len(tickets), n_pool)
def _on_wheel_row_select(self, _event=None):
for w in self._whl_detail.winfo_children():
w.destroy()
sel = self._whl_tree.selection()
if not sel:
return
vals = self._whl_tree.item(sel[0], "values")
try:
nums = [int(x) for x in str(vals[1]).split() if x.isdigit()]
except Exception:
return
if nums:
BallsBar(self._whl_detail, numbers=nums).pack(anchor="w")
def _save_wheel(self):
if not self._wheel_tickets:
return
game = get_game_by_name(self._whl_game_var.get())
if game is None:
return
for combo in self._wheel_tickets:
insert_prediction(game["id"], "Wheel", combo, bonus=None)
saved = len(self._wheel_tickets)
self._whl_status_var.set(f"Saved {saved} ticket{'s' if saved != 1 else ''} to DB.")
self._whl_save_btn.config(state="disabled")
self._refresh_saved()
logger.info("[WHEEL] Saved %d wheel tickets to DB", saved)
def _copy_wheel(self):
if not self._wheel_tickets:
return
lines = [
f"Ticket {i}: {' '.join(f'{n:02d}' for n in combo)}"
for i, combo in enumerate(self._wheel_tickets, 1)
]
self.clipboard_clear()
self.clipboard_append("\n".join(lines))
self._whl_status_var.set("Copied to clipboard.")
def _clear_wheel(self):
self._wheel_tickets = []
if hasattr(self, "_whl_tree"):
self._whl_tree.delete(*self._whl_tree.get_children())
if hasattr(self, "_whl_detail"):
for w in self._whl_detail.winfo_children():
w.destroy()
if hasattr(self, "_whl_save_btn"):
self._whl_save_btn.config(state="disabled")
if hasattr(self, "_whl_copy_btn"):
self._whl_copy_btn.config(state="disabled")
if hasattr(self, "_whl_status_var"):
self._whl_status_var.set("")
def _load_check_games(self):
games = get_all_games(active_only=True)
names = [g["name"] for g in games]