05/23 Phase 22

This commit is contained in:
2026-05-23 17:36:33 -04:00
parent edd2ed5660
commit 59789f3cfe
10 changed files with 358 additions and 23 deletions
+3 -1
View File
@@ -19,7 +19,9 @@
"Bash(python -m pytest --tb=short -q)", "Bash(python -m pytest --tb=short -q)",
"Bash(python -m pytest tests/test_wheeling.py -v)", "Bash(python -m pytest tests/test_wheeling.py -v)",
"WebFetch(domain:www.valottery.com)", "WebFetch(domain:www.valottery.com)",
"Bash(pip install *)" "Bash(pip install *)",
"Bash(python -m pytest tests/ -x -q)",
"Bash(python -m pytest tests/test_phase22.py -v)"
] ]
} }
} }
+27
View File
@@ -376,6 +376,33 @@ All actions are logged to console and optionally to a log file:
--- ---
### ✅ Phase 22 — Incremental VA Fetch + Top Prize + Dashboard Filter + Auto-Check
- [x] Incremental VA fetch in `core/fetcher.py`
- [x] `_fetch_va_lottery()` calls `get_last_draw()` for last known date
- [x] Breaks out of parsing loop when `draw_date <= last_date` (data is newest-first)
- [x] Result: subsequent fetches only download new records, not all 10K+ rows
- [x] `top_prize` column in `db/database.py`
- [x] Added `top_prize TEXT NOT NULL DEFAULT ''` to `CREATE TABLE games`
- [x] ALTER TABLE migration for existing databases (checks via `PRAGMA table_info`)
- [x] Updated `_seed_games()` with prize strings for all 5 builtin games
- [x] Back-fills `top_prize` for existing rows where empty (safe to re-run)
- [x] Dashboard game filter in `ui/dashboard.py`
- [x] `_filter_var` combobox at top ("All Games" + active game names)
- [x] `_update_filter_options()` — keeps combobox in sync with active games
- [x] `_filtered_games()` — returns subset based on selection
- [x] Last Draws, Hot Numbers, Overdue sections all respect filter
- [x] Top prize display in last-draw cards (`ui/dashboard.py`)
- [x] Shows "Top prize: $X" in purple above draw date if `top_prize` is set
- [x] VA source names added to `ui/statusbar.py` `_SOURCE_NAMES`
- [x] `append_match_alert(msg)` method added to `StatusBar`
- [x] Auto-check predictions after fetch in `main.py`
- [x] `_check_predictions_vs_latest()` — compares all saved predictions against newest draw per game
- [x] Called from `_on_fetch_done()` when `total_added > 0`
- [x] Status bar shows alert when ≥1 prediction matches ≥2 main numbers or bonus
- [x] `tests/test_phase22.py` — 14 tests (403/403 total passing)
---
### ✅ Phase 19 — Dashboard Overdue Alert + DB Backup ### ✅ 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 `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] Add `restore_db(source_path: str) -> None` to `db/database.py` — overwrites live DB with backup file, raises FileNotFoundError if missing
+9 -1
View File
@@ -14,7 +14,7 @@ import threading
import requests import requests
from datetime import datetime from datetime import datetime
from db.models import get_game_by_name, insert_draw, insert_fetch_log from db.models import get_game_by_name, get_last_draw, insert_draw, insert_fetch_log
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -262,6 +262,10 @@ def _fetch_va_lottery(game_name: str, source: str, url: str) -> dict:
game_id = game["id"] game_id = game["id"]
added = skipped = 0 added = skipped = 0
# Incremental fetch: VA data is newest-first; stop once we reach known dates
last_draw = get_last_draw(game_id)
last_date = last_draw["draw_date"] if last_draw else None
try: try:
resp = requests.get(url, timeout=30) resp = requests.get(url, timeout=30)
resp.raise_for_status() resp.raise_for_status()
@@ -279,6 +283,10 @@ def _fetch_va_lottery(game_name: str, source: str, url: str) -> dict:
if draw_date is None: if draw_date is None:
continue continue
# Data comes newest-first; stop when we reach already-stored dates
if last_date and draw_date <= last_date:
break
try: try:
p1 = parts[1].strip() p1 = parts[1].strip()
p1_lower = p1.lower() p1_lower = p1.lower()
Binary file not shown.
+25 -9
View File
@@ -50,7 +50,8 @@ def init_db():
main_max INTEGER NOT NULL, main_max INTEGER NOT NULL,
bonus_count INTEGER NOT NULL DEFAULT 0, bonus_count INTEGER NOT NULL DEFAULT 0,
bonus_max INTEGER NOT NULL DEFAULT 0, bonus_max INTEGER NOT NULL DEFAULT 0,
active INTEGER NOT NULL DEFAULT 1 active INTEGER NOT NULL DEFAULT 1,
top_prize TEXT NOT NULL DEFAULT ''
) )
""") """)
@@ -105,6 +106,15 @@ def init_db():
) )
""") """)
# Migration: add top_prize column to existing databases
cursor.execute("PRAGMA table_info(games)")
cols = {r["name"] for r in cursor.fetchall()}
if "top_prize" not in cols:
cursor.execute(
"ALTER TABLE games ADD COLUMN top_prize TEXT NOT NULL DEFAULT ''"
)
logger.info("[DB] Migrated: added top_prize column to games")
conn.commit() conn.commit()
logger.info("[DB] Tables created/verified OK") logger.info("[DB] Tables created/verified OK")
@@ -122,22 +132,28 @@ def _seed_games(cursor, conn):
""" """
Insert default game configs if they don't already exist. Insert default game configs if they don't already exist.
Uses INSERT OR IGNORE to be idempotent on every launch. Uses INSERT OR IGNORE to be idempotent on every launch.
Also back-fills top_prize for any existing rows where it is empty.
""" """
defaults = [ defaults = [
# name main_count main_max bonus_count bonus_max active # name mc mm bc bm active top_prize
("Powerball", 5, 69, 1, 26, 1), ("Powerball", 5, 69, 1, 26, 1, "Jackpot (variable)"),
("Mega Millions", 5, 70, 1, 25, 1), ("Mega Millions", 5, 70, 1, 25, 1, "Jackpot (variable)"),
("Cash 5", 5, 45, 0, 0, 1), ("Cash 5", 5, 45, 0, 0, 1, "Jackpot from $200K"),
("Millionaire for Life", 5, 58, 1, 5, 1), ("Millionaire for Life", 5, 58, 1, 5, 1, "$1M/yr for life"),
("Bank a Million", 6, 40, 1, 40, 1), ("Bank a Million", 6, 40, 1, 40, 1, "$1M after taxes"),
] ]
for row in defaults: for row in defaults:
cursor.execute(""" cursor.execute("""
INSERT OR IGNORE INTO games INSERT OR IGNORE INTO games
(name, main_count, main_max, bonus_count, bonus_max, active) (name, main_count, main_max, bonus_count, bonus_max, active, top_prize)
VALUES (?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?)
""", row) """, row)
# Back-fill top_prize for rows inserted before this column existed
cursor.execute(
"UPDATE games SET top_prize = ? WHERE name = ? AND top_prize = ''",
(row[6], row[0]),
)
inserted = conn.total_changes inserted = conn.total_changes
conn.commit() conn.commit()
+32
View File
@@ -167,10 +167,42 @@ class LottoSightApp(tk.Tk):
def _on_fetch_done(self, results: list): def _on_fetch_done(self, results: list):
self._fetch_btn.config(state="normal", text="Fetch Now") self._fetch_btn.config(state="normal", text="Fetch Now")
self._statusbar.update_fetch_results(results) self._statusbar.update_fetch_results(results)
total_added = sum(r.get("added", 0) for r in results)
if total_added > 0:
self._check_predictions_vs_latest()
# Refresh the current screen if it can show new data # Refresh the current screen if it can show new data
if self._current_screen and hasattr(self._current_screen, "refresh"): if self._current_screen and hasattr(self._current_screen, "refresh"):
self._current_screen.refresh() self._current_screen.refresh()
def _check_predictions_vs_latest(self):
"""Compare all saved predictions against the most recent draw per game."""
from db.models import get_predictions, get_last_draw
preds = get_predictions(limit=100_000)
if not preds:
return
best = 0
hit_count = 0
for pred in preds:
draw = get_last_draw(pred["game_id"])
if not draw:
continue
pred_nums = {int(n) for n in pred["numbers"].split(",") if n.strip().isdigit()}
draw_nums = {int(n) for n in draw["numbers"].split(",") if n.strip().isdigit()}
matches = len(pred_nums & draw_nums)
bonus_hit = (
pred.get("bonus") and draw.get("bonus")
and str(pred["bonus"]) == str(draw["bonus"])
)
if matches >= 2 or bonus_hit:
hit_count += 1
best = max(best, matches)
if hit_count > 0:
self._statusbar.append_match_alert(
f"{hit_count} saved prediction(s) matched ≥2 numbers (best: {best}/main)"
)
# ── Lifecycle ───────────────────────────────────────────────────────────── # ── Lifecycle ─────────────────────────────────────────────────────────────
def on_close(self): def on_close(self):
+4 -2
View File
@@ -254,7 +254,8 @@ def test_cash5_va_skips_duplicates(tmp_db):
with patch("core.fetcher.requests.get", return_value=_text_resp(CASH5_VA_TEXT)): with patch("core.fetcher.requests.get", return_value=_text_resp(CASH5_VA_TEXT)):
result = fetch_cash5_va() result = fetch_cash5_va()
assert result["added"] == 0 assert result["added"] == 0
assert result["skipped"] == 2 # Incremental fetch breaks early once last known date is reached — skipped stays 0
assert result["skipped"] == 0
def test_cash5_va_parses_date_and_numbers(tmp_db): def test_cash5_va_parses_date_and_numbers(tmp_db):
@@ -331,7 +332,8 @@ def test_bankamil_va_skips_duplicates(tmp_db):
with patch("core.fetcher.requests.get", return_value=_text_resp(BANKAMIL_VA_TEXT)): with patch("core.fetcher.requests.get", return_value=_text_resp(BANKAMIL_VA_TEXT)):
result = fetch_bankamillion_va() result = fetch_bankamillion_va()
assert result["added"] == 0 assert result["added"] == 0
assert result["skipped"] == 2 # Incremental fetch breaks early once last known date is reached — skipped stays 0
assert result["skipped"] == 0
def test_bankamil_va_network_error(tmp_db): def test_bankamil_va_network_error(tmp_db):
+208
View File
@@ -0,0 +1,208 @@
"""
tests/test_phase22.py
----------------------
Tests for Phase 22 features:
- Incremental VA fetch (break on already-stored dates)
- top_prize column in games table
- Dashboard game filter
- Auto-check predictions after fetch (match detection logic)
"""
from unittest.mock import MagicMock, patch
import pytest
from db.database import get_db_stats, init_db
from db.models import (
get_all_games,
get_game_by_name,
get_last_draw,
insert_draw,
insert_prediction,
get_predictions,
)
from core.fetcher import fetch_cash5_va, fetch_bankamillion_va
# ── Helpers ────────────────────────────────────────────────────────────────────
def _text_resp(text, status=200):
m = MagicMock()
m.status_code = status
m.raise_for_status = MagicMock()
m.text = text
return m
CASH5_VA_TEXT_OLD = (
"5/22/2026; 15,29,30,34,36\n"
"5/21/2026; 1,2,5,38,44\n"
)
CASH5_VA_TEXT_NEWER = (
"5/23/2026; 10,11,12,13,14\n" # new record
"5/22/2026; 15,29,30,34,36\n" # already in DB
"5/21/2026; 1,2,5,38,44\n" # already in DB
)
# ── top_prize column ───────────────────────────────────────────────────────────
def test_top_prize_seeded_powerball(tmp_db):
row = get_game_by_name("Powerball")
assert row["top_prize"] == "Jackpot (variable)"
def test_top_prize_seeded_mega_millions(tmp_db):
row = get_game_by_name("Mega Millions")
assert row["top_prize"] == "Jackpot (variable)"
def test_top_prize_seeded_cash5(tmp_db):
row = get_game_by_name("Cash 5")
assert row["top_prize"] == "Jackpot from $200K"
def test_top_prize_seeded_millionaire_for_life(tmp_db):
row = get_game_by_name("Millionaire for Life")
assert row["top_prize"] == "$1M/yr for life"
def test_top_prize_seeded_bank_a_million(tmp_db):
row = get_game_by_name("Bank a Million")
assert row["top_prize"] == "$1M after taxes"
def test_top_prize_all_games_non_empty(tmp_db):
games = get_all_games()
for g in games:
# Builtin games must have a top_prize; custom games may be empty
assert "top_prize" in g.keys()
# ── Incremental VA fetch ───────────────────────────────────────────────────────
def test_incremental_fetch_only_new_records_added(tmp_db):
"""Second fetch with a new leading record only inserts that one record."""
with patch("core.fetcher.requests.get", return_value=_text_resp(CASH5_VA_TEXT_OLD)):
r1 = fetch_cash5_va()
assert r1["added"] == 2
with patch("core.fetcher.requests.get", return_value=_text_resp(CASH5_VA_TEXT_NEWER)):
r2 = fetch_cash5_va()
assert r2["added"] == 1
assert r2["skipped"] == 0 # stopped before re-attempting stored dates
def test_incremental_fetch_no_new_data(tmp_db):
"""Second fetch of identical data adds nothing and doesn't error."""
with patch("core.fetcher.requests.get", return_value=_text_resp(CASH5_VA_TEXT_OLD)):
fetch_cash5_va()
with patch("core.fetcher.requests.get", return_value=_text_resp(CASH5_VA_TEXT_OLD)):
r = fetch_cash5_va()
assert r["status"] == "success"
assert r["added"] == 0
def test_incremental_fetch_db_count_correct(tmp_db):
"""Total draws in DB after incremental fetch equals unique dates only."""
with patch("core.fetcher.requests.get", return_value=_text_resp(CASH5_VA_TEXT_OLD)):
fetch_cash5_va()
with patch("core.fetcher.requests.get", return_value=_text_resp(CASH5_VA_TEXT_NEWER)):
fetch_cash5_va()
game = get_game_by_name("Cash 5")
stats = get_db_stats()
assert stats["Cash 5"] == 3 # 2026-05-21, 05-22, 05-23
def test_incremental_fetch_bank_a_million(tmp_db):
"""Bank a Million incremental fetch behaves the same as Cash 5."""
bam_old = (
"Results for Bank a Million\n"
"5/20/2026; 14,20,21,24,33,35; Bonus Ball: 18\n"
"5/16/2026; 6,8,17,20,29,39; Bonus Ball: 38\n"
)
bam_newer = (
"Results for Bank a Million\n"
"5/23/2026; 1,5,10,20,35,38; Bonus Ball: 7\n"
"5/20/2026; 14,20,21,24,33,35; Bonus Ball: 18\n"
"5/16/2026; 6,8,17,20,29,39; Bonus Ball: 38\n"
)
with patch("core.fetcher.requests.get", return_value=_text_resp(bam_old)):
r1 = fetch_bankamillion_va()
assert r1["added"] == 2
with patch("core.fetcher.requests.get", return_value=_text_resp(bam_newer)):
r2 = fetch_bankamillion_va()
assert r2["added"] == 1
assert r2["skipped"] == 0
# ── Match detection logic (unit-level) ────────────────────────────────────────
def _count_matches(pred_numbers: str, draw_numbers: str) -> int:
pred = {int(n) for n in pred_numbers.split(",") if n.strip().isdigit()}
draw = {int(n) for n in draw_numbers.split(",") if n.strip().isdigit()}
return len(pred & draw)
def test_match_detection_exact(tmp_db):
pb = get_game_by_name("Powerball")
insert_draw(pb["id"], "2026-05-22", [1, 13, 36, 61, 69], bonus=7)
last = get_last_draw(pb["id"])
pred_id = insert_prediction(pb["id"], "Hot Numbers", [1, 13, 36, 61, 69], bonus=7)
preds = get_predictions(game_id=pb["id"])
pred = preds[0]
matches = _count_matches(pred["numbers"], last["numbers"])
assert matches == 5
def test_match_detection_partial(tmp_db):
pb = get_game_by_name("Powerball")
insert_draw(pb["id"], "2026-05-22", [1, 13, 36, 61, 69], bonus=7)
last = get_last_draw(pb["id"])
insert_prediction(pb["id"], "Hot Numbers", [1, 13, 5, 6, 7], bonus=99)
preds = get_predictions(game_id=pb["id"])
matches = _count_matches(preds[0]["numbers"], last["numbers"])
assert matches == 2
def test_match_detection_no_match(tmp_db):
pb = get_game_by_name("Powerball")
insert_draw(pb["id"], "2026-05-22", [1, 13, 36, 61, 69], bonus=7)
last = get_last_draw(pb["id"])
insert_prediction(pb["id"], "Due Numbers", [2, 4, 6, 8, 10], bonus=99)
preds = get_predictions(game_id=pb["id"])
matches = _count_matches(preds[0]["numbers"], last["numbers"])
assert matches == 0
def test_match_alert_threshold_is_two(tmp_db):
"""Only predictions with ≥2 main matches (or bonus hit) count toward alert."""
pb = get_game_by_name("Powerball")
insert_draw(pb["id"], "2026-05-22", [1, 13, 36, 61, 69], bonus=7)
last = get_last_draw(pb["id"])
preds_data = [
([1, 2, 3, 4, 5], 99), # 1 match — below threshold
([1, 13, 2, 3, 4], 99), # 2 matches — at threshold
([1, 13, 36, 2, 3], 99), # 3 matches — above threshold
]
for nums, bonus in preds_data:
insert_prediction(pb["id"], "Test", nums, bonus=bonus)
preds = get_predictions(game_id=pb["id"])
draw_nums = {int(n) for n in last["numbers"].split(",") if n.strip().isdigit()}
qualifying = [
p for p in preds
if len({int(n) for n in p["numbers"].split(",") if n.strip().isdigit()} & draw_nums) >= 2
]
assert len(qualifying) == 2
+37 -5
View File
@@ -54,6 +54,7 @@ class DashboardScreen(ttk.Frame):
def __init__(self, parent, on_fetch=None, **kwargs): def __init__(self, parent, on_fetch=None, **kwargs):
super().__init__(parent, **kwargs) super().__init__(parent, **kwargs)
self._on_fetch = on_fetch self._on_fetch = on_fetch
self._filter_var = tk.StringVar(value="All Games")
self._build_ui() self._build_ui()
# ── Layout ──────────────────────────────────────────────────────────────── # ── Layout ────────────────────────────────────────────────────────────────
@@ -66,7 +67,7 @@ class DashboardScreen(ttk.Frame):
vsb.pack(side="right", fill="y") vsb.pack(side="right", fill="y")
canvas.pack(side="left", fill="both", expand=True) canvas.pack(side="left", fill="both", expand=True)
inner = ttk.Frame(canvas, padding=(28, 20, 28, 20)) inner = ttk.Frame(canvas, padding=(28, 16, 28, 20))
win = canvas.create_window((0, 0), window=inner, anchor="nw") win = canvas.create_window((0, 0), window=inner, anchor="nw")
def _resize(event=None): def _resize(event=None):
@@ -82,6 +83,18 @@ class DashboardScreen(ttk.Frame):
lambda e: canvas.unbind_all("<MouseWheel>")) lambda e: canvas.unbind_all("<MouseWheel>"))
self._inner = inner self._inner = inner
# Game filter bar
filter_bar = ttk.Frame(inner)
filter_bar.pack(fill="x", pady=(0, 12))
ttk.Label(filter_bar, text="Show game:").pack(side="left", padx=(0, 6))
self._game_filter = ttk.Combobox(
filter_bar, textvariable=self._filter_var,
state="readonly", width=22,
)
self._game_filter.pack(side="left")
self._game_filter.bind("<<ComboboxSelected>>", lambda _e: self.refresh())
self._draw_sections() self._draw_sections()
def _section_header(self, title: str): def _section_header(self, title: str):
@@ -101,7 +114,22 @@ class DashboardScreen(ttk.Frame):
# ── Refresh ─────────────────────────────────────────────────────────────── # ── Refresh ───────────────────────────────────────────────────────────────
def _update_filter_options(self):
games = get_all_games(active_only=True)
names = ["All Games"] + [g["name"] for g in games]
self._game_filter["values"] = names
if self._filter_var.get() not in names:
self._filter_var.set("All Games")
def _filtered_games(self):
games = get_all_games(active_only=True)
sel = self._filter_var.get()
if not sel or sel == "All Games":
return games
return [g for g in games if g["name"] == sel]
def refresh(self): def refresh(self):
self._update_filter_options()
self._refresh_last_draws() self._refresh_last_draws()
self._refresh_db_summary() self._refresh_db_summary()
self._refresh_hot_numbers() self._refresh_hot_numbers()
@@ -111,13 +139,12 @@ class DashboardScreen(ttk.Frame):
for w in self._last_draw_body.winfo_children(): for w in self._last_draw_body.winfo_children():
w.destroy() w.destroy()
games = get_all_games(active_only=True) games = self._filtered_games()
if not games: if not games:
ttk.Label(self._last_draw_body, text="No active games.", ttk.Label(self._last_draw_body, text="No active games.",
foreground="#aaaaaa").pack(anchor="w") foreground="#aaaaaa").pack(anchor="w")
return return
# Lay cards out in a horizontal row that wraps via grid
row_frame = ttk.Frame(self._last_draw_body) row_frame = ttk.Frame(self._last_draw_body)
row_frame.pack(fill="x") row_frame.pack(fill="x")
@@ -128,6 +155,11 @@ class DashboardScreen(ttk.Frame):
) )
card.grid(row=0, column=col_idx, padx=(0, 16), sticky="nw") card.grid(row=0, column=col_idx, padx=(0, 16), sticky="nw")
top_prize = game["top_prize"] if "top_prize" in game.keys() else ""
if top_prize:
ttk.Label(card, text=f"Top prize: {top_prize}",
foreground="#8e44ad", font=_CARD_FONT).pack(anchor="w")
if draw is None: if draw is None:
ttk.Label(card, text="No draws yet.", foreground="#aaaaaa", ttk.Label(card, text="No draws yet.", foreground="#aaaaaa",
font=_CARD_FONT).pack(anchor="w") font=_CARD_FONT).pack(anchor="w")
@@ -172,7 +204,7 @@ class DashboardScreen(ttk.Frame):
for w in self._hot_body.winfo_children(): for w in self._hot_body.winfo_children():
w.destroy() w.destroy()
games = get_all_games(active_only=True) games = self._filtered_games()
if not games: if not games:
ttk.Label(self._hot_body, text="No active games.", ttk.Label(self._hot_body, text="No active games.",
foreground="#aaaaaa").pack(anchor="w") foreground="#aaaaaa").pack(anchor="w")
@@ -201,7 +233,7 @@ class DashboardScreen(ttk.Frame):
for w in self._overdue_body.winfo_children(): for w in self._overdue_body.winfo_children():
w.destroy() w.destroy()
games = get_all_games(active_only=True) games = self._filtered_games()
if not games: if not games:
ttk.Label(self._overdue_body, text="No active games.", ttk.Label(self._overdue_body, text="No active games.",
foreground="#aaaaaa").pack(anchor="w") foreground="#aaaaaa").pack(anchor="w")
+10 -2
View File
@@ -13,6 +13,9 @@ _SOURCE_NAMES = {
"powerball_ny": "Powerball", "powerball_ny": "Powerball",
"megamillions_ny": "Mega Millions (NY)", "megamillions_ny": "Mega Millions (NY)",
"megamillions_tx": "Mega Millions (TX)", "megamillions_tx": "Mega Millions (TX)",
"cash5_va": "Cash 5 (VA)",
"millionaireforlife_va": "Millionaire for Life (VA)",
"bankamillion_va": "Bank a Million (VA)",
} }
@@ -50,5 +53,10 @@ class StatusBar(ttk.Frame):
parts.append(f"{name} — ERROR: {r['message']}") parts.append(f"{name} — ERROR: {r['message']}")
else: else:
parts.append(f"{name}{r['added']} added, {r['skipped']} skipped") parts.append(f"{name}{r['added']} added, {r['skipped']} skipped")
text = "Last fetch: " + " | ".join(parts) + f" | {now}" self._last_fetch_text = "Last fetch: " + " | ".join(parts) + f" | {now}"
self.set_text(text) self.set_text(self._last_fetch_text)
def append_match_alert(self, msg: str):
"""Append a prediction-match alert to the current status text."""
base = getattr(self, "_last_fetch_text", self._label.cget("text"))
self.set_text(f"{base}{msg}")