05/23 Phase 1
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
name: LottoSight CI
|
||||
|
||||
on: [push]
|
||||
|
||||
jobs:
|
||||
ci:
|
||||
name: Syntax Check & Tests
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
# ── 1. Checkout code ───────────────────────────────────────────────
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
# ── 2. Set up Python ───────────────────────────────────────────────
|
||||
- name: Set up Python 3.11
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
# ── 3. Cache pip dependencies ──────────────────────────────────────
|
||||
- name: Cache pip
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pip-
|
||||
|
||||
# ── 4. Install dependencies ────────────────────────────────────────
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
pip install pytest pytest-cov
|
||||
|
||||
# ── 5. Syntax check — compile all .py files ────────────────────────
|
||||
- name: Syntax check (py_compile)
|
||||
run: |
|
||||
echo "Running syntax check on all .py files..."
|
||||
find . -name "*.py" \
|
||||
-not -path "./.git/*" \
|
||||
-not -path "./exports/*" \
|
||||
| sort \
|
||||
| xargs python -m py_compile
|
||||
echo "Syntax check passed."
|
||||
|
||||
# ── 6. Run pytest ──────────────────────────────────────────────────
|
||||
- name: Run tests (pytest)
|
||||
run: |
|
||||
pytest tests/ \
|
||||
--tb=short \
|
||||
--cov=db \
|
||||
--cov=core \
|
||||
--cov-report=term-missing \
|
||||
-v
|
||||
@@ -0,0 +1,333 @@
|
||||
# LottoSight — Project Documentation
|
||||
|
||||
## App Overview
|
||||
|
||||
**Name:** LottoSight
|
||||
**Type:** Desktop Application
|
||||
**Framework:** Python + Tkinter
|
||||
**Database:** SQLite (local, zero-config)
|
||||
**Purpose:** Analyze historical lottery drawing data and predict next winning numbers using multiple statistical strategies.
|
||||
|
||||
---
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Component | Technology |
|
||||
|---------------|-----------------------------------|
|
||||
| UI | Tkinter + ttk |
|
||||
| Charts | Matplotlib (embedded in Tkinter) |
|
||||
| Database | SQLite via sqlite3 |
|
||||
| Data Fetch | requests + BeautifulSoup |
|
||||
| Analysis | collections, statistics, numpy |
|
||||
| Scheduler | APScheduler |
|
||||
| Export | openpyxl |
|
||||
| Packaging | PyInstaller |
|
||||
|
||||
---
|
||||
|
||||
## Supported Games
|
||||
|
||||
| Game | Main Balls | Pool | Bonus Ball | Pool |
|
||||
|---------------|------------|-------|------------|-------|
|
||||
| Powerball | 5 | 1–69 | 1 | 1–26 |
|
||||
| Mega Millions | 5 | 1–70 | 1 | 1–25 |
|
||||
| Custom | config | config| optional | config|
|
||||
|
||||
---
|
||||
|
||||
## Data Sources
|
||||
|
||||
| Source | Game | Coverage | Method |
|
||||
|-------------------------|---------------|------------------|--------------|
|
||||
| NY Open Data API | Powerball | 2010–present | REST API |
|
||||
| NY Open Data API | Mega Millions | 2002–present | REST API |
|
||||
| Texas Lottery CSV | Mega Millions | 2003–present | CSV download |
|
||||
|
||||
### API Endpoints
|
||||
- **Powerball (NY):** `https://data.ny.gov/resource/d6yy-54nr.json`
|
||||
- **Mega Millions (NY):** `https://data.ny.gov/resource/5xaw-6ayf.json`
|
||||
- **Mega Millions (TX):** `https://www.texaslottery.com/export/sites/lottery/Games/Mega_Millions/Winning_Numbers/download.html`
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
lottosight/
|
||||
├── main.py # Entry point, app window, toolbar
|
||||
├── db/
|
||||
│ ├── database.py # SQLite setup, schema, migrations
|
||||
│ └── models.py # CRUD operations, duplicate check
|
||||
├── core/
|
||||
│ ├── analyzer.py # All analysis logic
|
||||
│ ├── predictor.py # Prediction strategies (5 total)
|
||||
│ └── fetcher.py # Fetch logic — auto + manual, all 3 sources
|
||||
├── ui/
|
||||
│ ├── dashboard.py # Home/summary screen
|
||||
│ ├── history.py # Draw history browser
|
||||
│ ├── analysis.py # Charts and stats screen
|
||||
│ ├── predictor_ui.py # Prediction generator screen
|
||||
│ ├── settings.py # Settings screen + manual fetch button
|
||||
│ └── statusbar.py # Bottom status bar component
|
||||
├── assets/
|
||||
│ └── icon.png
|
||||
├── exports/ # Excel/CSV exports output folder
|
||||
├── requirements.txt
|
||||
└── CLAUDE.md # This file
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Database Schema
|
||||
|
||||
### Table: `games`
|
||||
| Column | Type | Description |
|
||||
|--------------|---------|------------------------------------|
|
||||
| id | INTEGER | Primary key |
|
||||
| name | TEXT | Game name (e.g. Powerball) |
|
||||
| main_count | INTEGER | Number of main balls |
|
||||
| main_max | INTEGER | Max value for main balls |
|
||||
| bonus_count | INTEGER | Number of bonus balls (0 if none) |
|
||||
| bonus_max | INTEGER | Max value for bonus ball |
|
||||
| active | INTEGER | 1 = active, 0 = disabled |
|
||||
|
||||
### Table: `draws`
|
||||
| Column | Type | Description |
|
||||
|--------------|---------|------------------------------------|
|
||||
| id | INTEGER | Primary key |
|
||||
| game_id | INTEGER | Foreign key → games.id |
|
||||
| draw_date | TEXT | ISO date string (YYYY-MM-DD) |
|
||||
| numbers | TEXT | Comma-separated main numbers |
|
||||
| bonus | TEXT | Bonus ball number(s) |
|
||||
| multiplier | TEXT | Power Play / Megaplier (nullable) |
|
||||
| source | TEXT | Data source identifier |
|
||||
| created_at | TEXT | Record insert timestamp |
|
||||
|
||||
### Table: `predictions`
|
||||
| Column | Type | Description |
|
||||
|--------------|---------|------------------------------------|
|
||||
| id | INTEGER | Primary key |
|
||||
| game_id | INTEGER | Foreign key → games.id |
|
||||
| strategy | TEXT | Strategy name used |
|
||||
| numbers | TEXT | Predicted main numbers |
|
||||
| bonus | TEXT | Predicted bonus number |
|
||||
| created_at | TEXT | Prediction timestamp |
|
||||
|
||||
### Table: `fetch_log`
|
||||
| Column | Type | Description |
|
||||
|--------------|---------|------------------------------------|
|
||||
| id | INTEGER | Primary key |
|
||||
| source | TEXT | Source name |
|
||||
| fetched_at | TEXT | Timestamp of fetch |
|
||||
| added | INTEGER | New records inserted |
|
||||
| skipped | INTEGER | Duplicate records skipped |
|
||||
| status | TEXT | success / error |
|
||||
| message | TEXT | Error message or notes |
|
||||
|
||||
---
|
||||
|
||||
## Fetch System
|
||||
|
||||
### Auto-Fetch
|
||||
- Triggers on **app launch** (background thread)
|
||||
- Repeats every **24 hours** via APScheduler
|
||||
- Runs in background — does not block UI
|
||||
|
||||
### Manual Fetch
|
||||
- Button in **Toolbar** (always visible, all screens)
|
||||
- Button in **Settings screen**
|
||||
- Shows "Fetching…" state while running
|
||||
|
||||
### Duplicate Handling
|
||||
- Compare incoming records by `draw_date` + `game_id`
|
||||
- Skip if already exists in DB
|
||||
- Report result in **status bar**: `X added, Y skipped`
|
||||
|
||||
### Status Bar Format
|
||||
```
|
||||
Last fetch: Powerball — 3 added, 2 skipped | Mega Millions — 5 added, 0 skipped | 2026-05-23 08:42 AM
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Analysis Features
|
||||
|
||||
| Feature | Description |
|
||||
|-----------------------|------------------------------------------------------|
|
||||
| Frequency analysis | Hot/cold numbers by total draw count |
|
||||
| Gap/skip analysis | How many draws since each number last appeared |
|
||||
| Positional frequency | Which numbers appear most in each draw position |
|
||||
| Pair/triplet patterns | Number combinations that appear together often |
|
||||
| Odd/even ratio | Ratio of odd vs even numbers per draw |
|
||||
| Sum range analysis | Distribution of total sums across all draws |
|
||||
| Delta patterns | Differences between consecutive numbers in a draw |
|
||||
|
||||
---
|
||||
|
||||
## Prediction Strategies
|
||||
|
||||
| # | Strategy | Description |
|
||||
|---|--------------------|-----------------------------------------------------------|
|
||||
| 1 | Hot Numbers | Top N most frequent numbers in last X draws |
|
||||
| 2 | Due Numbers | Numbers overdue based on expected frequency gap |
|
||||
| 3 | Weighted Random | numpy random choice weighted by historical frequency |
|
||||
| 4 | Monte Carlo | 10,000 simulations, pick most common result |
|
||||
| 5 | Positional | Most frequent number per draw position |
|
||||
|
||||
---
|
||||
|
||||
## UI Screens
|
||||
|
||||
| Screen | Description |
|
||||
|--------------|----------------------------------------------------------|
|
||||
| Dashboard | Summary stats, last draw result, quick actions |
|
||||
| History | Searchable/sortable draw history table |
|
||||
| Analysis | Charts — frequency bar, heatmap, gap chart |
|
||||
| Predictor | Pick strategy → generate ticket numbers |
|
||||
| Settings | Game config, data sources, fetch interval, manual fetch |
|
||||
|
||||
---
|
||||
|
||||
## Logging
|
||||
|
||||
All actions are logged to console and optionally to a log file:
|
||||
- `[FETCH]` — auto/manual fetch events
|
||||
- `[DB]` — insert, skip, error events
|
||||
- `[PREDICT]` — prediction generated
|
||||
- `[EXPORT]` — export actions
|
||||
- `[ERROR]` — any exception with traceback
|
||||
|
||||
---
|
||||
|
||||
## Build Phases & To-Do
|
||||
|
||||
### ✅ Phase 0 — Planning
|
||||
- [x] Define app scope and features
|
||||
- [x] Choose tech stack
|
||||
- [x] Design database schema
|
||||
- [x] Design fetch system
|
||||
- [x] Define data sources and API endpoints
|
||||
- [x] Create CLAUDE.md
|
||||
|
||||
---
|
||||
|
||||
### ✅ Phase 1 — Database Setup
|
||||
- [x] Create `lottosight/` project folder structure
|
||||
- [x] Write `db/database.py` — SQLite init, create all tables
|
||||
- [x] Write `db/models.py` — CRUD: insert draw, get draws, check duplicate, insert prediction, insert fetch log
|
||||
- [x] Seed default game configs (Powerball, Mega Millions)
|
||||
- [x] Write `requirements.txt`
|
||||
- [x] Test DB creation and seed on fresh run
|
||||
- [x] Write `tests/conftest.py` — shared tmp_db fixture
|
||||
- [x] Write `tests/test_database.py` — 8 tests for database.py (47/47 pass)
|
||||
- [x] Write `tests/test_models.py` — 39 tests for models.py (47/47 pass)
|
||||
- [x] Write `.gitea/workflows/ci.yml` — CI on every push (syntax check + pytest)
|
||||
|
||||
---
|
||||
|
||||
### 🔲 Phase 2 — Fetch System
|
||||
- [ ] Write `core/fetcher.py`
|
||||
- [ ] `fetch_powerball_ny()` — NY Open Data API
|
||||
- [ ] `fetch_megamillions_ny()` — NY Open Data API
|
||||
- [ ] `fetch_megamillions_tx()` — Texas Lottery CSV
|
||||
- [ ] `fetch_all()` — calls all 3, aggregates results
|
||||
- [ ] Duplicate detection logic
|
||||
- [ ] Return added/skipped counts per source
|
||||
- [ ] Write `ui/statusbar.py` — bottom status bar widget
|
||||
- [ ] Wire auto-fetch on app launch (background thread)
|
||||
- [ ] Wire 24hr scheduled fetch (APScheduler)
|
||||
- [ ] Write `main.py` — app window, toolbar with manual fetch button
|
||||
- [ ] Wire manual fetch button → `fetch_all()` → update status bar
|
||||
- [ ] Test: fresh DB → fetch → verify records inserted
|
||||
- [ ] Test: second fetch → verify duplicates skipped, counts correct
|
||||
- [ ] Test: status bar updates correctly after fetch
|
||||
|
||||
---
|
||||
|
||||
### 🔲 Phase 3 — History Browser
|
||||
- [ ] Write `ui/history.py`
|
||||
- [ ] Treeview table with columns: Date, Numbers, Bonus, Multiplier, Source
|
||||
- [ ] Filter by game (dropdown)
|
||||
- [ ] Search by date range
|
||||
- [ ] Sort by column headers
|
||||
- [ ] Row count display
|
||||
- [ ] Connect history screen to DB reads
|
||||
- [ ] Test with real fetched data
|
||||
|
||||
---
|
||||
|
||||
### 🔲 Phase 4 — Analysis Engine + Charts
|
||||
- [ ] Write `core/analyzer.py`
|
||||
- [ ] `frequency_analysis(game_id, last_n)`
|
||||
- [ ] `gap_analysis(game_id)`
|
||||
- [ ] `positional_frequency(game_id)`
|
||||
- [ ] `pair_analysis(game_id)`
|
||||
- [ ] `odd_even_ratio(game_id)`
|
||||
- [ ] `sum_range_analysis(game_id)`
|
||||
- [ ] `delta_analysis(game_id)`
|
||||
- [ ] Write `ui/analysis.py`
|
||||
- [ ] Frequency bar chart (Matplotlib embedded)
|
||||
- [ ] Heatmap of number frequency
|
||||
- [ ] Gap chart
|
||||
- [ ] Toggle between games
|
||||
- [ ] Test all analysis functions with real data
|
||||
|
||||
---
|
||||
|
||||
### 🔲 Phase 5 — Prediction Engine
|
||||
- [ ] Write `core/predictor.py`
|
||||
- [ ] `hot_numbers(game_id, last_n)`
|
||||
- [ ] `due_numbers(game_id)`
|
||||
- [ ] `weighted_random(game_id)`
|
||||
- [ ] `monte_carlo(game_id, simulations=10000)`
|
||||
- [ ] `positional_pick(game_id)`
|
||||
- [ ] Write `ui/predictor_ui.py`
|
||||
- [ ] Strategy selector dropdown
|
||||
- [ ] Number of tickets input
|
||||
- [ ] Generate button
|
||||
- [ ] Results display (generated tickets)
|
||||
- [ ] Save prediction to DB
|
||||
- [ ] Test all 5 strategies produce valid number sets
|
||||
|
||||
---
|
||||
|
||||
### 🔲 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 7 — Export + Polish
|
||||
- [ ] Add export to Excel (`openpyxl`) for:
|
||||
- [ ] Draw history
|
||||
- [ ] Predictions
|
||||
- [ ] Frequency analysis
|
||||
- [ ] Add export to CSV
|
||||
- [ ] Add `assets/icon.png`
|
||||
- [ ] App title bar + icon
|
||||
- [ ] Window min-size + resizable layout
|
||||
- [ ] Error handling — network down, API timeout, bad CSV
|
||||
|
||||
---
|
||||
|
||||
### 🔲 Phase 8 — Packaging
|
||||
- [ ] Write PyInstaller `.spec` file
|
||||
- [ ] Test build on target OS (Windows / Mac / Linux)
|
||||
- [ ] Bundle SQLite DB, assets, exports folder
|
||||
- [ ] Test packaged app fresh install (no Python required)
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- SQLite DB file stored at: `lottosight/data/lottosight.db`
|
||||
- Exports land in: `lottosight/exports/`
|
||||
- All fetch operations run in **background threads** to keep UI responsive
|
||||
- NY Open Data API supports `$limit` and `$offset` params for pagination
|
||||
- Texas Lottery CSV requires parsing — columns vary, needs header detection
|
||||
- Powerball matrix changed Oct 7, 2015 (5/69+1/26) — pre-2015 data has different pool sizes, flag in DB or filter
|
||||
+164
@@ -0,0 +1,164 @@
|
||||
"""
|
||||
db/database.py
|
||||
--------------
|
||||
SQLite initialization, schema creation, and default game seeding.
|
||||
All table definitions live here. Call init_db() once on app startup.
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
import logging
|
||||
import os
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# DB file lives in lottosight/data/lottosight.db
|
||||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
DB_PATH = os.path.join(BASE_DIR, "data", "lottosight.db")
|
||||
|
||||
|
||||
def get_connection():
|
||||
"""Return a sqlite3 connection with foreign keys enabled."""
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.execute("PRAGMA foreign_keys = ON")
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
|
||||
def init_db():
|
||||
"""
|
||||
Initialize the database — create all tables if they don't exist,
|
||||
then seed default game configs.
|
||||
Safe to call on every app launch (uses IF NOT EXISTS).
|
||||
"""
|
||||
logger.info("[DB] Initializing database at %s", DB_PATH)
|
||||
|
||||
# Ensure data/ directory exists
|
||||
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
|
||||
# ── games ──────────────────────────────────────────────────────────
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS games (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
main_count INTEGER NOT NULL,
|
||||
main_max INTEGER NOT NULL,
|
||||
bonus_count INTEGER NOT NULL DEFAULT 0,
|
||||
bonus_max INTEGER NOT NULL DEFAULT 0,
|
||||
active INTEGER NOT NULL DEFAULT 1
|
||||
)
|
||||
""")
|
||||
|
||||
# ── draws ──────────────────────────────────────────────────────────
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS draws (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
game_id INTEGER NOT NULL REFERENCES games(id),
|
||||
draw_date TEXT NOT NULL,
|
||||
numbers TEXT NOT NULL,
|
||||
bonus TEXT,
|
||||
multiplier TEXT,
|
||||
source TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)
|
||||
""")
|
||||
|
||||
# Unique constraint: one record per game per draw date
|
||||
cursor.execute("""
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_draws_game_date
|
||||
ON draws (game_id, draw_date)
|
||||
""")
|
||||
|
||||
# Index for fast date-range queries
|
||||
cursor.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_draws_date
|
||||
ON draws (draw_date)
|
||||
""")
|
||||
|
||||
# ── predictions ────────────────────────────────────────────────────
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS predictions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
game_id INTEGER NOT NULL REFERENCES games(id),
|
||||
strategy TEXT NOT NULL,
|
||||
numbers TEXT NOT NULL,
|
||||
bonus TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)
|
||||
""")
|
||||
|
||||
# ── fetch_log ──────────────────────────────────────────────────────
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS fetch_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
source TEXT NOT NULL,
|
||||
fetched_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
added INTEGER NOT NULL DEFAULT 0,
|
||||
skipped INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'success',
|
||||
message TEXT
|
||||
)
|
||||
""")
|
||||
|
||||
conn.commit()
|
||||
logger.info("[DB] Tables created/verified OK")
|
||||
|
||||
_seed_games(cursor, conn)
|
||||
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
logger.error("[ERROR] DB init failed: %s", e, exc_info=True)
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _seed_games(cursor, conn):
|
||||
"""
|
||||
Insert default game configs if they don't already exist.
|
||||
Uses INSERT OR IGNORE to be idempotent on every launch.
|
||||
"""
|
||||
defaults = [
|
||||
# name main_count main_max bonus_count bonus_max active
|
||||
("Powerball", 5, 69, 1, 26, 1),
|
||||
("Mega Millions", 5, 70, 1, 25, 1),
|
||||
]
|
||||
|
||||
for row in defaults:
|
||||
cursor.execute("""
|
||||
INSERT OR IGNORE INTO games
|
||||
(name, main_count, main_max, bonus_count, bonus_max, active)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
""", row)
|
||||
|
||||
inserted = conn.total_changes
|
||||
conn.commit()
|
||||
|
||||
if inserted > 0:
|
||||
logger.info("[DB] Seeded %d default game(s)", inserted)
|
||||
else:
|
||||
logger.info("[DB] Default games already seeded — skipped")
|
||||
|
||||
|
||||
def get_db_stats():
|
||||
"""
|
||||
Return a dict of basic DB stats for display in Settings screen.
|
||||
{game_name: draw_count, ...}
|
||||
"""
|
||||
conn = get_connection()
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
SELECT g.name, COUNT(d.id) as draw_count
|
||||
FROM games g
|
||||
LEFT JOIN draws d ON d.game_id = g.id
|
||||
GROUP BY g.id
|
||||
ORDER BY g.name
|
||||
""")
|
||||
rows = cursor.fetchall()
|
||||
return {row["name"]: row["draw_count"] for row in rows}
|
||||
finally:
|
||||
conn.close()
|
||||
+417
@@ -0,0 +1,417 @@
|
||||
"""
|
||||
db/models.py
|
||||
------------
|
||||
All CRUD operations for LottoSight.
|
||||
Functions:
|
||||
Games — get_all_games(), get_game_by_name(), get_game_by_id(), set_game_active()
|
||||
Draws — insert_draw(), draw_exists(), get_draws(), get_last_draw(), get_draw_count()
|
||||
Predict — insert_prediction(), get_predictions()
|
||||
Fetch — insert_fetch_log(), get_last_fetch_log(), get_fetch_logs()
|
||||
"""
|
||||
|
||||
import logging
|
||||
from db.database import get_connection
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
# GAMES
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
def get_all_games(active_only=False):
|
||||
"""
|
||||
Return list of all games as sqlite3.Row objects.
|
||||
Pass active_only=True to filter to enabled games only.
|
||||
"""
|
||||
conn = get_connection()
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
if active_only:
|
||||
cursor.execute("SELECT * FROM games WHERE active = 1 ORDER BY name")
|
||||
else:
|
||||
cursor.execute("SELECT * FROM games ORDER BY name")
|
||||
return cursor.fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_game_by_name(name):
|
||||
"""Return single game row by name, or None if not found."""
|
||||
conn = get_connection()
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT * FROM games WHERE name = ?", (name,))
|
||||
return cursor.fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_game_by_id(game_id):
|
||||
"""Return single game row by id, or None if not found."""
|
||||
conn = get_connection()
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT * FROM games WHERE id = ?", (game_id,))
|
||||
return cursor.fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def set_game_active(game_id, active: bool):
|
||||
"""Enable or disable a game. active=True enables, False disables."""
|
||||
conn = get_connection()
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"UPDATE games SET active = ? WHERE id = ?",
|
||||
(1 if active else 0, game_id)
|
||||
)
|
||||
conn.commit()
|
||||
logger.info("[DB] Game id=%d set active=%s", game_id, active)
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
logger.error("[ERROR] set_game_active failed: %s", e, exc_info=True)
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def add_custom_game(name, main_count, main_max, bonus_count=0, bonus_max=0):
|
||||
"""Insert a custom game config. Returns new game id."""
|
||||
conn = get_connection()
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
INSERT INTO games (name, main_count, main_max, bonus_count, bonus_max, active)
|
||||
VALUES (?, ?, ?, ?, ?, 1)
|
||||
""", (name, main_count, main_max, bonus_count, bonus_max))
|
||||
conn.commit()
|
||||
new_id = cursor.lastrowid
|
||||
logger.info("[DB] Custom game created: '%s' id=%d", name, new_id)
|
||||
return new_id
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
logger.error("[ERROR] add_custom_game failed: %s", e, exc_info=True)
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
# DRAWS
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
def draw_exists(game_id, draw_date):
|
||||
"""
|
||||
Check if a draw already exists for this game + date.
|
||||
Returns True if duplicate, False if new.
|
||||
"""
|
||||
conn = get_connection()
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"SELECT id FROM draws WHERE game_id = ? AND draw_date = ?",
|
||||
(game_id, draw_date)
|
||||
)
|
||||
return cursor.fetchone() is not None
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def insert_draw(game_id, draw_date, numbers, bonus=None, multiplier=None, source=None):
|
||||
"""
|
||||
Insert a single draw record.
|
||||
- numbers: list of ints or comma-separated string
|
||||
- bonus: int, str, or None
|
||||
- Returns: 'inserted' or 'skipped' (duplicate)
|
||||
"""
|
||||
# Normalize numbers to comma-separated string
|
||||
if isinstance(numbers, (list, tuple)):
|
||||
numbers_str = ",".join(str(n) for n in numbers)
|
||||
else:
|
||||
numbers_str = str(numbers).strip()
|
||||
|
||||
bonus_str = str(bonus) if bonus is not None else None
|
||||
multiplier_str = str(multiplier) if multiplier is not None else None
|
||||
|
||||
# Duplicate check
|
||||
if draw_exists(game_id, draw_date):
|
||||
logger.debug("[DB] Skipped duplicate: game_id=%d date=%s", game_id, draw_date)
|
||||
return "skipped"
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
INSERT INTO draws (game_id, draw_date, numbers, bonus, multiplier, source)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
""", (game_id, draw_date, numbers_str, bonus_str, multiplier_str, source))
|
||||
conn.commit()
|
||||
logger.debug("[DB] Inserted draw: game_id=%d date=%s numbers=%s",
|
||||
game_id, draw_date, numbers_str)
|
||||
return "inserted"
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
logger.error("[ERROR] insert_draw failed: %s", e, exc_info=True)
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_draws(game_id, limit=None, date_from=None, date_to=None, order="DESC"):
|
||||
"""
|
||||
Fetch draw records for a game.
|
||||
- date_from / date_to: ISO strings 'YYYY-MM-DD' (optional)
|
||||
- order: 'DESC' (newest first) or 'ASC' (oldest first)
|
||||
- limit: max rows to return (None = all)
|
||||
Returns list of sqlite3.Row objects.
|
||||
"""
|
||||
conn = get_connection()
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
query = "SELECT * FROM draws WHERE game_id = ?"
|
||||
params = [game_id]
|
||||
|
||||
if date_from:
|
||||
query += " AND draw_date >= ?"
|
||||
params.append(date_from)
|
||||
if date_to:
|
||||
query += " AND draw_date <= ?"
|
||||
params.append(date_to)
|
||||
|
||||
order = "DESC" if order.upper() == "DESC" else "ASC"
|
||||
query += f" ORDER BY draw_date {order}"
|
||||
|
||||
if limit:
|
||||
query += " LIMIT ?"
|
||||
params.append(limit)
|
||||
|
||||
cursor.execute(query, params)
|
||||
return cursor.fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_last_draw(game_id):
|
||||
"""Return the most recent draw record for a game, or None."""
|
||||
conn = get_connection()
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
SELECT * FROM draws
|
||||
WHERE game_id = ?
|
||||
ORDER BY draw_date DESC
|
||||
LIMIT 1
|
||||
""", (game_id,))
|
||||
return cursor.fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_draw_count(game_id):
|
||||
"""Return total number of draw records for a game."""
|
||||
conn = get_connection()
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"SELECT COUNT(*) as cnt FROM draws WHERE game_id = ?",
|
||||
(game_id,)
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
return row["cnt"] if row else 0
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_all_draws_numbers(game_id):
|
||||
"""
|
||||
Return all draws as list of dicts with parsed number lists.
|
||||
Used by analyzer and predictor.
|
||||
Format: [{"draw_date": str, "numbers": [int,...], "bonus": int|None}, ...]
|
||||
"""
|
||||
rows = get_draws(game_id, order="ASC")
|
||||
result = []
|
||||
for row in rows:
|
||||
try:
|
||||
numbers = [int(n.strip()) for n in row["numbers"].split(",")]
|
||||
bonus = int(row["bonus"]) if row["bonus"] else None
|
||||
result.append({
|
||||
"draw_date": row["draw_date"],
|
||||
"numbers": numbers,
|
||||
"bonus": bonus,
|
||||
"multiplier": row["multiplier"],
|
||||
"source": row["source"],
|
||||
})
|
||||
except Exception as e:
|
||||
logger.warning("[DB] Skipping malformed draw id=%d: %s", row["id"], e)
|
||||
return result
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
# PREDICTIONS
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
def insert_prediction(game_id, strategy, numbers, bonus=None):
|
||||
"""
|
||||
Save a generated prediction to DB.
|
||||
- numbers: list of ints or comma-separated string
|
||||
Returns new prediction id.
|
||||
"""
|
||||
if isinstance(numbers, (list, tuple)):
|
||||
numbers_str = ",".join(str(n) for n in numbers)
|
||||
else:
|
||||
numbers_str = str(numbers).strip()
|
||||
|
||||
bonus_str = str(bonus) if bonus is not None else None
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
INSERT INTO predictions (game_id, strategy, numbers, bonus)
|
||||
VALUES (?, ?, ?, ?)
|
||||
""", (game_id, strategy, numbers_str, bonus_str))
|
||||
conn.commit()
|
||||
new_id = cursor.lastrowid
|
||||
logger.info("[PREDICT] Saved prediction id=%d game_id=%d strategy='%s' numbers=%s bonus=%s",
|
||||
new_id, game_id, strategy, numbers_str, bonus_str)
|
||||
return new_id
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
logger.error("[ERROR] insert_prediction failed: %s", e, exc_info=True)
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_predictions(game_id=None, limit=50):
|
||||
"""
|
||||
Fetch saved predictions.
|
||||
- game_id: filter by game (None = all games)
|
||||
- limit: max rows
|
||||
Returns list of sqlite3.Row objects, newest first.
|
||||
"""
|
||||
conn = get_connection()
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
if game_id:
|
||||
cursor.execute("""
|
||||
SELECT p.*, g.name as game_name
|
||||
FROM predictions p
|
||||
JOIN games g ON g.id = p.game_id
|
||||
WHERE p.game_id = ?
|
||||
ORDER BY p.created_at DESC, p.id DESC
|
||||
LIMIT ?
|
||||
""", (game_id, limit))
|
||||
else:
|
||||
cursor.execute("""
|
||||
SELECT p.*, g.name as game_name
|
||||
FROM predictions p
|
||||
JOIN games g ON g.id = p.game_id
|
||||
ORDER BY p.created_at DESC, p.id DESC
|
||||
LIMIT ?
|
||||
""", (limit,))
|
||||
return cursor.fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
# FETCH LOG
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
def insert_fetch_log(source, added, skipped, status="success", message=None):
|
||||
"""
|
||||
Log a fetch operation result.
|
||||
Called after every auto or manual fetch attempt.
|
||||
Returns new log id.
|
||||
"""
|
||||
conn = get_connection()
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
INSERT INTO fetch_log (source, added, skipped, status, message)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""", (source, added, skipped, status, message))
|
||||
conn.commit()
|
||||
new_id = cursor.lastrowid
|
||||
logger.info("[FETCH] Log id=%d source='%s' added=%d skipped=%d status=%s",
|
||||
new_id, source, added, skipped, status)
|
||||
return new_id
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
logger.error("[ERROR] insert_fetch_log failed: %s", e, exc_info=True)
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_last_fetch_log(source=None):
|
||||
"""
|
||||
Return the most recent fetch log entry.
|
||||
- source: filter by source name (None = any source)
|
||||
Returns sqlite3.Row or None.
|
||||
"""
|
||||
conn = get_connection()
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
if source:
|
||||
cursor.execute("""
|
||||
SELECT * FROM fetch_log
|
||||
WHERE source = ?
|
||||
ORDER BY fetched_at DESC, id DESC
|
||||
LIMIT 1
|
||||
""", (source,))
|
||||
else:
|
||||
cursor.execute("""
|
||||
SELECT * FROM fetch_log
|
||||
ORDER BY fetched_at DESC, id DESC
|
||||
LIMIT 1
|
||||
""")
|
||||
return cursor.fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_fetch_logs(limit=50):
|
||||
"""
|
||||
Return recent fetch log entries, newest first.
|
||||
Used in Settings screen to show fetch history.
|
||||
"""
|
||||
conn = get_connection()
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
SELECT * FROM fetch_log
|
||||
ORDER BY fetched_at DESC, id DESC
|
||||
LIMIT ?
|
||||
""", (limit,))
|
||||
return cursor.fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_last_fetch_per_source():
|
||||
"""
|
||||
Return the most recent fetch log entry for each source.
|
||||
Returns dict: {source_name: Row, ...}
|
||||
Used by status bar and settings screen.
|
||||
"""
|
||||
conn = get_connection()
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
SELECT f.*
|
||||
FROM fetch_log f
|
||||
INNER JOIN (
|
||||
SELECT source, MAX(fetched_at) as max_at
|
||||
FROM fetch_log
|
||||
GROUP BY source
|
||||
) latest ON f.source = latest.source AND f.fetched_at = latest.max_at
|
||||
ORDER BY f.source
|
||||
""")
|
||||
rows = cursor.fetchall()
|
||||
return {row["source"]: row for row in rows}
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -0,0 +1,6 @@
|
||||
requests>=2.31.0
|
||||
beautifulsoup4>=4.12.0
|
||||
numpy>=1.26.0
|
||||
matplotlib>=3.8.0
|
||||
openpyxl>=3.1.0
|
||||
APScheduler>=3.10.0
|
||||
@@ -0,0 +1,38 @@
|
||||
"""
|
||||
tests/conftest.py
|
||||
-----------------
|
||||
Shared pytest fixtures for LottoSight test suite.
|
||||
Uses a temporary in-memory / temp-file SQLite DB so tests
|
||||
never touch the real lottosight.db.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import pytest
|
||||
|
||||
# Ensure project root is on the path so db/core imports resolve
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def tmp_db(monkeypatch, tmp_path):
|
||||
"""
|
||||
Redirect DB_PATH to a fresh temp file for each test function.
|
||||
Initializes the schema and seeds default games.
|
||||
Cleaned up automatically by pytest after each test.
|
||||
"""
|
||||
db_file = tmp_path / "test_lottosight.db"
|
||||
|
||||
# Patch the DB_PATH in database module before init
|
||||
import db.database as database_module
|
||||
monkeypatch.setattr(database_module, "DB_PATH", str(db_file))
|
||||
|
||||
# Also patch it in models (it imports get_connection which reads DB_PATH)
|
||||
# get_connection() reads DB_PATH at call time, so patching database_module is enough
|
||||
|
||||
from db.database import init_db
|
||||
init_db()
|
||||
|
||||
yield str(db_file)
|
||||
# tmp_path is auto-cleaned by pytest
|
||||
@@ -0,0 +1,144 @@
|
||||
"""
|
||||
tests/test_database.py
|
||||
----------------------
|
||||
Tests for db/database.py:
|
||||
- init_db() creates all required tables
|
||||
- Default games are seeded (Powerball, Mega Millions)
|
||||
- init_db() is idempotent (safe to call multiple times)
|
||||
- get_db_stats() returns correct draw counts
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from db.database import init_db, get_db_stats, get_connection
|
||||
|
||||
|
||||
def test_tables_created(tmp_db):
|
||||
"""All 4 tables must exist after init_db()."""
|
||||
conn = get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
|
||||
tables = {row["name"] for row in cursor.fetchall()}
|
||||
conn.close()
|
||||
|
||||
assert "games" in tables, "Missing table: games"
|
||||
assert "draws" in tables, "Missing table: draws"
|
||||
assert "predictions" in tables, "Missing table: predictions"
|
||||
assert "fetch_log" in tables, "Missing table: fetch_log"
|
||||
|
||||
|
||||
def test_default_games_seeded(tmp_db):
|
||||
"""Powerball and Mega Millions must be seeded after init_db()."""
|
||||
conn = get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT name FROM games ORDER BY name")
|
||||
names = [row["name"] for row in cursor.fetchall()]
|
||||
conn.close()
|
||||
|
||||
assert "Powerball" in names, "Powerball not seeded"
|
||||
assert "Mega Millions" in names, "Mega Millions not seeded"
|
||||
|
||||
|
||||
def test_powerball_config(tmp_db):
|
||||
"""Powerball config must match spec: 5 balls 1-69, bonus 1-26."""
|
||||
conn = get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT * FROM games WHERE name = 'Powerball'")
|
||||
row = cursor.fetchone()
|
||||
conn.close()
|
||||
|
||||
assert row is not None
|
||||
assert row["main_count"] == 5
|
||||
assert row["main_max"] == 69
|
||||
assert row["bonus_count"] == 1
|
||||
assert row["bonus_max"] == 26
|
||||
assert row["active"] == 1
|
||||
|
||||
|
||||
def test_megamillions_config(tmp_db):
|
||||
"""Mega Millions config must match spec: 5 balls 1-70, bonus 1-25."""
|
||||
conn = get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT * FROM games WHERE name = 'Mega Millions'")
|
||||
row = cursor.fetchone()
|
||||
conn.close()
|
||||
|
||||
assert row is not None
|
||||
assert row["main_count"] == 5
|
||||
assert row["main_max"] == 70
|
||||
assert row["bonus_count"] == 1
|
||||
assert row["bonus_max"] == 25
|
||||
assert row["active"] == 1
|
||||
|
||||
|
||||
def test_init_db_idempotent(tmp_db):
|
||||
"""Calling init_db() multiple times must not raise or duplicate games."""
|
||||
init_db()
|
||||
init_db()
|
||||
|
||||
conn = get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT COUNT(*) as cnt FROM games")
|
||||
count = cursor.fetchone()["cnt"]
|
||||
conn.close()
|
||||
|
||||
assert count == 2, f"Expected 2 games, got {count} — possible duplicate seed"
|
||||
|
||||
|
||||
def test_unique_index_on_draws(tmp_db):
|
||||
"""Unique index on (game_id, draw_date) must prevent duplicate inserts."""
|
||||
conn = get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Get Powerball id
|
||||
cursor.execute("SELECT id FROM games WHERE name = 'Powerball'")
|
||||
pb_id = cursor.fetchone()["id"]
|
||||
|
||||
# First insert — should succeed
|
||||
cursor.execute("""
|
||||
INSERT INTO draws (game_id, draw_date, numbers, bonus, source)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""", (pb_id, "2024-03-01", "5,12,33,47,65", "8", "test"))
|
||||
conn.commit()
|
||||
|
||||
# Duplicate insert — must raise IntegrityError
|
||||
import sqlite3
|
||||
with pytest.raises(sqlite3.IntegrityError):
|
||||
cursor.execute("""
|
||||
INSERT INTO draws (game_id, draw_date, numbers, bonus, source)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""", (pb_id, "2024-03-01", "1,2,3,4,5", "9", "test"))
|
||||
conn.commit()
|
||||
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_get_db_stats_empty(tmp_db):
|
||||
"""get_db_stats() returns 0 draw count for all games when DB is empty."""
|
||||
stats = get_db_stats()
|
||||
assert "Powerball" in stats
|
||||
assert "Mega Millions" in stats
|
||||
assert stats["Powerball"] == 0
|
||||
assert stats["Mega Millions"] == 0
|
||||
|
||||
|
||||
def test_get_db_stats_with_draws(tmp_db):
|
||||
"""get_db_stats() returns correct count after inserting draws."""
|
||||
conn = get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT id FROM games WHERE name = 'Powerball'")
|
||||
pb_id = cursor.fetchone()["id"]
|
||||
|
||||
cursor.execute("""
|
||||
INSERT INTO draws (game_id, draw_date, numbers, bonus)
|
||||
VALUES (?, ?, ?, ?)
|
||||
""", (pb_id, "2024-01-06", "5,12,33,47,65", "8"))
|
||||
cursor.execute("""
|
||||
INSERT INTO draws (game_id, draw_date, numbers, bonus)
|
||||
VALUES (?, ?, ?, ?)
|
||||
""", (pb_id, "2024-01-10", "2,19,30,44,58", "14"))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
stats = get_db_stats()
|
||||
assert stats["Powerball"] == 2
|
||||
assert stats["Mega Millions"] == 0
|
||||
@@ -0,0 +1,385 @@
|
||||
"""
|
||||
tests/test_models.py
|
||||
--------------------
|
||||
Tests for db/models.py covering all CRUD functions:
|
||||
Games — get_all_games, get_game_by_name, get_game_by_id,
|
||||
set_game_active, add_custom_game
|
||||
Draws — insert_draw, draw_exists, get_draws, get_last_draw,
|
||||
get_draw_count, get_all_draws_numbers
|
||||
Predict — insert_prediction, get_predictions
|
||||
FetchLog — insert_fetch_log, get_last_fetch_log,
|
||||
get_fetch_logs, get_last_fetch_per_source
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from db.models import (
|
||||
# Games
|
||||
get_all_games, get_game_by_name, get_game_by_id,
|
||||
set_game_active, add_custom_game,
|
||||
# Draws
|
||||
insert_draw, draw_exists, get_draws, get_last_draw,
|
||||
get_draw_count, get_all_draws_numbers,
|
||||
# Predictions
|
||||
insert_prediction, get_predictions,
|
||||
# Fetch log
|
||||
insert_fetch_log, get_last_fetch_log,
|
||||
get_fetch_logs, get_last_fetch_per_source,
|
||||
)
|
||||
|
||||
|
||||
# ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
def _pb_id(tmp_db):
|
||||
"""Return Powerball game id."""
|
||||
return get_game_by_name("Powerball")["id"]
|
||||
|
||||
def _mm_id(tmp_db):
|
||||
"""Return Mega Millions game id."""
|
||||
return get_game_by_name("Mega Millions")["id"]
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
# GAMES
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
def test_get_all_games_returns_two(tmp_db):
|
||||
games = get_all_games()
|
||||
assert len(games) == 2
|
||||
|
||||
|
||||
def test_get_all_games_active_only(tmp_db):
|
||||
"""active_only=True should return 2 by default (both active)."""
|
||||
games = get_all_games(active_only=True)
|
||||
assert len(games) == 2
|
||||
|
||||
|
||||
def test_get_game_by_name_powerball(tmp_db):
|
||||
row = get_game_by_name("Powerball")
|
||||
assert row is not None
|
||||
assert row["name"] == "Powerball"
|
||||
|
||||
|
||||
def test_get_game_by_name_not_found(tmp_db):
|
||||
row = get_game_by_name("NonExistentGame")
|
||||
assert row is None
|
||||
|
||||
|
||||
def test_get_game_by_id(tmp_db):
|
||||
pb = get_game_by_name("Powerball")
|
||||
row = get_game_by_id(pb["id"])
|
||||
assert row is not None
|
||||
assert row["name"] == "Powerball"
|
||||
|
||||
|
||||
def test_get_game_by_id_not_found(tmp_db):
|
||||
row = get_game_by_id(9999)
|
||||
assert row is None
|
||||
|
||||
|
||||
def test_set_game_active_disable(tmp_db):
|
||||
pb_id = _pb_id(tmp_db)
|
||||
set_game_active(pb_id, False)
|
||||
row = get_game_by_id(pb_id)
|
||||
assert row["active"] == 0
|
||||
|
||||
|
||||
def test_set_game_active_enable(tmp_db):
|
||||
pb_id = _pb_id(tmp_db)
|
||||
set_game_active(pb_id, False)
|
||||
set_game_active(pb_id, True)
|
||||
row = get_game_by_id(pb_id)
|
||||
assert row["active"] == 1
|
||||
|
||||
|
||||
def test_active_only_filters_disabled(tmp_db):
|
||||
pb_id = _pb_id(tmp_db)
|
||||
set_game_active(pb_id, False)
|
||||
active_games = get_all_games(active_only=True)
|
||||
names = [g["name"] for g in active_games]
|
||||
assert "Powerball" not in names
|
||||
assert "Mega Millions" in names
|
||||
|
||||
|
||||
def test_add_custom_game(tmp_db):
|
||||
new_id = add_custom_game("Pick 3", 3, 9, bonus_count=0, bonus_max=0)
|
||||
assert new_id is not None
|
||||
row = get_game_by_id(new_id)
|
||||
assert row["name"] == "Pick 3"
|
||||
assert row["main_count"] == 3
|
||||
assert row["main_max"] == 9
|
||||
assert row["active"] == 1
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
# DRAWS
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
def test_insert_draw_returns_inserted(tmp_db):
|
||||
pb_id = _pb_id(tmp_db)
|
||||
result = insert_draw(pb_id, "2024-01-06", [5, 12, 33, 47, 65],
|
||||
bonus=8, source="test")
|
||||
assert result == "inserted"
|
||||
|
||||
|
||||
def test_insert_draw_duplicate_returns_skipped(tmp_db):
|
||||
pb_id = _pb_id(tmp_db)
|
||||
insert_draw(pb_id, "2024-01-06", [5, 12, 33, 47, 65], bonus=8)
|
||||
result = insert_draw(pb_id, "2024-01-06", [1, 2, 3, 4, 5], bonus=9)
|
||||
assert result == "skipped"
|
||||
|
||||
|
||||
def test_insert_draw_accepts_string_numbers(tmp_db):
|
||||
pb_id = _pb_id(tmp_db)
|
||||
result = insert_draw(pb_id, "2024-02-01", "10,20,30,40,50", bonus="5")
|
||||
assert result == "inserted"
|
||||
|
||||
|
||||
def test_draw_exists_true(tmp_db):
|
||||
pb_id = _pb_id(tmp_db)
|
||||
insert_draw(pb_id, "2024-01-06", [5, 12, 33, 47, 65], bonus=8)
|
||||
assert draw_exists(pb_id, "2024-01-06") is True
|
||||
|
||||
|
||||
def test_draw_exists_false(tmp_db):
|
||||
pb_id = _pb_id(tmp_db)
|
||||
assert draw_exists(pb_id, "2099-12-31") is False
|
||||
|
||||
|
||||
def test_get_draws_returns_correct_game(tmp_db):
|
||||
pb_id = _pb_id(tmp_db)
|
||||
mm_id = _mm_id(tmp_db)
|
||||
insert_draw(pb_id, "2024-01-06", [5, 12, 33, 47, 65], bonus=8)
|
||||
insert_draw(mm_id, "2024-01-05", [3, 17, 28, 41, 60], bonus=12)
|
||||
|
||||
pb_draws = get_draws(pb_id)
|
||||
mm_draws = get_draws(mm_id)
|
||||
assert len(pb_draws) == 1
|
||||
assert len(mm_draws) == 1
|
||||
|
||||
|
||||
def test_get_draws_order_desc(tmp_db):
|
||||
pb_id = _pb_id(tmp_db)
|
||||
insert_draw(pb_id, "2024-01-01", [1, 2, 3, 4, 5], bonus=1)
|
||||
insert_draw(pb_id, "2024-01-10", [6, 7, 8, 9, 10], bonus=2)
|
||||
insert_draw(pb_id, "2024-01-20", [11, 12, 13, 14, 15], bonus=3)
|
||||
|
||||
draws = get_draws(pb_id, order="DESC")
|
||||
dates = [d["draw_date"] for d in draws]
|
||||
assert dates == sorted(dates, reverse=True)
|
||||
|
||||
|
||||
def test_get_draws_order_asc(tmp_db):
|
||||
pb_id = _pb_id(tmp_db)
|
||||
insert_draw(pb_id, "2024-01-01", [1, 2, 3, 4, 5], bonus=1)
|
||||
insert_draw(pb_id, "2024-01-10", [6, 7, 8, 9, 10], bonus=2)
|
||||
|
||||
draws = get_draws(pb_id, order="ASC")
|
||||
dates = [d["draw_date"] for d in draws]
|
||||
assert dates == sorted(dates)
|
||||
|
||||
|
||||
def test_get_draws_with_limit(tmp_db):
|
||||
pb_id = _pb_id(tmp_db)
|
||||
for i in range(1, 6):
|
||||
insert_draw(pb_id, f"2024-01-{i:02d}", [i, i+1, i+2, i+3, i+4], bonus=i)
|
||||
|
||||
draws = get_draws(pb_id, limit=3)
|
||||
assert len(draws) == 3
|
||||
|
||||
|
||||
def test_get_draws_date_filter(tmp_db):
|
||||
pb_id = _pb_id(tmp_db)
|
||||
insert_draw(pb_id, "2024-01-01", [1, 2, 3, 4, 5], bonus=1)
|
||||
insert_draw(pb_id, "2024-06-15", [6, 7, 8, 9, 10], bonus=2)
|
||||
insert_draw(pb_id, "2024-12-31", [11, 12, 13, 14, 15], bonus=3)
|
||||
|
||||
draws = get_draws(pb_id, date_from="2024-06-01", date_to="2024-12-01")
|
||||
assert len(draws) == 1
|
||||
assert draws[0]["draw_date"] == "2024-06-15"
|
||||
|
||||
|
||||
def test_get_last_draw(tmp_db):
|
||||
pb_id = _pb_id(tmp_db)
|
||||
insert_draw(pb_id, "2024-01-01", [1, 2, 3, 4, 5], bonus=1)
|
||||
insert_draw(pb_id, "2024-01-20", [6, 7, 8, 9, 10], bonus=2)
|
||||
|
||||
last = get_last_draw(pb_id)
|
||||
assert last["draw_date"] == "2024-01-20"
|
||||
|
||||
|
||||
def test_get_last_draw_empty(tmp_db):
|
||||
pb_id = _pb_id(tmp_db)
|
||||
assert get_last_draw(pb_id) is None
|
||||
|
||||
|
||||
def test_get_draw_count(tmp_db):
|
||||
pb_id = _pb_id(tmp_db)
|
||||
assert get_draw_count(pb_id) == 0
|
||||
insert_draw(pb_id, "2024-01-01", [1, 2, 3, 4, 5], bonus=1)
|
||||
insert_draw(pb_id, "2024-01-10", [6, 7, 8, 9, 10], bonus=2)
|
||||
assert get_draw_count(pb_id) == 2
|
||||
|
||||
|
||||
def test_get_all_draws_numbers_parses_correctly(tmp_db):
|
||||
pb_id = _pb_id(tmp_db)
|
||||
insert_draw(pb_id, "2024-01-06", [5, 12, 33, 47, 65], bonus=8)
|
||||
insert_draw(pb_id, "2024-01-10", [2, 19, 30, 44, 58], bonus=14)
|
||||
|
||||
draws = get_all_draws_numbers(pb_id)
|
||||
assert len(draws) == 2
|
||||
|
||||
# Oldest first (ASC)
|
||||
assert draws[0]["draw_date"] == "2024-01-06"
|
||||
assert draws[0]["numbers"] == [5, 12, 33, 47, 65]
|
||||
assert draws[0]["bonus"] == 8
|
||||
|
||||
assert draws[1]["draw_date"] == "2024-01-10"
|
||||
assert draws[1]["numbers"] == [2, 19, 30, 44, 58]
|
||||
assert draws[1]["bonus"] == 14
|
||||
|
||||
|
||||
def test_get_all_draws_numbers_empty(tmp_db):
|
||||
pb_id = _pb_id(tmp_db)
|
||||
draws = get_all_draws_numbers(pb_id)
|
||||
assert draws == []
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
# PREDICTIONS
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
def test_insert_prediction_returns_id(tmp_db):
|
||||
pb_id = _pb_id(tmp_db)
|
||||
new_id = insert_prediction(pb_id, "Hot Numbers", [7, 14, 22, 36, 55], bonus=18)
|
||||
assert isinstance(new_id, int)
|
||||
assert new_id > 0
|
||||
|
||||
|
||||
def test_insert_prediction_list_and_string(tmp_db):
|
||||
pb_id = _pb_id(tmp_db)
|
||||
id1 = insert_prediction(pb_id, "Hot Numbers", [1, 2, 3, 4, 5], bonus=6)
|
||||
id2 = insert_prediction(pb_id, "Due Numbers", "10,20,30,40,50", bonus=None)
|
||||
assert id1 != id2
|
||||
|
||||
|
||||
def test_get_predictions_by_game(tmp_db):
|
||||
pb_id = _pb_id(tmp_db)
|
||||
mm_id = _mm_id(tmp_db)
|
||||
insert_prediction(pb_id, "Hot Numbers", [1, 2, 3, 4, 5], bonus=6)
|
||||
insert_prediction(mm_id, "Monte Carlo", [10, 20, 30, 40, 50], bonus=7)
|
||||
|
||||
pb_preds = get_predictions(game_id=pb_id)
|
||||
mm_preds = get_predictions(game_id=mm_id)
|
||||
assert len(pb_preds) == 1
|
||||
assert len(mm_preds) == 1
|
||||
assert pb_preds[0]["strategy"] == "Hot Numbers"
|
||||
assert mm_preds[0]["strategy"] == "Monte Carlo"
|
||||
|
||||
|
||||
def test_get_predictions_all_games(tmp_db):
|
||||
pb_id = _pb_id(tmp_db)
|
||||
mm_id = _mm_id(tmp_db)
|
||||
insert_prediction(pb_id, "Hot Numbers", [1, 2, 3, 4, 5])
|
||||
insert_prediction(mm_id, "Due Numbers", [6, 7, 8, 9, 10])
|
||||
|
||||
all_preds = get_predictions()
|
||||
assert len(all_preds) == 2
|
||||
|
||||
|
||||
def test_get_predictions_respects_limit(tmp_db):
|
||||
pb_id = _pb_id(tmp_db)
|
||||
for i in range(10):
|
||||
insert_prediction(pb_id, "Weighted Random", [i+1, i+2, i+3, i+4, i+5])
|
||||
|
||||
preds = get_predictions(game_id=pb_id, limit=5)
|
||||
assert len(preds) == 5
|
||||
|
||||
|
||||
def test_get_predictions_newest_first(tmp_db):
|
||||
"""
|
||||
Verify get_predictions returns DESC order by id (newest first).
|
||||
We can't rely on created_at within the same second in SQLite,
|
||||
so compare by id: higher id = inserted later = should appear first.
|
||||
"""
|
||||
pb_id = _pb_id(tmp_db)
|
||||
id1 = insert_prediction(pb_id, "Hot Numbers", [1, 2, 3, 4, 5])
|
||||
id2 = insert_prediction(pb_id, "Due Numbers", [6, 7, 8, 9, 10])
|
||||
|
||||
preds = get_predictions(game_id=pb_id)
|
||||
ids = [p["id"] for p in preds]
|
||||
# Should be descending: id2 before id1
|
||||
assert ids.index(id2) < ids.index(id1), (
|
||||
"Predictions not returned newest-first by id"
|
||||
)
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
# FETCH LOG
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
def test_insert_fetch_log_returns_id(tmp_db):
|
||||
new_id = insert_fetch_log("NY Powerball", added=10, skipped=2)
|
||||
assert isinstance(new_id, int)
|
||||
assert new_id > 0
|
||||
|
||||
|
||||
def test_insert_fetch_log_error_status(tmp_db):
|
||||
new_id = insert_fetch_log("TX Mega Millions", added=0, skipped=0,
|
||||
status="error", message="Connection timeout")
|
||||
assert new_id > 0
|
||||
|
||||
|
||||
def test_get_last_fetch_log_any(tmp_db):
|
||||
"""
|
||||
get_last_fetch_log() with no source filter returns the most recent entry.
|
||||
Within the same second, SQLite order is undefined — use id to verify.
|
||||
"""
|
||||
id1 = insert_fetch_log("NY Powerball", added=5, skipped=1)
|
||||
id2 = insert_fetch_log("NY Mega Millions", added=3, skipped=0)
|
||||
row = get_last_fetch_log()
|
||||
assert row is not None
|
||||
# Most recent = highest id
|
||||
assert row["id"] == max(id1, id2)
|
||||
|
||||
|
||||
def test_get_last_fetch_log_by_source(tmp_db):
|
||||
insert_fetch_log("NY Powerball", added=5, skipped=1)
|
||||
insert_fetch_log("NY Mega Millions", added=3, skipped=0)
|
||||
row = get_last_fetch_log(source="NY Powerball")
|
||||
assert row is not None
|
||||
assert row["source"] == "NY Powerball"
|
||||
assert row["added"] == 5
|
||||
assert row["skipped"] == 1
|
||||
|
||||
|
||||
def test_get_last_fetch_log_none_when_empty(tmp_db):
|
||||
row = get_last_fetch_log()
|
||||
assert row is None
|
||||
|
||||
|
||||
def test_get_fetch_logs_limit(tmp_db):
|
||||
for i in range(10):
|
||||
insert_fetch_log(f"source_{i}", added=i, skipped=0)
|
||||
logs = get_fetch_logs(limit=5)
|
||||
assert len(logs) == 5
|
||||
|
||||
|
||||
def test_get_last_fetch_per_source(tmp_db):
|
||||
insert_fetch_log("NY Powerball", added=5, skipped=1)
|
||||
insert_fetch_log("NY Mega Millions", added=3, skipped=0)
|
||||
insert_fetch_log("TX Mega Millions", added=2, skipped=1)
|
||||
# Second Powerball fetch — should be the "latest" for that source
|
||||
insert_fetch_log("NY Powerball", added=1, skipped=4)
|
||||
|
||||
per_source = get_last_fetch_per_source()
|
||||
assert "NY Powerball" in per_source
|
||||
assert "NY Mega Millions" in per_source
|
||||
assert "TX Mega Millions" in per_source
|
||||
|
||||
# Latest Powerball fetch had added=1
|
||||
assert per_source["NY Powerball"]["added"] == 1
|
||||
|
||||
|
||||
def test_get_last_fetch_per_source_empty(tmp_db):
|
||||
per_source = get_last_fetch_per_source()
|
||||
assert per_source == {}
|
||||
Reference in New Issue
Block a user