diff --git a/helpers.py b/helpers.py index 3f64bf2..858430b 100644 --- a/helpers.py +++ b/helpers.py @@ -26,6 +26,10 @@ PD_SEASON = 9 FA_LOCK_WEEK = 14 SBA_COLOR = 'a6ce39' +# Salary cap constants +DEFAULT_SALARY_CAP = 32.0 +SALARY_CAP_TOLERANCE = 0.001 # Small tolerance for floating point comparisons + SBA_ROSTER_KEY = '1bt7LLJe6h7axkhDVlxJ4f319l8QmFB0zQH-pjM0c8a8' SBA_STATS_KEY = '1fnqx2uxC7DT5aTnx4EkXh83crwrL0W6eJefoC1d4KH4' SBA_STANDINGS_KEY = '1cXZcPY08RvqV_GeLvZ7PY5-0CyM-AijpJxsaFisZjBc' @@ -1192,7 +1196,7 @@ def get_team_url(this_team): async def send_owner_notification(message: str): """ Send a notification message to the bot owner via Discord webhook. - + Args: message (str): The message content to send """ @@ -1206,3 +1210,39 @@ async def send_owner_notification(message: str): await session.post(webhook_url, json=payload) except Exception as webhook_error: logger.error(f'Failed to send owner notification: {webhook_error}') + + +def get_team_salary_cap(team: dict) -> float: + """ + Get the salary cap for a team, falling back to the default if not set. + + Args: + team: Team dictionary containing team data. May have 'salary_cap' field. + + Returns: + float: The team's salary cap, or DEFAULT_SALARY_CAP (32.0) if not set. + + Why: Teams may have custom salary caps (e.g., for expansion teams or penalties). + This centralizes the fallback logic so all cap checks use the same source of truth. + """ + if team and team.get('salary_cap') is not None: + return team['salary_cap'] + return DEFAULT_SALARY_CAP + + +def exceeds_salary_cap(wara: float, team: dict) -> bool: + """ + Check if a WAR total exceeds the team's salary cap. + + Args: + wara: The total WAR value to check + team: Team dictionary containing team data + + Returns: + bool: True if wara exceeds the team's salary cap (with tolerance) + + Why: Centralizes the salary cap comparison logic with proper floating point + tolerance handling. All cap validation should use this function. + """ + cap = get_team_salary_cap(team) + return wara > (cap + SALARY_CAP_TOLERANCE) diff --git a/salary_cap_refactor_plan.json b/salary_cap_refactor_plan.json index f7033ff..89da157 100644 --- a/salary_cap_refactor_plan.json +++ b/salary_cap_refactor_plan.json @@ -8,11 +8,11 @@ "name": "Add salary cap helper function", "description": "Create a helper function in helpers.py that retrieves the salary cap for a team, with fallback to default 32.0 for backwards compatibility", "files": ["helpers.py"], - "lines": null, + "lines": [1215, 1248], "priority": 1, - "completed": false, + "completed": true, "tested": false, - "notes": "This should be done first as other tasks will depend on it" + "notes": "Added get_team_salary_cap() and exceeds_salary_cap() functions" }, { "id": "SWAR-002", @@ -74,11 +74,11 @@ "name": "Add default salary cap constant", "description": "Define DEFAULT_SALARY_CAP = 32.0 constant in helpers.py for use as fallback when team.salary_cap is None", "files": ["helpers.py"], - "lines": null, + "lines": [30, 31], "priority": 1, - "completed": false, + "completed": true, "tested": false, - "notes": "Centralized constant for maintainability" + "notes": "Added DEFAULT_SALARY_CAP and SALARY_CAP_TOLERANCE constants" }, { "id": "SWAR-008", @@ -87,9 +87,9 @@ "files": ["api_calls/team.py"], "lines": [26], "priority": 1, - "completed": false, + "completed": true, "tested": false, - "notes": "Field already added, need to verify API integration" + "notes": "Field exists: salary_cap: Optional[float] = None" } ], "completion_checklist": [ diff --git a/tests/test_salary_cap.py b/tests/test_salary_cap.py new file mode 100644 index 0000000..a1bf6ba --- /dev/null +++ b/tests/test_salary_cap.py @@ -0,0 +1,208 @@ +""" +Unit tests for salary cap helper functions in helpers.py. + +These tests verify: +1. get_team_salary_cap() returns correct cap values with fallback behavior +2. exceeds_salary_cap() correctly identifies when WAR exceeds team cap +3. Edge cases around None values and floating point tolerance + +Why these tests matter: +- Salary cap validation is critical for league integrity during trades/drafts +- The helper functions centralize logic previously scattered across cogs +- Proper fallback behavior ensures backwards compatibility +""" + +import pytest +from helpers import ( + DEFAULT_SALARY_CAP, + SALARY_CAP_TOLERANCE, + get_team_salary_cap, + exceeds_salary_cap +) + + +class TestGetTeamSalaryCap: + """Tests for get_team_salary_cap() function.""" + + def test_returns_team_salary_cap_when_set(self): + """ + When a team has a custom salary_cap value set, return that value. + + Why: Some teams may have different caps (expansion teams, penalties, etc.) + """ + team = {'abbrev': 'TEST', 'salary_cap': 35.0} + result = get_team_salary_cap(team) + assert result == 35.0 + + def test_returns_default_when_salary_cap_is_none(self): + """ + When team.salary_cap is None, return the default cap (32.0). + + Why: Most teams use the standard cap; None indicates no custom value. + """ + team = {'abbrev': 'TEST', 'salary_cap': None} + result = get_team_salary_cap(team) + assert result == DEFAULT_SALARY_CAP + assert result == 32.0 + + def test_returns_default_when_salary_cap_key_missing(self): + """ + When the salary_cap key doesn't exist in team dict, return default. + + Why: Backwards compatibility with older team data structures. + """ + team = {'abbrev': 'TEST', 'sname': 'Test Team'} + result = get_team_salary_cap(team) + assert result == DEFAULT_SALARY_CAP + + def test_returns_default_when_team_is_none(self): + """ + When team is None, return the default cap. + + Why: Defensive programming - callers may pass None in edge cases. + """ + result = get_team_salary_cap(None) + assert result == DEFAULT_SALARY_CAP + + def test_returns_default_when_team_is_empty_dict(self): + """ + When team is an empty dict, return the default cap. + + Why: Edge case handling for malformed team data. + """ + result = get_team_salary_cap({}) + assert result == DEFAULT_SALARY_CAP + + def test_respects_zero_salary_cap(self): + """ + When salary_cap is explicitly 0, return 0 (not default). + + Why: Zero is a valid value (e.g., suspended team), distinct from None. + """ + team = {'abbrev': 'BANNED', 'salary_cap': 0.0} + result = get_team_salary_cap(team) + assert result == 0.0 + + def test_handles_integer_salary_cap(self): + """ + When salary_cap is an integer, return it as-is. + + Why: API may return int instead of float; function should handle both. + """ + team = {'abbrev': 'TEST', 'salary_cap': 30} + result = get_team_salary_cap(team) + assert result == 30 + + +class TestExceedsSalaryCap: + """Tests for exceeds_salary_cap() function.""" + + def test_returns_false_when_under_cap(self): + """ + WAR of 30.0 should not exceed default cap of 32.0. + + Why: Normal case - team is under cap and should pass validation. + """ + team = {'abbrev': 'TEST', 'salary_cap': 32.0} + result = exceeds_salary_cap(30.0, team) + assert result is False + + def test_returns_false_when_exactly_at_cap(self): + """ + WAR of exactly 32.0 should not exceed cap (within tolerance). + + Why: Teams should be allowed to be exactly at cap limit. + """ + team = {'abbrev': 'TEST', 'salary_cap': 32.0} + result = exceeds_salary_cap(32.0, team) + assert result is False + + def test_returns_false_within_tolerance(self): + """ + WAR slightly above cap but within tolerance should not exceed. + + Why: Floating point math may produce values like 32.0000001; + tolerance prevents false positives from rounding errors. + """ + team = {'abbrev': 'TEST', 'salary_cap': 32.0} + # 32.0005 is within 0.001 tolerance of 32.0 + result = exceeds_salary_cap(32.0005, team) + assert result is False + + def test_returns_true_when_over_cap(self): + """ + WAR of 33.0 clearly exceeds cap of 32.0. + + Why: Core validation - must reject teams over cap. + """ + team = {'abbrev': 'TEST', 'salary_cap': 32.0} + result = exceeds_salary_cap(33.0, team) + assert result is True + + def test_returns_true_just_over_tolerance(self): + """ + WAR just beyond tolerance should exceed cap. + + Why: Tolerance has a boundary; values beyond it must fail. + """ + team = {'abbrev': 'TEST', 'salary_cap': 32.0} + # 32.002 is beyond 0.001 tolerance + result = exceeds_salary_cap(32.002, team) + assert result is True + + def test_uses_team_custom_cap(self): + """ + Should use team's custom cap, not default. + + Why: Teams with higher/lower caps must be validated correctly. + """ + team = {'abbrev': 'EXPANSION', 'salary_cap': 28.0} + # 30.0 is under default 32.0 but over custom 28.0 + result = exceeds_salary_cap(30.0, team) + assert result is True + + def test_uses_default_cap_when_team_cap_none(self): + """ + When team has no custom cap, use default for comparison. + + Why: Backwards compatibility - existing teams without salary_cap field. + """ + team = {'abbrev': 'TEST', 'salary_cap': None} + result = exceeds_salary_cap(33.0, team) + assert result is True + + result = exceeds_salary_cap(31.0, team) + assert result is False + + def test_handles_none_team(self): + """ + When team is None, use default cap for comparison. + + Why: Defensive programming for edge cases. + """ + result = exceeds_salary_cap(33.0, None) + assert result is True + + result = exceeds_salary_cap(31.0, None) + assert result is False + + +class TestConstants: + """Tests for salary cap constants.""" + + def test_default_salary_cap_value(self): + """ + DEFAULT_SALARY_CAP should be 32.0 (league standard). + + Why: Ensures constant wasn't accidentally changed. + """ + assert DEFAULT_SALARY_CAP == 32.0 + + def test_tolerance_value(self): + """ + SALARY_CAP_TOLERANCE should be 0.001. + + Why: Tolerance must be small enough to catch real violations + but large enough to handle floating point imprecision. + """ + assert SALARY_CAP_TOLERANCE == 0.001