diff --git a/.claude/settings.local.json b/.claude/settings.local.json index eb3d5e2..92ddad2 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -6,7 +6,8 @@ "Bash(python -m pytest tests/test_history.py -v)", "Bash(python -m pytest tests/test_analyzer.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)" ] } } diff --git a/CLAUDE.md b/CLAUDE.md index 828c009..9475a11 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -294,14 +294,16 @@ All actions are logged to console and optionally to a log file: --- -### πŸ”² Phase 6 β€” Settings Screen -- [ ] Write `ui/settings.py` - - [ ] Game enable/disable toggles - - [ ] Fetch interval display (24hrs, read-only for now) - - [ ] Manual fetch button (same as toolbar) - - [ ] Last fetch timestamp per source - - [ ] DB stats (total records per game) -- [ ] Wire settings to DB config reads/writes +### βœ… Phase 6 β€” Settings Screen +- [x] Write `ui/settings.py` + - [x] Game enable/disable checkboxes (set_game_active on toggle) + - [x] Fetch interval display (24 hours, read-only) + - [x] Manual fetch button (shared on_fetch callback from main.py) + - [x] Last fetch timestamp + added/skipped per source (colour-coded) + - [x] DB stats (draws per game + prediction count) + - [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) --- diff --git a/data/lottosight.db b/data/lottosight.db index 9089fa6..f3f3ea1 100644 Binary files a/data/lottosight.db and b/data/lottosight.db differ diff --git a/main.py b/main.py index f7c0040..ec4133d 100644 --- a/main.py +++ b/main.py @@ -19,6 +19,7 @@ from ui.statusbar import StatusBar from ui.history import HistoryScreen from ui.analysis import AnalysisScreen from ui.predictor_ui import PredictorScreen +from ui.settings import SettingsScreen logging.basicConfig( level=logging.INFO, @@ -104,6 +105,8 @@ class LottoSightApp(tk.Tk): return AnalysisScreen(self._content) if name == "Predictor": return PredictorScreen(self._content) + if name == "Settings": + return SettingsScreen(self._content, on_fetch=self._manual_fetch) # Placeholder for screens added in later phases placeholder = ttk.Label( self._content, text=f"{name} β€” coming soon", diff --git a/tests/test_settings.py b/tests/test_settings.py new file mode 100644 index 0000000..97b93f9 --- /dev/null +++ b/tests/test_settings.py @@ -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() diff --git a/ui/settings.py b/ui/settings.py new file mode 100644 index 0000000..2e663d6 --- /dev/null +++ b/ui/settings.py @@ -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("", lambda e: _resize()) + canvas.bind("", lambda e: _resize()) + canvas.bind("", + lambda e: canvas.bind_all("", + lambda ev: canvas.yview_scroll(-1 * (ev.delta // 120), "units"))) + canvas.bind("", + lambda e: canvas.unbind_all("")) + + 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.")