05/23 Phase 12
This commit is contained in:
@@ -325,6 +325,20 @@ All actions are logged to console and optionally to a log file:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### ✅ 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
|
||||||
|
- [x] Add "Number:" entry field to History filter bar; bound to `<Return>` for quick search
|
||||||
|
- [x] Wire through `_apply_filter()` and `_clear_filter()`
|
||||||
|
- [x] 7 new tests — exact match, boundary false-positive prevention, combined with game/date filter
|
||||||
|
- [x] Next draw schedule on Dashboard
|
||||||
|
- [x] `_next_draw_date(game_name, from_date)` — computes next Powerball (Mon/Wed/Sat) or Mega Millions (Tue/Fri) draw date
|
||||||
|
- [x] Shown on each Last Draw card as "Next draw: Sat, May 24" in green
|
||||||
|
- [x] 6 new tests — day-of-week transitions, unknown game, always-in-future guarantee
|
||||||
|
- [x] 227/227 tests passing
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### ✅ Phase 11 — Saved Predictions Viewer
|
### ✅ Phase 11 — Saved Predictions Viewer
|
||||||
- [x] Add `delete_prediction(pred_id)` and `delete_all_predictions(game_id=None)` to `db/models.py`
|
- [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] Refactor `ui/predictor_ui.py` to ttk.Notebook with two tabs
|
||||||
|
|||||||
Binary file not shown.
+7
-1
@@ -224,10 +224,12 @@ def get_draw_count(game_id):
|
|||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
def get_draws_with_game(game_id=None, limit=None, date_from=None, date_to=None, order="DESC"):
|
def get_draws_with_game(game_id=None, limit=None, date_from=None, date_to=None,
|
||||||
|
order="DESC", number=None):
|
||||||
"""
|
"""
|
||||||
Like get_draws() but JOINs games so each row includes game_name.
|
Like get_draws() but JOINs games so each row includes game_name.
|
||||||
game_id=None returns draws for all games.
|
game_id=None returns draws for all games.
|
||||||
|
number (int|str): if given, restrict to draws containing that ball number.
|
||||||
Used by the History screen.
|
Used by the History screen.
|
||||||
"""
|
"""
|
||||||
conn = get_connection()
|
conn = get_connection()
|
||||||
@@ -249,6 +251,10 @@ def get_draws_with_game(game_id=None, limit=None, date_from=None, date_to=None,
|
|||||||
if date_to:
|
if date_to:
|
||||||
query += " AND d.draw_date <= ?"
|
query += " AND d.draw_date <= ?"
|
||||||
params.append(date_to)
|
params.append(date_to)
|
||||||
|
if number is not None:
|
||||||
|
# Wrap stored CSV in commas so boundary matches are exact
|
||||||
|
query += " AND ',' || d.numbers || ',' LIKE ?"
|
||||||
|
params.append(f"%,{int(number)},%")
|
||||||
|
|
||||||
order_sql = "DESC" if order.upper() == "DESC" else "ASC"
|
order_sql = "DESC" if order.upper() == "DESC" else "ASC"
|
||||||
query += f" ORDER BY d.draw_date {order_sql}, g.name ASC"
|
query += f" ORDER BY d.draw_date {order_sql}, g.name ASC"
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ tests/test_dashboard.py
|
|||||||
Tests for the Dashboard screen data layer and UI smoke tests.
|
Tests for the Dashboard screen data layer and UI smoke tests.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from db.models import (
|
from db.models import (
|
||||||
get_all_games, get_game_by_name, get_last_draw,
|
get_all_games, get_game_by_name, get_last_draw,
|
||||||
@@ -11,6 +13,7 @@ from db.models import (
|
|||||||
)
|
)
|
||||||
from db.database import get_db_stats
|
from db.database import get_db_stats
|
||||||
from core.analyzer import frequency_analysis
|
from core.analyzer import frequency_analysis
|
||||||
|
from ui.dashboard import _next_draw_date
|
||||||
|
|
||||||
|
|
||||||
# ── Data-layer checks used by the Dashboard ───────────────────────────────────
|
# ── Data-layer checks used by the Dashboard ───────────────────────────────────
|
||||||
@@ -77,6 +80,48 @@ def test_predictions_count_correct(tmp_db):
|
|||||||
assert len(get_predictions(limit=100_000)) == 2
|
assert len(get_predictions(limit=100_000)) == 2
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# ── _next_draw_date ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_next_draw_powerball_from_friday():
|
||||||
|
# Friday → next Powerball is Saturday
|
||||||
|
fri = date(2024, 5, 24) # Friday, weekday=4
|
||||||
|
result = _next_draw_date("Powerball", from_date=fri)
|
||||||
|
assert "Sat" in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_next_draw_powerball_from_saturday():
|
||||||
|
# Saturday → next Powerball is Monday
|
||||||
|
sat = date(2024, 5, 25) # Saturday, weekday=5
|
||||||
|
result = _next_draw_date("Powerball", from_date=sat)
|
||||||
|
assert "Mon" in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_next_draw_mega_millions_from_friday():
|
||||||
|
# Friday → next MM is Tuesday (4 days away)
|
||||||
|
fri = date(2024, 5, 24)
|
||||||
|
result = _next_draw_date("Mega Millions", from_date=fri)
|
||||||
|
assert "Tue" in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_next_draw_mega_millions_from_monday():
|
||||||
|
# Monday → next MM is Tuesday (1 day away)
|
||||||
|
mon = date(2024, 5, 20) # Monday, weekday=0
|
||||||
|
result = _next_draw_date("Mega Millions", from_date=mon)
|
||||||
|
assert "Tue" in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_next_draw_unknown_game_returns_empty():
|
||||||
|
assert _next_draw_date("Unknown Game") == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_next_draw_always_in_future():
|
||||||
|
today = date.today()
|
||||||
|
for name in ("Powerball", "Mega Millions"):
|
||||||
|
result = _next_draw_date(name, from_date=today)
|
||||||
|
assert result != "" # always finds a future date within 7 days
|
||||||
|
|
||||||
|
|
||||||
# ── UI smoke tests ────────────────────────────────────────────────────────────
|
# ── UI smoke tests ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _has_display():
|
def _has_display():
|
||||||
|
|||||||
@@ -122,6 +122,72 @@ def test_combined_filters(tmp_db):
|
|||||||
assert rows[0]["draw_date"] == "2024-03-01"
|
assert rows[0]["draw_date"] == "2024-03-01"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# ── number filter ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_number_filter_returns_matching_draws(tmp_db):
|
||||||
|
pb = get_game_by_name("Powerball")
|
||||||
|
insert_draw(pb["id"], "2024-01-01", [1, 13, 36, 61, 69], bonus=7)
|
||||||
|
insert_draw(pb["id"], "2024-01-03", [2, 14, 37, 55, 68], bonus=10)
|
||||||
|
|
||||||
|
rows = get_draws_with_game(number=13)
|
||||||
|
assert len(rows) == 1
|
||||||
|
assert rows[0]["draw_date"] == "2024-01-01"
|
||||||
|
|
||||||
|
|
||||||
|
def test_number_filter_no_match_returns_empty(tmp_db):
|
||||||
|
pb = get_game_by_name("Powerball")
|
||||||
|
insert_draw(pb["id"], "2024-01-01", [1, 2, 3, 4, 5], bonus=7)
|
||||||
|
|
||||||
|
rows = get_draws_with_game(number=99)
|
||||||
|
assert rows == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_number_filter_does_not_false_match_substring(tmp_db):
|
||||||
|
pb = get_game_by_name("Powerball")
|
||||||
|
# numbers 1 and 13 — searching for 1 must NOT return the draw containing 13
|
||||||
|
insert_draw(pb["id"], "2024-01-01", [13, 23, 33, 43, 53], bonus=7)
|
||||||
|
|
||||||
|
rows = get_draws_with_game(number=1)
|
||||||
|
assert rows == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_number_filter_matches_first_position(tmp_db):
|
||||||
|
pb = get_game_by_name("Powerball")
|
||||||
|
insert_draw(pb["id"], "2024-01-01", [1, 2, 3, 4, 5], bonus=7)
|
||||||
|
|
||||||
|
rows = get_draws_with_game(number=1)
|
||||||
|
assert len(rows) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_number_filter_matches_last_position(tmp_db):
|
||||||
|
pb = get_game_by_name("Powerball")
|
||||||
|
insert_draw(pb["id"], "2024-01-01", [1, 2, 3, 4, 69], bonus=7)
|
||||||
|
|
||||||
|
rows = get_draws_with_game(number=69)
|
||||||
|
assert len(rows) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_number_filter_combined_with_game(tmp_db):
|
||||||
|
pb = get_game_by_name("Powerball")
|
||||||
|
mm = get_game_by_name("Mega Millions")
|
||||||
|
insert_draw(pb["id"], "2024-01-01", [1, 13, 36, 61, 69], bonus=7)
|
||||||
|
insert_draw(mm["id"], "2024-01-02", [1, 14, 37, 55, 68], bonus=4)
|
||||||
|
|
||||||
|
rows = get_draws_with_game(game_id=pb["id"], number=1)
|
||||||
|
assert len(rows) == 1
|
||||||
|
assert rows[0]["game_name"] == "Powerball"
|
||||||
|
|
||||||
|
|
||||||
|
def test_number_filter_none_returns_all(tmp_db):
|
||||||
|
pb = get_game_by_name("Powerball")
|
||||||
|
insert_draw(pb["id"], "2024-01-01", [1, 2, 3, 4, 5], bonus=7)
|
||||||
|
insert_draw(pb["id"], "2024-01-03", [6, 7, 8, 9, 10], bonus=2)
|
||||||
|
|
||||||
|
rows = get_draws_with_game(number=None)
|
||||||
|
assert len(rows) == 2
|
||||||
|
|
||||||
|
|
||||||
# ── UI smoke test (skipped on headless) ───────────────────────────────────────
|
# ── UI smoke test (skipped on headless) ───────────────────────────────────────
|
||||||
|
|
||||||
def _has_display():
|
def _has_display():
|
||||||
|
|||||||
@@ -11,11 +11,31 @@ Sections:
|
|||||||
|
|
||||||
import tkinter as tk
|
import tkinter as tk
|
||||||
from tkinter import ttk
|
from tkinter import ttk
|
||||||
|
from datetime import date, timedelta
|
||||||
|
|
||||||
from db.database import get_db_stats
|
from db.database import get_db_stats
|
||||||
from db.models import get_all_games, get_last_draw, get_draw_count, get_predictions
|
from db.models import get_all_games, get_last_draw, get_draw_count, get_predictions
|
||||||
from core.analyzer import frequency_analysis
|
from core.analyzer import frequency_analysis
|
||||||
|
|
||||||
|
# Weekday indices: Monday=0 … Sunday=6
|
||||||
|
_DRAW_DAYS: dict[str, list[int]] = {
|
||||||
|
"Powerball": [0, 2, 5], # Mon, Wed, Sat
|
||||||
|
"Mega Millions": [1, 4], # Tue, Fri
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _next_draw_date(game_name: str, from_date: date | None = None) -> str:
|
||||||
|
"""Return the next scheduled draw date as 'Day, Mon DD' (e.g. 'Sat, May 24')."""
|
||||||
|
draw_days = _DRAW_DAYS.get(game_name)
|
||||||
|
if not draw_days:
|
||||||
|
return ""
|
||||||
|
today = from_date or date.today()
|
||||||
|
for delta in range(1, 8):
|
||||||
|
candidate = today + timedelta(days=delta)
|
||||||
|
if candidate.weekday() in draw_days:
|
||||||
|
return f"{candidate.strftime('%a, %b')} {candidate.day}"
|
||||||
|
return ""
|
||||||
|
|
||||||
_HEADER_FONT = ("TkDefaultFont", 10, "bold")
|
_HEADER_FONT = ("TkDefaultFont", 10, "bold")
|
||||||
_CARD_FONT = ("TkDefaultFont", 9)
|
_CARD_FONT = ("TkDefaultFont", 9)
|
||||||
_NUMBER_FONT = ("TkDefaultFont", 12, "bold")
|
_NUMBER_FONT = ("TkDefaultFont", 12, "bold")
|
||||||
@@ -122,6 +142,11 @@ class DashboardScreen(ttk.Frame):
|
|||||||
ttk.Label(card, text=f"Multiplier: {draw['multiplier']}",
|
ttk.Label(card, text=f"Multiplier: {draw['multiplier']}",
|
||||||
foreground="#777777", font=_CARD_FONT).pack(anchor="w")
|
foreground="#777777", font=_CARD_FONT).pack(anchor="w")
|
||||||
|
|
||||||
|
next_draw = _next_draw_date(game["name"])
|
||||||
|
if next_draw:
|
||||||
|
ttk.Label(card, text=f"Next draw: {next_draw}",
|
||||||
|
foreground="#117a65", font=_CARD_FONT).pack(anchor="w", pady=(6, 0))
|
||||||
|
|
||||||
def _refresh_db_summary(self):
|
def _refresh_db_summary(self):
|
||||||
for w in self._db_body.winfo_children():
|
for w in self._db_body.winfo_children():
|
||||||
w.destroy()
|
w.destroy()
|
||||||
|
|||||||
@@ -78,6 +78,12 @@ class HistoryScreen(ttk.Frame):
|
|||||||
self._to_entry.bind("<FocusIn>", lambda e: self._clear_hint(self._to_entry, "YYYY-MM-DD"))
|
self._to_entry.bind("<FocusIn>", lambda e: self._clear_hint(self._to_entry, "YYYY-MM-DD"))
|
||||||
self._to_entry.bind("<FocusOut>", lambda e: self._restore_hint(self._to_entry, "YYYY-MM-DD"))
|
self._to_entry.bind("<FocusOut>", lambda e: self._restore_hint(self._to_entry, "YYYY-MM-DD"))
|
||||||
|
|
||||||
|
ttk.Label(bar, text="Number:", padding=(8, 0, 0, 0)).pack(side="left")
|
||||||
|
self._number_var = tk.StringVar()
|
||||||
|
self._number_entry = ttk.Entry(bar, textvariable=self._number_var, width=5)
|
||||||
|
self._number_entry.pack(side="left", padx=(4, 14))
|
||||||
|
self._number_entry.bind("<Return>", lambda e: self._apply_filter())
|
||||||
|
|
||||||
ttk.Button(bar, text="Search", command=self._apply_filter).pack(side="left", padx=(0, 4))
|
ttk.Button(bar, text="Search", command=self._apply_filter).pack(side="left", padx=(0, 4))
|
||||||
ttk.Button(bar, text="Clear", command=self._clear_filter).pack(side="left")
|
ttk.Button(bar, text="Clear", command=self._clear_filter).pack(side="left")
|
||||||
|
|
||||||
@@ -147,6 +153,9 @@ class HistoryScreen(ttk.Frame):
|
|||||||
date_from = raw_from if raw_from and raw_from != "YYYY-MM-DD" else None
|
date_from = raw_from if raw_from and raw_from != "YYYY-MM-DD" else None
|
||||||
date_to = raw_to if raw_to and raw_to != "YYYY-MM-DD" else None
|
date_to = raw_to if raw_to and raw_to != "YYYY-MM-DD" else None
|
||||||
|
|
||||||
|
raw_num = self._number_var.get().strip()
|
||||||
|
number = int(raw_num) if raw_num.isdigit() else None
|
||||||
|
|
||||||
if game_name == "All Games":
|
if game_name == "All Games":
|
||||||
game_id = None
|
game_id = None
|
||||||
else:
|
else:
|
||||||
@@ -158,6 +167,7 @@ class HistoryScreen(ttk.Frame):
|
|||||||
date_from=date_from,
|
date_from=date_from,
|
||||||
date_to=date_to,
|
date_to=date_to,
|
||||||
order="DESC" if not self._sort_asc else "ASC",
|
order="DESC" if not self._sort_asc else "ASC",
|
||||||
|
number=number,
|
||||||
)
|
)
|
||||||
self._populate(rows)
|
self._populate(rows)
|
||||||
|
|
||||||
@@ -214,6 +224,7 @@ class HistoryScreen(ttk.Frame):
|
|||||||
self._from_entry.insert(0, "YYYY-MM-DD")
|
self._from_entry.insert(0, "YYYY-MM-DD")
|
||||||
self._to_entry.delete(0, "end")
|
self._to_entry.delete(0, "end")
|
||||||
self._to_entry.insert(0, "YYYY-MM-DD")
|
self._to_entry.insert(0, "YYYY-MM-DD")
|
||||||
|
self._number_var.set("")
|
||||||
self._sort_col = "date"
|
self._sort_col = "date"
|
||||||
self._sort_asc = False
|
self._sort_asc = False
|
||||||
for c in _COLUMNS:
|
for c in _COLUMNS:
|
||||||
@@ -237,6 +248,10 @@ class HistoryScreen(ttk.Frame):
|
|||||||
date_to = raw_to if raw_to and raw_to != "YYYY-MM-DD" else None
|
date_to = raw_to if raw_to and raw_to != "YYYY-MM-DD" else None
|
||||||
return game_id, date_from, date_to
|
return game_id, date_from, date_to
|
||||||
|
|
||||||
|
def _active_number(self):
|
||||||
|
raw = self._number_var.get().strip()
|
||||||
|
return int(raw) if raw.isdigit() else None
|
||||||
|
|
||||||
def _export_excel(self):
|
def _export_excel(self):
|
||||||
ensure_exports_dir()
|
ensure_exports_dir()
|
||||||
fp = filedialog.asksaveasfilename(
|
fp = filedialog.asksaveasfilename(
|
||||||
|
|||||||
Reference in New Issue
Block a user