- Period base: playoff OT escalates indefinitely (P4=600, P5=750…), reg season P4=5-min OT (600), P5=shootout (700) - Time priority range increased (max ~300 vs old ~120), calibrated to period length so 5-min reg season OT reads correctly - Matchup multiplier inverted: higher period = less weight (any OT is exciting regardless of teams) - Replace unconditional score_total with closeness bonus: rewards tight games regardless of goal volume (5-4 == 1-0 at same diff) - Power play bonus: 30 (P1/P2) → 50/100/150 (P3 by time) → 200 (OT) - Comeback bonus: one-time pulse (+50/75/100 by period) when trailing team scores to within 2 goals; keyed on team names, clears after firing, skips intermission Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
416 lines
15 KiB
Python
416 lines
15 KiB
Python
import app.games
|
|
from tests.conftest import make_game
|
|
from app.games import (
|
|
calculate_game_priority,
|
|
convert_game_state,
|
|
format_record,
|
|
get_comeback_bonus,
|
|
get_game_outcome,
|
|
get_period,
|
|
get_power_play_info,
|
|
get_start_time,
|
|
get_time_remaining,
|
|
parse_games,
|
|
utc_to_eastern,
|
|
)
|
|
|
|
|
|
class TestConvertGameState:
|
|
def test_off_maps_to_final(self):
|
|
assert convert_game_state("OFF") == "FINAL"
|
|
|
|
def test_crit_maps_to_live(self):
|
|
assert convert_game_state("CRIT") == "LIVE"
|
|
|
|
def test_fut_maps_to_pre(self):
|
|
assert convert_game_state("FUT") == "PRE"
|
|
|
|
def test_unknown_state_passes_through(self):
|
|
assert convert_game_state("LIVE") == "LIVE"
|
|
|
|
|
|
class TestProcessRecord:
|
|
def test_na_returns_na(self):
|
|
assert format_record("N/A") == "N/A"
|
|
|
|
def test_pads_single_digit_parts(self):
|
|
assert format_record("5-3-1") == "05-03-01"
|
|
|
|
def test_already_padded_unchanged(self):
|
|
assert format_record("40-25-10") == "40-25-10"
|
|
|
|
|
|
class TestProcessPeriod:
|
|
def test_pre_game_returns_zero(self):
|
|
game = make_game(game_state="PRE")
|
|
assert get_period(game) == 0
|
|
|
|
def test_fut_game_returns_zero(self):
|
|
game = make_game(game_state="FUT")
|
|
assert get_period(game) == 0
|
|
|
|
def test_final_game_returns_na(self):
|
|
game = make_game(game_state="OFF")
|
|
assert get_period(game) == "N/A"
|
|
|
|
def test_live_game_returns_period_number(self):
|
|
game = make_game(game_state="LIVE", period=2)
|
|
assert get_period(game) == 2
|
|
|
|
|
|
class TestProcessTimeRemaining:
|
|
def test_pre_game_returns_2000(self):
|
|
game = make_game(game_state="FUT")
|
|
assert get_time_remaining(game) == "20:00"
|
|
|
|
def test_final_game_returns_0000(self):
|
|
game = make_game(game_state="OFF")
|
|
assert get_time_remaining(game) == "00:00"
|
|
|
|
def test_live_game_returns_clock(self):
|
|
game = make_game(game_state="LIVE", seconds_remaining=305)
|
|
assert get_time_remaining(game) == "05:05"
|
|
|
|
def test_live_game_at_zero_returns_end(self):
|
|
game = make_game(game_state="LIVE", seconds_remaining=0)
|
|
assert get_time_remaining(game) == "END"
|
|
|
|
|
|
class TestProcessStartTime:
|
|
def test_pre_game_returns_est_time(self):
|
|
game = make_game(game_state="FUT", start_time_utc="2024-04-10T23:00:00Z")
|
|
result = get_start_time(game)
|
|
assert result == "7:00 PM"
|
|
|
|
def test_pre_game_strips_leading_zero(self):
|
|
game = make_game(game_state="FUT", start_time_utc="2024-04-10T22:00:00Z")
|
|
result = get_start_time(game)
|
|
assert not result.startswith("0")
|
|
|
|
def test_live_game_returns_na(self):
|
|
game = make_game(game_state="LIVE")
|
|
assert get_start_time(game) == "N/A"
|
|
|
|
|
|
class TestGetGameOutcome:
|
|
def test_final_game_returns_last_period_type(self):
|
|
game = make_game(game_state="OFF")
|
|
assert get_game_outcome(game, "FINAL") == "REG"
|
|
|
|
def test_live_game_returns_na(self):
|
|
game = make_game(game_state="LIVE")
|
|
assert get_game_outcome(game, "LIVE") == "N/A"
|
|
|
|
|
|
class TestUtcToEstTime:
|
|
def test_converts_utc_to_edt(self):
|
|
# April is EDT (UTC-4): 23:00 UTC → 07:00 PM EDT
|
|
result = utc_to_eastern("2024-04-10T23:00:00Z")
|
|
assert result == "07:00 PM"
|
|
|
|
def test_converts_utc_to_est(self):
|
|
# January is EST (UTC-5): 23:00 UTC → 06:00 PM EST
|
|
result = utc_to_eastern("2024-01-15T23:00:00Z")
|
|
assert result == "06:00 PM"
|
|
|
|
|
|
class TestParseGames:
|
|
def test_returns_empty_list_for_none(self):
|
|
assert parse_games(None) == []
|
|
|
|
def test_returns_empty_list_for_empty_dict(self):
|
|
assert parse_games({}) == []
|
|
|
|
|
|
class TestGetPowerPlayInfo:
|
|
def test_returns_empty_when_no_situation(self):
|
|
game = make_game()
|
|
assert get_power_play_info(game, "Maple Leafs") == ""
|
|
|
|
def test_returns_pp_info_for_away_team(self):
|
|
game = make_game(away_name="Bruins")
|
|
game["situation"] = {
|
|
"situationDescriptions": ["PP"],
|
|
"timeRemaining": "1:30",
|
|
}
|
|
assert get_power_play_info(game, "Bruins") == "PP 1:30"
|
|
|
|
def test_returns_pp_info_for_home_team(self):
|
|
game = make_game(home_name="Maple Leafs", away_name="Bruins")
|
|
game["situation"] = {
|
|
"situationDescriptions": ["PP"],
|
|
"timeRemaining": "0:45",
|
|
}
|
|
assert get_power_play_info(game, "Maple Leafs") == "PP 0:45"
|
|
|
|
|
|
class TestCalculateGamePriority:
|
|
def _live_game(
|
|
self,
|
|
period=3,
|
|
seconds_remaining=300,
|
|
home_score=2,
|
|
away_score=1,
|
|
in_intermission=False,
|
|
):
|
|
return make_game(
|
|
game_state="LIVE",
|
|
period=period,
|
|
seconds_remaining=seconds_remaining,
|
|
home_score=home_score,
|
|
away_score=away_score,
|
|
in_intermission=in_intermission,
|
|
)
|
|
|
|
def test_returns_zero_for_final(self):
|
|
game = make_game(game_state="OFF")
|
|
assert calculate_game_priority(game) == 0
|
|
|
|
def test_returns_zero_for_pre(self):
|
|
game = make_game(game_state="FUT")
|
|
assert calculate_game_priority(game) == 0
|
|
|
|
def test_intermission_returns_negative(self, mocker):
|
|
mocker.patch(
|
|
"app.games.get_team_standings",
|
|
return_value={"league_sequence": 0, "league_l10_sequence": 0},
|
|
)
|
|
game = self._live_game(in_intermission=True, seconds_remaining=0)
|
|
assert calculate_game_priority(game) < 0
|
|
|
|
def test_score_diff_greater_than_3(self, mocker):
|
|
mocker.patch(
|
|
"app.games.get_team_standings",
|
|
return_value={"league_sequence": 0, "league_l10_sequence": 0},
|
|
)
|
|
game = self._live_game(home_score=5, away_score=0)
|
|
result = calculate_game_priority(game)
|
|
assert isinstance(result, int)
|
|
|
|
def test_score_diff_greater_than_2(self, mocker):
|
|
mocker.patch(
|
|
"app.games.get_team_standings",
|
|
return_value={"league_sequence": 0, "league_l10_sequence": 0},
|
|
)
|
|
game = self._live_game(home_score=4, away_score=1)
|
|
result = calculate_game_priority(game)
|
|
assert isinstance(result, int)
|
|
|
|
def test_score_diff_greater_than_1(self, mocker):
|
|
mocker.patch(
|
|
"app.games.get_team_standings",
|
|
return_value={"league_sequence": 0, "league_l10_sequence": 0},
|
|
)
|
|
game = self._live_game(home_score=3, away_score=1)
|
|
result = calculate_game_priority(game)
|
|
assert isinstance(result, int)
|
|
|
|
def test_late_3rd_tied_bonus(self, mocker):
|
|
mocker.patch(
|
|
"app.games.get_team_standings",
|
|
return_value={"league_sequence": 0, "league_l10_sequence": 0},
|
|
)
|
|
game = self._live_game(
|
|
period=3, seconds_remaining=600, home_score=2, away_score=2
|
|
)
|
|
result = calculate_game_priority(game)
|
|
assert isinstance(result, int)
|
|
|
|
def test_final_6_minutes_tied_bonus(self, mocker):
|
|
mocker.patch(
|
|
"app.games.get_team_standings",
|
|
return_value={"league_sequence": 0, "league_l10_sequence": 0},
|
|
)
|
|
game = self._live_game(
|
|
period=3, seconds_remaining=300, home_score=2, away_score=2
|
|
)
|
|
result = calculate_game_priority(game)
|
|
assert isinstance(result, int)
|
|
|
|
def test_playoff_ot_escalates_per_period(self, mocker):
|
|
mocker.patch(
|
|
"app.games.get_team_standings",
|
|
return_value={"league_sequence": 0, "league_l10_sequence": 0},
|
|
)
|
|
p4 = make_game(game_state="LIVE", period=4, seconds_remaining=600, game_type=3)
|
|
p5 = make_game(game_state="LIVE", period=5, seconds_remaining=600, game_type=3)
|
|
assert calculate_game_priority(p5) > calculate_game_priority(p4)
|
|
|
|
def test_regular_season_shootout_p5_above_p4(self, mocker):
|
|
mocker.patch(
|
|
"app.games.get_team_standings",
|
|
return_value={"league_sequence": 0, "league_l10_sequence": 0},
|
|
)
|
|
p4 = make_game(game_state="LIVE", period=4, seconds_remaining=150, game_type=2)
|
|
p5 = make_game(game_state="LIVE", period=5, seconds_remaining=0, game_type=2)
|
|
assert calculate_game_priority(p5) > calculate_game_priority(p4)
|
|
|
|
def test_playoff_p4_higher_than_regular_season_p4(self, mocker):
|
|
mocker.patch(
|
|
"app.games.get_team_standings",
|
|
return_value={"league_sequence": 0, "league_l10_sequence": 0},
|
|
)
|
|
reg = make_game(game_state="LIVE", period=4, seconds_remaining=150, game_type=2)
|
|
playoff = make_game(
|
|
game_state="LIVE", period=4, seconds_remaining=600, game_type=3
|
|
)
|
|
assert calculate_game_priority(playoff) > calculate_game_priority(reg)
|
|
|
|
def test_closeness_bonus_tied_beats_one_goal(self, mocker):
|
|
mocker.patch(
|
|
"app.games.get_team_standings",
|
|
return_value={"league_sequence": 0, "league_l10_sequence": 0},
|
|
)
|
|
tied = self._live_game(home_score=2, away_score=2)
|
|
one_goal = self._live_game(home_score=2, away_score=1)
|
|
assert calculate_game_priority(tied) > calculate_game_priority(one_goal)
|
|
|
|
def test_5_4_same_priority_as_1_0(self, mocker):
|
|
mocker.patch(
|
|
"app.games.get_team_standings",
|
|
return_value={"league_sequence": 0, "league_l10_sequence": 0},
|
|
)
|
|
high_scoring = self._live_game(home_score=5, away_score=4)
|
|
low_scoring = self._live_game(home_score=1, away_score=0)
|
|
assert calculate_game_priority(high_scoring) == calculate_game_priority(
|
|
low_scoring
|
|
)
|
|
|
|
def test_pp_in_ot_adds_200(self, mocker):
|
|
mocker.patch(
|
|
"app.games.get_team_standings",
|
|
return_value={"league_sequence": 0, "league_l10_sequence": 0},
|
|
)
|
|
base = make_game(game_state="LIVE", period=4, seconds_remaining=600)
|
|
with_pp = make_game(
|
|
game_state="LIVE",
|
|
period=4,
|
|
seconds_remaining=600,
|
|
situation={"situationDescriptions": ["PP"], "timeRemaining": "1:30"},
|
|
)
|
|
assert calculate_game_priority(with_pp) - calculate_game_priority(base) == 200
|
|
|
|
def test_pp_late_p3_adds_150(self, mocker):
|
|
mocker.patch(
|
|
"app.games.get_team_standings",
|
|
return_value={"league_sequence": 0, "league_l10_sequence": 0},
|
|
)
|
|
base = self._live_game(period=3, seconds_remaining=240)
|
|
with_pp = make_game(
|
|
game_state="LIVE",
|
|
period=3,
|
|
seconds_remaining=240,
|
|
situation={"situationDescriptions": ["PP"], "timeRemaining": "1:30"},
|
|
)
|
|
assert calculate_game_priority(with_pp) - calculate_game_priority(base) == 150
|
|
|
|
def test_pp_mid_p3_adds_100(self, mocker):
|
|
mocker.patch(
|
|
"app.games.get_team_standings",
|
|
return_value={"league_sequence": 0, "league_l10_sequence": 0},
|
|
)
|
|
base = self._live_game(period=3, seconds_remaining=600)
|
|
with_pp = make_game(
|
|
game_state="LIVE",
|
|
period=3,
|
|
seconds_remaining=600,
|
|
situation={"situationDescriptions": ["PP"], "timeRemaining": "1:30"},
|
|
)
|
|
assert calculate_game_priority(with_pp) - calculate_game_priority(base) == 100
|
|
|
|
def test_pp_early_p3_adds_50(self, mocker):
|
|
mocker.patch(
|
|
"app.games.get_team_standings",
|
|
return_value={"league_sequence": 0, "league_l10_sequence": 0},
|
|
)
|
|
base = self._live_game(period=3, seconds_remaining=900)
|
|
with_pp = make_game(
|
|
game_state="LIVE",
|
|
period=3,
|
|
seconds_remaining=900,
|
|
situation={"situationDescriptions": ["PP"], "timeRemaining": "1:30"},
|
|
)
|
|
assert calculate_game_priority(with_pp) - calculate_game_priority(base) == 50
|
|
|
|
def test_pp_p1_adds_30(self, mocker):
|
|
mocker.patch(
|
|
"app.games.get_team_standings",
|
|
return_value={"league_sequence": 0, "league_l10_sequence": 0},
|
|
)
|
|
base = self._live_game(period=1, seconds_remaining=600)
|
|
with_pp = make_game(
|
|
game_state="LIVE",
|
|
period=1,
|
|
seconds_remaining=600,
|
|
situation={"situationDescriptions": ["PP"], "timeRemaining": "1:30"},
|
|
)
|
|
assert calculate_game_priority(with_pp) - calculate_game_priority(base) == 30
|
|
|
|
def test_time_priority_increases_as_clock_runs(self, mocker):
|
|
mocker.patch(
|
|
"app.games.get_team_standings",
|
|
return_value={"league_sequence": 0, "league_l10_sequence": 0},
|
|
)
|
|
early = self._live_game(period=3, seconds_remaining=1100)
|
|
late = self._live_game(period=3, seconds_remaining=200)
|
|
assert calculate_game_priority(late) > calculate_game_priority(early)
|
|
|
|
|
|
class TestGetComebackBonus:
|
|
def setup_method(self):
|
|
app.games._score_cache.clear()
|
|
|
|
def test_returns_zero_on_first_call(self):
|
|
game = make_game(home_score=2, away_score=1)
|
|
assert get_comeback_bonus(game) == 0
|
|
|
|
def test_cache_populated_after_first_call(self):
|
|
game = make_game(home_score=2, away_score=1)
|
|
get_comeback_bonus(game)
|
|
assert ("Maple Leafs", "Bruins") in app.games._score_cache
|
|
|
|
def test_no_bonus_when_leading_team_scores(self):
|
|
app.games._score_cache[("Maple Leafs", "Bruins")] = (2, 1)
|
|
game = make_game(home_score=3, away_score=1) # leading home scored again
|
|
assert get_comeback_bonus(game) == 0
|
|
|
|
def test_bonus_when_trailing_home_scores_p3(self):
|
|
app.games._score_cache[("Maple Leafs", "Bruins")] = (1, 3)
|
|
game = make_game(home_score=2, away_score=3, period=3)
|
|
assert get_comeback_bonus(game) == 75
|
|
|
|
def test_bonus_when_trailing_away_scores_p2(self):
|
|
app.games._score_cache[("Maple Leafs", "Bruins")] = (3, 1)
|
|
game = make_game(home_score=3, away_score=2, period=2)
|
|
assert get_comeback_bonus(game) == 50
|
|
|
|
def test_no_bonus_when_diff_still_above_2(self):
|
|
# 5-1 → 5-2: diff 4→3, still > 2, no bonus
|
|
app.games._score_cache[("Maple Leafs", "Bruins")] = (1, 5)
|
|
game = make_game(home_score=2, away_score=5)
|
|
assert get_comeback_bonus(game) == 0
|
|
|
|
def test_bonus_fires_once_then_clears(self):
|
|
app.games._score_cache[("Maple Leafs", "Bruins")] = (1, 3)
|
|
game = make_game(home_score=2, away_score=3, period=3)
|
|
assert get_comeback_bonus(game) == 75
|
|
# Same score on next call — cache now (2, 3), no change
|
|
assert get_comeback_bonus(game) == 0
|
|
|
|
def test_no_bonus_in_intermission(self):
|
|
app.games._score_cache[("Maple Leafs", "Bruins")] = (1, 3)
|
|
game = make_game(home_score=2, away_score=3, in_intermission=True)
|
|
assert get_comeback_bonus(game) == 0
|
|
# Cache should NOT be updated
|
|
assert app.games._score_cache[("Maple Leafs", "Bruins")] == (1, 3)
|
|
|
|
def test_no_bonus_for_non_live_state(self):
|
|
app.games._score_cache[("Maple Leafs", "Bruins")] = (1, 3)
|
|
game = make_game(game_state="OFF", home_score=2, away_score=3)
|
|
assert get_comeback_bonus(game) == 0
|
|
|
|
def test_ot_comeback_bonus_is_100(self):
|
|
app.games._score_cache[("Maple Leafs", "Bruins")] = (1, 3)
|
|
game = make_game(home_score=2, away_score=3, period=4)
|
|
assert get_comeback_bonus(game) == 100
|