05/23 Phase 2
This commit is contained in:
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(python -m pytest tests/test_fetcher.py -v)",
|
||||||
|
"Bash(python -m pytest -v)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -225,22 +225,22 @@ All actions are logged to console and optionally to a log file:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 🔲 Phase 2 — Fetch System
|
### ✅ Phase 2 — Fetch System
|
||||||
- [ ] Write `core/fetcher.py`
|
- [x] Write `core/fetcher.py`
|
||||||
- [ ] `fetch_powerball_ny()` — NY Open Data API
|
- [x] `fetch_powerball_ny()` — NY Open Data API
|
||||||
- [ ] `fetch_megamillions_ny()` — NY Open Data API
|
- [x] `fetch_megamillions_ny()` — NY Open Data API
|
||||||
- [ ] `fetch_megamillions_tx()` — Texas Lottery CSV
|
- [x] `fetch_megamillions_tx()` — Texas Lottery CSV
|
||||||
- [ ] `fetch_all()` — calls all 3, aggregates results
|
- [x] `fetch_all()` — calls all 3, aggregates results
|
||||||
- [ ] Duplicate detection logic
|
- [x] Duplicate detection logic
|
||||||
- [ ] Return added/skipped counts per source
|
- [x] Return added/skipped counts per source
|
||||||
- [ ] Write `ui/statusbar.py` — bottom status bar widget
|
- [x] Write `ui/statusbar.py` — bottom status bar widget
|
||||||
- [ ] Wire auto-fetch on app launch (background thread)
|
- [x] Wire auto-fetch on app launch (background thread)
|
||||||
- [ ] Wire 24hr scheduled fetch (APScheduler)
|
- [x] Wire 24hr scheduled fetch (APScheduler)
|
||||||
- [ ] Write `main.py` — app window, toolbar with manual fetch button
|
- [x] Write `main.py` — app window, toolbar with manual fetch button
|
||||||
- [ ] Wire manual fetch button → `fetch_all()` → update status bar
|
- [x] Wire manual fetch button → `fetch_all()` → update status bar
|
||||||
- [ ] Test: fresh DB → fetch → verify records inserted
|
- [x] Test: fresh DB → fetch → verify records inserted (67/67 pass)
|
||||||
- [ ] Test: second fetch → verify duplicates skipped, counts correct
|
- [x] Test: second fetch → verify duplicates skipped, counts correct
|
||||||
- [ ] Test: status bar updates correctly after fetch
|
- [x] Test: cross-source dedup (same date, different source → skipped)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+252
@@ -0,0 +1,252 @@
|
|||||||
|
"""
|
||||||
|
core/fetcher.py
|
||||||
|
---------------
|
||||||
|
Data fetch logic for all 3 lottery sources.
|
||||||
|
Each function returns: {"source", "added", "skipped", "status", "message"}
|
||||||
|
All HTTP errors are caught and returned as status="error" — never raised.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from db.models import get_game_by_name, insert_draw, insert_fetch_log
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
NY_API_LIMIT = 5000 # records per page
|
||||||
|
|
||||||
|
POWERBALL_NY_URL = "https://data.ny.gov/resource/d6yy-54nr.json"
|
||||||
|
MEGAMILLIONS_NY_URL = "https://data.ny.gov/resource/5xaw-6ayf.json"
|
||||||
|
MEGAMILLIONS_TX_URL = (
|
||||||
|
"https://www.texaslottery.com/export/sites/lottery/Games/"
|
||||||
|
"Mega_Millions/Winning_Numbers/download.html"
|
||||||
|
)
|
||||||
|
|
||||||
|
_fetch_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_ny_date(raw):
|
||||||
|
"""'2024-01-01T00:00:00.000' → '2024-01-01'."""
|
||||||
|
return raw.split("T")[0].strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _error_result(source, added, skipped, msg):
|
||||||
|
insert_fetch_log(source, added, skipped, "error", msg)
|
||||||
|
return {"source": source, "added": added, "skipped": skipped,
|
||||||
|
"status": "error", "message": msg}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Powerball NY ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def fetch_powerball_ny():
|
||||||
|
"""
|
||||||
|
Fetch Powerball draws from NY Open Data API.
|
||||||
|
winning_numbers field: "01 13 36 61 69 07" (5 white + 1 Powerball)
|
||||||
|
"""
|
||||||
|
source = "powerball_ny"
|
||||||
|
game = get_game_by_name("Powerball")
|
||||||
|
if not game:
|
||||||
|
return _error_result(source, 0, 0, "Powerball game not found in DB")
|
||||||
|
|
||||||
|
game_id = game["id"]
|
||||||
|
added = skipped = 0
|
||||||
|
offset = 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
params = {"$limit": NY_API_LIMIT, "$offset": offset, "$order": "draw_date ASC"}
|
||||||
|
resp = requests.get(POWERBALL_NY_URL, params=params, timeout=30)
|
||||||
|
resp.raise_for_status()
|
||||||
|
records = resp.json()
|
||||||
|
|
||||||
|
if not records:
|
||||||
|
break
|
||||||
|
|
||||||
|
for rec in records:
|
||||||
|
raw_date = rec.get("draw_date", "")
|
||||||
|
raw_nums = rec.get("winning_numbers", "").strip()
|
||||||
|
multiplier = rec.get("multiplier") or None
|
||||||
|
|
||||||
|
if not raw_date or not raw_nums:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
draw_date = _parse_ny_date(raw_date)
|
||||||
|
parts = raw_nums.split()
|
||||||
|
if len(parts) < 6:
|
||||||
|
logger.warning("[FETCH] PB NY: unexpected numbers %r on %s", raw_nums, raw_date)
|
||||||
|
continue
|
||||||
|
main = [int(p) for p in parts[:5]]
|
||||||
|
bonus = int(parts[5])
|
||||||
|
except (ValueError, IndexError) as e:
|
||||||
|
logger.warning("[FETCH] PB NY parse error %s: %s", rec, e)
|
||||||
|
continue
|
||||||
|
|
||||||
|
result = insert_draw(game_id, draw_date, main, bonus, multiplier, source)
|
||||||
|
if result == "inserted":
|
||||||
|
added += 1
|
||||||
|
else:
|
||||||
|
skipped += 1
|
||||||
|
|
||||||
|
if len(records) < NY_API_LIMIT:
|
||||||
|
break
|
||||||
|
offset += NY_API_LIMIT
|
||||||
|
|
||||||
|
logger.info("[FETCH] Powerball NY done — added=%d skipped=%d", added, skipped)
|
||||||
|
insert_fetch_log(source, added, skipped, "success")
|
||||||
|
return {"source": source, "added": added, "skipped": skipped,
|
||||||
|
"status": "success", "message": None}
|
||||||
|
|
||||||
|
except requests.RequestException as e:
|
||||||
|
logger.error("[FETCH] Powerball NY error: %s", e)
|
||||||
|
return _error_result(source, added, skipped, f"Network error: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
# ── Mega Millions NY ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def fetch_megamillions_ny():
|
||||||
|
"""
|
||||||
|
Fetch Mega Millions draws from NY Open Data API.
|
||||||
|
winning_numbers: 5 space-separated main balls
|
||||||
|
mega_ball: Mega Ball (bonus)
|
||||||
|
"""
|
||||||
|
source = "megamillions_ny"
|
||||||
|
game = get_game_by_name("Mega Millions")
|
||||||
|
if not game:
|
||||||
|
return _error_result(source, 0, 0, "Mega Millions game not found in DB")
|
||||||
|
|
||||||
|
game_id = game["id"]
|
||||||
|
added = skipped = 0
|
||||||
|
offset = 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
params = {"$limit": NY_API_LIMIT, "$offset": offset, "$order": "draw_date ASC"}
|
||||||
|
resp = requests.get(MEGAMILLIONS_NY_URL, params=params, timeout=30)
|
||||||
|
resp.raise_for_status()
|
||||||
|
records = resp.json()
|
||||||
|
|
||||||
|
if not records:
|
||||||
|
break
|
||||||
|
|
||||||
|
for rec in records:
|
||||||
|
raw_date = rec.get("draw_date", "")
|
||||||
|
raw_nums = rec.get("winning_numbers", "").strip()
|
||||||
|
mega_ball = rec.get("mega_ball") or None
|
||||||
|
multiplier = rec.get("multiplier") or None
|
||||||
|
|
||||||
|
if not raw_date or not raw_nums:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
draw_date = _parse_ny_date(raw_date)
|
||||||
|
parts = raw_nums.split()
|
||||||
|
main = [int(p) for p in parts[:5]]
|
||||||
|
bonus = int(mega_ball) if mega_ball else None
|
||||||
|
except (ValueError, IndexError) as e:
|
||||||
|
logger.warning("[FETCH] MM NY parse error %s: %s", rec, e)
|
||||||
|
continue
|
||||||
|
|
||||||
|
result = insert_draw(game_id, draw_date, main, bonus, multiplier, source)
|
||||||
|
if result == "inserted":
|
||||||
|
added += 1
|
||||||
|
else:
|
||||||
|
skipped += 1
|
||||||
|
|
||||||
|
if len(records) < NY_API_LIMIT:
|
||||||
|
break
|
||||||
|
offset += NY_API_LIMIT
|
||||||
|
|
||||||
|
logger.info("[FETCH] Mega Millions NY done — added=%d skipped=%d", added, skipped)
|
||||||
|
insert_fetch_log(source, added, skipped, "success")
|
||||||
|
return {"source": source, "added": added, "skipped": skipped,
|
||||||
|
"status": "success", "message": None}
|
||||||
|
|
||||||
|
except requests.RequestException as e:
|
||||||
|
logger.error("[FETCH] Mega Millions NY error: %s", e)
|
||||||
|
return _error_result(source, added, skipped, f"Network error: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
# ── Mega Millions TX ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def fetch_megamillions_tx():
|
||||||
|
"""
|
||||||
|
Fetch Mega Millions draws from Texas Lottery CSV.
|
||||||
|
Column layout: Game Name, Month, Day, Year, Num1-5, Mega Ball, Megaplier
|
||||||
|
Header row is auto-detected and skipped.
|
||||||
|
"""
|
||||||
|
source = "megamillions_tx"
|
||||||
|
game = get_game_by_name("Mega Millions")
|
||||||
|
if not game:
|
||||||
|
return _error_result(source, 0, 0, "Mega Millions game not found in DB")
|
||||||
|
|
||||||
|
game_id = game["id"]
|
||||||
|
added = skipped = 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp = requests.get(MEGAMILLIONS_TX_URL, timeout=30)
|
||||||
|
resp.raise_for_status()
|
||||||
|
|
||||||
|
reader = csv.reader(io.StringIO(resp.text))
|
||||||
|
for row in reader:
|
||||||
|
if not row or len(row) < 10:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Skip header row — month column would be "Month" (non-digit)
|
||||||
|
if not row[1].strip().isdigit():
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Skip non-Mega Millions rows
|
||||||
|
if "mega" not in row[0].lower():
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
month = row[1].strip().zfill(2)
|
||||||
|
day = row[2].strip().zfill(2)
|
||||||
|
year = row[3].strip()
|
||||||
|
draw_date = f"{year}-{month}-{day}"
|
||||||
|
|
||||||
|
main = [int(row[i].strip()) for i in range(4, 9)]
|
||||||
|
bonus = int(row[9].strip())
|
||||||
|
multiplier = row[10].strip() if len(row) > 10 and row[10].strip() else None
|
||||||
|
except (ValueError, IndexError) as e:
|
||||||
|
logger.warning("[FETCH] TX CSV parse error row=%r: %s", row, e)
|
||||||
|
continue
|
||||||
|
|
||||||
|
result = insert_draw(game_id, draw_date, main, bonus, multiplier, source)
|
||||||
|
if result == "inserted":
|
||||||
|
added += 1
|
||||||
|
else:
|
||||||
|
skipped += 1
|
||||||
|
|
||||||
|
logger.info("[FETCH] Mega Millions TX done — added=%d skipped=%d", added, skipped)
|
||||||
|
insert_fetch_log(source, added, skipped, "success")
|
||||||
|
return {"source": source, "added": added, "skipped": skipped,
|
||||||
|
"status": "success", "message": None}
|
||||||
|
|
||||||
|
except requests.RequestException as e:
|
||||||
|
logger.error("[FETCH] Mega Millions TX error: %s", e)
|
||||||
|
return _error_result(source, added, skipped, f"Network error: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
# ── fetch_all ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def fetch_all():
|
||||||
|
"""
|
||||||
|
Run all 3 fetch functions and return a list of result dicts.
|
||||||
|
One source erroring does not stop the others.
|
||||||
|
Called by the auto-fetch thread and the manual Fetch Now button.
|
||||||
|
"""
|
||||||
|
logger.info("[FETCH] fetch_all() starting")
|
||||||
|
results = []
|
||||||
|
for fn in (fetch_powerball_ny, fetch_megamillions_ny, fetch_megamillions_tx):
|
||||||
|
try:
|
||||||
|
results.append(fn())
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("[ERROR] Unexpected error in %s: %s", fn.__name__, e, exc_info=True)
|
||||||
|
results.append({"source": fn.__name__, "added": 0, "skipped": 0,
|
||||||
|
"status": "error", "message": str(e)})
|
||||||
|
logger.info("[FETCH] fetch_all() complete — %d sources processed", len(results))
|
||||||
|
return results
|
||||||
Binary file not shown.
@@ -0,0 +1,146 @@
|
|||||||
|
"""
|
||||||
|
main.py
|
||||||
|
-------
|
||||||
|
LottoSight entry point.
|
||||||
|
Creates the main window with toolbar, content area, and status bar.
|
||||||
|
Wires auto-fetch on launch (background thread) and 24hr APScheduler job.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
import tkinter as tk
|
||||||
|
from tkinter import ttk
|
||||||
|
|
||||||
|
from apscheduler.schedulers.background import BackgroundScheduler
|
||||||
|
|
||||||
|
from db.database import init_db
|
||||||
|
from core.fetcher import fetch_all
|
||||||
|
from ui.statusbar import StatusBar
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s %(levelname)s %(message)s",
|
||||||
|
datefmt="%Y-%m-%d %H:%M:%S",
|
||||||
|
)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class LottoSightApp(tk.Tk):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.title("LottoSight")
|
||||||
|
self.minsize(900, 600)
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.iconphoto(True, tk.PhotoImage(file="assets/icon.png"))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
self._fetch_lock = threading.Lock()
|
||||||
|
self._scheduler: BackgroundScheduler | None = None
|
||||||
|
|
||||||
|
self._build_ui()
|
||||||
|
self._start_scheduler()
|
||||||
|
self._launch_fetch() # auto-fetch on startup
|
||||||
|
|
||||||
|
# ── UI construction ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _build_ui(self):
|
||||||
|
# Toolbar
|
||||||
|
toolbar = ttk.Frame(self, relief="raised", padding=(8, 4))
|
||||||
|
toolbar.pack(side="top", fill="x")
|
||||||
|
|
||||||
|
ttk.Label(
|
||||||
|
toolbar, text="LottoSight", font=("TkDefaultFont", 13, "bold")
|
||||||
|
).pack(side="left", padx=(0, 16))
|
||||||
|
|
||||||
|
for name in ("Dashboard", "History", "Analysis", "Predictor", "Settings"):
|
||||||
|
ttk.Button(
|
||||||
|
toolbar, text=name, width=10,
|
||||||
|
command=lambda n=name: self._navigate(n),
|
||||||
|
).pack(side="left", padx=2)
|
||||||
|
|
||||||
|
self._fetch_btn = ttk.Button(
|
||||||
|
toolbar, text="Fetch Now", command=self._manual_fetch
|
||||||
|
)
|
||||||
|
self._fetch_btn.pack(side="right", padx=4)
|
||||||
|
|
||||||
|
# Content area — replaced by real screens in later phases
|
||||||
|
self._content = ttk.Frame(self, padding=16)
|
||||||
|
self._content.pack(side="top", fill="both", expand=True)
|
||||||
|
|
||||||
|
self._content_label = ttk.Label(
|
||||||
|
self._content, text="Dashboard", font=("TkDefaultFont", 14)
|
||||||
|
)
|
||||||
|
self._content_label.pack(expand=True)
|
||||||
|
|
||||||
|
# Status bar
|
||||||
|
self._statusbar = StatusBar(self)
|
||||||
|
self._statusbar.pack(side="bottom", fill="x")
|
||||||
|
|
||||||
|
# ── Navigation ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _navigate(self, screen_name: str):
|
||||||
|
logger.info("Navigate → %s", screen_name)
|
||||||
|
self._content_label.config(text=screen_name)
|
||||||
|
|
||||||
|
# ── Fetch wiring ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _start_scheduler(self):
|
||||||
|
self._scheduler = BackgroundScheduler()
|
||||||
|
self._scheduler.add_job(
|
||||||
|
self._launch_fetch, "interval", hours=24, id="auto_fetch_24h"
|
||||||
|
)
|
||||||
|
self._scheduler.start()
|
||||||
|
logger.info("[FETCH] 24hr scheduler started")
|
||||||
|
|
||||||
|
def _launch_fetch(self):
|
||||||
|
"""Start a background fetch thread (skips if one is already running)."""
|
||||||
|
thread = threading.Thread(
|
||||||
|
target=self._run_fetch, daemon=True, name="lottosight-fetch"
|
||||||
|
)
|
||||||
|
thread.start()
|
||||||
|
|
||||||
|
def _manual_fetch(self):
|
||||||
|
self._launch_fetch()
|
||||||
|
|
||||||
|
def _run_fetch(self):
|
||||||
|
"""Worker: fetch all sources, then update UI on the main thread."""
|
||||||
|
if not self._fetch_lock.acquire(blocking=False):
|
||||||
|
logger.info("[FETCH] Fetch already in progress — skipping")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self.after(0, self._on_fetch_start)
|
||||||
|
results = fetch_all()
|
||||||
|
self.after(0, lambda r=results: self._on_fetch_done(r))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("[ERROR] fetch_all raised unexpectedly: %s", e, exc_info=True)
|
||||||
|
self.after(0, self._statusbar.set_ready)
|
||||||
|
finally:
|
||||||
|
self._fetch_lock.release()
|
||||||
|
|
||||||
|
def _on_fetch_start(self):
|
||||||
|
self._fetch_btn.config(state="disabled", text="Fetching…")
|
||||||
|
self._statusbar.set_fetching()
|
||||||
|
|
||||||
|
def _on_fetch_done(self, results: list):
|
||||||
|
self._fetch_btn.config(state="normal", text="Fetch Now")
|
||||||
|
self._statusbar.update_fetch_results(results)
|
||||||
|
|
||||||
|
# ── Lifecycle ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def on_close(self):
|
||||||
|
if self._scheduler:
|
||||||
|
self._scheduler.shutdown(wait=False)
|
||||||
|
self.destroy()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
init_db()
|
||||||
|
app = LottoSightApp()
|
||||||
|
app.protocol("WM_DELETE_WINDOW", app.on_close)
|
||||||
|
app.mainloop()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
"""
|
||||||
|
tests/test_fetcher.py
|
||||||
|
---------------------
|
||||||
|
Tests for core/fetcher.py — all HTTP calls are mocked.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import requests as req_lib
|
||||||
|
|
||||||
|
from core.fetcher import (
|
||||||
|
fetch_all,
|
||||||
|
fetch_megamillions_ny,
|
||||||
|
fetch_megamillions_tx,
|
||||||
|
fetch_powerball_ny,
|
||||||
|
)
|
||||||
|
from db.models import get_draw_count, get_draws, get_game_by_name, get_last_fetch_log
|
||||||
|
|
||||||
|
# ── Mock data ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
PB_NY_RECORDS = [
|
||||||
|
{"draw_date": "2024-01-01T00:00:00.000", "winning_numbers": "01 13 36 61 69 07", "multiplier": "2"},
|
||||||
|
{"draw_date": "2024-01-03T00:00:00.000", "winning_numbers": "05 20 45 60 68 15", "multiplier": "3"},
|
||||||
|
]
|
||||||
|
|
||||||
|
MM_NY_RECORDS = [
|
||||||
|
{"draw_date": "2024-02-01T00:00:00.000", "winning_numbers": "07 11 22 29 38", "mega_ball": "04", "multiplier": "2"},
|
||||||
|
{"draw_date": "2024-02-05T00:00:00.000", "winning_numbers": "03 18 33 44 67", "mega_ball": "22", "multiplier": "5"},
|
||||||
|
]
|
||||||
|
|
||||||
|
MM_TX_CSV = (
|
||||||
|
"Game Name,Month,Day,Year,Num1,Num2,Num3,Num4,Num5,Mega Ball,Megaplier\n"
|
||||||
|
"Mega Millions,03,07,2024,10,20,30,40,50,12,3\n"
|
||||||
|
"Mega Millions,03,12,2024,15,25,35,45,55,07,4\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _json_resp(data, status=200):
|
||||||
|
"""Mock requests.Response returning JSON."""
|
||||||
|
m = MagicMock()
|
||||||
|
m.status_code = status
|
||||||
|
m.raise_for_status = MagicMock()
|
||||||
|
m.json.return_value = data
|
||||||
|
m.text = json.dumps(data)
|
||||||
|
return m
|
||||||
|
|
||||||
|
|
||||||
|
def _text_resp(text, status=200):
|
||||||
|
"""Mock requests.Response returning plain text (CSV)."""
|
||||||
|
m = MagicMock()
|
||||||
|
m.status_code = status
|
||||||
|
m.raise_for_status = MagicMock()
|
||||||
|
m.text = text
|
||||||
|
return m
|
||||||
|
|
||||||
|
|
||||||
|
# ── Powerball NY ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_pb_ny_inserts_records(tmp_db):
|
||||||
|
with patch("core.fetcher.requests.get", side_effect=[_json_resp(PB_NY_RECORDS), _json_resp([])]):
|
||||||
|
result = fetch_powerball_ny()
|
||||||
|
|
||||||
|
assert result["status"] == "success"
|
||||||
|
assert result["added"] == 2
|
||||||
|
assert result["skipped"] == 0
|
||||||
|
game = get_game_by_name("Powerball")
|
||||||
|
assert get_draw_count(game["id"]) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_pb_ny_skips_duplicates(tmp_db):
|
||||||
|
for _ in range(2):
|
||||||
|
with patch("core.fetcher.requests.get", side_effect=[_json_resp(PB_NY_RECORDS), _json_resp([])]):
|
||||||
|
result = fetch_powerball_ny()
|
||||||
|
|
||||||
|
assert result["added"] == 0
|
||||||
|
assert result["skipped"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_pb_ny_parses_numbers_and_bonus(tmp_db):
|
||||||
|
with patch("core.fetcher.requests.get", side_effect=[_json_resp(PB_NY_RECORDS[:1]), _json_resp([])]):
|
||||||
|
fetch_powerball_ny()
|
||||||
|
|
||||||
|
game = get_game_by_name("Powerball")
|
||||||
|
draws = get_draws(game["id"])
|
||||||
|
assert draws[0]["draw_date"] == "2024-01-01"
|
||||||
|
assert draws[0]["numbers"] == "1,13,36,61,69"
|
||||||
|
assert draws[0]["bonus"] == "7"
|
||||||
|
assert draws[0]["multiplier"] == "2"
|
||||||
|
assert draws[0]["source"] == "powerball_ny"
|
||||||
|
|
||||||
|
|
||||||
|
def test_pb_ny_network_error(tmp_db):
|
||||||
|
with patch("core.fetcher.requests.get", side_effect=req_lib.RequestException("timeout")):
|
||||||
|
result = fetch_powerball_ny()
|
||||||
|
|
||||||
|
assert result["status"] == "error"
|
||||||
|
assert result["added"] == 0
|
||||||
|
assert "timeout" in result["message"]
|
||||||
|
log = get_last_fetch_log("powerball_ny")
|
||||||
|
assert log["status"] == "error"
|
||||||
|
|
||||||
|
|
||||||
|
def test_pb_ny_malformed_row_skipped(tmp_db):
|
||||||
|
"""Row with too few numbers is skipped without crashing."""
|
||||||
|
bad_records = [{"draw_date": "2024-01-01T00:00:00.000", "winning_numbers": "01 13 36"}]
|
||||||
|
with patch("core.fetcher.requests.get", side_effect=[_json_resp(bad_records), _json_resp([])]):
|
||||||
|
result = fetch_powerball_ny()
|
||||||
|
|
||||||
|
assert result["status"] == "success"
|
||||||
|
assert result["added"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
# ── Mega Millions NY ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_mm_ny_inserts_records(tmp_db):
|
||||||
|
with patch("core.fetcher.requests.get", side_effect=[_json_resp(MM_NY_RECORDS), _json_resp([])]):
|
||||||
|
result = fetch_megamillions_ny()
|
||||||
|
|
||||||
|
assert result["status"] == "success"
|
||||||
|
assert result["added"] == 2
|
||||||
|
assert result["skipped"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_mm_ny_skips_duplicates(tmp_db):
|
||||||
|
for _ in range(2):
|
||||||
|
with patch("core.fetcher.requests.get", side_effect=[_json_resp(MM_NY_RECORDS), _json_resp([])]):
|
||||||
|
result = fetch_megamillions_ny()
|
||||||
|
|
||||||
|
assert result["added"] == 0
|
||||||
|
assert result["skipped"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_mm_ny_parses_mega_ball(tmp_db):
|
||||||
|
with patch("core.fetcher.requests.get", side_effect=[_json_resp(MM_NY_RECORDS[:1]), _json_resp([])]):
|
||||||
|
fetch_megamillions_ny()
|
||||||
|
|
||||||
|
game = get_game_by_name("Mega Millions")
|
||||||
|
draws = get_draws(game["id"])
|
||||||
|
assert draws[0]["bonus"] == "4"
|
||||||
|
assert draws[0]["numbers"] == "7,11,22,29,38"
|
||||||
|
|
||||||
|
|
||||||
|
def test_mm_ny_network_error(tmp_db):
|
||||||
|
with patch("core.fetcher.requests.get", side_effect=req_lib.RequestException("refused")):
|
||||||
|
result = fetch_megamillions_ny()
|
||||||
|
|
||||||
|
assert result["status"] == "error"
|
||||||
|
log = get_last_fetch_log("megamillions_ny")
|
||||||
|
assert log["status"] == "error"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Mega Millions TX ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_mm_tx_inserts_records(tmp_db):
|
||||||
|
with patch("core.fetcher.requests.get", return_value=_text_resp(MM_TX_CSV)):
|
||||||
|
result = fetch_megamillions_tx()
|
||||||
|
|
||||||
|
assert result["status"] == "success"
|
||||||
|
assert result["added"] == 2
|
||||||
|
assert result["skipped"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_mm_tx_skips_duplicates(tmp_db):
|
||||||
|
for _ in range(2):
|
||||||
|
with patch("core.fetcher.requests.get", return_value=_text_resp(MM_TX_CSV)):
|
||||||
|
result = fetch_megamillions_tx()
|
||||||
|
|
||||||
|
assert result["added"] == 0
|
||||||
|
assert result["skipped"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_mm_tx_parses_date_and_numbers(tmp_db):
|
||||||
|
with patch("core.fetcher.requests.get", return_value=_text_resp(MM_TX_CSV)):
|
||||||
|
fetch_megamillions_tx()
|
||||||
|
|
||||||
|
game = get_game_by_name("Mega Millions")
|
||||||
|
draws = get_draws(game["id"], order="ASC")
|
||||||
|
assert draws[0]["draw_date"] == "2024-03-07"
|
||||||
|
assert draws[0]["numbers"] == "10,20,30,40,50"
|
||||||
|
assert draws[0]["bonus"] == "12"
|
||||||
|
assert draws[0]["multiplier"] == "3"
|
||||||
|
|
||||||
|
|
||||||
|
def test_mm_tx_cross_source_dedup(tmp_db):
|
||||||
|
"""TX record on same date as an already-inserted NY record is skipped."""
|
||||||
|
with patch("core.fetcher.requests.get", side_effect=[_json_resp(MM_NY_RECORDS[:1]), _json_resp([])]):
|
||||||
|
fetch_megamillions_ny() # inserts 2024-02-01
|
||||||
|
|
||||||
|
overlapping_csv = (
|
||||||
|
"Game Name,Month,Day,Year,Num1,Num2,Num3,Num4,Num5,Mega Ball,Megaplier\n"
|
||||||
|
"Mega Millions,02,01,2024,07,11,22,29,38,4,2\n"
|
||||||
|
)
|
||||||
|
with patch("core.fetcher.requests.get", return_value=_text_resp(overlapping_csv)):
|
||||||
|
result = fetch_megamillions_tx()
|
||||||
|
|
||||||
|
assert result["skipped"] == 1
|
||||||
|
assert result["added"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_mm_tx_network_error(tmp_db):
|
||||||
|
with patch("core.fetcher.requests.get", side_effect=req_lib.RequestException("connect")):
|
||||||
|
result = fetch_megamillions_tx()
|
||||||
|
|
||||||
|
assert result["status"] == "error"
|
||||||
|
assert result["added"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_mm_tx_no_header_row(tmp_db):
|
||||||
|
"""CSV without a header row still parses correctly."""
|
||||||
|
no_header = "Mega Millions,03,07,2024,10,20,30,40,50,12,3\n"
|
||||||
|
with patch("core.fetcher.requests.get", return_value=_text_resp(no_header)):
|
||||||
|
result = fetch_megamillions_tx()
|
||||||
|
|
||||||
|
assert result["added"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
# ── fetch_all ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_fetch_all_returns_three_sources(tmp_db):
|
||||||
|
# Both NY APIs stop after 1 call (2 records < NY_API_LIMIT), so 3 calls total
|
||||||
|
with patch("core.fetcher.requests.get", side_effect=[
|
||||||
|
_json_resp(PB_NY_RECORDS),
|
||||||
|
_json_resp(MM_NY_RECORDS),
|
||||||
|
_text_resp(MM_TX_CSV),
|
||||||
|
]):
|
||||||
|
results = fetch_all()
|
||||||
|
|
||||||
|
assert len(results) == 3
|
||||||
|
sources = {r["source"] for r in results}
|
||||||
|
assert sources == {"powerball_ny", "megamillions_ny", "megamillions_tx"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_all_aggregated_counts(tmp_db):
|
||||||
|
with patch("core.fetcher.requests.get", side_effect=[
|
||||||
|
_json_resp(PB_NY_RECORDS),
|
||||||
|
_json_resp(MM_NY_RECORDS),
|
||||||
|
_text_resp(MM_TX_CSV),
|
||||||
|
]):
|
||||||
|
results = fetch_all()
|
||||||
|
|
||||||
|
by_source = {r["source"]: r for r in results}
|
||||||
|
assert by_source["powerball_ny"]["added"] == 2
|
||||||
|
assert by_source["megamillions_ny"]["added"] == 2
|
||||||
|
assert by_source["megamillions_tx"]["added"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_all_continues_after_one_error(tmp_db):
|
||||||
|
"""If one source errors, the remaining sources still complete."""
|
||||||
|
error_result = {"source": "powerball_ny", "added": 0, "skipped": 0, "status": "error", "message": "timeout"}
|
||||||
|
ny_result = {"source": "megamillions_ny", "added": 2, "skipped": 0, "status": "success", "message": None}
|
||||||
|
tx_result = {"source": "megamillions_tx", "added": 2, "skipped": 0, "status": "success", "message": None}
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("core.fetcher.fetch_powerball_ny", return_value=error_result),
|
||||||
|
patch("core.fetcher.fetch_megamillions_ny", return_value=ny_result),
|
||||||
|
patch("core.fetcher.fetch_megamillions_tx", return_value=tx_result),
|
||||||
|
):
|
||||||
|
results = fetch_all()
|
||||||
|
|
||||||
|
assert len(results) == 3
|
||||||
|
by_source = {r["source"]: r for r in results}
|
||||||
|
assert by_source["powerball_ny"]["status"] == "error"
|
||||||
|
assert by_source["megamillions_ny"]["status"] == "success"
|
||||||
|
assert by_source["megamillions_tx"]["status"] == "success"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Fetch log ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_fetch_log_written_on_success(tmp_db):
|
||||||
|
with patch("core.fetcher.requests.get", side_effect=[_json_resp(PB_NY_RECORDS), _json_resp([])]):
|
||||||
|
fetch_powerball_ny()
|
||||||
|
|
||||||
|
log = get_last_fetch_log("powerball_ny")
|
||||||
|
assert log is not None
|
||||||
|
assert log["status"] == "success"
|
||||||
|
assert log["added"] == 2
|
||||||
|
assert log["skipped"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_log_written_on_error(tmp_db):
|
||||||
|
with patch("core.fetcher.requests.get", side_effect=req_lib.RequestException("fail")):
|
||||||
|
fetch_powerball_ny()
|
||||||
|
|
||||||
|
log = get_last_fetch_log("powerball_ny")
|
||||||
|
assert log is not None
|
||||||
|
assert log["status"] == "error"
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
"""
|
||||||
|
ui/statusbar.py
|
||||||
|
---------------
|
||||||
|
Bottom status bar widget for LottoSight.
|
||||||
|
Updated after every fetch via update_fetch_results().
|
||||||
|
"""
|
||||||
|
|
||||||
|
import tkinter as tk
|
||||||
|
from tkinter import ttk
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
_SOURCE_NAMES = {
|
||||||
|
"powerball_ny": "Powerball",
|
||||||
|
"megamillions_ny": "Mega Millions (NY)",
|
||||||
|
"megamillions_tx": "Mega Millions (TX)",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class StatusBar(ttk.Frame):
|
||||||
|
"""
|
||||||
|
Thin horizontal bar docked at the bottom of the main window.
|
||||||
|
All public methods are safe to call from the main thread only.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, parent, **kwargs):
|
||||||
|
super().__init__(parent, relief="sunken", **kwargs)
|
||||||
|
self._label = ttk.Label(self, text="Ready", anchor="w", padding=(6, 2))
|
||||||
|
self._label.pack(fill="x", expand=True)
|
||||||
|
|
||||||
|
def set_text(self, text: str):
|
||||||
|
self._label.config(text=text)
|
||||||
|
|
||||||
|
def set_fetching(self):
|
||||||
|
self._label.config(text="Fetching data…")
|
||||||
|
|
||||||
|
def set_ready(self):
|
||||||
|
self._label.config(text="Ready")
|
||||||
|
|
||||||
|
def update_fetch_results(self, results: list):
|
||||||
|
"""
|
||||||
|
Build and display status text from a fetch_all() result list.
|
||||||
|
Format:
|
||||||
|
Last fetch: Powerball — 3 added, 2 skipped | Mega Millions (NY) — 5 added, 0 skipped | 2026-05-23 08:42 AM
|
||||||
|
"""
|
||||||
|
now = datetime.now().strftime("%Y-%m-%d %I:%M %p")
|
||||||
|
parts = []
|
||||||
|
for r in results:
|
||||||
|
name = _SOURCE_NAMES.get(r["source"], r["source"])
|
||||||
|
if r["status"] == "error":
|
||||||
|
parts.append(f"{name} — ERROR: {r['message']}")
|
||||||
|
else:
|
||||||
|
parts.append(f"{name} — {r['added']} added, {r['skipped']} skipped")
|
||||||
|
text = "Last fetch: " + " | ".join(parts) + f" | {now}"
|
||||||
|
self.set_text(text)
|
||||||
Reference in New Issue
Block a user