diff --git a/CLAUDE.md b/CLAUDE.md index 23c028f..478122a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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) - [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 diff --git a/data/lottosight.db b/data/lottosight.db index 275a2db..24f6e9d 100644 Binary files a/data/lottosight.db and b/data/lottosight.db differ diff --git a/db/models.py b/db/models.py index 03a3ef3..3007d72 100644 --- a/db/models.py +++ b/db/models.py @@ -356,6 +356,41 @@ def get_predictions(game_id=None, limit=50): 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 # ══════════════════════════════════════════════════════════════════════════════ diff --git a/tests/test_saved_predictions.py b/tests/test_saved_predictions.py new file mode 100644 index 0000000..061ff1c --- /dev/null +++ b/tests/test_saved_predictions.py @@ -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() diff --git a/ui/predictor_ui.py b/ui/predictor_ui.py index 820e80b..c1db668 100644 --- a/ui/predictor_ui.py +++ b/ui/predictor_ui.py @@ -1,8 +1,11 @@ """ ui/predictor_ui.py ------------------ -Prediction generator screen. -Pick a strategy + game + ticket count → generate → save to DB. +Prediction generator screen — two 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 """ import os @@ -10,7 +13,11 @@ import tkinter as tk from tkinter import ttk, filedialog, messagebox 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 ( 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)] +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): def __init__(self, parent, **kwargs): super().__init__(parent, **kwargs) - self._game_id: int | None = None - self._tickets: list[dict] = [] # [{"numbers": [...], "bonus": int|None}] - self._strategy_name: str = "Hot Numbers" + self._game_id: int | None = None + self._tickets: list[dict] = [] + self._strategy_name: str = "Hot Numbers" self._build_ui() # ── UI construction ─────────────────────────────────────────────────────── def _build_ui(self): - # ── Controls bar ────────────────────────────────────────────────────── - bar = ttk.Frame(self, padding=(6, 6, 6, 4)) + nb = ttk.Notebook(self) + 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") 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.pack(side="left", padx=(14, 0)) - # ── Results Treeview ────────────────────────────────────────────────── - tree_frame = ttk.Frame(self) + # Results treeview + tree_frame = ttk.Frame(parent) tree_frame.pack(fill="both", expand=True, padx=6, pady=(4, 0)) cols = ("#", "numbers", "bonus") @@ -100,8 +133,8 @@ class PredictorScreen(ttk.Frame): self._tree.pack(side="left", fill="both", expand=True) vsb.pack(side="right", fill="y") - # ── Bottom bar ──────────────────────────────────────────────────────── - bottom = ttk.Frame(self, padding=(6, 4)) + # Bottom bar + bottom = ttk.Frame(parent, padding=(6, 4)) bottom.pack(fill="x") self._desc_var = tk.StringVar(value=_DESCRIPTIONS["Hot Numbers"]) @@ -119,22 +152,79 @@ class PredictorScreen(ttk.Frame): ) self._save_btn.pack(side="right") - ttk.Button( - bottom, text="Clear", command=self._clear - ).pack(side="right", padx=(0, 4)) + ttk.Button(bottom, text="Clear", command=self._clear + ).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( - bottom, text="Export CSV", command=self._export_csv, state="normal" - ).pack(side="right", padx=(0, 4)) + # ── Saved tab ───────────────────────────────────────────────────────────── - ttk.Button( - bottom, text="Export Excel", command=self._export_excel, state="normal" - ).pack(side="right", padx=(0, 4)) + def _build_saved_tab(self, parent): + # Filter + action bar + 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("<>", 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): self._load_games() + self._refresh_saved() def _load_games(self): games = get_all_games(active_only=True) @@ -146,6 +236,59 @@ class PredictorScreen(ttk.Frame): game = get_game_by_name(self._game_var.get()) 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): game = get_game_by_name(self._game_var.get()) self._game_id = game["id"] if game else None @@ -183,7 +326,7 @@ class PredictorScreen(ttk.Frame): def _display(self, tickets): 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 for i, t in enumerate(tickets, start=1): @@ -201,6 +344,7 @@ class PredictorScreen(ttk.Frame): saved += 1 self._status_var.set(f"Saved {saved} prediction{'s' if saved != 1 else ''} to DB.") self._save_btn.config(state="disabled") + self._refresh_saved() logger.info("[PREDICT] Saved %d prediction(s) to DB", saved) def _clear(self): @@ -220,8 +364,7 @@ class PredictorScreen(ttk.Frame): if not fp: return try: - game_id = self._game_id - export_predictions_excel(fp, game_id=game_id) + export_predictions_excel(fp, game_id=self._game_id) self._status_var.set(f"Exported → {os.path.basename(fp)}") except Exception as e: messagebox.showerror("Export failed", str(e)) @@ -237,8 +380,32 @@ class PredictorScreen(ttk.Frame): if not fp: return try: - game_id = self._game_id - export_predictions_csv(fp, game_id=game_id) + export_predictions_csv(fp, game_id=self._game_id) self._status_var.set(f"Exported → {os.path.basename(fp)}") except Exception as 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()