feat: game importance factor in hype scoring
Adds calculate_game_importance() that boosts Priority for high-stakes regular-season matchups based on season progress (sharp ramp after game 55), playoff bubble proximity (wildcard rank ~17-19 = max relevance), and divisional/conference rivalry (1.4x/1.2x multipliers). Max bonus 150 pts applied to both LIVE and PRE games; playoff and FINAL games are unaffected. Extends standings schema with division, conference, games_played, and wildcard_sequence fields fetched from the NHL API. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -78,7 +78,8 @@ def flask_client(tmp_path, monkeypatch):
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
conn.execute(
|
||||
"CREATE TABLE standings "
|
||||
"(team_common_name TEXT, league_sequence INTEGER, league_l10_sequence INTEGER)"
|
||||
"(team_common_name TEXT, league_sequence INTEGER, league_l10_sequence INTEGER, "
|
||||
"division_abbrev TEXT, conference_abbrev TEXT, games_played INTEGER, wildcard_sequence INTEGER)"
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import app.games
|
||||
from tests.conftest import make_game
|
||||
from app.games import (
|
||||
calculate_game_importance,
|
||||
calculate_game_priority,
|
||||
convert_game_state,
|
||||
format_record,
|
||||
@@ -413,3 +414,123 @@ class TestGetComebackBonus:
|
||||
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
|
||||
|
||||
|
||||
class TestCalculateGameImportance:
|
||||
def _standings(
|
||||
self,
|
||||
league_seq=10,
|
||||
l10_seq=10,
|
||||
div="ATL",
|
||||
conf="E",
|
||||
gp=65,
|
||||
wc=18,
|
||||
):
|
||||
return {
|
||||
"league_sequence": league_seq,
|
||||
"league_l10_sequence": l10_seq,
|
||||
"division_abbrev": div,
|
||||
"conference_abbrev": conf,
|
||||
"games_played": gp,
|
||||
"wildcard_sequence": wc,
|
||||
}
|
||||
|
||||
def test_returns_zero_for_playoff_game(self):
|
||||
game = make_game(game_type=3)
|
||||
assert calculate_game_importance(game) == 0
|
||||
|
||||
def test_returns_zero_for_final_game(self):
|
||||
game = make_game(game_state="OFF")
|
||||
assert calculate_game_importance(game) == 0
|
||||
|
||||
def test_near_zero_early_in_season(self, mocker):
|
||||
mocker.patch(
|
||||
"app.games.get_team_standings",
|
||||
return_value=self._standings(gp=10, wc=18),
|
||||
)
|
||||
game = make_game(game_state="FUT")
|
||||
assert calculate_game_importance(game) <= 10
|
||||
|
||||
def test_max_bonus_late_season_bubble_division_game(self, mocker):
|
||||
mocker.patch(
|
||||
"app.games.get_team_standings",
|
||||
return_value=self._standings(gp=82, wc=18, div="ATL", conf="E"),
|
||||
)
|
||||
game = make_game(game_state="FUT")
|
||||
assert calculate_game_importance(game) == 150
|
||||
|
||||
def test_same_division_beats_same_conference(self, mocker):
|
||||
home_st = self._standings(gp=70, wc=18, div="ATL", conf="E")
|
||||
away_st_same_div = self._standings(gp=70, wc=18, div="ATL", conf="E")
|
||||
away_st_diff_div = self._standings(gp=70, wc=18, div="MET", conf="E")
|
||||
|
||||
mocker.patch(
|
||||
"app.games.get_team_standings",
|
||||
side_effect=[home_st, away_st_same_div],
|
||||
)
|
||||
result_div = calculate_game_importance(make_game(game_state="FUT"))
|
||||
|
||||
mocker.patch(
|
||||
"app.games.get_team_standings",
|
||||
side_effect=[home_st, away_st_diff_div],
|
||||
)
|
||||
result_conf = calculate_game_importance(make_game(game_state="FUT"))
|
||||
|
||||
assert result_div > result_conf
|
||||
|
||||
def test_same_conference_beats_different_conference(self, mocker):
|
||||
home_st = self._standings(gp=70, wc=18, div="ATL", conf="E")
|
||||
away_same_conf = self._standings(gp=70, wc=18, div="MET", conf="E")
|
||||
away_diff_conf = self._standings(gp=70, wc=18, div="PAC", conf="W")
|
||||
|
||||
mocker.patch(
|
||||
"app.games.get_team_standings",
|
||||
side_effect=[home_st, away_same_conf],
|
||||
)
|
||||
result_same = calculate_game_importance(make_game(game_state="FUT"))
|
||||
|
||||
mocker.patch(
|
||||
"app.games.get_team_standings",
|
||||
side_effect=[home_st, away_diff_conf],
|
||||
)
|
||||
result_diff = calculate_game_importance(make_game(game_state="FUT"))
|
||||
|
||||
assert result_same > result_diff
|
||||
|
||||
def test_bubble_teams_beat_safely_in_teams(self, mocker):
|
||||
mocker.patch(
|
||||
"app.games.get_team_standings",
|
||||
return_value=self._standings(gp=70, wc=18),
|
||||
)
|
||||
result_bubble = calculate_game_importance(make_game(game_state="FUT"))
|
||||
|
||||
mocker.patch(
|
||||
"app.games.get_team_standings",
|
||||
return_value=self._standings(gp=70, wc=5),
|
||||
)
|
||||
result_safe = calculate_game_importance(make_game(game_state="FUT"))
|
||||
|
||||
assert result_bubble > result_safe
|
||||
|
||||
def test_eliminated_teams_have_lowest_relevance(self, mocker):
|
||||
mocker.patch(
|
||||
"app.games.get_team_standings",
|
||||
return_value=self._standings(gp=70, wc=30),
|
||||
)
|
||||
assert calculate_game_importance(make_game(game_state="FUT")) < 30
|
||||
|
||||
def test_result_is_non_negative_int(self, mocker):
|
||||
mocker.patch(
|
||||
"app.games.get_team_standings",
|
||||
return_value=self._standings(gp=0, wc=32),
|
||||
)
|
||||
result = calculate_game_importance(make_game(game_state="FUT"))
|
||||
assert isinstance(result, int)
|
||||
assert result >= 0
|
||||
|
||||
def test_result_never_exceeds_150(self, mocker):
|
||||
mocker.patch(
|
||||
"app.games.get_team_standings",
|
||||
return_value=self._standings(gp=82, wc=18, div="ATL", conf="E"),
|
||||
)
|
||||
assert calculate_game_importance(make_game(game_state="FUT")) <= 150
|
||||
|
||||
@@ -6,6 +6,7 @@ from app.standings import (
|
||||
create_standings_table,
|
||||
fetch_standings,
|
||||
insert_standings,
|
||||
migrate_standings_table,
|
||||
refresh_standings,
|
||||
truncate_standings_table,
|
||||
)
|
||||
@@ -16,11 +17,19 @@ SAMPLE_API_RESPONSE = {
|
||||
"teamCommonName": {"default": "Bruins"},
|
||||
"leagueSequence": 1,
|
||||
"leagueL10Sequence": 2,
|
||||
"divisionAbbrev": "ATL",
|
||||
"conferenceAbbrev": "E",
|
||||
"gamesPlayed": 60,
|
||||
"wildcardSequence": 5,
|
||||
},
|
||||
{
|
||||
"teamCommonName": {"default": "Maple Leafs"},
|
||||
"leagueSequence": 5,
|
||||
"leagueL10Sequence": 3,
|
||||
"divisionAbbrev": "ATL",
|
||||
"conferenceAbbrev": "E",
|
||||
"gamesPlayed": 61,
|
||||
"wildcardSequence": 8,
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -38,6 +47,10 @@ class TestFetchStandings:
|
||||
"team_common_name": "Bruins",
|
||||
"league_sequence": 1,
|
||||
"league_l10_sequence": 2,
|
||||
"division_abbrev": "ATL",
|
||||
"conference_abbrev": "E",
|
||||
"games_played": 60,
|
||||
"wildcard_sequence": 5,
|
||||
}
|
||||
assert result[1]["team_common_name"] == "Maple Leafs"
|
||||
|
||||
@@ -98,6 +111,10 @@ class TestTruncateStandingsTable:
|
||||
"team_common_name": "Bruins",
|
||||
"league_sequence": 1,
|
||||
"league_l10_sequence": 2,
|
||||
"division_abbrev": "ATL",
|
||||
"conference_abbrev": "E",
|
||||
"games_played": 60,
|
||||
"wildcard_sequence": 5,
|
||||
}
|
||||
],
|
||||
)
|
||||
@@ -119,11 +136,19 @@ class TestInsertStandings:
|
||||
"team_common_name": "Bruins",
|
||||
"league_sequence": 1,
|
||||
"league_l10_sequence": 2,
|
||||
"division_abbrev": "ATL",
|
||||
"conference_abbrev": "E",
|
||||
"games_played": 60,
|
||||
"wildcard_sequence": 5,
|
||||
},
|
||||
{
|
||||
"team_common_name": "Maple Leafs",
|
||||
"league_sequence": 5,
|
||||
"league_l10_sequence": 3,
|
||||
"division_abbrev": "ATL",
|
||||
"conference_abbrev": "E",
|
||||
"games_played": 61,
|
||||
"wildcard_sequence": 8,
|
||||
},
|
||||
]
|
||||
insert_standings(conn, data)
|
||||
@@ -142,6 +167,10 @@ class TestInsertStandings:
|
||||
"team_common_name": "Bruins",
|
||||
"league_sequence": 1,
|
||||
"league_l10_sequence": 2,
|
||||
"division_abbrev": "ATL",
|
||||
"conference_abbrev": "E",
|
||||
"games_played": 60,
|
||||
"wildcard_sequence": 5,
|
||||
}
|
||||
],
|
||||
)
|
||||
@@ -162,6 +191,10 @@ class TestRefreshStandings:
|
||||
"team_common_name": "Bruins",
|
||||
"league_sequence": 1,
|
||||
"league_l10_sequence": 2,
|
||||
"division_abbrev": "ATL",
|
||||
"conference_abbrev": "E",
|
||||
"games_played": 60,
|
||||
"wildcard_sequence": 5,
|
||||
}
|
||||
]
|
||||
mocker.patch("app.standings.fetch_standings", return_value=standings)
|
||||
@@ -183,6 +216,10 @@ class TestRefreshStandings:
|
||||
"team_common_name": "Bruins",
|
||||
"league_sequence": 1,
|
||||
"league_l10_sequence": 2,
|
||||
"division_abbrev": "ATL",
|
||||
"conference_abbrev": "E",
|
||||
"games_played": 60,
|
||||
"wildcard_sequence": 5,
|
||||
}
|
||||
]
|
||||
mocker.patch("app.standings.fetch_standings", return_value=first)
|
||||
@@ -193,11 +230,19 @@ class TestRefreshStandings:
|
||||
"team_common_name": "Oilers",
|
||||
"league_sequence": 3,
|
||||
"league_l10_sequence": 1,
|
||||
"division_abbrev": "PAC",
|
||||
"conference_abbrev": "W",
|
||||
"games_played": 62,
|
||||
"wildcard_sequence": 3,
|
||||
},
|
||||
{
|
||||
"team_common_name": "Jets",
|
||||
"league_sequence": 4,
|
||||
"league_l10_sequence": 2,
|
||||
"division_abbrev": "CEN",
|
||||
"conference_abbrev": "W",
|
||||
"games_played": 61,
|
||||
"wildcard_sequence": 4,
|
||||
},
|
||||
]
|
||||
mocker.patch("app.standings.fetch_standings", return_value=second)
|
||||
@@ -218,6 +263,10 @@ class TestRefreshStandings:
|
||||
"team_common_name": "Bruins",
|
||||
"league_sequence": 1,
|
||||
"league_l10_sequence": 2,
|
||||
"division_abbrev": "ATL",
|
||||
"conference_abbrev": "E",
|
||||
"games_played": 60,
|
||||
"wildcard_sequence": 5,
|
||||
}
|
||||
]
|
||||
mocker.patch("app.standings.fetch_standings", return_value=seed)
|
||||
@@ -231,3 +280,29 @@ class TestRefreshStandings:
|
||||
count = conn.execute("SELECT COUNT(*) FROM standings").fetchone()[0]
|
||||
conn.close()
|
||||
assert count == 1
|
||||
|
||||
|
||||
class TestMigrateStandingsTable:
|
||||
def test_adds_missing_columns_to_existing_table(self, tmp_path):
|
||||
conn = sqlite3.connect(str(tmp_path / "test.db"))
|
||||
conn.execute(
|
||||
"CREATE TABLE standings "
|
||||
"(team_common_name TEXT, league_sequence INTEGER, league_l10_sequence INTEGER)"
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
migrate_standings_table(conn)
|
||||
|
||||
cols = [row[1] for row in conn.execute("PRAGMA table_info(standings)").fetchall()]
|
||||
assert "division_abbrev" in cols
|
||||
assert "conference_abbrev" in cols
|
||||
assert "games_played" in cols
|
||||
assert "wildcard_sequence" in cols
|
||||
conn.close()
|
||||
|
||||
def test_is_idempotent(self, tmp_path):
|
||||
conn = sqlite3.connect(str(tmp_path / "test.db"))
|
||||
create_standings_table(conn)
|
||||
migrate_standings_table(conn)
|
||||
migrate_standings_table(conn) # must not raise
|
||||
conn.close()
|
||||
|
||||
Reference in New Issue
Block a user