Compare commits

...

10 Commits

Author SHA1 Message Date
Cal Corum
cdfe54cdf7 Fix Player model validation in test_patch_draftpick_success
Add required fields (wara, image, season, pos_1) to Player instantiation
in the test to match the Pydantic model requirements.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-09 22:30:44 -06:00
Cal Corum
5496c96b32 Standardize sWAR display formatting to 2 decimal places
Fixed 10 locations with inconsistent WAR formatting:

cogs/transactions.py:
- Line 343: Trade player display
- Line 910: Week transaction display
- Lines 1296, 1783, 2084: Roster error displays (now >5.2f)
- Lines 2166, 2175: Guaranteed/frozen move displays
- Line 2303: MiL demotion display

cogs/draft.py:
- Line 218: Core players display

cogs/players.py:
- Line 2859: Player update display (both old and new values)

All user-facing sWAR values now consistently use :.2f format.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-09 20:16:47 -06:00
Cal Corum
4bd5a0b786 Add comprehensive edge case and integration tests for salary cap
New test classes:
- TestEdgeCases: Negative values, large numbers, precision boundaries
- TestRealTeamModel: Tests with actual api_calls.team.Team model

Added 9 new tests (30 total):
- Negative salary cap handling
- Negative WAR values
- Very large/small cap values
- Float precision boundary (exactly at tolerance)
- Real Pydantic Team model integration

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-09 17:19:27 -06:00
Cal Corum
bbb4233b45 Replace hardcoded salary cap with dynamic Team.salary_cap
P2 Tasks completed:
- SWAR-002: Update draft.py cap check to use exceeds_salary_cap()
- SWAR-003: Update trade validation in transactions.py
- SWAR-004: Update first drop/add validation
- SWAR-005: Update second drop/add validation
- SWAR-006: Update legal command roster validation

Changes:
- Enhanced helper functions to support both dict and Pydantic models
- All error messages now show actual team cap value
- Added 4 additional tests for Pydantic model support (21 total)
- All salary cap checks now use centralized exceeds_salary_cap()

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-09 17:14:17 -06:00
Cal Corum
cd8cf0aee8 Add salary cap helper functions and unit tests
- Add DEFAULT_SALARY_CAP (32.0) and SALARY_CAP_TOLERANCE (0.001) constants
- Add get_team_salary_cap() for retrieving team cap with fallback
- Add exceeds_salary_cap() for centralized cap validation
- Add 17 unit tests covering all edge cases
- Update refactor plan marking P1 tasks complete

These helpers will be used by P2 tasks to replace hardcoded 32.0/32.001
values in draft.py and transactions.py

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-09 17:09:25 -06:00
Cal Corum
1aaf4ccb50 Add salary cap refactor plan
Tracks 8 tasks to replace hardcoded 32.0/32.001 salary cap values
with dynamic Team.salary_cap field across draft.py and transactions.py

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-09 17:02:58 -06:00
Cal Corum
bb3894d6f1 Add salary_cap field to Team model
Syncs with database schema change - new nullable float column for tracking team salary caps.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-09 16:53:27 -06:00
Cal Corum
4e71c33344 Reapply bug fixes to branch 2025-09-20 11:02:05 -05:00
Cal Corum
2b4b84e193 Schedule fix, transaction priority fix 2025-07-25 10:00:25 -05:00
Cal Corum
9bb84ce287 Post Draft fixes for season 12 2025-07-12 23:15:00 -05:00
12 changed files with 1128 additions and 142 deletions

2
.gitignore vendored
View File

@ -60,3 +60,5 @@ card-creation/
.dockerignore
docker-compose.yml
venv/
CLAUDE.md
/.claude**

View File

@ -34,4 +34,12 @@ class Player(pydantic.BaseModel):
strat_code: Optional[str] = None
bbref_id: Optional[str] = None
injury_rating: Optional[str] = None
sbaplayer_id: Optional[int] = None
sbaplayer_id: Optional[int] = None
async def get_one_player(player_id: int) -> Player:
data = await db_get('players', object_id=player_id)
if not data:
log_exception(ApiException(f'No player found with ID {player_id}'))
return Player(**data)

View File

@ -22,4 +22,5 @@ class Team(pydantic.BaseModel):
thumbnail: Optional[str] = None
color: Optional[str] = None
dice_color: Optional[str] = None
season: int
season: int
salary_cap: Optional[float] = None

View File

@ -9,7 +9,7 @@ from api_calls.draft_pick import DraftPick, get_one_draftpick, patch_draftpick
from api_calls.player import Player
from exceptions import ApiException, log_exception
from helpers import *
from db_calls import db_get, db_patch, put_player, db_post, get_player_by_name
from db_calls import db_delete, db_get, db_patch, put_player, db_post, get_player_by_name
from discord.ext import commands, tasks
from discord import TextChannel, app_commands
@ -215,7 +215,7 @@ class Draft(commands.Cog):
for x in p_query['players']:
if count < 5:
core_players_string += f'{x["pos_1"]} {x["name"]} ({x["wara"]})\n'
core_players_string += f'{x["pos_1"]} {x["name"]} ({x["wara"]:.2f})\n'
else:
break
count += 1
@ -439,16 +439,18 @@ class Draft(commands.Cog):
total_swar += x['wara']
if total_swar > 32.00001:
team_cap = get_team_salary_cap(draft_pick.owner)
if exceeds_salary_cap(total_swar, draft_pick.owner):
return {
'success': False,
'error': f'Drafting {player["name"]} would put you at {total_swar:.2f} '
f'sWAR, friendo.'
f'sWAR (cap {team_cap:.1f}), friendo.'
}
logger.info(
f'{draft_pick.owner.lname} selects {player["name"]} with the #{draft_pick.overall} overall pick'
)
draft_pick.player = Player(**player)
logger.info(f'draft_pick test: {draft_pick}')
await patch_draftpick(draft_pick) # TODO: uncomment for live draft
player['team']['id'] = draft_pick.owner.id
@ -780,13 +782,34 @@ class Draft(commands.Cog):
temp_player['team'] = fa_team
this_player = await put_player(temp_player)
# this_player = await get_player_by_name(current.season, this_pick['player']['id'])
# this_player = await db_get('players', object_id=this_pick['player']['id'])
await interaction.edit_original_response(
content='Don\'t forget to delete the transaction from the database.',
embed=await get_player_embed(this_player, current)
t_query = await db_get(
'transactions',
params=[('season', 12), ('move_id', f'draft-overall-{this_pick.overall}')]
)
if not t_query or t_query.get('count', 0) == 0:
await interaction.edit_original_response(
content='I couldn\'t find the transaction record of this move so that\'s Cal\'s problem now. The pick is wiped, though.',
embed=await get_player_embed(this_player, current)
)
else:
try:
del_query = await db_delete(
'transactions',
object_id=t_query['transactions'][0]['moveid']
)
except ValueError as e:
logger.error(f'Could not delete draft transaction: {e}')
await interaction.edit_original_response(
content='I couldn\'t delete the transaction record of this move so that\'s Cal\'s problem now. The pick is wiped, though.',
embed=await get_player_embed(this_player, current)
)
return
await interaction.edit_original_response(
content=f'[I gotchu]({random_gif("blows kiss")}) boo boo, like it never even happened.',
embed=await get_player_embed(this_player, current)
)
return
if pick_lock is not None:

View File

@ -800,7 +800,7 @@ class Gameday(commands.Cog):
@commands.has_any_role(SBA_PLAYERS_ROLE_NAME, PD_PLAYERS_ROLE_NAME)
async def new_game_command(self, ctx):
current = await db_get('current')
this_team = await get_team_by_owner(current['season'], ctx.author.id)
this_team = await get_team_by_owner(current.season, ctx.author.id)
await ctx.send('**Note:** Make sure this is the channel where you will be playing commands. Once this is set, '
'I will only take gameplay commands here.')
@ -828,9 +828,9 @@ class Gameday(commands.Cog):
try:
this_matchup = await get_one_schedule(
season=current['season'],
season=current.season,
team_abbrev1=this_team['abbrev'],
week=current['week']
week=current.week
)
except ValueError as e:
home_team = await get_team('home')

View File

@ -998,12 +998,15 @@ class Players(commands.Cog):
param_list.append(('week', current.week))
g_query = await db_get('games', params=param_list)
if g_query['count'] == 0:
if not g_query or g_query['count'] == 0:
await interaction.edit_original_response(
content=f'Hm. I don\'t see any games then.'
)
return
# Sort games by game_id
g_query['games'] = sorted(g_query['games'], key=lambda game: game['id'])
if team_abbrev is not None:
title = f'{this_team["lname"]} Schedule'
embed = get_team_embed(title=title, team=this_team)
@ -1191,19 +1194,19 @@ class Players(commands.Cog):
async def get_division_standings(self, current) -> discord.Embed:
d1_query = await db_get('standings', params=[
('season', current.season), ('division_abbrev', 'SD')
('season', current.season), ('division_abbrev', 'TC')
])
div_one = d1_query['standings']
d2_query = await db_get('standings', params=[
('season', current.season), ('division_abbrev', 'DC')
('season', current.season), ('division_abbrev', 'ETSOS')
])
div_two = d2_query['standings']
d3_query = await db_get('standings', params=[
('season', current.season), ('division_abbrev', 'FIP')
('season', current.season), ('division_abbrev', 'APL')
])
div_three = d3_query['standings']
d4_query = await db_get('standings', params=[
('season', current.season), ('division_abbrev', 'DOC')
('season', current.season), ('division_abbrev', 'BBC')
])
div_four = d4_query['standings']
@ -1233,10 +1236,10 @@ class Players(commands.Cog):
f'{progress["games_played"]}/{progress["game_count"]} games played',
color=0xB70000)
embed.add_field(name=f'**Full Standings**', value=SBA_STANDINGS_URL, inline=False)
embed.add_field(name=f'**Snow Day**', value=div_one_standings, inline=False)
embed.add_field(name=f'**Dinger Central**', value=div_two_standings, inline=False)
embed.add_field(name=f'**Just the FIP**', value=div_three_standings, inline=False)
embed.add_field(name=f'**Division of Cook**', value=div_four_standings, inline=False)
embed.add_field(name=f'**Traveling Circus**', value=div_one_standings, inline=False)
embed.add_field(name=f'**ETSOS**', value=div_two_standings, inline=False)
embed.add_field(name=f'**Apple**', value=div_three_standings, inline=False)
embed.add_field(name=f'**Big Chungus**', value=div_four_standings, inline=False)
return embed
@ -1520,7 +1523,7 @@ class Players(commands.Cog):
setup_tab = scorecard.worksheet_by_title('Setup')
scorecard_version = setup_tab.get_value('V35')
if int(scorecard_version) != current.bet_week:
if scorecard_version != current.bet_week:
await interaction.edit_original_response(
content=f'It looks like this scorecard is out of date. Did you create a new card at the start of the '
f'game? If you did, let Cal know about this error. If not, I\'ll need you to use an up to '
@ -2337,8 +2340,7 @@ class Players(commands.Cog):
@app_commands.command(name='charts')
async def chart_command(self, interaction: discord.Interaction, chart_name: Literal[
'block-plate', 'defense-matters', 'fly-b', 'g1', 'g2', 'g3', 'groundball', 'hit-and-run',
'rest', 'rob-hr', 'sac-bunt', 'squeeze-bunt']):
'rob-hr', 'defense', 'fly-b', 'g1', 'g2', 'g3', 'groundball','hit-and-run', 'rest', 'sac-bunt', 'squeeze-bunt']):
gb_url = 'https://sombaseball.ddns.net/static/images/season04/ground-ball-chart'
all_charts = {
'rest': [f'{SBA_IMAGE_URL}/season05/charts/rest.png'],
@ -2350,8 +2352,8 @@ class Players(commands.Cog):
f'{SBA_IMAGE_URL}/season05/charts/squeeze-bunt.png',
f'{SBA_IMAGE_URL}/season05/charts/squeeze-bunt-help.png'
],
'rob-hr': [f'{SBA_IMAGE_URL}/season05/charts/rob-hr.png'],
'defense-matters': [f'{SBA_IMAGE_URL}/season05/charts/defense-matters.png'],
'rob-hr': [f'https://sba-cards-2024.s3.us-east-1.amazonaws.com/static-images/rob-hr.png'],
'defense': [f'https://sba-cards-2024.s3.us-east-1.amazonaws.com/static-images/defense.png'],
'block-plate': [f'{SBA_IMAGE_URL}/season05/charts/block-plate.png'],
'hit-and-run': [
f'{SBA_IMAGE_URL}/season05/charts/hit-and-run.png',
@ -2854,7 +2856,7 @@ class Players(commands.Cog):
update_string = ''
if swar is not None:
update_string += f'sWAR: {this_player["wara"]} => {swar}\n'
update_string += f'sWAR: {this_player["wara"]:.2f} => {swar:.2f}\n'
this_player['wara'] = swar
if injury_rating is not None:
update_string += f'injury_rating: {this_player["injury_rating"]} => {injury_rating}\n'

View File

@ -1,14 +1,232 @@
import re
import copy
import random
import os
from dataclasses import dataclass
from typing import List, Tuple, Optional, Dict, Set
from helpers import *
from api_calls.current import get_current
from db_calls import db_get, db_patch, get_team_by_owner, get_team_by_abbrev, get_player_by_name, put_player, db_post
from discord.ext import commands, tasks
OFFSEASON_FLAG = True
OFFSEASON_FLAG = False
USE_NEW_TRANSACTION_LOGIC = os.getenv('USE_NEW_TRANSACTION_LOGIC', 'true').lower() == 'true'
logger = logging.getLogger('discord_app')
@dataclass
class TransactionPriority:
"""Data class to hold transaction priority calculation results"""
roster_priority: int # 1 = major league, 2 = minor league
win_percentage: float # Lower is better (worse teams get priority)
random_tiebreaker: int # Random number for final tie resolution
move_id: str
major_league_team_abbrev: str # The major league team (without MiL)
contested_players: List[str] # List of contested player names in this transaction
def get_sort_tuple(self) -> Tuple[int, float, int]:
"""Return tuple for sorting - lower values have higher priority"""
return (self.roster_priority, self.win_percentage, self.random_tiebreaker)
def get_major_league_team_abbrev(old_team_abbrev: str, new_team_abbrev: str) -> str:
"""
Extract the major league team abbreviation from a transaction.
Args:
old_team_abbrev: Source team abbreviation
new_team_abbrev: Destination team abbreviation
Returns:
Major league team abbreviation (the one that's not FA and doesn't end in MiL)
"""
# Find the team that is not FA and strip MiL if needed
if old_team_abbrev != 'FA':
return old_team_abbrev.replace('MiL', '') if old_team_abbrev.endswith('MiL') else old_team_abbrev
elif new_team_abbrev != 'FA':
return new_team_abbrev.replace('MiL', '') if new_team_abbrev.endswith('MiL') else new_team_abbrev
else:
raise ValueError("Both teams cannot be FA")
def determine_roster_priority(moves: List[dict]) -> int:
"""
Determine roster priority for a transaction based on all movements.
Args:
moves: List of all movements in the transaction
Returns:
1 for major league priority, 2 for minor league priority
"""
# If ANY move involves major league roster (no MiL suffix), entire transaction gets major league priority
for move in moves:
old_abbrev = move['oldteam']['abbrev']
new_abbrev = move['newteam']['abbrev']
# Check if either team is major league (not FA and doesn't end with MiL)
if (old_abbrev != 'FA' and not old_abbrev.endswith('MiL')) or \
(new_abbrev != 'FA' and not new_abbrev.endswith('MiL')):
return 1 # Major league priority
return 2 # Minor league priority (all moves involve MiL teams)
async def calculate_transaction_priority(move_id: str, moves: List[dict], current_season: int,
contested_players: Set[str]) -> TransactionPriority:
"""
Calculate priority for an entire transaction (all moves with same move_id).
Args:
move_id: The transaction ID
moves: List of all movements in this transaction
current_season: Current season number
contested_players: Set of all contested player names
Returns:
TransactionPriority object with calculated values
"""
if not moves:
raise ValueError("No moves provided for transaction")
# Get major league team abbreviation from any move in the transaction
first_move = moves[0]
major_league_team_abbrev = get_major_league_team_abbrev(
first_move['oldteam']['abbrev'],
first_move['newteam']['abbrev']
)
# Determine roster priority for entire transaction
roster_priority = determine_roster_priority(moves)
# Calculate win percentage using major league team's record
if major_league_team_abbrev == 'FA':
win_percentage = 0.0
else:
major_league_team = await get_team_by_abbrev(major_league_team_abbrev, current_season)
if major_league_team is None:
raise ValueError(f'Team `{major_league_team_abbrev}` not found')
standings_query = await db_get('standings', params=[
('season', current_season),
('team_id', major_league_team['id']),
]
)
if standings_query is None:
raise ValueError(f'Standings not found for `{major_league_team_abbrev}`')
standings = standings_query['standings'][0]
total_games = standings['wins'] + standings['losses']
win_percentage = standings['wins'] / total_games if total_games > 0 else 0.0
# Find which players in this transaction are contested
transaction_contested_players = [
move['player']['name'] for move in moves
if move['player']['name'] in contested_players
]
return TransactionPriority(
roster_priority=roster_priority,
win_percentage=win_percentage,
random_tiebreaker=random.randint(1, 100000),
move_id=move_id,
major_league_team_abbrev=major_league_team_abbrev,
contested_players=transaction_contested_players
)
async def resolve_contested_transactions(moves: List[dict], current_season: int) -> Tuple[List[str], List[str]]:
"""
Resolve disputes for contested players at the transaction level.
Args:
moves: List of all transaction moves from database
current_season: Current season number
Returns:
Tuple of (winning_move_ids, losing_move_ids)
"""
# Group moves by move_id (transaction)
transactions = {}
for move in moves:
move_id = move['moveid']
if move_id not in transactions:
transactions[move_id] = []
transactions[move_id].append(move)
# Group moves by player name to identify contested players
player_to_move_ids = {}
for move in moves:
player_name = move['player']['name']
move_id = move['moveid']
if player_name not in player_to_move_ids:
player_to_move_ids[player_name] = set()
player_to_move_ids[player_name].add(move_id)
# Find contested players (claimed by multiple transactions)
contested_players = {
player_name for player_name, move_ids in player_to_move_ids.items()
if len(move_ids) > 1
}
logger.info(f"Found {len(contested_players)} contested players: {list(contested_players)}")
# Find transactions that claim any contested player
contested_move_ids = set()
for player_name in contested_players:
contested_move_ids.update(player_to_move_ids[player_name])
logger.info(f"Found {len(contested_move_ids)} transactions claiming contested players")
# Calculate priorities for contested transactions
contested_priorities = []
for move_id in contested_move_ids:
priority = await calculate_transaction_priority(
move_id, transactions[move_id], current_season, contested_players
)
contested_priorities.append(priority)
# Group contested transactions by the players they're fighting over
player_disputes = {}
for priority in contested_priorities:
for player_name in priority.contested_players:
if player_name not in player_disputes:
player_disputes[player_name] = []
player_disputes[player_name].append(priority)
# Resolve each player dispute
winning_move_ids = set()
losing_move_ids = set()
for player_name, competing_priorities in player_disputes.items():
# Sort by priority tuple (lower values = higher priority)
sorted_priorities = sorted(competing_priorities, key=lambda p: p.get_sort_tuple())
winner = sorted_priorities[0]
losers = sorted_priorities[1:]
winning_move_ids.add(winner.move_id)
for loser in losers:
losing_move_ids.add(loser.move_id)
# Log the resolution decision
logger.info(f"Contested player '{player_name}' resolution:")
logger.info(f" Winner: {winner.major_league_team_abbrev} (move_id={winner.move_id}, "
f"roster_priority={winner.roster_priority}, win_pct={winner.win_percentage:.3f}, "
f"random={winner.random_tiebreaker})")
for i, loser in enumerate(losers):
logger.info(f" Loser {i+1}: {loser.major_league_team_abbrev} (move_id={loser.move_id}, "
f"roster_priority={loser.roster_priority}, win_pct={loser.win_percentage:.3f}, "
f"random={loser.random_tiebreaker})")
# Add non-contested transactions to winners
non_contested_move_ids = [
move_id for move_id in transactions.keys()
if move_id not in contested_move_ids
]
winning_move_ids.update(non_contested_move_ids)
return list(winning_move_ids), list(losing_move_ids)
class SBaTransaction:
def __init__(self, channel, current, move_type, this_week=False, first_team=None, team_role=None):
self.players = {} # Example: { <Player ID>: { "player": { Strat Player Dict }, "to": { Strat Team Dict } } }
@ -76,13 +294,39 @@ class SBaTransaction:
return True
return False
def get_gms(self, bot, team=None):
async def get_gms(self, bot, team=None):
if team is None:
return [bot.get_user(x) for x in self.gms]
gms = []
for gm_id in self.gms:
try:
user = await bot.fetch_user(gm_id)
if user:
gms.append(user)
except discord.NotFound:
logger.warning(f'User {gm_id} not found')
except Exception as e:
logger.error(f'Error fetching user {gm_id}: {e}')
return gms
else:
these_gms = [bot.get_user(self.teams[team]['team']['gmid'])]
these_gms = []
try:
user = await bot.fetch_user(self.teams[team]['team']['gmid'])
if user:
these_gms.append(user)
except discord.NotFound:
logger.warning(f'User {self.teams[team]["team"]["gmid"]} not found')
except Exception as e:
logger.error(f'Error fetching user {self.teams[team]["team"]["gmid"]}: {e}')
if self.teams[team]['team']['gmid2']:
these_gms.append(bot.get_user(self.teams[team]['team']['gmid2']))
try:
user = await bot.fetch_user(self.teams[team]['team']['gmid2'])
if user:
these_gms.append(user)
except discord.NotFound:
logger.warning(f'User {self.teams[team]["team"]["gmid2"]} not found')
except Exception as e:
logger.error(f'Error fetching user {self.teams[team]["team"]["gmid2"]}: {e}')
return these_gms
async def send(self, content=None, embed=None):
@ -96,7 +340,7 @@ class SBaTransaction:
# Get player string
player_string = ''
for x in self.players:
player_string += f'**{self.players[x]["player"]["name"]}** ({self.players[x]["player"]["wara"]}) from ' \
player_string += f'**{self.players[x]["player"]["name"]}** ({self.players[x]["player"]["wara"]:.2f}) from ' \
f'{self.players[x]["player"]["team"]["abbrev"]} to {self.players[x]["to"]["abbrev"]}\n'
if len(player_string) == 0:
@ -196,11 +440,11 @@ class SBaTransaction:
# If player is leaving this team, remove from roster and subtract WARa
if x['oldteam'] == this_team:
team_roster.remove(x['player'])
team_roster = [p for p in team_roster if p['id'] != x['player']['id']]
wara -= x['player']['wara']
# If player is leaving MiL team, remove from roster and subtract WARa
elif x['oldteam']['abbrev'] == f'{this_team["abbrev"]}MiL':
mil_roster.remove(x['player'])
mil_roster = [p for p in mil_roster if p['id'] != x['player']['id']]
mil_wara -= x['player']['wara']
logger.info(f'updating rosters')
@ -222,7 +466,7 @@ class SBaTransaction:
if self.players[x]['player']['team'] == this_team:
logger.info(f'major league player')
# logger.info(f'team roster: {team_roster}')
team_roster.remove(self.players[x]['player'])
team_roster = [p for p in team_roster if p['id'] != self.players[x]['player']['id']]
# 06-13: COMMENTED OUT TO RESOLVE MID-WEEK IL REPLACEMENT BEING SENT BACK DOWN
# if self.effective_week != self.current.week:
wara -= self.players[x]['player']['wara']
@ -230,7 +474,7 @@ class SBaTransaction:
# If player is leaving MiL team next week, remove from roster and subtract WARa
if self.players[x]['player']['team']['abbrev'] == f'{this_team["abbrev"]}MiL':
logger.info(f'minor league player')
mil_roster.remove(self.players[x]['player'])
mil_roster = [p for p in mil_roster if p['id'] != self.players[x]['player']['id']]
# logger.info(f'mil roster: {mil_roster}')
if self.effective_week != self.current.week:
mil_wara -= self.players[x]['player']['wara']
@ -329,6 +573,7 @@ class Transactions(commands.Cog):
def __init__(self, bot):
self.bot = bot
self.trade_season = False
self.weekly_warning_sent = False
self.weekly_loop.start()
@ -342,47 +587,64 @@ class Transactions(commands.Cog):
@tasks.loop(minutes=1)
async def weekly_loop(self):
if OFFSEASON_FLAG:
return
try:
logger.info(f'Inside weekly_loop')
if OFFSEASON_FLAG:
logger.info(f'Exiting weekly_loop; OFFSEASON_FLAG: {OFFSEASON_FLAG}')
return
current = await get_current()
now = datetime.datetime.now()
logger.debug(f'Datetime: {now} / weekday: {now.weekday()}')
current = await get_current()
now = datetime.datetime.now()
logger.info(f'Datetime: {now} / weekday: {now.weekday()} / current: {current}')
# Begin Freeze
# if now.weekday() == 0 and now.hour == 5 and not current.freeze: # Spring/Summer
if now.weekday() == 0 and now.hour == 0 and not current.freeze: # Fall/Winter
current.week += 1
await db_patch('current', object_id=current['id'], params=[('week', current.week), ('freeze', True)])
await self.run_transactions(current)
# Begin Freeze
# if now.weekday() == 0 and now.hour == 5 and not current.freeze: # Spring/Summer
if now.weekday() == 0 and now.hour == 0 and not current.freeze: # Fall/Winter
logger.info(f'weekly_loop - setting the freeze')
current.week += 1
await db_patch('current', object_id=current.id, params=[('week', current.week), ('freeze', True)])
await self.run_transactions(current)
logger.debug(f'Building freeze string')
week_num = f'Week {current.week}'
stars = f'{"":*<32}'
freeze_message = f'```\n' \
f'{stars}\n'\
f'{week_num: >9} Freeze Period Begins\n' \
f'{stars}\n```'
logger.debug(f'Freeze string:\n\n{freeze_message}')
await send_to_channel(self.bot, 'transaction-log', freeze_message)
logger.debug(f'Building freeze string')
week_num = f'Week {current.week}'
stars = f'{"":*<32}'
freeze_message = f'```\n' \
f'{stars}\n'\
f'{week_num: >9} Freeze Period Begins\n' \
f'{stars}\n```'
logger.debug(f'Freeze string:\n\n{freeze_message}')
await send_to_channel(self.bot, 'transaction-log', freeze_message)
if current.week > 0 and current.week <= 18:
await self.post_weekly_info(current)
if current.week > 0 and current.week <= 18:
await self.post_weekly_info(current)
# End Freeze
# elif now.weekday() == 5 and now.hour == 5 and current.freeze: # Spring/Summer
elif now.weekday() == 5 and now.hour == 0 and current.freeze: # Fall/Winter
await db_patch('current', object_id=current['id'], params=[('freeze', False)])
# End Freeze
# elif now.weekday() == 5 and now.hour == 5 and current.freeze: # Spring/Summer
elif now.weekday() == 5 and now.hour == 0 and current.freeze: # Fall/Winter
logger.info(f'weekly_loop - running the un-freeze')
await db_patch('current', object_id=current.id, params=[('freeze', False)])
week_num = f'Week {current.week}'
stars = f'{"":*<30}'
freeze_message = f'```\n' \
f'{stars}\n'\
f'{week_num: >9} Freeze Period Ends\n' \
f'{stars}\n```'
await self.process_freeze_moves(current)
await send_to_channel(self.bot, 'transaction-log', freeze_message)
self.trade_season = False
week_num = f'Week {current.week}'
stars = f'{"":*<30}'
freeze_message = f'```\n' \
f'{stars}\n'\
f'{week_num: >9} Freeze Period Ends\n' \
f'{stars}\n```'
await self.process_freeze_moves(current)
await send_to_channel(self.bot, 'transaction-log', freeze_message)
self.trade_season = False
else:
logger.info(f'weekly_loop - No freeze actions being taken')
except Exception as e:
logger.error(f'Unhandled exception in weekly_loop: {e}')
error_message = f"⚠️ **Weekly Freeze Task Failed**\n```\nError: {str(e)}\nTime: {datetime.datetime.now()}\nTask: weekly_loop in transactions.py\n```"
try:
if not self.weekly_warning_sent:
await send_owner_notification(error_message)
self.weekly_warning_sent = True
except Exception as e:
logger.error(f'Failed to send error message :( {e}')
@weekly_loop.before_loop
async def before_notif_check(self):
@ -443,7 +705,8 @@ class Transactions(commands.Cog):
except Exception as e:
logger.error(f'Could not notifiy GM2 of {team["abbrev"]} ({team["gmid2"]})of move cancellation: {e}')
async def process_freeze_moves(self, current):
async def process_freeze_moves_backup(self, current):
"""Original implementation - kept for rollback purposes"""
# all_moves = await get_transactions(
# season=current.season,
# week_start=current.week,
@ -538,6 +801,84 @@ class Transactions(commands.Cog):
await db_patch('transactions', object_id=move_id, params=[('frozen', False)])
await self.post_move_to_transaction_log(move_id)
async def process_freeze_moves(self, current):
"""
Route to either new or backup implementation based on feature flag.
Set USE_NEW_TRANSACTION_LOGIC=false to use original implementation.
"""
if USE_NEW_TRANSACTION_LOGIC:
logger.info("Using new transaction priority logic")
await self.process_freeze_moves_new(current)
else:
logger.info("Using backup transaction priority logic")
await self.process_freeze_moves_backup(current)
async def process_freeze_moves_new(self, current):
"""
New implementation of freeze move processing with proper transaction-level priority resolution.
This function is designed to be easily testable by extracting business logic
into pure functions.
"""
# Get all frozen transactions for this week
moves_query = await db_get('transactions', params=[
('season', current.season),
('week_start', current.week),
('week_end', current.week + 1),
('frozen', True)
])
if moves_query['count'] == 0:
logger.warning(f'No transactions to process for the freeze in week {current.week}')
return
moves = moves_query['transactions']
logger.info(f'Processing {len(moves)} frozen moves across multiple transactions for week {current.week}')
try:
# Resolve contested transactions using extracted business logic
winning_move_ids, losing_move_ids = await resolve_contested_transactions(moves, current.season)
# Cancel losing transactions (entire transactions, not just individual moves)
for losing_move_id in losing_move_ids:
# Get all moves in this losing transaction for notification
losing_moves = [m for m in moves if m['moveid'] == losing_move_id]
# Cancel all moves in this transaction
await db_patch('transactions', object_id=losing_move_id,
params=[('frozen', False), ('cancelled', True)])
# Notify the losing team about the cancelled transaction
if losing_moves:
# Use first move to identify the team for notification
first_move = losing_moves[0]
# Find the non-FA team for notification
team_for_notification = (first_move['newteam']
if first_move['newteam']['abbrev'] != 'FA'
else first_move['oldteam'])
await self.notify_cancel([first_move['player'], team_for_notification])
contested_players_in_transaction = [
move['player']['name'] for move in losing_moves
]
logger.info(f"Cancelled entire transaction {losing_move_id} due to contested players: "
f"{contested_players_in_transaction}")
# Unfreeze winning transactions
for winning_move_id in winning_move_ids:
await db_patch('transactions', object_id=winning_move_id, params=[('frozen', False)])
await self.post_move_to_transaction_log(winning_move_id)
logger.info(f"Processed successful transaction {winning_move_id}")
logger.info(f"Freeze processing complete: {len(winning_move_ids)} successful transactions, "
f"{len(losing_move_ids)} cancelled transactions")
except Exception as e:
logger.error(f"Error during freeze processing: {e}", exc_info=True)
raise
async def post_move_to_transaction_log(self, move_id):
current = await get_current()
# all_moves = await get_transactions(
@ -566,7 +907,7 @@ class Transactions(commands.Cog):
if week_num is None:
week_num = move['week']
move_string += f'**{move["player"]["name"]}** ({move["player"]["wara"]}) from ' \
move_string += f'**{move["player"]["name"]}** ({move["player"]["wara"]:.2f}) from ' \
f'{move["oldteam"]["abbrev"]} to {move["newteam"]["abbrev"]}\n'
embed = get_team_embed(f'Week {week_num} Transaction', this_team)
@ -624,7 +965,7 @@ class Transactions(commands.Cog):
@commands.is_owner()
async def process_freeze_helper_command(self, ctx):
current = await get_current()
await self.process_freeze_moves(current)
await self.process_freeze_moves_new(current)
await ctx.send(random_conf_gif())
@commands.command(name='post-weekly')
@ -643,7 +984,7 @@ class Transactions(commands.Cog):
await ctx.send(f'The trade deadline is **week {current.trade_deadline}**. Since it is currently **week '
f'{current.week}** I am just going to stop you right there.')
return
if current.week < -2:
if current.week < -2 or current.week == -1:
await ctx.send(await get_emoji(ctx, 'oof', False))
await ctx.send(f'Patience, grasshopper. Trades open soon.')
return
@ -704,7 +1045,7 @@ class Transactions(commands.Cog):
while True:
prompt = 'Are you adding a team to this deal? (Yes/No)'
this_q = Question(self.bot, trade.channel, prompt, 'yesno', 30)
resp = await this_q.ask(trade.get_gms(self.bot))
resp = await this_q.ask(await trade.get_gms(self.bot))
if resp is None:
await trade.send('RIP this move. Maybe next time.')
@ -715,7 +1056,7 @@ class Transactions(commands.Cog):
else:
this_q.prompt = 'Please enter the team\'s abbreviation.'
this_q.qtype = 'text'
resp = await this_q.ask(trade.get_gms(self.bot))
resp = await this_q.ask(await trade.get_gms(self.bot))
if not resp:
await trade.send('RIP this move. Maybe next time.')
@ -736,7 +1077,7 @@ class Transactions(commands.Cog):
while True:
prompt = f'Are you trading a player between teams?'
this_q = Question(self.bot, trade.channel, prompt, 'yesno', 300)
resp = await this_q.ask(trade.get_gms(self.bot))
resp = await this_q.ask(await trade.get_gms(self.bot))
if resp is None:
await trade.send('RIP this move. Maybe next time.')
@ -747,7 +1088,7 @@ class Transactions(commands.Cog):
else:
this_q.prompt = 'Which player is being traded?'
this_q.qtype = 'text'
resp = await this_q.ask(trade.get_gms(self.bot))
resp = await this_q.ask(await trade.get_gms(self.bot))
if not resp:
pass
@ -773,7 +1114,7 @@ class Transactions(commands.Cog):
await trade.send(f'Ope. {player["name"]} is already one the move next week.')
else:
this_q.prompt = 'Where are they going? Please enter the destination team\'s abbreviation.'
resp = await this_q.ask(trade.get_gms(self.bot))
resp = await this_q.ask(await trade.get_gms(self.bot))
if resp is None:
await trade.send('RIP this move. Maybe next time.')
@ -799,7 +1140,7 @@ class Transactions(commands.Cog):
# while True and current.pick_trade_end >= current.week >= current.pick_trade_start:
# prompt = f'Are you trading any draft picks?'
# this_q = Question(self.bot, trade.channel, prompt, 'yesno', 300)
# resp = await this_q.ask(trade.get_gms(self.bot))
# resp = await this_q.ask(await trade.get_gms(self.bot))
# effective_season = current.season if OFFSEASON_FLAG else current.season + 1
# team_season = current.season
#
@ -813,7 +1154,7 @@ class Transactions(commands.Cog):
# # Get first pick
# this_q.prompt = 'Enter the pick\'s original owner and round number like this: TIT 17'
# this_q.qtype = 'text'
# resp = await this_q.ask(trade.get_gms(self.bot))
# resp = await this_q.ask(await trade.get_gms(self.bot))
#
# if not resp:
# await trade.send('RIP this move. Maybe next time.')
@ -834,7 +1175,7 @@ class Transactions(commands.Cog):
# f'round number, please.')
# else:
# this_q.prompt = 'Now enter the return pick\'s original owner and round number like this: TIT 17'
# resp = await this_q.ask(trade.get_gms(self.bot))
# resp = await this_q.ask(await trade.get_gms(self.bot))
#
# if not resp:
# await trade.send('RIP this move. Maybe next time.')
@ -884,7 +1225,7 @@ class Transactions(commands.Cog):
while True:
this_q.prompt = f'{trade.teams[team]["role"].mention}\nAre you making an FA drop?'
this_q.qtype = 'yesno'
resp = await this_q.ask(trade.get_gms(self.bot, team))
resp = await this_q.ask(await trade.get_gms(self.bot, team))
if resp is None:
await trade.send('RIP this move. Maybe next time.')
@ -895,7 +1236,7 @@ class Transactions(commands.Cog):
else:
this_q.prompt = 'Who are you dropping?'
this_q.qtype = 'text'
resp = await this_q.ask(trade.get_gms(self.bot, team))
resp = await this_q.ask(await trade.get_gms(self.bot, team))
if resp is None:
await trade.send('RIP this move. Maybe next time.')
@ -937,20 +1278,22 @@ class Transactions(commands.Cog):
roster_errors = []
for team in trade.teams:
data = await trade.check_major_league_errors(team)
team_obj = trade.teams[team]["team"]
team_cap = get_team_salary_cap(team_obj)
logger.warning(f'Done checking data - checking sWAR now ({data["wara"]}')
if data['wara'] > 32.001 and not OFFSEASON_FLAG:
errors.append(f'- {trade.teams[team]["team"]["abbrev"]} would have {data["wara"]:.2f} WARa')
if exceeds_salary_cap(data['wara'], team_obj) and not OFFSEASON_FLAG:
errors.append(f'- {team_obj["abbrev"]} would have {data["wara"]:.2f} WARa (cap {team_cap:.1f})')
logger.warning(f'Now checking roster {len(data["roster"])}')
if len(data['roster']) > 26 and not OFFSEASON_FLAG:
errors.append(f'- {trade.teams[team]["team"]["abbrev"]} would have {len(data["roster"])} players')
errors.append(f'- {team_obj["abbrev"]} would have {len(data["roster"])} players')
logger.warning(f'Any errors? {errors}')
if (data['wara'] > 32.001 or len(data['roster']) > 26) and not OFFSEASON_FLAG:
if (exceeds_salary_cap(data['wara'], team_obj) or len(data['roster']) > 26) and not OFFSEASON_FLAG:
roster_string = ''
for x in data['roster']:
roster_string += f'{x["wara"]: >5} - {x["name"]}\n'
roster_string += f'{x["wara"]: >5.2f} - {x["name"]}\n'
roster_errors.append(f'- This is the roster I have for {trade.teams[team]["team"]["abbrev"]}:\n'
f'```\n{roster_string}```')
@ -967,7 +1310,7 @@ class Transactions(commands.Cog):
for team in trade.teams:
this_q.prompt = f'{trade.teams[team]["role"].mention}\nDo you accept this trade?'
this_q.qtype = 'yesno'
resp = await this_q.ask(trade.get_gms(self.bot, team))
resp = await this_q.ask(await trade.get_gms(self.bot, team))
if not resp:
await trade.send('RIP this move. Maybe next time.')
@ -1044,7 +1387,7 @@ class Transactions(commands.Cog):
# while True:
# prompt = 'Are you adding a team to this deal? (Yes/No)'
# this_q = Question(self.bot, trade.channel, prompt, 'yesno', 30)
# resp = await this_q.ask(trade.get_gms(self.bot))
# resp = await this_q.ask(await trade.get_gms(self.bot))
#
# if resp is None:
# await trade.send('RIP this move. Maybe next time.')
@ -1055,7 +1398,7 @@ class Transactions(commands.Cog):
# else:
# this_q.prompt = 'Please enter the team\'s abbreviation.'
# this_q.qtype = 'text'
# resp = await this_q.ask(trade.get_gms(self.bot))
# resp = await this_q.ask(await trade.get_gms(self.bot))
#
# if not resp:
# await trade.send('RIP this move. Maybe next time.')
@ -1077,7 +1420,7 @@ class Transactions(commands.Cog):
# while True:
# prompt = f'Are you trading any draft picks?'
# this_q = Question(self.bot, trade.channel, prompt, 'yesno', 300)
# resp = await this_q.ask(trade.get_gms(self.bot))
# resp = await this_q.ask(await trade.get_gms(self.bot))
# effective_season = current.season if OFFSEASON_FLAG else current.season + 1
# team_season = current.season
#
@ -1091,7 +1434,7 @@ class Transactions(commands.Cog):
# # Get first pick
# this_q.prompt = 'Enter the overall pick number being traded.'
# this_q.qtype = 'int'
# resp = await this_q.ask(trade.get_gms(self.bot))
# resp = await this_q.ask(await trade.get_gms(self.bot))
#
# if not resp:
# await trade.send('RIP this move. Maybe next time.')
@ -1107,7 +1450,7 @@ class Transactions(commands.Cog):
# await trade.send(f'Frick, I couldn\'t find pick #{resp}.')
# else:
# this_q.prompt = 'Now enter the return pick\'s overall pick number.'
# resp = await this_q.ask(trade.get_gms(self.bot))
# resp = await this_q.ask(await trade.get_gms(self.bot))
#
# if not resp:
# await trade.send('RIP this move. Maybe next time.')
@ -1181,7 +1524,7 @@ class Transactions(commands.Cog):
# for team in trade.teams:
# this_q.prompt = f'{trade.teams[team]["role"].mention}\nDo you accept this trade?'
# this_q.qtype = 'yesno'
# resp = await this_q.ask(trade.get_gms(self.bot, team))
# resp = await this_q.ask(await trade.get_gms(self.bot, team))
#
# if not resp:
# await trade.send('RIP this move. Maybe next time.')
@ -1254,7 +1597,7 @@ class Transactions(commands.Cog):
while True and not OFFSEASON_FLAG:
prompt = f'Are you adding someone to the Minor League roster?'
this_q = Question(self.bot, dropadd.channel, prompt, 'yesno', 300)
resp = await this_q.ask(dropadd.get_gms(self.bot))
resp = await this_q.ask(await dropadd.get_gms(self.bot))
if resp is None:
await dropadd.send('RIP this move. Maybe next time.')
@ -1265,7 +1608,7 @@ class Transactions(commands.Cog):
else:
this_q.prompt = 'Who are you sending to the MiL?'
this_q.qtype = 'text'
resp = await this_q.ask(dropadd.get_gms(self.bot))
resp = await this_q.ask(await dropadd.get_gms(self.bot))
if resp is None:
await dropadd.send('RIP this move. Maybe next time.')
@ -1307,7 +1650,7 @@ class Transactions(commands.Cog):
while True and not OFFSEASON_FLAG:
prompt = f'Are you adding any players to the Major League roster?'
this_q = Question(self.bot, dropadd.channel, prompt, 'yesno', 300)
resp = await this_q.ask(dropadd.get_gms(self.bot))
resp = await this_q.ask(await dropadd.get_gms(self.bot))
if resp is None:
await dropadd.send('RIP this move. Maybe next time.')
@ -1318,7 +1661,7 @@ class Transactions(commands.Cog):
else:
this_q.prompt = 'Who are you adding?'
this_q.qtype = 'text'
resp = await this_q.ask(dropadd.get_gms(self.bot))
resp = await this_q.ask(await dropadd.get_gms(self.bot))
if resp is None:
await dropadd.send('RIP this move. Maybe next time.')
@ -1367,7 +1710,7 @@ class Transactions(commands.Cog):
while True:
prompt = f'Are you dropping anyone to FA?'
this_q = Question(self.bot, dropadd.channel, prompt, 'yesno', 300)
resp = await this_q.ask(dropadd.get_gms(self.bot))
resp = await this_q.ask(await dropadd.get_gms(self.bot))
if resp is None:
await dropadd.send('RIP this move. Maybe next time.')
@ -1378,7 +1721,7 @@ class Transactions(commands.Cog):
else:
this_q.prompt = 'Who are you dropping to FA?'
this_q.qtype = 'text'
resp = await this_q.ask(dropadd.get_gms(self.bot))
resp = await this_q.ask(await dropadd.get_gms(self.bot))
if resp is None:
await dropadd.send('RIP this move. Maybe next time.')
@ -1422,21 +1765,23 @@ class Transactions(commands.Cog):
roster_errors = []
for team in dropadd.teams:
data = await dropadd.check_major_league_errors(team)
team_obj = dropadd.teams[team]["team"]
team_cap = get_team_salary_cap(team_obj)
logger.warning(f'Done checking data - checking WARa now ({data["wara"]})')
if data['wara'] > 32.001 and not OFFSEASON_FLAG:
errors.append(f'- {dropadd.teams[team]["team"]["abbrev"]} would have {data["wara"]:.2f} sWAR')
if exceeds_salary_cap(data['wara'], team_obj) and not OFFSEASON_FLAG:
errors.append(f'- {team_obj["abbrev"]} would have {data["wara"]:.2f} sWAR (cap {team_cap:.1f})')
logger.warning(f'Now checking roster {len(data["roster"])}')
if len(data['roster']) > 26 and not OFFSEASON_FLAG:
errors.append(f'- {dropadd.teams[team]["team"]["abbrev"]} would have {len(data["roster"])} players')
errors.append(f'- {team_obj["abbrev"]} would have {len(data["roster"])} players')
logger.warning(f'Any errors? {errors}')
if (data['wara'] > 32.001 or len(data['roster']) > 26) and not OFFSEASON_FLAG:
if (exceeds_salary_cap(data['wara'], team_obj) or len(data['roster']) > 26) and not OFFSEASON_FLAG:
roster_string = ''
for x in data['roster']:
roster_string += f'{x["wara"]: >5} - {x["name"]}\n'
errors.append(f'- This is the roster I have for {dropadd.teams[team]["team"]["abbrev"]}:\n'
roster_string += f'{x["wara"]: >5.2f} - {x["name"]}\n'
errors.append(f'- This is the roster I have for {team_obj["abbrev"]}:\n'
f'```\n{roster_string}```')
mil_cap = 6 if current.week <= FA_LOCK_WEEK else 14
@ -1458,7 +1803,7 @@ class Transactions(commands.Cog):
for team in dropadd.teams:
this_q.prompt = f'{dropadd.teams[team]["role"].mention}\nWould you like me to run this move?'
this_q.qtype = 'yesno'
resp = await this_q.ask(dropadd.get_gms(self.bot, team))
resp = await this_q.ask(await dropadd.get_gms(self.bot, team))
if not resp:
await dropadd.send('RIP this move. Maybe next time.')
@ -1484,7 +1829,7 @@ class Transactions(commands.Cog):
q_string = player_name.replace(' ', '%20')
dest_url = f'https://www.bing.com/images/search?q=rule%2034%20{q_string}&safesearch=off'
else:
dest_url = 'https://docs.google.com/spreadsheets/d/1kGNSQbwocG5NuXKw5NXFPJ1F2V7zM0_qsMv_1o7QjLs/edit?gid=0#gid=0'
dest_url = 'https://docs.google.com/spreadsheets/d/1nNkStOm1St1U2aQ0Jrdhyti5Qe0b-sI8xTUGCTOLuXE/edit?usp=sharing'
await ctx.send(
content=f'To play the 50/50, click the \'Trust Links\' button that pops up for [Bing](<https://www.bing.com/>) and for [Sheets](<https://docs.google.com/spreadsheets/u/0/>).\n\n[Rule 34 Draft](<{dest_url}>)'
@ -1540,7 +1885,7 @@ class Transactions(commands.Cog):
while True:
prompt = f'Are you sending someone to the IL?'
this_q = Question(self.bot, dropadd.channel, prompt, 'yesno', 300)
resp = await this_q.ask(dropadd.get_gms(self.bot))
resp = await this_q.ask(await dropadd.get_gms(self.bot))
if resp is None:
await dropadd.send('RIP this move. Maybe next time.')
@ -1551,7 +1896,7 @@ class Transactions(commands.Cog):
else:
this_q.prompt = 'Who are you sending to the IL?'
this_q.qtype = 'text'
resp = await this_q.ask(dropadd.get_gms(self.bot))
resp = await this_q.ask(await dropadd.get_gms(self.bot))
if resp is None:
await dropadd.send('RIP this move. Maybe next time.')
@ -1583,7 +1928,7 @@ class Transactions(commands.Cog):
while True and not OFFSEASON_FLAG:
prompt = f'Are you adding someone to the Major League team?'
this_q = Question(self.bot, dropadd.channel, prompt, 'yesno', 300)
resp = await this_q.ask(dropadd.get_gms(self.bot))
resp = await this_q.ask(await dropadd.get_gms(self.bot))
if resp is None:
await dropadd.send('RIP this move. Maybe next time.')
@ -1594,7 +1939,7 @@ class Transactions(commands.Cog):
else:
this_q.prompt = 'Who are you adding?'
this_q.qtype = 'text'
resp = await this_q.ask(dropadd.get_gms(self.bot))
resp = await this_q.ask(await dropadd.get_gms(self.bot))
if resp is None:
await dropadd.send('RIP this move. Maybe next time.')
@ -1625,7 +1970,7 @@ class Transactions(commands.Cog):
while True and not OFFSEASON_FLAG:
prompt = f'Are you adding someone to the Minor League team?'
this_q = Question(self.bot, dropadd.channel, prompt, 'yesno', 300)
resp = await this_q.ask(dropadd.get_gms(self.bot))
resp = await this_q.ask(await dropadd.get_gms(self.bot))
if resp is None:
await dropadd.send('RIP this move. Maybe next time.')
@ -1636,7 +1981,7 @@ class Transactions(commands.Cog):
else:
this_q.prompt = 'Who are you sending to the MiL?'
this_q.qtype = 'text'
resp = await this_q.ask(dropadd.get_gms(self.bot))
resp = await this_q.ask(await dropadd.get_gms(self.bot))
if resp is None:
await dropadd.send('RIP this move. Maybe next time.')
@ -1671,7 +2016,7 @@ class Transactions(commands.Cog):
while True:
prompt = f'Are you dropping anyone to FA?'
this_q = Question(self.bot, dropadd.channel, prompt, 'yesno', 300)
resp = await this_q.ask(dropadd.get_gms(self.bot))
resp = await this_q.ask(await dropadd.get_gms(self.bot))
if resp is None:
await dropadd.send('RIP this move. Maybe next time.')
@ -1682,7 +2027,7 @@ class Transactions(commands.Cog):
else:
this_q.prompt = 'Who are you dropping to FA?'
this_q.qtype = 'text'
resp = await this_q.ask(dropadd.get_gms(self.bot))
resp = await this_q.ask(await dropadd.get_gms(self.bot))
if resp is None:
await dropadd.send('RIP this move. Maybe next time.')
@ -1724,18 +2069,20 @@ class Transactions(commands.Cog):
roster_errors = []
for team in dropadd.teams:
data = await dropadd.check_major_league_errors(team)
team_obj = dropadd.teams[team]["team"]
team_cap = get_team_salary_cap(team_obj)
if data['wara'] > 32.001 and not OFFSEASON_FLAG:
errors.append(f'- {dropadd.teams[team]["team"]["abbrev"]} would have {data["wara"]:.2f} WARa')
if exceeds_salary_cap(data['wara'], team_obj) and not OFFSEASON_FLAG:
errors.append(f'- {team_obj["abbrev"]} would have {data["wara"]:.2f} WARa (cap {team_cap:.1f})')
if len(data['roster']) > 26 and not OFFSEASON_FLAG:
errors.append(f'- {dropadd.teams[team]["team"]["abbrev"]} would have {len(data["roster"])} players')
errors.append(f'- {team_obj["abbrev"]} would have {len(data["roster"])} players')
if (data['wara'] > 32.001 or len(data['roster']) > 26) and not OFFSEASON_FLAG:
if (exceeds_salary_cap(data['wara'], team_obj) or len(data['roster']) > 26) and not OFFSEASON_FLAG:
roster_string = ''
for x in data['roster']:
roster_string += f'{x["wara"]: >5} - {x["name"]}\n'
errors.append(f'- This is the roster I have for {dropadd.teams[team]["team"]["abbrev"]}:\n'
roster_string += f'{x["wara"]: >5.2f} - {x["name"]}\n'
errors.append(f'- This is the roster I have for {team_obj["abbrev"]}:\n'
f'```\n{roster_string}```')
if len(errors) + len(roster_errors) > 0:
@ -1751,7 +2098,7 @@ class Transactions(commands.Cog):
for team in dropadd.teams:
this_q.prompt = f'{dropadd.teams[team]["role"].mention}\nWould you like me to run this move?'
this_q.qtype = 'yesno'
resp = await this_q.ask(dropadd.get_gms(self.bot, team))
resp = await this_q.ask(await dropadd.get_gms(self.bot, team))
if not resp:
await dropadd.send('RIP this move. Maybe next time.')
@ -1816,7 +2163,7 @@ class Transactions(commands.Cog):
guaranteed[x["moveid"]] = []
guaranteed[x["moveid"]].append(
f'**{x["player"]["name"]}** ({x["player"]["wara"]}) from '
f'**{x["player"]["name"]}** ({x["player"]["wara"]:.2f}) from '
f'{x["oldteam"]["abbrev"]} to {x["newteam"]["abbrev"]}\n'
)
@ -1825,7 +2172,7 @@ class Transactions(commands.Cog):
frozen[x["moveid"]] = []
frozen[x["moveid"]].append(
f'**{x["player"]["name"]}** ({x["player"]["wara"]}) from '
f'**{x["player"]["name"]}** ({x["player"]["wara"]:.2f}) from '
f'{x["oldteam"]["abbrev"]} to {x["newteam"]["abbrev"]}\n'
)
@ -1866,6 +2213,7 @@ class Transactions(commands.Cog):
embed.description = f'Week {current.week + 1}'
errors = []
team_cap = get_team_salary_cap(this_team)
sil_wara = roster['shortil']['WARa']
total_wara = roster['active']['WARa']
@ -1874,8 +2222,8 @@ class Transactions(commands.Cog):
wara_string += f' ({sil_wara:.2f} IL)'
embed.add_field(name='sWAR', value=wara_string)
if total_wara > 32.001:
errors.append(f'- sWAR currently {total_wara:.2f} (cap 32.0)')
if exceeds_salary_cap(total_wara, this_team):
errors.append(f'- sWAR currently {total_wara:.2f} (cap {team_cap:.1f})')
player_count = len(roster["active"]["players"])
embed.add_field(name='Player Count', value=f'{player_count}')
@ -1952,7 +2300,7 @@ class Transactions(commands.Cog):
if player['team']['id'] != team['id']:
await ctx.send(f'Omg stop trying to make {player["name"]} happen. It\'s not going to happen.')
else:
output_string += f'**{player["name"]}** ({player["wara"]}) to MiL\n'
output_string += f'**{player["name"]}** ({player["wara"]:.2f}) to MiL\n'
if player['team']['id'] == team['id']:
moves.append({
'week': 1,

View File

@ -160,6 +160,12 @@ async def db_delete(endpoint: str, object_id: int, api_ver: int = 3, timeout=3):
raise ValueError(f'DB: {e}')
# async def db_delete_transaction(move_id: str):
# req_url = get_req_url('transactions', api_ver=3, object_id=move_id)
# log_string = f'delete:\n{endpoint} {object_id}'
# logger.info(log_string) if master_debug else logger.debug(log_string)
async def get_team_by_abbrev(team_abbrev: str, season: int):
t_query = await db_get('teams', params=[('season', season), ('team_abbrev', team_abbrev)])
if not t_query or t_query['count'] == 0:

View File

@ -14,6 +14,7 @@ import json
import discord
import requests
import aiohttp
from discord.ext import commands
from difflib import get_close_matches
@ -25,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'
@ -37,7 +42,8 @@ SBA_SEASON7_DRAFT_KEY = '1BgySsUlQf9K21_uOjQOY7O0GrRfF6zt1BBaEFlvBokY'
SBA_SEASON8_DRAFT_KEY = '1FG4cAs8OeTdrreRqu8D-APxibjB3RiEzn34KTTBLLDk'
SBA_SEASON9_DRAFT_KEY = '1eyHqaVU9rtmhG1p0ZktOrz7FMDp3c_unCcFyMMYceLc'
DRAFT_KEY = {
11: '1Fz3GcTb7b9tLe8vkpyn59wRwC6P2QzxnLKtp7371sUc'
11: '1Fz3GcTb7b9tLe8vkpyn59wRwC6P2QzxnLKtp7371sUc',
12: '1OF-sAFykebc_2BrcYCgxCR-4rJo0GaNmTstagV-PMBU'
}
SBA_STANDINGS_URL = f'{SBA_BASE_URL}/standings'
SBA_SCHEDULE_URL = f'{SBA_BASE_URL}/schedule'
@ -213,9 +219,12 @@ class Question:
def text(mes):
return mes.channel == self.channel and mes.author in responders
logger.info(f'Sending watched message')
await self.channel.send(content=self.prompt, embed=self.embed)
logger.info(f'message is sent')
try:
logger.info(f'waiting for a response')
resp = await self.bot.wait_for(
'message',
timeout=self.timeout,
@ -864,7 +873,7 @@ async def get_player_embed(player, current, ctx=None, season=None):
embed.add_field(name='P Injury', value=f'{player["pitcher_injury"]} (6-{13 - player["pitcher_injury"]})')
else:
embed.add_field(name='P Injury', value=f'{player["pitcher_injury"]} (---)')
if d_query['count'] > 0:
if d_query and d_query.get('count') > 0:
pick = d_query['picks'][0]
num = pick["overall"] % 16
if num == 0:
@ -895,7 +904,7 @@ async def get_player_embed(player, current, ctx=None, season=None):
b_query = await db_get('battingstats/totals', params=[
('season', player['season']), ('player_id', player['id']), ('s_type', 'regular')])
if b_query['count'] > 0:
if b_query and b_query.get('count') > 0:
b = b_query['stats'][0]
if b['ab'] > 0:
@ -926,7 +935,7 @@ async def get_player_embed(player, current, ctx=None, season=None):
p_query = await db_get('pitchingstats/totals', params=[
('season', player['season']), ('player_id', player['id']), ('s_type', 'regular')])
if p_query['count'] > 0:
if p_query and p_query.get('count') > 0:
p = p_query['stats'][0]
if p['ip'] > 0:
@ -966,7 +975,7 @@ async def get_player_embed(player, current, ctx=None, season=None):
batter_priority = True
b, p, batting_string, pitching_string = None, None, None, None
if b_query['count'] > 0:
if b_query and b_query.get('count') > 0:
b = b_query['stats'][0]
batting_string = f'```\n' \
f' AVG OBP SLG\n' \
@ -976,7 +985,7 @@ async def get_player_embed(player, current, ctx=None, season=None):
f' PA H RBI 2B 3B HR SB\n' \
f'{b["pa"]: >3} {b["hit"]: ^3} {b["rbi"]: ^3} {b["double"]: >2} {b["triple"]: >2} ' \
f'{b["hr"]: >2} {b["sb"]: >2}```\n'
if p_query['count'] > 0:
if p_query and p_query.get('count') > 0:
p = p_query['stats'][0]
if b is None or p["tbf"] > b["pa"]:
batter_priority = False
@ -1182,3 +1191,67 @@ def random_from_list(data_list: list):
def get_team_url(this_team):
return f'https://sba.manticorum.com/teams/{this_team["season"]}/{this_team["abbrev"]}'
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
"""
logger = logging.getLogger('discord_app')
try:
async with aiohttp.ClientSession() as session:
webhook_url = "https://discord.com/api/webhooks/1408811717424840876/7RXG_D5IqovA3Jwa9YOobUjVcVMuLc6cQyezABcWuXaHo5Fvz1en10M7J43o3OJ3bzGW"
payload = {
"content": message
}
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) -> float:
"""
Get the salary cap for a team, falling back to the default if not set.
Args:
team: Team data - can be a dict or Pydantic model with 'salary_cap' attribute.
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 is None:
return DEFAULT_SALARY_CAP
# Handle both dict and Pydantic model (or any object with salary_cap attribute)
if isinstance(team, dict):
salary_cap = team.get('salary_cap')
else:
salary_cap = getattr(team, 'salary_cap', None)
if salary_cap is not None:
return salary_cap
return DEFAULT_SALARY_CAP
def exceeds_salary_cap(wara: float, team) -> bool:
"""
Check if a WAR total exceeds the team's salary cap.
Args:
wara: The total WAR value to check
team: Team data - can be a dict or Pydantic model
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)

View File

@ -0,0 +1,102 @@
{
"project": "Dynamic Salary Cap Refactor",
"description": "Replace hardcoded salary cap values (32.0/32.001) with Team.salary_cap field",
"created": "2025-12-09",
"tasks": [
{
"id": "SWAR-001",
"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": [1215, 1257],
"priority": 1,
"completed": true,
"tested": true,
"notes": "Added get_team_salary_cap() and exceeds_salary_cap() functions with Pydantic model support"
},
{
"id": "SWAR-002",
"name": "Update draft cap space check",
"description": "Replace hardcoded 32.00001 cap check in draft.py with team.salary_cap from the Team model",
"files": ["cogs/draft.py"],
"lines": [442, 447],
"priority": 2,
"completed": true,
"tested": false,
"notes": "Now uses exceeds_salary_cap() and shows actual cap in error message"
},
{
"id": "SWAR-003",
"name": "Update trade validation sWAR check",
"description": "Replace hardcoded 32.001 cap threshold in trade validation with team.salary_cap",
"files": ["cogs/transactions.py"],
"lines": [1281, 1293],
"priority": 2,
"completed": true,
"tested": false,
"notes": "Now uses exceeds_salary_cap() and shows actual cap in error message"
},
{
"id": "SWAR-004",
"name": "Update first drop/add validation",
"description": "Replace hardcoded 32.001 cap threshold in first drop/add validation block with team.salary_cap",
"files": ["cogs/transactions.py"],
"lines": [1768, 1780],
"priority": 2,
"completed": true,
"tested": false,
"notes": "Now uses exceeds_salary_cap() and shows actual cap in error message"
},
{
"id": "SWAR-005",
"name": "Update second drop/add validation",
"description": "Replace hardcoded 32.001 cap threshold in second drop/add validation block with team.salary_cap",
"files": ["cogs/transactions.py"],
"lines": [2072, 2081],
"priority": 2,
"completed": true,
"tested": false,
"notes": "Now uses exceeds_salary_cap() and shows actual cap in error message"
},
{
"id": "SWAR-006",
"name": "Update roster validation error message",
"description": "Replace hardcoded '(cap 32.0)' in error message with actual team salary_cap value",
"files": ["cogs/transactions.py"],
"lines": [2216, 2226],
"priority": 2,
"completed": true,
"tested": false,
"notes": "Now uses exceeds_salary_cap() and shows actual cap in error message"
},
{
"id": "SWAR-007",
"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": [30, 31],
"priority": 1,
"completed": true,
"tested": true,
"notes": "Added DEFAULT_SALARY_CAP and SALARY_CAP_TOLERANCE constants"
},
{
"id": "SWAR-008",
"name": "Verify Team model salary_cap field",
"description": "Confirm api_calls/team.py has salary_cap field properly defined and API returns this value",
"files": ["api_calls/team.py"],
"lines": [26],
"priority": 1,
"completed": true,
"tested": true,
"notes": "Field exists: salary_cap: Optional[float] = None"
}
],
"completion_checklist": [
"All hardcoded 32.0/32.001 values replaced - DONE",
"Helper function created with fallback logic - DONE",
"All affected cogs tested manually - PENDING",
"Unit tests added for salary cap validation - DONE (21 tests)",
"Error messages display correct cap values - DONE"
]
}

View File

@ -61,7 +61,7 @@ async def test_patch_draftpick_success():
round=1,
origowner=Team(id=1, abbrev='AAA', sname='Alpha', lname='Alpha Squad', season=2025),
owner=Team(id=2, abbrev='BBB', sname='Beta', lname='Beta Crew', season=2025),
player=Player(id=99, name="Test Player", team=Team(id=2, abbrev='BBB', sname='Beta', lname='Beta Crew', season=2025))
player=Player(id=99, name="Test Player", wara=2.5, image="test.png", season=2025, pos_1="SS", team=Team(id=2, abbrev='BBB', sname='Beta', lname='Beta Crew', season=2025))
)
mock_response = {

421
tests/test_salary_cap.py Normal file
View File

@ -0,0 +1,421 @@
"""
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 TestPydanticModelSupport:
"""Tests for Pydantic model support in helper functions."""
def test_get_team_salary_cap_with_pydantic_model(self):
"""
Should work with Pydantic models that have salary_cap attribute.
Why: Team objects in the codebase are often Pydantic models,
not just dicts. The helper must support both.
"""
class MockTeam:
salary_cap = 35.0
abbrev = 'TEST'
team = MockTeam()
result = get_team_salary_cap(team)
assert result == 35.0
def test_get_team_salary_cap_with_pydantic_model_none_cap(self):
"""
Pydantic model with salary_cap=None should return default.
Why: Many existing Team objects have salary_cap=None.
"""
class MockTeam:
salary_cap = None
abbrev = 'TEST'
team = MockTeam()
result = get_team_salary_cap(team)
assert result == DEFAULT_SALARY_CAP
def test_get_team_salary_cap_with_object_missing_attribute(self):
"""
Object without salary_cap attribute should return default.
Why: Defensive handling for objects that don't have the attribute.
"""
class MockTeam:
abbrev = 'TEST'
team = MockTeam()
result = get_team_salary_cap(team)
assert result == DEFAULT_SALARY_CAP
def test_exceeds_salary_cap_with_pydantic_model(self):
"""
exceeds_salary_cap should work with Pydantic-like objects.
Why: Draft and transaction code passes Team objects directly.
"""
class MockTeam:
salary_cap = 28.0
abbrev = 'EXPANSION'
team = MockTeam()
# 30.0 exceeds custom cap of 28.0
result = exceeds_salary_cap(30.0, team)
assert result is True
# 27.0 does not exceed custom cap of 28.0
result = exceeds_salary_cap(27.0, team)
assert result is False
class TestEdgeCases:
"""Tests for edge cases and boundary conditions."""
def test_negative_salary_cap(self):
"""
Negative salary cap should be returned as-is (even if nonsensical).
Why: Function should not validate business logic - just return the value.
If someone sets a negative cap, that's a data issue, not a helper issue.
"""
team = {'abbrev': 'BROKE', 'salary_cap': -5.0}
result = get_team_salary_cap(team)
assert result == -5.0
def test_negative_wara_under_cap(self):
"""
Negative WAR should not exceed any positive cap.
Why: Teams with negative WAR (all bad players) are clearly under cap.
"""
team = {'abbrev': 'TEST', 'salary_cap': 32.0}
result = exceeds_salary_cap(-10.0, team)
assert result is False
def test_negative_wara_with_negative_cap(self):
"""
Negative WAR vs negative cap - WAR higher than cap exceeds it.
Why: Edge case where both values are negative. -3.0 > -5.0 + 0.001
"""
team = {'abbrev': 'BROKE', 'salary_cap': -5.0}
# -3.0 > -4.999 (which is -5.0 + 0.001), so it exceeds
result = exceeds_salary_cap(-3.0, team)
assert result is True
# -6.0 < -4.999, so it does not exceed
result = exceeds_salary_cap(-6.0, team)
assert result is False
def test_very_large_salary_cap(self):
"""
Very large salary cap values should work correctly.
Why: Ensure no overflow or precision issues with large numbers.
"""
team = {'abbrev': 'RICH', 'salary_cap': 1000000.0}
result = get_team_salary_cap(team)
assert result == 1000000.0
result = exceeds_salary_cap(999999.0, team)
assert result is False
result = exceeds_salary_cap(1000001.0, team)
assert result is True
def test_very_small_salary_cap(self):
"""
Very small (but positive) salary cap should work.
Why: Some hypothetical penalty scenario with tiny cap.
"""
team = {'abbrev': 'TINY', 'salary_cap': 0.5}
result = exceeds_salary_cap(0.4, team)
assert result is False
result = exceeds_salary_cap(0.6, team)
assert result is True
def test_float_precision_boundary(self):
"""
Test exact boundary of tolerance (cap + 0.001).
Why: Ensure the boundary condition is handled correctly.
The check is wara > (cap + tolerance), so exactly at boundary should NOT exceed.
"""
team = {'abbrev': 'TEST', 'salary_cap': 32.0}
# Exactly at cap + tolerance = 32.001
result = exceeds_salary_cap(32.001, team)
assert result is False # Not greater than, equal to
# Just barely over
result = exceeds_salary_cap(32.0011, team)
assert result is True
class TestRealTeamModel:
"""Tests using the actual Team Pydantic model from api_calls."""
def test_with_real_team_model(self):
"""
Test with the actual Team Pydantic model used in production.
Why: Ensures the helper works with real Team objects, not just mocks.
"""
from api_calls.team import Team
team = Team(
id=1,
abbrev='TEST',
sname='Test Team',
lname='Test Team Long Name',
season=12,
salary_cap=28.5
)
result = get_team_salary_cap(team)
assert result == 28.5
def test_with_real_team_model_none_cap(self):
"""
Real Team model with salary_cap=None should use default.
Why: This is the most common case in production.
"""
from api_calls.team import Team
team = Team(
id=2,
abbrev='STD',
sname='Standard Team',
lname='Standard Team Long Name',
season=12,
salary_cap=None
)
result = get_team_salary_cap(team)
assert result == DEFAULT_SALARY_CAP
def test_exceeds_with_real_team_model(self):
"""
exceeds_salary_cap with real Team model.
Why: End-to-end test with actual production model.
"""
from api_calls.team import Team
team = Team(
id=3,
abbrev='EXP',
sname='Expansion',
lname='Expansion Team',
season=12,
salary_cap=28.0
)
# 30.0 exceeds 28.0 cap
assert exceeds_salary_cap(30.0, team) is True
# 27.0 does not exceed 28.0 cap
assert exceeds_salary_cap(27.0, team) 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