| """Unit tests for the tactical alert rules (crafted states, deterministic).""" |
|
|
| from __future__ import annotations |
|
|
| import pandas as pd |
|
|
| from hoops_lab.live.alert_engine import evaluate_alerts |
| from hoops_lab.live.game_state import GameState, TeamLiveStats |
|
|
| HOME, AWAY = 10, 20 |
|
|
|
|
| def _possessions(rows: list[tuple[int, int, str]]) -> pd.DataFrame: |
| """rows: (offense_team_id, points, ended_by).""" |
| return pd.DataFrame( |
| { |
| "game_id": "g1", |
| "possession_number": range(1, len(rows) + 1), |
| "offense_team_id": [r[0] for r in rows], |
| "period": 4, |
| "points": [r[1] for r in rows], |
| "ended_by": [r[2] for r in rows], |
| } |
| ) |
|
|
|
|
| def _state( |
| possessions: pd.DataFrame, home_stats=None, away_stats=None, clock="PT06M00.00S" |
| ) -> GameState: |
| return GameState( |
| home_id=HOME, |
| away_id=AWAY, |
| period=2, |
| clock=clock, |
| score=(50, 48), |
| team_stats={HOME: home_stats or TeamLiveStats(), AWAY: away_stats or TeamLiveStats()}, |
| possessions=possessions, |
| ) |
|
|
|
|
| def _baselines(efg: float = 0.54, pace: float = 99.0) -> dict[int, dict[str, float]]: |
| return { |
| HOME: {"EFG_PCT": efg, "PACE": pace, "TM_TOV_PCT": 13.0}, |
| AWAY: {"EFG_PCT": efg, "PACE": pace, "TM_TOV_PCT": 13.0}, |
| } |
|
|
|
|
| def test_scoring_run_alert_fires_on_one_sided_stretch() -> None: |
| rows = [(HOME, 2, "made_fg"), (AWAY, 0, "def_rebound")] * 2 |
| rows += [ |
| (HOME, 3, "made_fg"), |
| (AWAY, 0, "def_rebound"), |
| (HOME, 2, "made_fg"), |
| (AWAY, 0, "turnover"), |
| (HOME, 3, "made_fg"), |
| (AWAY, 0, "def_rebound"), |
| (HOME, 2, "made_fg"), |
| (AWAY, 0, "turnover"), |
| ] |
| alerts = evaluate_alerts(_state(_possessions(rows)), _baselines()) |
|
|
| runs = [a for a in alerts if a.rule == "scoring_run"] |
| assert len(runs) == 1 |
| assert "10-0" in runs[0].message |
| assert runs[0].severity == "warning" |
|
|
|
|
| def test_turnover_trouble_alert() -> None: |
| rows = [ |
| (AWAY, 0, "turnover"), |
| (HOME, 2, "made_fg"), |
| (AWAY, 0, "turnover"), |
| (HOME, 0, "def_rebound"), |
| (AWAY, 0, "turnover"), |
| (HOME, 2, "made_fg"), |
| ] |
| alerts = evaluate_alerts(_state(_possessions(rows)), _baselines()) |
|
|
| tov = [a for a in alerts if a.rule == "turnover_trouble"] |
| assert len(tov) == 1 |
| assert "3 turnovers" in tov[0].message.lower() |
|
|
|
|
| def test_cold_shooting_alert_needs_sample_and_gap() -> None: |
| cold = TeamLiveStats(fgm=5, fga=20, fg3m=1, fg3a=8) |
| alerts = evaluate_alerts( |
| _state(_possessions([(HOME, 2, "made_fg")]), home_stats=cold), _baselines(efg=0.54) |
| ) |
| assert any(a.rule == "cold_shooting" for a in alerts) |
|
|
| small_sample = TeamLiveStats(fgm=1, fga=8) |
| alerts = evaluate_alerts( |
| _state(_possessions([(HOME, 2, "made_fg")]), home_stats=small_sample), _baselines() |
| ) |
| assert not any(a.rule == "cold_shooting" for a in alerts) |
|
|
|
|
| def test_no_alerts_on_calm_game() -> None: |
| rows = [ |
| (HOME, 2, "made_fg"), |
| (AWAY, 2, "made_fg"), |
| (HOME, 0, "def_rebound"), |
| (AWAY, 3, "made_fg"), |
| (HOME, 2, "made_fg"), |
| (AWAY, 0, "turnover"), |
| ] |
| normal = TeamLiveStats(fgm=10, fga=20, fg3m=2, fg3a=6) |
| |
| state = _state(_possessions(rows), home_stats=normal, away_stats=normal) |
| live_pace = state.pace_estimate |
| alerts = evaluate_alerts(state, _baselines(pace=live_pace)) |
|
|
| assert alerts == [] |
|
|