"""Unit tests for the trifecta predictor. All tests run without live API access. Run: python -m unittest discover -s tests """ from __future__ import annotations import os import sys import unittest from pathlib import Path # Make package importable ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT)) from trifecta_bro.config import settings # noqa: E402 from trifecta_bro.data.normalizer import normalise_race, normalise_meeting # noqa: E402 from trifecta_bro.model.feature_engine import ( # noqa: E402 parse_form_positions, form_quality_score, spell_count, is_fresh_after_spell) from trifecta_bro.model.pace_analysis import classify_pace, pace_adjustment # noqa: E402 from trifecta_bro.model.scoring import score_runner, load_weights # noqa: E402 from trifecta_bro.model.probability import ( # noqa: E402 generate_trifecta, compute_confidence, softmax_probabilities) from trifecta_bro.model.analyse_race import analyse_race # noqa: E402 from trifecta_bro.data.storage import Storage # noqa: E402 from trifecta_bro.evaluation.backtester import Backtester, _parse_actual # noqa: E402 from tests.mock_formfav import ( # noqa: E402 meetings_payload, race_form_full, race_form_with_scratch_and_missing, race_form_abandoned) DB_PATH = ROOT / "data" / "test_trincta_bro.db" def fresh_storage() -> Storage: if DB_PATH.exists(): DB_PATH.unlink() return Storage(db_path=DB_PATH) class TestDateLogic(unittest.TestCase): def test_tomorrow_australia(self): from trifecta_bro.jobs.daily_prediction import tomorrow_australia # deterministic check: a known now -> next day result = tomorrow_australia() self.assertEqual(len(result), 10) self.assertRegex(result, r"^\d{4}-\d{2}-\d{2}$") class TestFormParsing(unittest.TestCase): def test_positions_basic(self): self.assertEqual(parse_form_positions("12345"), [1, 2, 3, 4, 5]) def test_x_is_spell_not_position(self): pos = parse_form_positions("1x231") self.assertEqual(pos, [1, None, 2, 3, 1]) def test_zero_is_tenth_or_worse(self): pos = parse_form_positions("10") self.assertEqual(pos, [1, 0]) def test_spell_count_and_fresh(self): self.assertEqual(spell_count("1x23x"), 2) self.assertTrue(is_fresh_after_spell("123x")) self.assertFalse(is_fresh_after_spell("x123")) class TestFormQuality(unittest.TestCase): def test_strong_recent_form_scores_high(self): score, diag = form_quality_score("11122") self.assertGreater(score, 70) self.assertGreater(diag["consistency"], 0.8) def test_poor_form_scores_low(self): score, _ = form_quality_score("88880") self.assertLess(score, 40) def test_spell_does_not_count_as_position(self): score_with_spell, _ = form_quality_score("1x231") score_no_spell, _ = form_quality_score("12231") # spell slightly lowers availability but shouldn't be treated as a 10th self.assertLess(score_with_spell, score_no_spell + 1) def test_empty_form(self): score, diag = form_quality_score("") self.assertEqual(score, 0.0) self.assertIn("no form data", diag["reason"]) class TestPace(unittest.TestCase): def test_classify_and_roles(self): race = normalise_race(race_form_full()) pace = classify_pace(race, race.active_runners) self.assertIn(pace["label"], {"SLOW", "MODERATE", "FAST", "VERY FAST"}) self.assertIn(1, pace["roles"]) def test_leader_advantaged_in_slow(self): from trifecta_bro.data.models import RaceModel, RunnerModel r = RunnerModel(number=1, barrier=1, win_percent=20.0) slow = {"label": "SLOW", "roles": {1: "leader/on-speed"}} self.assertGreater(pace_adjustment(r, slow), 0) def test_leader_penalised_in_fast(self): from trifecta_bro.data.models import RunnerModel r = RunnerModel(number=1, barrier=1, win_percent=20.0) fast = {"label": "FAST", "roles": {1: "leader/on-speed"}} self.assertLess(pace_adjustment(r, fast), 0) class TestScoring(unittest.TestCase): def test_weights_load_and_sums(self): w = load_weights() self.assertAlmostEqual(sum(w.values()), 1.0, places=2) def test_score_runner_runs(self): race = normalise_race(race_form_full()) pace = classify_pace(race, race.active_runners) w = load_weights() res = score_runner(race.active_runners[0], race, pace, w) self.assertIn("score", res) self.assertGreaterEqual(res["score"], 0) self.assertLessEqual(res["score"], 100) def test_missing_fields_graceful(self): race = normalise_race(race_form_with_scratch_and_missing()) pace = classify_pace(race, race.active_runners) w = load_weights() # Ghost runner has almost no data; should not crash and returns a score ghost = [r for r in race.active_runners if r.name == "Ghost"][0] res = score_runner(ghost, race, pace, w) self.assertIsInstance(res["score"], float) class TestScratchedAbandoned(unittest.TestCase): def test_scratched_excluded(self): race = normalise_race(race_form_with_scratch_and_missing()) numbers = [r.number for r in race.active_runners] self.assertNotIn(4, numbers) # scratched self.assertIn(8, numbers) def test_abandoned_flag(self): race = normalise_race(race_form_abandoned()) self.assertTrue(race.abandoned) def test_missing_form_falls_back(self): race = normalise_race(race_form_with_scratch_and_missing()) only = [r for r in race.active_runners if r.name == "OnlyOne"][0] self.assertTrue(only.form) # derived from last20Starts class TestTrifectaGen(unittest.TestCase): def test_generate_trifecta(self): race = normalise_race(race_form_full()) a = analyse_race(race) self.assertEqual(len(a["trifecta"]), 3) self.assertCountEqual(a["top3"], a["trifecta"]) self.assertIsInstance(a["alternatives"], list) def test_probabilities_sum_to_one(self): probs = softmax_probabilities([50, 60, 70], temperature=12.0) self.assertAlmostEqual(sum(probs), 1.0, places=3) def test_insufficient_runners_skips(self): # build a 2-runner race payload = race_form_full() payload["runners"] = payload["runners"][:2] race = normalise_race(payload) a = analyse_race(race) self.assertTrue(a.get("skippable")) class TestConfidence(unittest.TestCase): def test_confidence_categories(self): race = normalise_race(race_form_full()) a = analyse_race(race) self.assertIn(a["confidence"]["category"], {"HIGH", "MEDIUM", "LOW", "AVOID"}) def test_avoid_on_weak_leader(self): from trifecta_bro.model.probability import compute_confidence # fake ranking with weak leader ranked = [{"number": 1, "score": 12.0}, {"number": 2, "score": 11.0}] conf = compute_confidence(ranked, 0.9, {"label": "SLOW"}, {}) self.assertEqual(conf["category"], "AVOID") class TestValidation(unittest.TestCase): def test_meetings_validation_flags_error(self): from trifecta_bro.api.validator import validate_meetings issues = validate_meetings({"foo": 1}) self.assertTrue(any(i.level == "error" for i in issues)) def test_race_form_validation(self): from trifecta_bro.api.validator import validate_race_form issues = validate_race_form(race_form_full()) self.assertFalse(any(i.level == "error" for i in issues)) class TestStorage(unittest.TestCase): def setUp(self): self.store = fresh_storage() def tearDown(self): self.store.close() if DB_PATH.exists(): DB_PATH.unlink() def test_upsert_and_read(self): race = normalise_race(race_form_full()) rid = self.store.upsert_race(race) self.assertGreater(rid, 0) # save a prediction self.store.save_prediction({ "date": race.date, "track": race.track, "track_slug": race.track_slug, "race_number": race.race_number, "model_version": "1.0.0", "generated_at": "2026-08-11T00:00:00Z", "top3": [3, 1, 2], "trifecta": [3, 1, 2], "alternatives": [[1, 3, 2]], "confidence": "MEDIUM", "confidence_score": 55, "ranked_runners": [{"no": 3, "name": "x", "score": 70}], "data_completeness": 0.9, }) preds = self.store.get_predictions(race.date) self.assertEqual(len(preds), 1) self.assertEqual(preds[0]["trifecta"], [3, 1, 2]) def test_save_result(self): race = normalise_race(race_form_full()) self.store.upsert_race(race) self.store.save_result(race.date, race.track, race.track_slug, race.race_number, [3, 1, 2]) # re-saving is idempotent self.store.save_result(race.date, race.track, race.track_slug, race.race_number, [3, 1, 2]) class TestBacktester(unittest.TestCase): def test_parse_actual_variants(self): self.assertEqual(_parse_actual("1,7,9"), [1, 7, 9]) self.assertEqual(_parse_actual([4, 5, 6]), [4, 5, 6]) self.assertIsNone(_parse_actual("")) self.assertIsNone(_parse_actual(None)) def test_backtest_no_leakage(self): race = normalise_race(race_form_full()) # analyse WITHOUT actuals first a = analyse_race(race) pred = a["trifecta"] # now compute metrics against actual — actual never fed to model bt = Backtester() result = bt.evaluate([race], {("dubbo", 1): [3, 1, 2]}) rec = result["records"][0] self.assertTrue(rec["scored"]) # exact hit only if prediction matched the provided actual self.assertEqual(rec["exact_hit"], (pred == [3, 1, 2])) # metrics present self.assertIn("exact_trifecta_hit_rate", result) self.assertIn("top1_accuracy", result) def test_backtest_partial_coverage(self): race = normalise_race(race_form_full()) bt = Backtester() result = bt.evaluate([race], {("dubbo", 1): [9, 9, 9]}) # none match rec = result["records"][0] self.assertEqual(rec["partial3"], 0) class TestEndToEndMocked(unittest.TestCase): def test_full_pipeline_with_fake_client(self): from trifecta_bro.api.formfav_client import FormFavClient class FakeClient(FormFavClient): def get_meetings(self, date, country=None, race_code=None): return meetings_payload(date)["meetings"] def get_race_form(self, date, track, race, country=None, race_code=None, timezone=None): if track == "dubbo" and race == 1: return race_form_full(date, track, race) if track == "dubbo" and race == 2: return race_form_abandoned(date, track, race) if track == "randwick" and race == 7: return race_form_with_scratch_and_missing(date, track, race) raise FileNotFoundError("no mock") # exercise analyse_race for each race form directly for fn in (race_form_full, race_form_with_scratch_and_missing, race_form_abandoned): race = normalise_race(fn()) a = analyse_race(race) self.assertIn("confidence", a) if __name__ == "__main__": unittest.main(verbosity=2)