05/23 Phase 18,19
This commit is contained in:
@@ -14,7 +14,9 @@
|
||||
"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/ -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
|
||||
- [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`
|
||||
|
||||
@@ -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 logging
|
||||
import os
|
||||
import shutil
|
||||
from datetime import datetime
|
||||
|
||||
from core.paths import user_data_dir
|
||||
|
||||
@@ -143,6 +145,27 @@ def _seed_games(cursor, conn):
|
||||
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():
|
||||
"""
|
||||
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.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
|
||||
|
||||
# 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._db_body = self._section_header("Database Summary")
|
||||
self._hot_body = self._section_header("Hot Numbers (last 100 draws)")
|
||||
self._overdue_body = self._section_header("Most Overdue Numbers")
|
||||
|
||||
# ── Refresh ───────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -104,6 +105,7 @@ class DashboardScreen(ttk.Frame):
|
||||
self._refresh_last_draws()
|
||||
self._refresh_db_summary()
|
||||
self._refresh_hot_numbers()
|
||||
self._refresh_overdue()
|
||||
|
||||
def _refresh_last_draws(self):
|
||||
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)
|
||||
ttk.Label(row, text=counts_str, foreground="#aaaaaa",
|
||||
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
|
||||
from tkinter import ttk, messagebox
|
||||
from tkinter import ttk, messagebox, filedialog
|
||||
import logging
|
||||
|
||||
from db.database import get_db_stats
|
||||
from db.database import get_db_stats, backup_db, restore_db
|
||||
from db.models import (
|
||||
get_all_games, get_draw_count, set_game_active,
|
||||
get_last_fetch_per_source, get_predictions,
|
||||
add_game, delete_game, _BUILTIN_GAMES,
|
||||
)
|
||||
from core.importer import import_draws_csv
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -80,6 +81,7 @@ class SettingsScreen(ttk.Frame):
|
||||
self._sources_body = self._section("Data Sources")
|
||||
self._fetch_body = self._build_fetch_section()
|
||||
self._db_body = self._section("Database")
|
||||
self._build_db_actions()
|
||||
|
||||
def _build_fetch_section(self):
|
||||
body = self._section("Fetch Schedule")
|
||||
@@ -132,11 +134,16 @@ class SettingsScreen(ttk.Frame):
|
||||
foreground="#777777",
|
||||
).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:
|
||||
ttk.Button(
|
||||
row, text="Delete",
|
||||
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(
|
||||
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=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 ───────────────────────────────────────────────────────────────
|
||||
|
||||
def _toggle_game(self, game_id: int, var: tk.BooleanVar):
|
||||
@@ -203,6 +217,54 @@ class SettingsScreen(ttk.Frame):
|
||||
else:
|
||||
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):
|
||||
_AddGameDialog(self, on_save=self._refresh_games)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user