05/23 Phase 18,19
This commit is contained in:
@@ -14,7 +14,9 @@
|
|||||||
"Bash(python -c \"from ui.dashboard import DashboardScreen\")",
|
"Bash(python -c \"from ui.dashboard import DashboardScreen\")",
|
||||||
"Bash(python -m pytest tests/test_analysis_charts.py -v --tb=short)",
|
"Bash(python -m pytest tests/test_analysis_charts.py -v --tb=short)",
|
||||||
"Bash(python -m pytest tests/ -q)",
|
"Bash(python -m pytest tests/ -q)",
|
||||||
"Bash(python -c ' *)"
|
"Bash(python -c ' *)",
|
||||||
|
"Bash(python -m pytest tests/test_backup.py -v)",
|
||||||
|
"Bash(python -m pytest --tb=short -q)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -341,6 +341,38 @@ All actions are logged to console and optionally to a log file:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### ✅ Phase 19 — Dashboard Overdue Alert + DB Backup
|
||||||
|
- [x] Add `backup_db(dest_dir=None) -> str` to `db/database.py` — copies live DB to timestamped file, creates dest dir if needed
|
||||||
|
- [x] Add `restore_db(source_path: str) -> None` to `db/database.py` — overwrites live DB with backup file, raises FileNotFoundError if missing
|
||||||
|
- [x] Update `ui/dashboard.py`
|
||||||
|
- [x] Import `gap_analysis` from `core.analyzer`
|
||||||
|
- [x] Add "Most Overdue Numbers" section below Hot Numbers
|
||||||
|
- [x] `_refresh_overdue()` — top-5 highest-gap numbers per active game, shown as orange-highlighted `BallsBar` with gap counts
|
||||||
|
- [x] Called from `refresh()`
|
||||||
|
- [x] Update `ui/settings.py`
|
||||||
|
- [x] Import `backup_db, restore_db` from `db.database`
|
||||||
|
- [x] Add "Database Actions" section with Backup DB + Restore DB buttons
|
||||||
|
- [x] `_backup_db()` — runs backup, shows path in success dialog
|
||||||
|
- [x] `_restore_db()` — file dialog, confirm prompt, restores, advises restart
|
||||||
|
- [x] Write `tests/test_backup.py` — 10 tests (361/361 total passing)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ✅ Phase 18 — Draw CSV Import
|
||||||
|
- [x] Write `core/importer.py`
|
||||||
|
- [x] `_parse_date(text)` — accepts ISO (YYYY-MM-DD), US slash (MM/DD/YYYY), US dash, day-first formats
|
||||||
|
- [x] `_is_header(row)` — heuristic: first cell is not a valid date
|
||||||
|
- [x] `_parse_row_lottosight` — handles LottoSight's own export format (Game, Date, Numbers, Bonus, …)
|
||||||
|
- [x] `_parse_row_generic` — handles wide format (Date, N1, N2, …) and packed format (Date, "N1,N2,…")
|
||||||
|
- [x] `import_draws_csv(game_id, filepath) -> {added, skipped, errors}` — validates count + range, calls `insert_draw`, deduplicates via existing DB logic
|
||||||
|
- [x] Update `ui/settings.py`
|
||||||
|
- [x] Add `filedialog` import
|
||||||
|
- [x] "Import CSV" button on every game row (builtin and custom)
|
||||||
|
- [x] `_import_csv(game_id, game_name)` — file dialog → import → show summary with error preview → refresh games
|
||||||
|
- [x] Write `tests/test_importer.py` — 23 tests (351/351 total passing)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### ✅ Phase 17 — Predictor Power Features
|
### ✅ Phase 17 — Predictor Power Features
|
||||||
- [x] Add `quick_pick(game_id, exclude=None)` to `core/predictor.py` — pure random, no draw history required
|
- [x] Add `quick_pick(game_id, exclude=None)` to `core/predictor.py` — pure random, no draw history required
|
||||||
- [x] Add `exclude: set | None = None` parameter to all 5 existing strategies + `_random_ticket` + `_fill_to_count`
|
- [x] Add `exclude: set | None = None` parameter to all 5 existing strategies + `_random_ticket` + `_fill_to_count`
|
||||||
|
|||||||
@@ -0,0 +1,183 @@
|
|||||||
|
"""
|
||||||
|
core/importer.py
|
||||||
|
----------------
|
||||||
|
CSV draw importer for LottoSight.
|
||||||
|
|
||||||
|
Supported formats (auto-detected):
|
||||||
|
1. LottoSight export — Game, Date, "N1,N2,...", Bonus, Multiplier, Source
|
||||||
|
2. Wide format — Date, N1, N2, ..., Nn [, Bonus]
|
||||||
|
3. Packed format — Date, "N1,N2,...", [Bonus]
|
||||||
|
|
||||||
|
Header rows are auto-detected and skipped.
|
||||||
|
Dates accepted: YYYY-MM-DD, MM/DD/YYYY, M/D/YYYY, MM-DD-YYYY.
|
||||||
|
Duplicate draws (same game + date already in DB) are skipped, not errored.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import csv
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from db.models import get_game_by_id, insert_draw
|
||||||
|
|
||||||
|
|
||||||
|
# ── Date parsing ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_DATE_FORMATS = ("%Y-%m-%d", "%m/%d/%Y", "%m-%d-%Y", "%d/%m/%Y", "%-m/%-d/%Y")
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_date(text: str) -> str | None:
|
||||||
|
"""Return ISO date string (YYYY-MM-DD) or None."""
|
||||||
|
text = text.strip()
|
||||||
|
for fmt in _DATE_FORMATS:
|
||||||
|
try:
|
||||||
|
return datetime.strptime(text, fmt).strftime("%Y-%m-%d")
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _is_header(row: list[str]) -> bool:
|
||||||
|
"""Heuristic: first cell is not a recognisable date → row is a header."""
|
||||||
|
return bool(row) and _parse_date(row[0]) is None
|
||||||
|
|
||||||
|
|
||||||
|
# ── Row parsing ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _parse_row_lottosight(cells: list[str]) -> dict | None:
|
||||||
|
"""Parse LottoSight export row: Game, Date, Numbers, Bonus, ..."""
|
||||||
|
if len(cells) < 3:
|
||||||
|
return None
|
||||||
|
date_str = _parse_date(cells[1])
|
||||||
|
if date_str is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
numbers = [int(n.strip()) for n in cells[2].split(",") if n.strip()]
|
||||||
|
bonus = int(cells[3]) if len(cells) > 3 and cells[3].strip().isdigit() else None
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
return None
|
||||||
|
return {"date": date_str, "numbers": numbers, "bonus": bonus}
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_row_generic(cells: list[str], game: dict) -> dict | None:
|
||||||
|
"""Parse wide or packed format: Date, [nums...]"""
|
||||||
|
date_str = _parse_date(cells[0])
|
||||||
|
if date_str is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
rest = cells[1:]
|
||||||
|
if not rest:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Packed: second cell contains comma-separated numbers
|
||||||
|
if "," in rest[0]:
|
||||||
|
try:
|
||||||
|
numbers = [int(n.strip()) for n in rest[0].split(",") if n.strip()]
|
||||||
|
bonus = int(rest[1]) if len(rest) > 1 and rest[1].strip().isdigit() else None
|
||||||
|
return {"date": date_str, "numbers": numbers, "bonus": bonus}
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Wide: each number in its own column
|
||||||
|
nums: list[int] = []
|
||||||
|
for col in rest:
|
||||||
|
col = col.strip()
|
||||||
|
if col.isdigit():
|
||||||
|
nums.append(int(col))
|
||||||
|
else:
|
||||||
|
break # stop at first non-numeric cell (e.g. multiplier string)
|
||||||
|
|
||||||
|
if len(nums) < game["main_count"]:
|
||||||
|
return None
|
||||||
|
|
||||||
|
numbers = nums[: game["main_count"]]
|
||||||
|
bonus = (nums[game["main_count"]]
|
||||||
|
if game["bonus_count"] > 0 and len(nums) > game["main_count"]
|
||||||
|
else None)
|
||||||
|
return {"date": date_str, "numbers": numbers, "bonus": bonus}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Public API ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def import_draws_csv(game_id: int, filepath: str) -> dict:
|
||||||
|
"""
|
||||||
|
Import draw records from *filepath* into the database for *game_id*.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{"added": int, "skipped": int, "errors": list[str]}
|
||||||
|
"""
|
||||||
|
game = get_game_by_id(game_id)
|
||||||
|
if game is None:
|
||||||
|
return {"added": 0, "skipped": 0, "errors": [f"Unknown game_id {game_id}"]}
|
||||||
|
|
||||||
|
added = skipped = 0
|
||||||
|
errors: list[str] = []
|
||||||
|
|
||||||
|
# Read file
|
||||||
|
try:
|
||||||
|
with open(filepath, newline="", encoding="utf-8-sig") as f:
|
||||||
|
sample = f.read(4096)
|
||||||
|
f.seek(0)
|
||||||
|
try:
|
||||||
|
dialect = csv.Sniffer().sniff(sample, delimiters=",\t;")
|
||||||
|
except csv.Error:
|
||||||
|
dialect = csv.excel
|
||||||
|
rows = list(csv.reader(f, dialect))
|
||||||
|
except FileNotFoundError:
|
||||||
|
return {"added": 0, "skipped": 0, "errors": [f"File not found: {filepath}"]}
|
||||||
|
except Exception as e:
|
||||||
|
return {"added": 0, "skipped": 0, "errors": [f"Could not read file: {e}"]}
|
||||||
|
|
||||||
|
if not rows:
|
||||||
|
return {"added": 0, "skipped": 0, "errors": ["File is empty"]}
|
||||||
|
|
||||||
|
# Detect LottoSight export format by header
|
||||||
|
lottosight_fmt = (
|
||||||
|
len(rows[0]) >= 3
|
||||||
|
and rows[0][0].strip().lower() == "game"
|
||||||
|
and rows[0][1].strip().lower() == "date"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Determine start row (skip header if present)
|
||||||
|
start = 1 if (lottosight_fmt or _is_header(rows[0])) else 0
|
||||||
|
|
||||||
|
for line_num, row in enumerate(rows[start:], start=start + 1):
|
||||||
|
cells = [c.strip() for c in row]
|
||||||
|
if not any(cells):
|
||||||
|
continue # blank line
|
||||||
|
|
||||||
|
if lottosight_fmt:
|
||||||
|
parsed = _parse_row_lottosight(cells)
|
||||||
|
else:
|
||||||
|
parsed = _parse_row_generic(cells, game)
|
||||||
|
|
||||||
|
if parsed is None:
|
||||||
|
errors.append(f"Line {line_num}: could not parse — {row}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
numbers = parsed["numbers"]
|
||||||
|
|
||||||
|
# Validate count
|
||||||
|
if len(numbers) != game["main_count"]:
|
||||||
|
errors.append(
|
||||||
|
f"Line {line_num}: expected {game['main_count']} numbers, "
|
||||||
|
f"got {len(numbers)}"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Validate range
|
||||||
|
bad = [n for n in numbers if not (1 <= n <= game["main_max"])]
|
||||||
|
if bad:
|
||||||
|
errors.append(
|
||||||
|
f"Line {line_num}: numbers out of range 1–{game['main_max']}: {bad}"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
result = insert_draw(
|
||||||
|
game_id, parsed["date"], numbers,
|
||||||
|
bonus=parsed["bonus"], source="csv_import",
|
||||||
|
)
|
||||||
|
if result == "inserted":
|
||||||
|
added += 1
|
||||||
|
else:
|
||||||
|
skipped += 1
|
||||||
|
|
||||||
|
return {"added": added, "skipped": skipped, "errors": errors}
|
||||||
Binary file not shown.
@@ -8,6 +8,8 @@ All table definitions live here. Call init_db() once on app startup.
|
|||||||
import sqlite3
|
import sqlite3
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import shutil
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
from core.paths import user_data_dir
|
from core.paths import user_data_dir
|
||||||
|
|
||||||
@@ -143,6 +145,27 @@ def _seed_games(cursor, conn):
|
|||||||
logger.info("[DB] Default games already seeded — skipped")
|
logger.info("[DB] Default games already seeded — skipped")
|
||||||
|
|
||||||
|
|
||||||
|
def backup_db(dest_dir: str | None = None) -> str:
|
||||||
|
"""Copy the live DB to dest_dir and return the backup file path."""
|
||||||
|
if dest_dir is None:
|
||||||
|
dest_dir = os.path.join(user_data_dir(), "exports")
|
||||||
|
os.makedirs(dest_dir, exist_ok=True)
|
||||||
|
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
dest = os.path.join(dest_dir, f"lottosight_backup_{ts}.db")
|
||||||
|
shutil.copy2(DB_PATH, dest)
|
||||||
|
logger.info("[DB] Backup created: %s", dest)
|
||||||
|
return dest
|
||||||
|
|
||||||
|
|
||||||
|
def restore_db(source_path: str) -> None:
|
||||||
|
"""Overwrite the live DB with a backup file."""
|
||||||
|
if not os.path.isfile(source_path):
|
||||||
|
raise FileNotFoundError(f"Backup file not found: {source_path}")
|
||||||
|
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
|
||||||
|
shutil.copy2(source_path, DB_PATH)
|
||||||
|
logger.info("[DB] Database restored from: %s", source_path)
|
||||||
|
|
||||||
|
|
||||||
def get_db_stats():
|
def get_db_stats():
|
||||||
"""
|
"""
|
||||||
Return a dict of basic DB stats for display in Settings screen.
|
Return a dict of basic DB stats for display in Settings screen.
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
"""
|
||||||
|
tests/test_backup.py
|
||||||
|
---------------------
|
||||||
|
Tests for db.database backup_db() and restore_db().
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import pytest
|
||||||
|
from db.database import backup_db, restore_db, DB_PATH, get_connection
|
||||||
|
from db.models import insert_draw, get_draws_with_game, get_game_by_name
|
||||||
|
|
||||||
|
|
||||||
|
# ── backup_db ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_backup_creates_file(tmp_db, tmp_path):
|
||||||
|
dest = backup_db(dest_dir=str(tmp_path))
|
||||||
|
assert os.path.isfile(dest)
|
||||||
|
|
||||||
|
|
||||||
|
def test_backup_filename_contains_timestamp(tmp_db, tmp_path):
|
||||||
|
dest = backup_db(dest_dir=str(tmp_path))
|
||||||
|
basename = os.path.basename(dest)
|
||||||
|
assert basename.startswith("lottosight_backup_")
|
||||||
|
assert basename.endswith(".db")
|
||||||
|
|
||||||
|
|
||||||
|
def test_backup_returns_path_string(tmp_db, tmp_path):
|
||||||
|
result = backup_db(dest_dir=str(tmp_path))
|
||||||
|
assert isinstance(result, str)
|
||||||
|
assert len(result) > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_backup_file_is_valid_sqlite(tmp_db, tmp_path):
|
||||||
|
dest = backup_db(dest_dir=str(tmp_path))
|
||||||
|
import sqlite3
|
||||||
|
conn = sqlite3.connect(dest)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("SELECT name FROM sqlite_master WHERE type='table'")
|
||||||
|
tables = {r[0] for r in cursor.fetchall()}
|
||||||
|
conn.close()
|
||||||
|
assert "games" in tables
|
||||||
|
assert "draws" in tables
|
||||||
|
|
||||||
|
|
||||||
|
def test_backup_preserves_draw_data(tmp_db, tmp_path):
|
||||||
|
game = get_game_by_name("Powerball")
|
||||||
|
insert_draw(game["id"], "2024-06-01", [5, 14, 22, 36, 69], bonus=7)
|
||||||
|
dest = backup_db(dest_dir=str(tmp_path))
|
||||||
|
import sqlite3
|
||||||
|
conn = sqlite3.connect(dest)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("SELECT numbers FROM draws WHERE draw_date='2024-06-01'")
|
||||||
|
row = cursor.fetchone()
|
||||||
|
conn.close()
|
||||||
|
assert row is not None
|
||||||
|
assert "5" in row["numbers"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_backup_creates_dest_dir_if_missing(tmp_db, tmp_path):
|
||||||
|
nested = str(tmp_path / "a" / "b" / "c")
|
||||||
|
dest = backup_db(dest_dir=nested)
|
||||||
|
assert os.path.isfile(dest)
|
||||||
|
|
||||||
|
|
||||||
|
def test_backup_default_dest_dir(tmp_db, monkeypatch, tmp_path):
|
||||||
|
from core.paths import user_data_dir as _udd
|
||||||
|
monkeypatch.setattr("db.database.user_data_dir", lambda: str(tmp_path))
|
||||||
|
dest = backup_db() # no dest_dir — should use exports/ sub-dir
|
||||||
|
assert os.path.isfile(dest)
|
||||||
|
assert "exports" in dest
|
||||||
|
|
||||||
|
|
||||||
|
# ── restore_db ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_restore_missing_file_raises(tmp_db):
|
||||||
|
with pytest.raises(FileNotFoundError):
|
||||||
|
restore_db("/nonexistent/backup.db")
|
||||||
|
|
||||||
|
|
||||||
|
def test_restore_replaces_live_db(tmp_db, tmp_path):
|
||||||
|
game = get_game_by_name("Powerball")
|
||||||
|
insert_draw(game["id"], "2024-07-04", [3, 17, 28, 45, 62], bonus=12)
|
||||||
|
backup_path = backup_db(dest_dir=str(tmp_path))
|
||||||
|
|
||||||
|
# Delete the draw from the live DB
|
||||||
|
conn = get_connection()
|
||||||
|
conn.execute("DELETE FROM draws WHERE draw_date='2024-07-04'")
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
draws_before = get_draws_with_game(game_id=game["id"])
|
||||||
|
assert not any(d["draw_date"] == "2024-07-04" for d in draws_before)
|
||||||
|
|
||||||
|
restore_db(backup_path)
|
||||||
|
|
||||||
|
draws_after = get_draws_with_game(game_id=game["id"])
|
||||||
|
assert any(d["draw_date"] == "2024-07-04" for d in draws_after)
|
||||||
|
|
||||||
|
|
||||||
|
def test_restore_returns_none(tmp_db, tmp_path):
|
||||||
|
backup_path = backup_db(dest_dir=str(tmp_path))
|
||||||
|
result = restore_db(backup_path)
|
||||||
|
assert result is None
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
"""
|
||||||
|
tests/test_importer.py
|
||||||
|
----------------------
|
||||||
|
Tests for core/importer.py — CSV draw import.
|
||||||
|
|
||||||
|
Powerball config: 5 main from 1–69, 1 bonus from 1–26.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import pytest
|
||||||
|
from db.models import get_game_by_name, add_game, get_draws_with_game
|
||||||
|
from core.importer import import_draws_csv, _parse_date
|
||||||
|
|
||||||
|
|
||||||
|
# ── _parse_date ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_parse_date_iso():
|
||||||
|
assert _parse_date("2024-01-15") == "2024-01-15"
|
||||||
|
|
||||||
|
def test_parse_date_us_slash():
|
||||||
|
assert _parse_date("01/15/2024") == "2024-01-15"
|
||||||
|
|
||||||
|
def test_parse_date_us_dash():
|
||||||
|
assert _parse_date("01-15-2024") == "2024-01-15"
|
||||||
|
|
||||||
|
def test_parse_date_invalid():
|
||||||
|
assert _parse_date("not-a-date") is None
|
||||||
|
|
||||||
|
def test_parse_date_header_text():
|
||||||
|
assert _parse_date("Date") is None
|
||||||
|
assert _parse_date("draw_date") is None
|
||||||
|
|
||||||
|
|
||||||
|
# ── Wide format (Date, N1, N2, ...) ──────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_wide_no_header(tmp_db, tmp_path):
|
||||||
|
game = get_game_by_name("Powerball")
|
||||||
|
csv_file = tmp_path / "draws.csv"
|
||||||
|
csv_file.write_text(
|
||||||
|
"2024-01-01,1,13,36,61,69,7\n"
|
||||||
|
"2024-01-03,2,7,22,45,61,15\n"
|
||||||
|
)
|
||||||
|
result = import_draws_csv(game["id"], str(csv_file))
|
||||||
|
assert result["added"] == 2
|
||||||
|
assert result["skipped"] == 0
|
||||||
|
assert result["errors"] == []
|
||||||
|
|
||||||
|
def test_wide_with_header(tmp_db, tmp_path):
|
||||||
|
game = get_game_by_name("Powerball")
|
||||||
|
csv_file = tmp_path / "draws.csv"
|
||||||
|
csv_file.write_text(
|
||||||
|
"Date,Ball1,Ball2,Ball3,Ball4,Ball5,Bonus\n"
|
||||||
|
"2024-01-01,1,13,36,61,69,7\n"
|
||||||
|
"2024-01-03,2,7,22,45,61,15\n"
|
||||||
|
)
|
||||||
|
result = import_draws_csv(game["id"], str(csv_file))
|
||||||
|
assert result["added"] == 2
|
||||||
|
assert result["errors"] == []
|
||||||
|
|
||||||
|
def test_wide_no_bonus_column(tmp_db, tmp_path):
|
||||||
|
game = get_game_by_name("Powerball")
|
||||||
|
csv_file = tmp_path / "draws.csv"
|
||||||
|
csv_file.write_text("2024-01-01,1,13,36,61,69\n")
|
||||||
|
result = import_draws_csv(game["id"], str(csv_file))
|
||||||
|
assert result["added"] == 1
|
||||||
|
draws = get_draws_with_game(game_id=game["id"])
|
||||||
|
assert draws[0]["bonus"] is None
|
||||||
|
|
||||||
|
def test_wide_us_date_format(tmp_db, tmp_path):
|
||||||
|
game = get_game_by_name("Powerball")
|
||||||
|
csv_file = tmp_path / "draws.csv"
|
||||||
|
csv_file.write_text("01/15/2024,5,14,22,36,69,7\n")
|
||||||
|
result = import_draws_csv(game["id"], str(csv_file))
|
||||||
|
assert result["added"] == 1
|
||||||
|
draws = get_draws_with_game(game_id=game["id"])
|
||||||
|
assert draws[0]["draw_date"] == "2024-01-15"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Packed format (Date, "N1,N2,...", Bonus) ──────────────────────────────────
|
||||||
|
|
||||||
|
def test_packed_format(tmp_db, tmp_path):
|
||||||
|
game = get_game_by_name("Powerball")
|
||||||
|
csv_file = tmp_path / "draws.csv"
|
||||||
|
csv_file.write_text('2024-01-01,"1,13,36,61,69",7\n')
|
||||||
|
result = import_draws_csv(game["id"], str(csv_file))
|
||||||
|
assert result["added"] == 1
|
||||||
|
assert result["errors"] == []
|
||||||
|
|
||||||
|
def test_packed_with_header(tmp_db, tmp_path):
|
||||||
|
game = get_game_by_name("Powerball")
|
||||||
|
csv_file = tmp_path / "draws.csv"
|
||||||
|
csv_file.write_text(
|
||||||
|
'Date,Numbers,Bonus\n'
|
||||||
|
'2024-01-01,"1,13,36,61,69",7\n'
|
||||||
|
'2024-01-03,"2,7,22,45,61",15\n'
|
||||||
|
)
|
||||||
|
result = import_draws_csv(game["id"], str(csv_file))
|
||||||
|
assert result["added"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
# ── LottoSight export format ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_lottosight_export_format(tmp_db, tmp_path):
|
||||||
|
game = get_game_by_name("Powerball")
|
||||||
|
csv_file = tmp_path / "export.csv"
|
||||||
|
csv_file.write_text(
|
||||||
|
"Game,Date,Numbers,Bonus,Multiplier,Source\n"
|
||||||
|
'Powerball,2024-01-01,"1,13,36,61,69",7,2x,powerball_ny\n'
|
||||||
|
'Powerball,2024-01-03,"2,7,22,45,61",15,3x,powerball_ny\n'
|
||||||
|
)
|
||||||
|
result = import_draws_csv(game["id"], str(csv_file))
|
||||||
|
assert result["added"] == 2
|
||||||
|
assert result["errors"] == []
|
||||||
|
|
||||||
|
|
||||||
|
# ── Duplicate detection ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_duplicate_skipped(tmp_db, tmp_path):
|
||||||
|
game = get_game_by_name("Powerball")
|
||||||
|
csv_file = tmp_path / "draws.csv"
|
||||||
|
csv_file.write_text(
|
||||||
|
"2024-01-01,1,13,36,61,69,7\n"
|
||||||
|
"2024-01-01,1,13,36,61,69,7\n" # same date → duplicate
|
||||||
|
)
|
||||||
|
result = import_draws_csv(game["id"], str(csv_file))
|
||||||
|
assert result["added"] == 1
|
||||||
|
assert result["skipped"] == 1
|
||||||
|
|
||||||
|
def test_reimport_all_skipped(tmp_db, tmp_path):
|
||||||
|
game = get_game_by_name("Powerball")
|
||||||
|
csv_file = tmp_path / "draws.csv"
|
||||||
|
csv_file.write_text("2024-01-01,1,13,36,61,69,7\n")
|
||||||
|
import_draws_csv(game["id"], str(csv_file))
|
||||||
|
result = import_draws_csv(game["id"], str(csv_file))
|
||||||
|
assert result["added"] == 0
|
||||||
|
assert result["skipped"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
# ── Validation errors ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_wrong_number_count(tmp_db, tmp_path):
|
||||||
|
game = get_game_by_name("Powerball")
|
||||||
|
csv_file = tmp_path / "draws.csv"
|
||||||
|
csv_file.write_text("2024-01-01,1,13,36,61\n") # only 4 numbers
|
||||||
|
result = import_draws_csv(game["id"], str(csv_file))
|
||||||
|
assert result["added"] == 0
|
||||||
|
assert len(result["errors"]) == 1 # rejected — too few numbers to parse
|
||||||
|
|
||||||
|
def test_number_out_of_range(tmp_db, tmp_path):
|
||||||
|
game = get_game_by_name("Powerball")
|
||||||
|
csv_file = tmp_path / "draws.csv"
|
||||||
|
csv_file.write_text("2024-01-01,1,13,36,61,99,7\n") # 99 > 69
|
||||||
|
result = import_draws_csv(game["id"], str(csv_file))
|
||||||
|
assert result["added"] == 0
|
||||||
|
assert len(result["errors"]) == 1
|
||||||
|
|
||||||
|
def test_mixed_valid_invalid(tmp_db, tmp_path):
|
||||||
|
game = get_game_by_name("Powerball")
|
||||||
|
csv_file = tmp_path / "draws.csv"
|
||||||
|
csv_file.write_text(
|
||||||
|
"2024-01-01,1,13,36,61,69,7\n"
|
||||||
|
"bad_date,1,2,3,4,5,6\n" # unparseable
|
||||||
|
"2024-01-03,2,7,22,45,61,15\n"
|
||||||
|
)
|
||||||
|
result = import_draws_csv(game["id"], str(csv_file))
|
||||||
|
assert result["added"] == 2
|
||||||
|
assert len(result["errors"]) == 1
|
||||||
|
|
||||||
|
|
||||||
|
# ── Edge cases ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_file_not_found(tmp_db):
|
||||||
|
game = get_game_by_name("Powerball")
|
||||||
|
result = import_draws_csv(game["id"], "/nonexistent/path/file.csv")
|
||||||
|
assert result["added"] == 0
|
||||||
|
assert len(result["errors"]) == 1
|
||||||
|
assert "not found" in result["errors"][0].lower()
|
||||||
|
|
||||||
|
def test_empty_file(tmp_db, tmp_path):
|
||||||
|
game = get_game_by_name("Powerball")
|
||||||
|
csv_file = tmp_path / "empty.csv"
|
||||||
|
csv_file.write_text("")
|
||||||
|
result = import_draws_csv(game["id"], str(csv_file))
|
||||||
|
assert result["added"] == 0
|
||||||
|
assert result["errors"] == ["File is empty"]
|
||||||
|
|
||||||
|
def test_blank_lines_ignored(tmp_db, tmp_path):
|
||||||
|
game = get_game_by_name("Powerball")
|
||||||
|
csv_file = tmp_path / "draws.csv"
|
||||||
|
csv_file.write_text(
|
||||||
|
"2024-01-01,1,13,36,61,69,7\n"
|
||||||
|
"\n"
|
||||||
|
"2024-01-03,2,7,22,45,61,15\n"
|
||||||
|
"\n"
|
||||||
|
)
|
||||||
|
result = import_draws_csv(game["id"], str(csv_file))
|
||||||
|
assert result["added"] == 2
|
||||||
|
assert result["errors"] == []
|
||||||
|
|
||||||
|
def test_unknown_game_id(tmp_db, tmp_path):
|
||||||
|
csv_file = tmp_path / "draws.csv"
|
||||||
|
csv_file.write_text("2024-01-01,1,2,3,4,5\n")
|
||||||
|
result = import_draws_csv(99999, str(csv_file))
|
||||||
|
assert result["added"] == 0
|
||||||
|
assert len(result["errors"]) == 1
|
||||||
|
|
||||||
|
def test_custom_game_import(tmp_db, tmp_path):
|
||||||
|
gid = add_game("My Lotto", 6, 49, bonus_count=0, bonus_max=0)
|
||||||
|
csv_file = tmp_path / "draws.csv"
|
||||||
|
csv_file.write_text(
|
||||||
|
"2024-01-01,5,14,22,33,41,48\n"
|
||||||
|
"2024-01-08,3,17,28,35,44,49\n"
|
||||||
|
)
|
||||||
|
result = import_draws_csv(gid, str(csv_file))
|
||||||
|
assert result["added"] == 2
|
||||||
|
assert result["errors"] == []
|
||||||
|
|
||||||
|
def test_data_persisted_correctly(tmp_db, tmp_path):
|
||||||
|
game = get_game_by_name("Powerball")
|
||||||
|
csv_file = tmp_path / "draws.csv"
|
||||||
|
csv_file.write_text("2024-03-15,5,14,22,36,69,7\n")
|
||||||
|
import_draws_csv(game["id"], str(csv_file))
|
||||||
|
draws = get_draws_with_game(game_id=game["id"])
|
||||||
|
assert len(draws) == 1
|
||||||
|
assert draws[0]["draw_date"] == "2024-03-15"
|
||||||
|
nums = [int(n) for n in draws[0]["numbers"].split(",")]
|
||||||
|
assert sorted(nums) == [5, 14, 22, 36, 69]
|
||||||
|
assert str(draws[0]["bonus"]) == "7"
|
||||||
+36
-1
@@ -15,7 +15,7 @@ from datetime import date, timedelta
|
|||||||
|
|
||||||
from db.database import get_db_stats
|
from db.database import get_db_stats
|
||||||
from db.models import get_all_games, get_last_draw, get_draw_count, get_predictions
|
from db.models import get_all_games, get_last_draw, get_draw_count, get_predictions
|
||||||
from core.analyzer import frequency_analysis
|
from core.analyzer import frequency_analysis, gap_analysis
|
||||||
from ui.widgets import BallsBar
|
from ui.widgets import BallsBar
|
||||||
|
|
||||||
# Weekday indices: Monday=0 … Sunday=6
|
# Weekday indices: Monday=0 … Sunday=6
|
||||||
@@ -97,6 +97,7 @@ class DashboardScreen(ttk.Frame):
|
|||||||
self._last_draw_body = self._section_header("Last Draw Results")
|
self._last_draw_body = self._section_header("Last Draw Results")
|
||||||
self._db_body = self._section_header("Database Summary")
|
self._db_body = self._section_header("Database Summary")
|
||||||
self._hot_body = self._section_header("Hot Numbers (last 100 draws)")
|
self._hot_body = self._section_header("Hot Numbers (last 100 draws)")
|
||||||
|
self._overdue_body = self._section_header("Most Overdue Numbers")
|
||||||
|
|
||||||
# ── Refresh ───────────────────────────────────────────────────────────────
|
# ── Refresh ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -104,6 +105,7 @@ class DashboardScreen(ttk.Frame):
|
|||||||
self._refresh_last_draws()
|
self._refresh_last_draws()
|
||||||
self._refresh_db_summary()
|
self._refresh_db_summary()
|
||||||
self._refresh_hot_numbers()
|
self._refresh_hot_numbers()
|
||||||
|
self._refresh_overdue()
|
||||||
|
|
||||||
def _refresh_last_draws(self):
|
def _refresh_last_draws(self):
|
||||||
for w in self._last_draw_body.winfo_children():
|
for w in self._last_draw_body.winfo_children():
|
||||||
@@ -194,3 +196,36 @@ class DashboardScreen(ttk.Frame):
|
|||||||
counts_str = " ".join(f"({freq[n]}×)" for n in top5)
|
counts_str = " ".join(f"({freq[n]}×)" for n in top5)
|
||||||
ttk.Label(row, text=counts_str, foreground="#aaaaaa",
|
ttk.Label(row, text=counts_str, foreground="#aaaaaa",
|
||||||
font=_CARD_FONT).pack(side="left")
|
font=_CARD_FONT).pack(side="left")
|
||||||
|
|
||||||
|
def _refresh_overdue(self):
|
||||||
|
for w in self._overdue_body.winfo_children():
|
||||||
|
w.destroy()
|
||||||
|
|
||||||
|
games = get_all_games(active_only=True)
|
||||||
|
if not games:
|
||||||
|
ttk.Label(self._overdue_body, text="No active games.",
|
||||||
|
foreground="#aaaaaa").pack(anchor="w")
|
||||||
|
return
|
||||||
|
|
||||||
|
_ORANGE = ("#e67e22", "#ffffff")
|
||||||
|
|
||||||
|
for game in games:
|
||||||
|
gaps = gap_analysis(game["id"]) # {number: gap}
|
||||||
|
row = ttk.Frame(self._overdue_body)
|
||||||
|
row.pack(fill="x", pady=3)
|
||||||
|
|
||||||
|
ttk.Label(row, text=f"{game['name']}:", width=18, anchor="w",
|
||||||
|
font=_CARD_FONT).pack(side="left")
|
||||||
|
|
||||||
|
if not gaps:
|
||||||
|
ttk.Label(row, text="No data yet.", foreground="#aaaaaa",
|
||||||
|
font=_CARD_FONT).pack(side="left")
|
||||||
|
continue
|
||||||
|
|
||||||
|
top5 = sorted(sorted(gaps, key=gaps.get, reverse=True)[:5])
|
||||||
|
highlights = {n: _ORANGE for n in top5}
|
||||||
|
BallsBar(row, numbers=top5, radius=11,
|
||||||
|
highlights=highlights).pack(side="left", padx=(0, 6))
|
||||||
|
gaps_str = " ".join(f"({gaps[n]} ago)" for n in top5)
|
||||||
|
ttk.Label(row, text=gaps_str, foreground="#aaaaaa",
|
||||||
|
font=_CARD_FONT).pack(side="left")
|
||||||
|
|||||||
+65
-3
@@ -7,15 +7,16 @@ on_fetch: callable injected by main.py to trigger the shared fetch thread.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import tkinter as tk
|
import tkinter as tk
|
||||||
from tkinter import ttk, messagebox
|
from tkinter import ttk, messagebox, filedialog
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from db.database import get_db_stats
|
from db.database import get_db_stats, backup_db, restore_db
|
||||||
from db.models import (
|
from db.models import (
|
||||||
get_all_games, get_draw_count, set_game_active,
|
get_all_games, get_draw_count, set_game_active,
|
||||||
get_last_fetch_per_source, get_predictions,
|
get_last_fetch_per_source, get_predictions,
|
||||||
add_game, delete_game, _BUILTIN_GAMES,
|
add_game, delete_game, _BUILTIN_GAMES,
|
||||||
)
|
)
|
||||||
|
from core.importer import import_draws_csv
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -80,6 +81,7 @@ class SettingsScreen(ttk.Frame):
|
|||||||
self._sources_body = self._section("Data Sources")
|
self._sources_body = self._section("Data Sources")
|
||||||
self._fetch_body = self._build_fetch_section()
|
self._fetch_body = self._build_fetch_section()
|
||||||
self._db_body = self._section("Database")
|
self._db_body = self._section("Database")
|
||||||
|
self._build_db_actions()
|
||||||
|
|
||||||
def _build_fetch_section(self):
|
def _build_fetch_section(self):
|
||||||
body = self._section("Fetch Schedule")
|
body = self._section("Fetch Schedule")
|
||||||
@@ -132,11 +134,16 @@ class SettingsScreen(ttk.Frame):
|
|||||||
foreground="#777777",
|
foreground="#777777",
|
||||||
).pack(side="left", padx=(16, 0))
|
).pack(side="left", padx=(16, 0))
|
||||||
|
|
||||||
|
ttk.Button(
|
||||||
|
row, text="Import CSV",
|
||||||
|
command=lambda gid=game["id"], gname=game["name"]: self._import_csv(gid, gname),
|
||||||
|
).pack(side="left", padx=(12, 0))
|
||||||
|
|
||||||
if game["name"] not in _BUILTIN_GAMES:
|
if game["name"] not in _BUILTIN_GAMES:
|
||||||
ttk.Button(
|
ttk.Button(
|
||||||
row, text="Delete",
|
row, text="Delete",
|
||||||
command=lambda gid=game["id"], gname=game["name"]: self._delete_game(gid, gname),
|
command=lambda gid=game["id"], gname=game["name"]: self._delete_game(gid, gname),
|
||||||
).pack(side="left", padx=(12, 0))
|
).pack(side="left", padx=(6, 0))
|
||||||
|
|
||||||
ttk.Button(
|
ttk.Button(
|
||||||
self._games_body, text="+ Add Custom Game",
|
self._games_body, text="+ Add Custom Game",
|
||||||
@@ -187,6 +194,13 @@ class SettingsScreen(ttk.Frame):
|
|||||||
ttk.Label(row, text="Predictions:", width=18, anchor="w").pack(side="left")
|
ttk.Label(row, text="Predictions:", width=18, anchor="w").pack(side="left")
|
||||||
ttk.Label(row, text=str(pred_count), foreground="#555555").pack(side="left")
|
ttk.Label(row, text=str(pred_count), foreground="#555555").pack(side="left")
|
||||||
|
|
||||||
|
def _build_db_actions(self):
|
||||||
|
body = self._section("Database Actions")
|
||||||
|
row = ttk.Frame(body)
|
||||||
|
row.pack(fill="x")
|
||||||
|
ttk.Button(row, text="Backup DB", command=self._backup_db ).pack(side="left", padx=(0, 8))
|
||||||
|
ttk.Button(row, text="Restore DB", command=self._restore_db).pack(side="left")
|
||||||
|
|
||||||
# ── Actions ───────────────────────────────────────────────────────────────
|
# ── Actions ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _toggle_game(self, game_id: int, var: tk.BooleanVar):
|
def _toggle_game(self, game_id: int, var: tk.BooleanVar):
|
||||||
@@ -203,6 +217,54 @@ class SettingsScreen(ttk.Frame):
|
|||||||
else:
|
else:
|
||||||
self._fetch_msg_var.set("Fetch not available.")
|
self._fetch_msg_var.set("Fetch not available.")
|
||||||
|
|
||||||
|
def _import_csv(self, game_id: int, game_name: str):
|
||||||
|
fp = filedialog.askopenfilename(
|
||||||
|
title=f"Import draws for {game_name}",
|
||||||
|
filetypes=[("CSV files", "*.csv"), ("All files", "*.*")],
|
||||||
|
)
|
||||||
|
if not fp:
|
||||||
|
return
|
||||||
|
result = import_draws_csv(game_id, fp)
|
||||||
|
added = result["added"]
|
||||||
|
skipped = result["skipped"]
|
||||||
|
errors = result["errors"]
|
||||||
|
msg = f"Added {added:,} draw{'s' if added != 1 else ''}."
|
||||||
|
if skipped:
|
||||||
|
msg += f"\nSkipped {skipped:,} duplicate{'s' if skipped != 1 else ''}."
|
||||||
|
if errors:
|
||||||
|
preview = "\n".join(errors[:5])
|
||||||
|
suffix = f"\n… and {len(errors) - 5} more." if len(errors) > 5 else ""
|
||||||
|
msg += f"\n\n{len(errors)} row error(s):\n{preview}{suffix}"
|
||||||
|
messagebox.showinfo("Import Complete", msg)
|
||||||
|
self._refresh_games()
|
||||||
|
|
||||||
|
def _backup_db(self):
|
||||||
|
try:
|
||||||
|
dest = backup_db()
|
||||||
|
messagebox.showinfo("Backup Complete", f"Database backed up to:\n{dest}")
|
||||||
|
except Exception as e:
|
||||||
|
messagebox.showerror("Backup Failed", str(e))
|
||||||
|
|
||||||
|
def _restore_db(self):
|
||||||
|
fp = filedialog.askopenfilename(
|
||||||
|
title="Select backup file to restore",
|
||||||
|
filetypes=[("SQLite DB", "*.db"), ("All files", "*.*")],
|
||||||
|
)
|
||||||
|
if not fp:
|
||||||
|
return
|
||||||
|
if not messagebox.askyesno(
|
||||||
|
"Confirm Restore",
|
||||||
|
"Restoring will overwrite the current database.\n"
|
||||||
|
"This cannot be undone. Continue?",
|
||||||
|
):
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
restore_db(fp)
|
||||||
|
messagebox.showinfo("Restore Complete",
|
||||||
|
"Database restored. Please restart the app.")
|
||||||
|
except Exception as e:
|
||||||
|
messagebox.showerror("Restore Failed", str(e))
|
||||||
|
|
||||||
def _open_add_game_dialog(self):
|
def _open_add_game_dialog(self):
|
||||||
_AddGameDialog(self, on_save=self._refresh_games)
|
_AddGameDialog(self, on_save=self._refresh_games)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user