05/23 Phase 11

This commit is contained in:
2026-05-23 12:31:20 -04:00
parent f8c11b8979
commit 78e88ae8ab
5 changed files with 412 additions and 27 deletions
+15
View File
@@ -325,6 +325,21 @@ All actions are logged to console and optionally to a log file:
--- ---
### ✅ Phase 11 — Saved Predictions Viewer
- [x] Add `delete_prediction(pred_id)` and `delete_all_predictions(game_id=None)` to `db/models.py`
- [x] Refactor `ui/predictor_ui.py` to ttk.Notebook with two tabs
- [x] Generate tab — existing UI unchanged
- [x] Saved tab — treeview of all DB predictions (ID, Game, Strategy, Numbers, Bonus, Matches, Saved)
- [x] Match column — shows "X/5" comparing prediction vs last real draw (green if > 0, grey if zero)
- [x] Game filter dropdown — show all games or filter to one
- [x] Delete Selected — removes checked rows from DB + refreshes
- [x] Clear All — confirm dialog, then wipes all (or game-filtered) predictions
- [x] Auto-refreshes Saved tab after Save to DB
- [x] `_count_matches()` helper — set intersection between prediction and last draw numbers
- [x] `tests/test_saved_predictions.py` — 17 tests (214/214 total passing)
---
### ✅ Phase 10 — Complete Analysis Screen (7 Charts) ### ✅ Phase 10 — Complete Analysis Screen (7 Charts)
- [x] Extend `ui/analysis.py` with 4 new chart tabs (was 3, now 7) - [x] Extend `ui/analysis.py` with 4 new chart tabs (was 3, now 7)
- [x] Pairs — horizontal bar chart, top-20 most common number pairs - [x] Pairs — horizontal bar chart, top-20 most common number pairs
Binary file not shown.
+35
View File
@@ -356,6 +356,41 @@ def get_predictions(game_id=None, limit=50):
conn.close() conn.close()
def delete_prediction(pred_id: int):
"""Delete a single saved prediction by id."""
conn = get_connection()
try:
cursor = conn.cursor()
cursor.execute("DELETE FROM predictions WHERE id = ?", (pred_id,))
conn.commit()
logger.info("[PREDICT] Deleted prediction id=%d", pred_id)
except Exception as e:
conn.rollback()
logger.error("[ERROR] delete_prediction failed: %s", e, exc_info=True)
raise
finally:
conn.close()
def delete_all_predictions(game_id=None):
"""Delete all saved predictions, optionally filtered by game."""
conn = get_connection()
try:
cursor = conn.cursor()
if game_id is not None:
cursor.execute("DELETE FROM predictions WHERE game_id = ?", (game_id,))
else:
cursor.execute("DELETE FROM predictions")
conn.commit()
logger.info("[PREDICT] Cleared all predictions (game_id=%s)", game_id)
except Exception as e:
conn.rollback()
logger.error("[ERROR] delete_all_predictions failed: %s", e, exc_info=True)
raise
finally:
conn.close()
# ══════════════════════════════════════════════════════════════════════════════ # ══════════════════════════════════════════════════════════════════════════════
# FETCH LOG # FETCH LOG
# ══════════════════════════════════════════════════════════════════════════════ # ══════════════════════════════════════════════════════════════════════════════
+168
View File
@@ -0,0 +1,168 @@
"""
tests/test_saved_predictions.py
--------------------------------
Tests for the saved-prediction model functions (delete_prediction,
delete_all_predictions) and the _count_matches helper in predictor_ui.
"""
import pytest
from db.models import (
get_game_by_name, insert_draw, insert_prediction,
get_predictions, delete_prediction, delete_all_predictions,
get_last_draw,
)
from ui.predictor_ui import _count_matches
# ── Fixtures ──────────────────────────────────────────────────────────────────
@pytest.fixture
def pb(tmp_db):
return get_game_by_name("Powerball")
@pytest.fixture
def mm(tmp_db):
return get_game_by_name("Mega Millions")
@pytest.fixture
def with_predictions(pb, mm):
insert_draw(pb["id"], "2024-01-01", [1, 13, 36, 61, 69], bonus=7)
p1 = insert_prediction(pb["id"], "Hot Numbers", [1, 13, 36, 61, 69], bonus=7)
p2 = insert_prediction(pb["id"], "Due Numbers", [5, 10, 20, 30, 40], bonus=15)
p3 = insert_prediction(mm["id"], "Monte Carlo", [2, 14, 37, 62, 70], bonus=5)
return p1, p2, p3
# ── delete_prediction ─────────────────────────────────────────────────────────
def test_delete_prediction_removes_row(pb, with_predictions):
p1, p2, p3 = with_predictions
delete_prediction(p1)
ids = [p["id"] for p in get_predictions(limit=100_000)]
assert p1 not in ids
assert p2 in ids
def test_delete_prediction_leaves_others_intact(pb, with_predictions):
p1, p2, p3 = with_predictions
delete_prediction(p1)
remaining = get_predictions(limit=100_000)
assert len(remaining) == 2
def test_delete_nonexistent_prediction_no_error(tmp_db):
delete_prediction(99999) # should not raise
# ── delete_all_predictions ────────────────────────────────────────────────────
def test_delete_all_clears_everything(pb, with_predictions):
delete_all_predictions()
assert get_predictions(limit=100_000) == []
def test_delete_all_with_game_id_only_removes_that_game(pb, mm, with_predictions):
p1, p2, p3 = with_predictions
delete_all_predictions(game_id=pb["id"])
remaining = get_predictions(limit=100_000)
assert len(remaining) == 1
assert remaining[0]["id"] == p3
def test_delete_all_empty_db_no_error(tmp_db):
delete_all_predictions() # should not raise
def test_delete_all_game_id_no_match_no_error(pb, tmp_db):
delete_all_predictions(game_id=pb["id"]) # nothing to delete
# ── _count_matches ────────────────────────────────────────────────────────────
def test_count_matches_full_match(pb):
insert_draw(pb["id"], "2024-01-01", [1, 13, 36, 61, 69], bonus=7)
last = get_last_draw(pb["id"])
assert _count_matches("1,13,36,61,69", last) == "5/5"
def test_count_matches_partial(pb):
insert_draw(pb["id"], "2024-01-01", [1, 13, 36, 61, 69], bonus=7)
last = get_last_draw(pb["id"])
assert _count_matches("1,13,2,3,4", last) == "2/5"
def test_count_matches_zero(pb):
insert_draw(pb["id"], "2024-01-01", [1, 13, 36, 61, 69], bonus=7)
last = get_last_draw(pb["id"])
assert _count_matches("2,3,4,5,6", last) == "0/5"
def test_count_matches_no_last_draw(tmp_db):
pb = get_game_by_name("Powerball")
assert _count_matches("1,2,3,4,5", None) == ""
# ── 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_predictor_screen_saved_tab_loads(tmp_db):
import tkinter as tk
from ui.predictor_ui import PredictorScreen
root = tk.Tk(); root.withdraw()
try:
screen = PredictorScreen(root)
screen.refresh()
assert screen.winfo_exists()
finally:
root.destroy()
@pytest.mark.skipif(not _has_display(), reason="no display available")
def test_saved_tab_shows_predictions(tmp_db):
import tkinter as tk
from ui.predictor_ui import PredictorScreen
pb = get_game_by_name("Powerball")
insert_prediction(pb["id"], "Hot Numbers", [1, 13, 36, 61, 69], bonus=7)
root = tk.Tk(); root.withdraw()
try:
screen = PredictorScreen(root)
screen.refresh()
children = screen._saved_tree.get_children()
assert len(children) == 1
finally:
root.destroy()
@pytest.mark.skipif(not _has_display(), reason="no display available")
def test_saved_tab_delete_selected(tmp_db):
import tkinter as tk
from ui.predictor_ui import PredictorScreen
pb = get_game_by_name("Powerball")
pid = insert_prediction(pb["id"], "Hot Numbers", [1, 13, 36, 61, 69], bonus=7)
root = tk.Tk(); root.withdraw()
try:
screen = PredictorScreen(root)
screen.refresh()
iid = screen._saved_tree.get_children()[0]
screen._saved_tree.selection_set(iid)
screen._delete_selected()
assert len(screen._saved_tree.get_children()) == 0
assert get_predictions(limit=100_000) == []
finally:
root.destroy()
+194 -27
View File
@@ -1,8 +1,11 @@
""" """
ui/predictor_ui.py ui/predictor_ui.py
------------------ ------------------
Prediction generator screen. Prediction generator screen two tabs inside a ttk.Notebook.
Pick a strategy + game + ticket count generate save to DB.
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
""" """
import os import os
@@ -10,7 +13,11 @@ import tkinter as tk
from tkinter import ttk, filedialog, messagebox from tkinter import ttk, filedialog, messagebox
import logging import logging
from db.models import get_all_games, get_game_by_name, insert_prediction from db.models import (
get_all_games, get_game_by_name, get_last_draw,
insert_prediction, get_predictions,
delete_prediction, delete_all_predictions,
)
from core.predictor import ( from core.predictor import (
hot_numbers, due_numbers, weighted_random, monte_carlo, positional_pick, hot_numbers, due_numbers, weighted_random, monte_carlo, positional_pick,
) )
@@ -37,19 +44,45 @@ _DESCRIPTIONS = {
_TICKET_COUNTS = [str(n) for n in range(1, 11)] _TICKET_COUNTS = [str(n) for n in range(1, 11)]
def _count_matches(pred_numbers: str, last_draw) -> str:
"""Compare prediction numbers to last draw, return 'X/5' string."""
if last_draw is None:
return ""
try:
pred_set = {int(n) for n in pred_numbers.split(",")}
draw_set = {int(n) for n in last_draw["numbers"].split(",")}
matched = len(pred_set & draw_set)
return f"{matched}/{len(pred_set)}"
except Exception:
return ""
class PredictorScreen(ttk.Frame): class PredictorScreen(ttk.Frame):
def __init__(self, parent, **kwargs): def __init__(self, parent, **kwargs):
super().__init__(parent, **kwargs) super().__init__(parent, **kwargs)
self._game_id: int | None = None self._game_id: int | None = None
self._tickets: list[dict] = [] # [{"numbers": [...], "bonus": int|None}] self._tickets: list[dict] = []
self._strategy_name: str = "Hot Numbers" self._strategy_name: str = "Hot Numbers"
self._build_ui() self._build_ui()
# ── UI construction ─────────────────────────────────────────────────────── # ── UI construction ───────────────────────────────────────────────────────
def _build_ui(self): def _build_ui(self):
# ── Controls bar ────────────────────────────────────────────────────── nb = ttk.Notebook(self)
bar = ttk.Frame(self, padding=(6, 6, 6, 4)) nb.pack(fill="both", expand=True)
gen_frame = ttk.Frame(nb)
saved_frame = ttk.Frame(nb)
nb.add(gen_frame, text="Generate")
nb.add(saved_frame, text="Saved")
self._build_generate_tab(gen_frame)
self._build_saved_tab(saved_frame)
# ── Generate tab ──────────────────────────────────────────────────────────
def _build_generate_tab(self, parent):
bar = ttk.Frame(parent, padding=(6, 6, 6, 4))
bar.pack(fill="x") bar.pack(fill="x")
ttk.Label(bar, text="Game:").pack(side="left") ttk.Label(bar, text="Game:").pack(side="left")
@@ -80,8 +113,8 @@ class PredictorScreen(ttk.Frame):
self._gen_btn = ttk.Button(bar, text="Generate", command=self._generate) self._gen_btn = ttk.Button(bar, text="Generate", command=self._generate)
self._gen_btn.pack(side="left", padx=(14, 0)) self._gen_btn.pack(side="left", padx=(14, 0))
# ── Results Treeview ────────────────────────────────────────────────── # Results treeview
tree_frame = ttk.Frame(self) tree_frame = ttk.Frame(parent)
tree_frame.pack(fill="both", expand=True, padx=6, pady=(4, 0)) tree_frame.pack(fill="both", expand=True, padx=6, pady=(4, 0))
cols = ("#", "numbers", "bonus") cols = ("#", "numbers", "bonus")
@@ -100,8 +133,8 @@ class PredictorScreen(ttk.Frame):
self._tree.pack(side="left", fill="both", expand=True) self._tree.pack(side="left", fill="both", expand=True)
vsb.pack(side="right", fill="y") vsb.pack(side="right", fill="y")
# ── Bottom bar ──────────────────────────────────────────────────────── # Bottom bar
bottom = ttk.Frame(self, padding=(6, 4)) bottom = ttk.Frame(parent, padding=(6, 4))
bottom.pack(fill="x") bottom.pack(fill="x")
self._desc_var = tk.StringVar(value=_DESCRIPTIONS["Hot Numbers"]) self._desc_var = tk.StringVar(value=_DESCRIPTIONS["Hot Numbers"])
@@ -119,22 +152,79 @@ class PredictorScreen(ttk.Frame):
) )
self._save_btn.pack(side="right") self._save_btn.pack(side="right")
ttk.Button( ttk.Button(bottom, text="Clear", command=self._clear
bottom, text="Clear", command=self._clear ).pack(side="right", padx=(0, 4))
).pack(side="right", padx=(0, 4)) ttk.Button(bottom, text="Export CSV", command=self._export_csv
).pack(side="right", padx=(0, 4))
ttk.Button(bottom, text="Export Excel", command=self._export_excel
).pack(side="right", padx=(0, 4))
ttk.Button( # ── Saved tab ─────────────────────────────────────────────────────────────
bottom, text="Export CSV", command=self._export_csv, state="normal"
).pack(side="right", padx=(0, 4))
ttk.Button( def _build_saved_tab(self, parent):
bottom, text="Export Excel", command=self._export_excel, state="normal" # Filter + action bar
).pack(side="right", padx=(0, 4)) bar = ttk.Frame(parent, padding=(6, 6, 6, 4))
bar.pack(fill="x")
# ── Callbacks ───────────────────────────────────────────────────────────── ttk.Label(bar, text="Game:").pack(side="left")
self._saved_game_var = tk.StringVar(value="All Games")
self._saved_game_cb = ttk.Combobox(
bar, textvariable=self._saved_game_var, state="readonly", width=15
)
self._saved_game_cb.pack(side="left", padx=(4, 14))
self._saved_game_cb.bind("<<ComboboxSelected>>", lambda _: self._refresh_saved())
ttk.Button(bar, text="↻ Refresh", command=self._refresh_saved).pack(side="left", padx=(0, 4))
ttk.Button(bar, text="Delete Selected", command=self._delete_selected).pack(side="left", padx=(0, 4))
ttk.Button(bar, text="Clear All", command=self._clear_all_saved).pack(side="left")
self._saved_count_var = tk.StringVar(value="0 saved")
ttk.Label(bar, textvariable=self._saved_count_var,
foreground="#777777").pack(side="right")
# Treeview
tree_frame = ttk.Frame(parent)
tree_frame.pack(fill="both", expand=True, padx=6, pady=(2, 6))
s_cols = ("id", "game", "strategy", "numbers", "bonus", "matches", "saved")
self._saved_tree = ttk.Treeview(
tree_frame, columns=s_cols, show="headings", selectmode="extended"
)
self._saved_tree.heading("id", text="ID")
self._saved_tree.heading("game", text="Game")
self._saved_tree.heading("strategy", text="Strategy")
self._saved_tree.heading("numbers", text="Numbers")
self._saved_tree.heading("bonus", text="Bonus")
self._saved_tree.heading("matches", text="Matches")
self._saved_tree.heading("saved", text="Saved")
self._saved_tree.column("id", width=45, anchor="center", stretch=False)
self._saved_tree.column("game", width=120, anchor="w", stretch=False)
self._saved_tree.column("strategy", width=120, anchor="w", stretch=False)
self._saved_tree.column("numbers", width=220, anchor="w")
self._saved_tree.column("bonus", width=55, anchor="center", stretch=False)
self._saved_tree.column("matches", width=65, anchor="center", stretch=False)
self._saved_tree.column("saved", width=130, anchor="center", stretch=False)
vsb = ttk.Scrollbar(tree_frame, orient="vertical", command=self._saved_tree.yview)
hsb = ttk.Scrollbar(tree_frame, orient="horizontal", command=self._saved_tree.xview)
self._saved_tree.configure(yscrollcommand=vsb.set, xscrollcommand=hsb.set)
self._saved_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)
# Colour rows by match count
self._saved_tree.tag_configure("match_good", foreground="#1e8449")
self._saved_tree.tag_configure("match_zero", foreground="#aaaaaa")
# ── Refresh / data ────────────────────────────────────────────────────────
def refresh(self): def refresh(self):
self._load_games() self._load_games()
self._refresh_saved()
def _load_games(self): def _load_games(self):
games = get_all_games(active_only=True) games = get_all_games(active_only=True)
@@ -146,6 +236,59 @@ class PredictorScreen(ttk.Frame):
game = get_game_by_name(self._game_var.get()) game = get_game_by_name(self._game_var.get())
self._game_id = game["id"] if game else None self._game_id = game["id"] if game else None
saved_names = ["All Games"] + names
self._saved_game_cb["values"] = saved_names
if self._saved_game_var.get() not in saved_names:
self._saved_game_var.set("All Games")
def _refresh_saved(self):
name = self._saved_game_var.get()
game_id = None
if name != "All Games":
g = get_game_by_name(name)
game_id = g["id"] if g else None
preds = get_predictions(game_id=game_id, limit=100_000)
# Cache last draw per game to avoid repeated DB hits
last_draw_cache: dict[int, object] = {}
self._saved_tree.delete(*self._saved_tree.get_children())
for p in preds:
gid = p["game_id"]
if gid not in last_draw_cache:
last_draw_cache[gid] = get_last_draw(gid)
last = last_draw_cache[gid]
match_str = _count_matches(p["numbers"], last)
nums_fmt = " ".join(f"{int(n):02d}" for n in p["numbers"].split(","))
saved_ts = p["created_at"][:16].replace("T", " ")
try:
matched_n = int(match_str.split("/")[0])
tag = "match_good" if matched_n > 0 else "match_zero"
except Exception:
tag = ""
self._saved_tree.insert("", "end",
iid=str(p["id"]),
values=(
p["id"],
p["game_name"],
p["strategy"],
nums_fmt,
p["bonus"] or "",
match_str,
saved_ts,
),
tags=(tag,),
)
count = len(preds)
self._saved_count_var.set(f"{count} saved prediction{'s' if count != 1 else ''}")
# ── Generate tab callbacks ────────────────────────────────────────────────
def _on_game_change(self): def _on_game_change(self):
game = get_game_by_name(self._game_var.get()) game = get_game_by_name(self._game_var.get())
self._game_id = game["id"] if game else None self._game_id = game["id"] if game else None
@@ -183,7 +326,7 @@ class PredictorScreen(ttk.Frame):
def _display(self, tickets): def _display(self, tickets):
self._tree.delete(*self._tree.get_children()) self._tree.delete(*self._tree.get_children())
game = get_game_by_name(self._game_var.get()) game = get_game_by_name(self._game_var.get())
show_bonus = game is not None and game["bonus_count"] > 0 show_bonus = game is not None and game["bonus_count"] > 0
for i, t in enumerate(tickets, start=1): for i, t in enumerate(tickets, start=1):
@@ -201,6 +344,7 @@ class PredictorScreen(ttk.Frame):
saved += 1 saved += 1
self._status_var.set(f"Saved {saved} prediction{'s' if saved != 1 else ''} to DB.") self._status_var.set(f"Saved {saved} prediction{'s' if saved != 1 else ''} to DB.")
self._save_btn.config(state="disabled") self._save_btn.config(state="disabled")
self._refresh_saved()
logger.info("[PREDICT] Saved %d prediction(s) to DB", saved) logger.info("[PREDICT] Saved %d prediction(s) to DB", saved)
def _clear(self): def _clear(self):
@@ -220,8 +364,7 @@ class PredictorScreen(ttk.Frame):
if not fp: if not fp:
return return
try: try:
game_id = self._game_id export_predictions_excel(fp, game_id=self._game_id)
export_predictions_excel(fp, game_id=game_id)
self._status_var.set(f"Exported → {os.path.basename(fp)}") self._status_var.set(f"Exported → {os.path.basename(fp)}")
except Exception as e: except Exception as e:
messagebox.showerror("Export failed", str(e)) messagebox.showerror("Export failed", str(e))
@@ -237,8 +380,32 @@ class PredictorScreen(ttk.Frame):
if not fp: if not fp:
return return
try: try:
game_id = self._game_id export_predictions_csv(fp, game_id=self._game_id)
export_predictions_csv(fp, game_id=game_id)
self._status_var.set(f"Exported → {os.path.basename(fp)}") self._status_var.set(f"Exported → {os.path.basename(fp)}")
except Exception as e: except Exception as e:
messagebox.showerror("Export failed", str(e)) messagebox.showerror("Export failed", str(e))
# ── Saved tab callbacks ───────────────────────────────────────────────────
def _delete_selected(self):
selected = self._saved_tree.selection()
if not selected:
return
for iid in selected:
pred_id = int(self._saved_tree.item(iid, "values")[0])
delete_prediction(pred_id)
self._refresh_saved()
def _clear_all_saved(self):
name = self._saved_game_var.get()
game_id = None
if name != "All Games":
g = get_game_by_name(name)
game_id = g["id"] if g else None
scope = f" for {name}" if game_id else ""
if not messagebox.askyesno("Clear All",
f"Delete all saved predictions{scope}?"):
return
delete_all_predictions(game_id=game_id)
self._refresh_saved()