05/23 Phase 6
This commit is contained in:
@@ -6,7 +6,8 @@
|
|||||||
"Bash(python -m pytest tests/test_history.py -v)",
|
"Bash(python -m pytest tests/test_history.py -v)",
|
||||||
"Bash(python -m pytest tests/test_analyzer.py -v)",
|
"Bash(python -m pytest tests/test_analyzer.py -v)",
|
||||||
"Bash(python -m pytest tests/test_predictor.py -v)",
|
"Bash(python -m pytest tests/test_predictor.py -v)",
|
||||||
"Bash(python -m pytest)"
|
"Bash(python -m pytest)",
|
||||||
|
"Bash(python -m pytest tests/test_settings.py -v)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -294,14 +294,16 @@ All actions are logged to console and optionally to a log file:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 🔲 Phase 6 — Settings Screen
|
### ✅ Phase 6 — Settings Screen
|
||||||
- [ ] Write `ui/settings.py`
|
- [x] Write `ui/settings.py`
|
||||||
- [ ] Game enable/disable toggles
|
- [x] Game enable/disable checkboxes (set_game_active on toggle)
|
||||||
- [ ] Fetch interval display (24hrs, read-only for now)
|
- [x] Fetch interval display (24 hours, read-only)
|
||||||
- [ ] Manual fetch button (same as toolbar)
|
- [x] Manual fetch button (shared on_fetch callback from main.py)
|
||||||
- [ ] Last fetch timestamp per source
|
- [x] Last fetch timestamp + added/skipped per source (colour-coded)
|
||||||
- [ ] DB stats (total records per game)
|
- [x] DB stats (draws per game + prediction count)
|
||||||
- [ ] Wire settings to DB config reads/writes
|
- [x] Scrollable canvas layout for future growth
|
||||||
|
- [x] Wire settings to DB reads/writes via main.py callback injection
|
||||||
|
- [x] 14 tests (138/138 total passing)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
@@ -19,6 +19,7 @@ from ui.statusbar import StatusBar
|
|||||||
from ui.history import HistoryScreen
|
from ui.history import HistoryScreen
|
||||||
from ui.analysis import AnalysisScreen
|
from ui.analysis import AnalysisScreen
|
||||||
from ui.predictor_ui import PredictorScreen
|
from ui.predictor_ui import PredictorScreen
|
||||||
|
from ui.settings import SettingsScreen
|
||||||
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
level=logging.INFO,
|
level=logging.INFO,
|
||||||
@@ -104,6 +105,8 @@ class LottoSightApp(tk.Tk):
|
|||||||
return AnalysisScreen(self._content)
|
return AnalysisScreen(self._content)
|
||||||
if name == "Predictor":
|
if name == "Predictor":
|
||||||
return PredictorScreen(self._content)
|
return PredictorScreen(self._content)
|
||||||
|
if name == "Settings":
|
||||||
|
return SettingsScreen(self._content, on_fetch=self._manual_fetch)
|
||||||
# Placeholder for screens added in later phases
|
# Placeholder for screens added in later phases
|
||||||
placeholder = ttk.Label(
|
placeholder = ttk.Label(
|
||||||
self._content, text=f"{name} — coming soon",
|
self._content, text=f"{name} — coming soon",
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
"""
|
||||||
|
tests/test_settings.py
|
||||||
|
-----------------------
|
||||||
|
Tests for the Settings screen data layer and game-toggle logic.
|
||||||
|
UI smoke tests run only when a display is available.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from db.models import (
|
||||||
|
get_all_games, get_game_by_name, get_draw_count,
|
||||||
|
set_game_active, insert_draw,
|
||||||
|
insert_fetch_log, get_last_fetch_per_source,
|
||||||
|
)
|
||||||
|
from db.database import get_db_stats
|
||||||
|
|
||||||
|
|
||||||
|
# ── Game toggle (model layer) ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_game_starts_active(tmp_db):
|
||||||
|
game = get_game_by_name("Powerball")
|
||||||
|
assert game["active"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_disable_game(tmp_db):
|
||||||
|
game = get_game_by_name("Powerball")
|
||||||
|
set_game_active(game["id"], False)
|
||||||
|
game = get_game_by_name("Powerball")
|
||||||
|
assert game["active"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_enable_game_after_disable(tmp_db):
|
||||||
|
game = get_game_by_name("Powerball")
|
||||||
|
set_game_active(game["id"], False)
|
||||||
|
set_game_active(game["id"], True)
|
||||||
|
game = get_game_by_name("Powerball")
|
||||||
|
assert game["active"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_disabled_game_excluded_from_active_only(tmp_db):
|
||||||
|
game = get_game_by_name("Powerball")
|
||||||
|
set_game_active(game["id"], False)
|
||||||
|
active = get_all_games(active_only=True)
|
||||||
|
names = [g["name"] for g in active]
|
||||||
|
assert "Powerball" not in names
|
||||||
|
assert "Mega Millions" in names
|
||||||
|
|
||||||
|
|
||||||
|
# ── DB stats ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_db_stats_zero_on_fresh_db(tmp_db):
|
||||||
|
stats = get_db_stats()
|
||||||
|
assert all(v == 0 for v in stats.values())
|
||||||
|
|
||||||
|
|
||||||
|
def test_db_stats_reflects_inserts(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", [5, 10, 20, 30, 40], bonus=15)
|
||||||
|
stats = get_db_stats()
|
||||||
|
assert stats["Powerball"] == 2
|
||||||
|
assert stats["Mega Millions"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_draw_count_matches_db_stats(tmp_db):
|
||||||
|
pb = get_game_by_name("Powerball")
|
||||||
|
insert_draw(pb["id"], "2024-01-01", [1, 2, 3, 4, 5], bonus=7)
|
||||||
|
assert get_draw_count(pb["id"]) == get_db_stats()["Powerball"]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Last fetch per source ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_last_fetch_empty_on_fresh_db(tmp_db):
|
||||||
|
assert get_last_fetch_per_source() == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_last_fetch_shows_all_sources(tmp_db):
|
||||||
|
for src in ("powerball_ny", "megamillions_ny", "megamillions_tx"):
|
||||||
|
insert_fetch_log(src, added=2, skipped=1, status="success")
|
||||||
|
result = get_last_fetch_per_source()
|
||||||
|
assert set(result.keys()) == {"powerball_ny", "megamillions_ny", "megamillions_tx"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_last_fetch_returns_most_recent(tmp_db):
|
||||||
|
insert_fetch_log("powerball_ny", added=1, skipped=0, status="success", message=None)
|
||||||
|
insert_fetch_log("powerball_ny", added=5, skipped=2, status="success", message=None)
|
||||||
|
result = get_last_fetch_per_source()
|
||||||
|
assert result["powerball_ny"]["added"] == 5
|
||||||
|
|
||||||
|
|
||||||
|
def test_last_fetch_error_status_preserved(tmp_db):
|
||||||
|
insert_fetch_log("powerball_ny", added=0, skipped=0, status="error", message="timeout")
|
||||||
|
result = get_last_fetch_per_source()
|
||||||
|
assert result["powerball_ny"]["status"] == "error"
|
||||||
|
|
||||||
|
|
||||||
|
# ── 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_screen_instantiates(tmp_db):
|
||||||
|
import tkinter as tk
|
||||||
|
from ui.settings import SettingsScreen
|
||||||
|
|
||||||
|
root = tk.Tk()
|
||||||
|
root.withdraw()
|
||||||
|
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_game_toggle_updates_db(tmp_db):
|
||||||
|
import tkinter as tk
|
||||||
|
from ui.settings import SettingsScreen
|
||||||
|
|
||||||
|
root = tk.Tk()
|
||||||
|
root.withdraw()
|
||||||
|
try:
|
||||||
|
screen = SettingsScreen(root)
|
||||||
|
screen.refresh()
|
||||||
|
|
||||||
|
# Simulate unchecking Powerball via its BooleanVar
|
||||||
|
pb = get_game_by_name("Powerball")
|
||||||
|
var = screen._game_vars.get(pb["id"])
|
||||||
|
assert var is not None
|
||||||
|
var.set(False)
|
||||||
|
screen._toggle_game(pb["id"], var)
|
||||||
|
|
||||||
|
pb_after = get_game_by_name("Powerball")
|
||||||
|
assert pb_after["active"] == 0
|
||||||
|
finally:
|
||||||
|
root.destroy()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(not _has_display(), reason="no display available")
|
||||||
|
def test_settings_shows_fetch_history(tmp_db):
|
||||||
|
import tkinter as tk
|
||||||
|
from ui.settings import SettingsScreen
|
||||||
|
|
||||||
|
insert_fetch_log("powerball_ny", added=3, skipped=1, status="success")
|
||||||
|
root = tk.Tk()
|
||||||
|
root.withdraw()
|
||||||
|
try:
|
||||||
|
screen = SettingsScreen(root)
|
||||||
|
screen.refresh()
|
||||||
|
# No assertion on widget text — just verify no exceptions during refresh
|
||||||
|
assert screen.winfo_exists()
|
||||||
|
finally:
|
||||||
|
root.destroy()
|
||||||
+192
@@ -0,0 +1,192 @@
|
|||||||
|
"""
|
||||||
|
ui/settings.py
|
||||||
|
--------------
|
||||||
|
Settings screen: game toggles, data-source fetch history,
|
||||||
|
manual fetch button, and database statistics.
|
||||||
|
on_fetch: callable injected by main.py to trigger the shared fetch thread.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import tkinter as tk
|
||||||
|
from tkinter import ttk
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_SOURCE_NAMES = {
|
||||||
|
"powerball_ny": "Powerball (NY)",
|
||||||
|
"megamillions_ny": "Mega Millions (NY)",
|
||||||
|
"megamillions_tx": "Mega Millions (TX)",
|
||||||
|
}
|
||||||
|
_ALL_SOURCES = ["powerball_ny", "megamillions_ny", "megamillions_tx"]
|
||||||
|
|
||||||
|
_HEADER_FONT = ("TkDefaultFont", 10, "bold")
|
||||||
|
|
||||||
|
|
||||||
|
class SettingsScreen(ttk.Frame):
|
||||||
|
def __init__(self, parent, on_fetch=None, **kwargs):
|
||||||
|
super().__init__(parent, **kwargs)
|
||||||
|
self._on_fetch = on_fetch # injected by main.py
|
||||||
|
self._game_vars: dict[int, tk.BooleanVar] = {}
|
||||||
|
|
||||||
|
self._build_ui()
|
||||||
|
|
||||||
|
# ── Layout ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _build_ui(self):
|
||||||
|
# Scrollable container
|
||||||
|
canvas = tk.Canvas(self, highlightthickness=0)
|
||||||
|
vsb = ttk.Scrollbar(self, orient="vertical", command=canvas.yview)
|
||||||
|
canvas.configure(yscrollcommand=vsb.set)
|
||||||
|
vsb.pack(side="right", fill="y")
|
||||||
|
canvas.pack(side="left", fill="both", expand=True)
|
||||||
|
|
||||||
|
inner = ttk.Frame(canvas, padding=(24, 18, 24, 18))
|
||||||
|
win = canvas.create_window((0, 0), window=inner, anchor="nw")
|
||||||
|
|
||||||
|
def _resize(event=None):
|
||||||
|
canvas.configure(scrollregion=canvas.bbox("all"))
|
||||||
|
canvas.itemconfig(win, width=canvas.winfo_width())
|
||||||
|
|
||||||
|
inner.bind("<Configure>", lambda e: _resize())
|
||||||
|
canvas.bind("<Configure>", lambda e: _resize())
|
||||||
|
canvas.bind("<Enter>",
|
||||||
|
lambda e: canvas.bind_all("<MouseWheel>",
|
||||||
|
lambda ev: canvas.yview_scroll(-1 * (ev.delta // 120), "units")))
|
||||||
|
canvas.bind("<Leave>",
|
||||||
|
lambda e: canvas.unbind_all("<MouseWheel>"))
|
||||||
|
|
||||||
|
self._inner = inner
|
||||||
|
self._build_sections()
|
||||||
|
|
||||||
|
def _section(self, title):
|
||||||
|
"""Create and return a new section frame appended to the inner frame."""
|
||||||
|
frame = ttk.Frame(self._inner)
|
||||||
|
frame.pack(fill="x", pady=(0, 22))
|
||||||
|
ttk.Label(frame, text=title, font=_HEADER_FONT).pack(anchor="w")
|
||||||
|
ttk.Separator(frame, orient="horizontal").pack(fill="x", pady=(4, 10))
|
||||||
|
body = ttk.Frame(frame)
|
||||||
|
body.pack(fill="x")
|
||||||
|
return body
|
||||||
|
|
||||||
|
def _build_sections(self):
|
||||||
|
self._games_body = self._section("Games")
|
||||||
|
self._sources_body = self._section("Data Sources")
|
||||||
|
self._fetch_body = self._build_fetch_section()
|
||||||
|
self._db_body = self._section("Database")
|
||||||
|
|
||||||
|
def _build_fetch_section(self):
|
||||||
|
body = self._section("Fetch Schedule")
|
||||||
|
|
||||||
|
row1 = ttk.Frame(body)
|
||||||
|
row1.pack(fill="x", pady=(0, 8))
|
||||||
|
ttk.Label(row1, text="Auto-fetch interval:", width=22, anchor="w").pack(side="left")
|
||||||
|
ttk.Label(row1, text="24 hours (fixed)", foreground="#666666").pack(side="left")
|
||||||
|
|
||||||
|
row2 = ttk.Frame(body)
|
||||||
|
row2.pack(fill="x")
|
||||||
|
self._fetch_btn = ttk.Button(row2, text="Fetch Now", command=self._do_fetch)
|
||||||
|
self._fetch_btn.pack(side="left")
|
||||||
|
self._fetch_msg_var = tk.StringVar()
|
||||||
|
ttk.Label(row2, textvariable=self._fetch_msg_var,
|
||||||
|
foreground="#555555").pack(side="left", padx=(12, 0))
|
||||||
|
|
||||||
|
return body
|
||||||
|
|
||||||
|
# ── Data loading ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def refresh(self):
|
||||||
|
self._fetch_btn.config(state="normal")
|
||||||
|
self._fetch_msg_var.set("")
|
||||||
|
self._refresh_games()
|
||||||
|
self._refresh_sources()
|
||||||
|
self._refresh_db()
|
||||||
|
|
||||||
|
def _refresh_games(self):
|
||||||
|
for w in self._games_body.winfo_children():
|
||||||
|
w.destroy()
|
||||||
|
self._game_vars.clear()
|
||||||
|
|
||||||
|
games = get_all_games()
|
||||||
|
for game in games:
|
||||||
|
row = ttk.Frame(self._games_body)
|
||||||
|
row.pack(fill="x", pady=3)
|
||||||
|
|
||||||
|
var = tk.BooleanVar(value=bool(game["active"]))
|
||||||
|
self._game_vars[game["id"]] = var
|
||||||
|
|
||||||
|
ttk.Checkbutton(
|
||||||
|
row, text=game["name"], variable=var,
|
||||||
|
command=lambda gid=game["id"], v=var: self._toggle_game(gid, v),
|
||||||
|
).pack(side="left")
|
||||||
|
|
||||||
|
count = get_draw_count(game["id"])
|
||||||
|
ttk.Label(
|
||||||
|
row, text=f"{count:,} draws",
|
||||||
|
foreground="#777777",
|
||||||
|
).pack(side="left", padx=(16, 0))
|
||||||
|
|
||||||
|
def _refresh_sources(self):
|
||||||
|
for w in self._sources_body.winfo_children():
|
||||||
|
w.destroy()
|
||||||
|
|
||||||
|
last = get_last_fetch_per_source() # {source: Row}
|
||||||
|
|
||||||
|
for source in _ALL_SOURCES:
|
||||||
|
row = ttk.Frame(self._sources_body)
|
||||||
|
row.pack(fill="x", pady=3)
|
||||||
|
|
||||||
|
ttk.Label(
|
||||||
|
row, text=_SOURCE_NAMES.get(source, source),
|
||||||
|
width=22, anchor="w",
|
||||||
|
).pack(side="left")
|
||||||
|
|
||||||
|
log = last.get(source)
|
||||||
|
if log:
|
||||||
|
ts = log["fetched_at"][:16].replace("T", " ")
|
||||||
|
status = log["status"]
|
||||||
|
added = log["added"]
|
||||||
|
skipped= log["skipped"]
|
||||||
|
color = "#27ae60" if status == "success" else "#e74c3c"
|
||||||
|
text = f"Last: {ts} +{added} added, {skipped} skipped"
|
||||||
|
ttk.Label(row, text=text, foreground=color).pack(side="left")
|
||||||
|
else:
|
||||||
|
ttk.Label(row, text="Never fetched", foreground="#aaaaaa").pack(side="left")
|
||||||
|
|
||||||
|
def _refresh_db(self):
|
||||||
|
for w in self._db_body.winfo_children():
|
||||||
|
w.destroy()
|
||||||
|
|
||||||
|
stats = get_db_stats() # {game_name: draw_count}
|
||||||
|
for name, count in sorted(stats.items()):
|
||||||
|
row = ttk.Frame(self._db_body)
|
||||||
|
row.pack(fill="x", pady=2)
|
||||||
|
ttk.Label(row, text=f"{name}:", width=18, anchor="w").pack(side="left")
|
||||||
|
ttk.Label(row, text=f"{count:,} draws", foreground="#555555").pack(side="left")
|
||||||
|
|
||||||
|
pred_count = len(get_predictions(limit=100_000))
|
||||||
|
row = ttk.Frame(self._db_body)
|
||||||
|
row.pack(fill="x", pady=2)
|
||||||
|
ttk.Label(row, text="Predictions:", width=18, anchor="w").pack(side="left")
|
||||||
|
ttk.Label(row, text=str(pred_count), foreground="#555555").pack(side="left")
|
||||||
|
|
||||||
|
# ── Actions ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _toggle_game(self, game_id: int, var: tk.BooleanVar):
|
||||||
|
active = var.get()
|
||||||
|
set_game_active(game_id, active)
|
||||||
|
logger.info("[SETTINGS] Game id=%d set active=%s", game_id, active)
|
||||||
|
self._refresh_games()
|
||||||
|
|
||||||
|
def _do_fetch(self):
|
||||||
|
if self._on_fetch:
|
||||||
|
self._fetch_btn.config(state="disabled")
|
||||||
|
self._fetch_msg_var.set("Fetching…")
|
||||||
|
self._on_fetch()
|
||||||
|
else:
|
||||||
|
self._fetch_msg_var.set("Fetch not available.")
|
||||||
Reference in New Issue
Block a user