diff --git a/espn_api/base_settings.py b/espn_api/base_settings.py index ca8061233..e7478fef3 100644 --- a/espn_api/base_settings.py +++ b/espn_api/base_settings.py @@ -22,6 +22,13 @@ def __init__(self, data): self._raw_schedule_settings = data.get('scheduleSettings', {}) self.faab = data['acquisitionSettings']['isUsingAcquisitionBudget'] self.acquisition_budget = data.get('acquisitionSettings', {}).get('acquisitionBudget', 0) + self.acquisition_limit = data.get('acquisitionSettings', {}).get('acquisitionLimit') + self.matchup_acquisition_limit = data.get('acquisitionSettings', {}).get('matchupAcquisitionLimit') + self.matchup_limit_per_scoring_period = data.get('acquisitionSettings', {}).get('matchupLimitPerScoringPeriod') + self.minimum_bid = data.get('acquisitionSettings', {}).get('minimumBid', 0) + self.waiver_process_days = list(data.get('acquisitionSettings', {}).get('waiverProcessDays', [])) + self.waiver_process_hour = data.get('acquisitionSettings', {}).get('waiverProcessHour') + self.trade_revision_hours = data.get('tradeSettings', {}).get('revisionHours') divisions = data.get('scheduleSettings', {}).get('divisions', []) for division in divisions: self.division_map[division.get('id', 0)] = division.get('name') diff --git a/espn_api/baseball/__init__.py b/espn_api/baseball/__init__.py index 898b3e849..5a8822063 100644 --- a/espn_api/baseball/__init__.py +++ b/espn_api/baseball/__init__.py @@ -1,10 +1,15 @@ -__all__ = ['League', - 'Team', - 'Player', - 'Matchup', - ] - -from .league import League -from .team import Team -from .player import Player -from .matchup import Matchup \ No newline at end of file +__all__ = ['League', + 'Team', + 'Player', + 'Matchup', + 'Transaction', + 'TransactionItem', + 'Settings', + ] + +from .league import League +from .team import Team +from .player import Player +from .matchup import Matchup +from .transaction import Transaction, TransactionItem +from .settings import Settings diff --git a/espn_api/baseball/box_score.py b/espn_api/baseball/box_score.py index 29e271816..b837ab643 100644 --- a/espn_api/baseball/box_score.py +++ b/espn_api/baseball/box_score.py @@ -82,7 +82,7 @@ def _process_team(self, team_data, is_home_team): def _get_team_data(self, team, data, pro_schedule, week, year): if team not in data: - return (0, 0, -1, []) + return (0, 0, -1, []) # -1 projected score indicates no projection available (bye week / missing) team_id = data[team]['teamId'] team_projected = -1 @@ -95,3 +95,70 @@ def _get_team_data(self, team, data, pro_schedule, week, year): team_lineup = [BoxPlayer(player, pro_schedule, week, year) for player in team_roster] return (team_id, team_score, team_projected, team_lineup) + + +class RotoBoxScore(BoxScore): + '''Boxscore for rotisserie (ROTO) leagues. + + In roto there is no home/away matchup — all teams accumulate stats and are + ranked against each other in each category. One RotoBoxScore is returned + per matchup period, containing every team's cumulative category stats and + their league-wide rank in each category. + + Inherits from BoxScore so that isinstance(x, BoxScore) holds for all four + baseball box-score shapes, but winner/home_team/away_team are always None + since roto has no head-to-head matchup. + + Attributes: + matchup_period (int): the matchup period this snapshot covers + teams (list[dict]): one entry per team, each containing: + - team : team_id (int) initially; replaced with Team object + by League.box_scores() + - total_points (float): cumulative roto points (sum of category ranks) + - stats : dict mapping stat name → {'score': float, 'rank': float} + - lineup : list[BoxPlayer] for the current scoring period + ''' + def __init__(self, data, pro_schedule, year, scoring_period=0): + # Skip BoxScore.__init__ — roto data has no winner/home/away keys. + # Set the inherited attributes to None so callers can still access them. + self.winner = None + self.home_team = None + self.away_team = None + + self.matchup_period = data.get('matchupPeriodId') + self.teams = [] + + for team_data in data.get('teams', []): + team_id = team_data['teamId'] + live = team_data.get('totalPointsLive') + total = round(live if live is not None else team_data.get('totalPoints', 0), 2) + + stats = {} + for stat_id_str, stat_dict in team_data.get('cumulativeScore', {}).get('scoreByStat', {}).items(): + stat_name = STATS_MAP.get(int(stat_id_str), f'stat_{stat_id_str}') + score = stat_dict['score'] + # ESPN sends the literal string 'Infinity' for rate stats (e.g. WHIP, + # ERA) when the denominator is zero — coerce to float('inf') so + # callers can do arithmetic/comparisons without type-switching. + if score == 'Infinity': + score = float('inf') + stats[stat_name] = {'score': score, 'rank': stat_dict['rank']} + + entries = team_data.get('rosterForCurrentScoringPeriod', {}).get('entries', []) + lineup = [BoxPlayer(p, pro_schedule, scoring_period, year) for p in entries] + + self.teams.append({ + 'team': team_id, + 'total_points': total, + 'stats': stats, + 'lineup': lineup, + }) + + def _process_team(self, team_data, is_home_team): + # Roto has no home/away concept — this abstract method is required by + # the BoxScore base class but is never called (RotoBoxScore overrides + # __init__ entirely). + pass + + def __repr__(self): + return f'Roto Box Score(period:{self.matchup_period})' diff --git a/espn_api/baseball/constant.py b/espn_api/baseball/constant.py index 09f8f0fa0..552d07770 100644 --- a/espn_api/baseball/constant.py +++ b/espn_api/baseball/constant.py @@ -188,6 +188,31 @@ 99: 'STARTER', } +STAT_SPLIT_MAP = { + 0: 'season', + 1: 'last_7', + 2: 'last_15', + 3: 'last_30', + 5: 'box_score', +} + +# Stat IDs that apply only to pitchers +PITCHER_ONLY_STATS = { + 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, + 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 62, 63, 65, 66, 76, 77, 82, 83 +} + +# Stat IDs that apply only to batters (excludes shared fielding stats and games played) +BATTER_ONLY_STATS = { + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 13, 14, 15, 16, 17, 18, 19, 20, 21, + 23, 24, 25, 26, 27, 28, 29, 31, 74, 75 +} + +# Eligibility-slot names used to classify a player as a pitcher or batter +# when filtering stats by role (e.g. dropping batter-only stats from a pitcher). +PITCHER_POSITIONS = {'SP', 'RP', 'P'} +BATTER_POSITIONS = {'C', '1B', '2B', '3B', 'SS', 'OF', 'LF', 'CF', 'RF', 'DH', 'UTIL', '2B/SS', '1B/3B'} + ACTIVITY_MAP = { 178: 'FA ADDED', 180: 'WAIVER ADDED', @@ -200,3 +225,8 @@ 'TRADED': 244 } +TRANSACTION_TYPES = { + 'DRAFT', 'TRADE_ACCEPT', 'WAIVER', 'TRADE_VETO', 'FUTURE_ROSTER', + 'ROSTER', 'RETRO_ROSTER', 'TRADE_PROPOSAL', 'TRADE_UPHOLD', + 'FREEAGENT', 'TRADE_DECLINE', 'WAIVER_ERROR', 'TRADE_ERROR' +} diff --git a/espn_api/baseball/league.py b/espn_api/baseball/league.py index 28d672cbc..9992d56b9 100644 --- a/espn_api/baseball/league.py +++ b/espn_api/baseball/league.py @@ -2,20 +2,22 @@ import time import json import math -from typing import List, Tuple, Union +from typing import List, Set, Tuple, Union from ..base_league import BaseLeague from .team import Team from .player import Player from .matchup import Matchup -from .box_score import BoxScore, H2HCategoryBoxScore, H2HPointsBoxScore +from .box_score import BoxScore, H2HCategoryBoxScore, H2HPointsBoxScore, RotoBoxScore from .activity import Activity -from .constant import POSITION_MAP, ACTIVITY_MAP +from .constant import POSITION_MAP, ACTIVITY_MAP, TRANSACTION_TYPES +from .settings import Settings +from .transaction import Transaction class League(BaseLeague): '''Creates a League instance for Public/Private ESPN league''' - ScoreTypes = {'H2H_CATEGORY': H2HCategoryBoxScore, 'H2H_POINTS': H2HPointsBoxScore} + ScoreTypes = {'H2H_CATEGORY': H2HCategoryBoxScore, 'H2H_POINTS': H2HPointsBoxScore, 'ROTO': RotoBoxScore} def __init__(self, league_id: int, year: int, espn_s2=None, swid=None, fetch_league=True, debug=False): super().__init__(league_id=league_id, year=year, sport='mlb', espn_s2=espn_s2, swid=swid, debug=debug) @@ -38,7 +40,7 @@ def fetch_league(self): super()._fetch_draft() def _fetch_league(self): - data = super()._fetch_league() + data = super()._fetch_league(SettingsClass=Settings) self._fetch_players() return data @@ -81,6 +83,30 @@ def scoreboard(self, matchupPeriod: int = None) -> List[Matchup]: return matchups + def transactions(self, types: Set[str] = None, scoring_period: int = None) -> List[Transaction]: + '''Returns a list of transactions for a given scoring period''' + if types is None: + types = TRANSACTION_TYPES + for t in types: + if t not in TRANSACTION_TYPES: + raise ValueError(f'Invalid transaction type: {t}. Valid types: {TRANSACTION_TYPES}') + + if scoring_period is None: + scoring_period = self.scoringPeriodId + + params = { + 'view': 'mTransactions2', + 'scoringPeriodId': scoring_period, + } + filters = {'transactions': {'filterType': {'value': list(types)}}} + headers = {'x-fantasy-filter': json.dumps(filters)} + data = self.espn_request.league_get(params=params, headers=headers) + + return [ + Transaction(t, self.player_map, self.get_team_data) + for t in data.get('transactions', []) + ] + def recent_activity(self, size: int = 25, msg_type: str = None, offset: int = 0) -> List[Activity]: '''Returns a list of recent league activities (Add, Drop, Trade)''' if self.year < 2019: @@ -129,8 +155,16 @@ def free_agents(self, week: int=None, size: int=50, position: str=None, position return [Player(player, self.year) for player in players] - def box_scores(self, matchup_period: int = None, scoring_period: int = None) -> List[Union[BoxScore, H2HCategoryBoxScore]]: - '''Returns list of box score for a given matchup or scoring period''' + def box_scores(self, matchup_period: int = None, scoring_period: int = None) -> List[BoxScore]: + '''Returns list of box score for a given matchup or scoring period. + + The concrete type depends on the league's scoring_type: + - H2H_CATEGORY → H2HCategoryBoxScore (home/away with category results) + - H2H_POINTS → H2HPointsBoxScore (home/away with point totals) + - ROTO → RotoBoxScore (no home/away — callers iterate + the .teams list instead) + All three inherit from BoxScore. + ''' if self.year < 2019: raise Exception('Cant use box score before 2019') @@ -155,10 +189,53 @@ def box_scores(self, matchup_period: int = None, scoring_period: int = None) -> schedule = data['schedule'] box_data = [self._box_score_class(matchup, pro_schedule, self.year, scoring_id) for matchup in schedule] - for team in self.teams: - for matchup in box_data: - if matchup.home_team == team.team_id: - matchup.home_team = team - elif matchup.away_team == team.team_id: - matchup.away_team = team + team_map = {t.team_id: t for t in self.teams} + for matchup in box_data: + if self.scoring_type == 'ROTO': + for entry in matchup.teams: + entry['team'] = team_map.get(entry['team'], entry['team']) + else: + if matchup.home_team in team_map: + matchup.home_team = team_map[matchup.home_team] + if matchup.away_team in team_map: + matchup.away_team = team_map[matchup.away_team] return box_data + + def player_info(self, name: str = None, playerId: Union[int, list] = None) -> Union[Player, List[Player]]: + '''Returns Player class if name or playerId found''' + if name and name in self.player_map: + playerId = self.player_map[name] + if playerId is None: + return None + if not isinstance(playerId, list): + playerId = [playerId] + + split_filters = ["0{}{}".format(i, self.year) for i in range(1, 4)] + data = self.espn_request.get_player_card(playerId, self.finalScoringPeriod, additional_filters=split_filters) + if len(data['players']) == 1: + return Player(data['players'][0], self.year) + if len(data['players']) > 1: + return [Player(player, self.year) for player in data['players']] + + def refresh(self): + '''Gets latest league data without re-fetching all players''' + data = super()._fetch_league(SettingsClass=Settings) + self.scoring_type = data['settings']['scoringSettings']['scoringType'] + self._fetch_teams(data) + self._box_score_class = self._set_scoring_class(self.scoring_type) + + def load_roster_week(self, week: int) -> None: + '''Sets Teams Roster for a Certain Week''' + params = { + 'view': 'mRoster', + 'scoringPeriodId': week + } + data = self.espn_request.league_get(params=params) + + team_roster = {} + for team in data['teams']: + team_roster[team['id']] = team['roster'] + + for team in self.teams: + roster = team_roster[team.team_id] + team._fetch_roster(roster, self.year) diff --git a/espn_api/baseball/matchup.py b/espn_api/baseball/matchup.py index 2144155dd..85dcff918 100644 --- a/espn_api/baseball/matchup.py +++ b/espn_api/baseball/matchup.py @@ -1,5 +1,3 @@ -import pdb - from .constant import STATS_MAP class Matchup(object): diff --git a/espn_api/baseball/player.py b/espn_api/baseball/player.py index 69baa977f..cb8f7bc06 100644 --- a/espn_api/baseball/player.py +++ b/espn_api/baseball/player.py @@ -1,4 +1,8 @@ -from .constant import DEFAULT_POSITION_MAP, POSITION_MAP, PRO_TEAM_MAP, STATS_MAP +from datetime import datetime +from .constant import ( + DEFAULT_POSITION_MAP, POSITION_MAP, PRO_TEAM_MAP, STATS_MAP, STAT_SPLIT_MAP, + PITCHER_ONLY_STATS, BATTER_ONLY_STATS, PITCHER_POSITIONS, BATTER_POSITIONS, +) from .utils import json_parsing class Player(object): @@ -10,37 +14,99 @@ def __init__(self, data, year): self.lineupSlot = POSITION_MAP.get(data.get('lineupSlotId'), '') self.eligibleSlots = [POSITION_MAP.get(pos, pos) for pos in json_parsing(data, 'eligibleSlots')] # if position isn't in position map, just use the position id number self.acquisitionType = json_parsing(data, 'acquisitionType') + raw_acq_date = json_parsing(data, 'acquisitionDate') + self.acquisitionDate = datetime.fromtimestamp(raw_acq_date / 1000) if raw_acq_date else None # ESPN timestamps are milliseconds self.proTeam = PRO_TEAM_MAP.get(json_parsing(data, 'proTeamId'), json_parsing(data, 'proTeamId')) self.injuryStatus = json_parsing(data, 'injuryStatus') self.status = json_parsing(data, 'status') self.stats = {} - player = data.get('playerPoolEntry', {}).get('player') or data['player'] + # pool entry fields exist either nested under 'playerPoolEntry' (roster/free agent) + # or at the top level (player_info card response) + pool_entry = data['playerPoolEntry'] if 'playerPoolEntry' in data else data + self.on_team_id = pool_entry.get('onTeamId') + self.keeper_value = pool_entry.get('keeperValue') + self.keeper_value_future = pool_entry.get('keeperValueFuture') + self.lineup_locked = pool_entry.get('lineupLocked', False) + self.roster_locked = pool_entry.get('rosterLocked', False) + self.trade_locked = pool_entry.get('tradeLocked', False) + + player = pool_entry.get('player') or data.get('player', {}) self.injuryStatus = player.get('injuryStatus', self.injuryStatus) self.injured = player.get('injured', False) - self.percent_owned = round(player.get('ownership', {}).get('percentOwned', -1), 2) - self.percent_started = round(player.get('ownership', {}).get('percentStarted', -1), 2) + self.first_name = player.get('firstName', '') + self.last_name = player.get('lastName', '') + self.active = player.get('active', True) + self.droppable = player.get('droppable', True) + self.jersey = player.get('jersey') + self.laterality = player.get('laterality') + self.stance = player.get('stance') + raw_news_date = player.get('lastNewsDate') + self.last_news_date = datetime.fromtimestamp(raw_news_date / 1000) if raw_news_date else None # ESPN timestamps are milliseconds + self.season_outlook = player.get('seasonOutlook', '') + + ownership = player.get('ownership', {}) + self.percent_owned = round(ownership.get('percentOwned', -1), 2) + self.percent_started = round(ownership.get('percentStarted', -1), 2) + self.percent_owned_change = ownership.get('percentChange') + self.adp = ownership.get('averageDraftPosition') + self.adp_change = ownership.get('averageDraftPositionPercentChange') + self.auction_value = ownership.get('auctionValueAverage') + self.auction_value_change = ownership.get('auctionValueAverageChange') + + self.draft_ranks = { + rank_type: {'rank': rank_data['rank'], 'auction_value': rank_data['auctionValue']} + for rank_type, rank_data in player.get('draftRanksByRankType', {}).items() + } + + # filter stats based on player eligibility: exclude pitcher-only stats if not eligible for pitcher slots, + # and exclude batter-only stats if not eligible for batter slots (handles multi-position players like Ohtani) + eligible_set = set(self.eligibleSlots) + is_eligible_pitcher = bool(PITCHER_POSITIONS & eligible_set) + is_eligible_batter = bool(BATTER_POSITIONS & eligible_set) # add available stats + self.stats_splits = {label: {} for label in STAT_SPLIT_MAP.values()} player_stats = player.get('stats', []) for stats in player_stats: stats_split_type = stats.get('statSplitTypeId') - if stats.get('seasonId') != year or (stats_split_type != 0 and stats_split_type != 5): + if stats.get('seasonId') != year: continue + if stats_split_type not in STAT_SPLIT_MAP: + continue # intentionally skip split types not in STAT_SPLIT_MAP (e.g. projected season) stats_breakdown = stats.get('stats') or stats.get('appliedStats', {}) - breakdown = {STATS_MAP.get(int(k), k):v for (k,v) in stats_breakdown.items()} + filtered_breakdown = {} + for k, v in stats_breakdown.items(): + stat_id = int(k) + if not is_eligible_pitcher and stat_id in PITCHER_ONLY_STATS: + continue # skip pitcher-only stats if not eligible for pitcher positions + if not is_eligible_batter and stat_id in BATTER_ONLY_STATS: + continue # skip batter-only stats if not eligible for batter positions + filtered_breakdown[k] = v + breakdown = {STATS_MAP.get(int(k), k): v for (k, v) in filtered_breakdown.items()} points = round(stats.get('appliedTotal', 0), 2) scoring_period = stats.get('scoringPeriodId') stat_source = stats.get('statSourceId') - # TODO update stats to include stat split type (0: Season, 1: Last 7 Days, 2: Last 15 Days, 3: Last 30, 4: ??, 5: ?? Used in Box Scores) (points_type, breakdown_type) = ('points', 'breakdown') if stat_source == 0 else ('projected_points', 'projected_breakdown') - if self.stats.get(scoring_period): - self.stats[scoring_period][points_type] = points - self.stats[scoring_period][breakdown_type] = breakdown + # populate stats_splits for all split types + split_label = STAT_SPLIT_MAP[stats_split_type] + split_bucket = self.stats_splits[split_label] + if scoring_period in split_bucket: + split_bucket[scoring_period][points_type] = points + split_bucket[scoring_period][breakdown_type] = breakdown else: - self.stats[scoring_period] = {points_type: points, breakdown_type: breakdown} + split_bucket[scoring_period] = {points_type: points, breakdown_type: breakdown} + # keep self.stats backwards-compatible: mirror season (0) and box_score (5) + # splits into the legacy flat dict that existed before stats_splits + if stats_split_type in (0, 5): + if scoring_period in self.stats: + self.stats[scoring_period][points_type] = points + self.stats[scoring_period][breakdown_type] = breakdown + else: + self.stats[scoring_period] = {points_type: points, breakdown_type: breakdown} + self.total_points = self.stats.get(0, {}).get('points', 0) self.projected_total_points = self.stats.get(0, {}).get('projected_points', 0) - + def __repr__(self): return 'Player(%s)' % (self.name, ) diff --git a/espn_api/baseball/settings.py b/espn_api/baseball/settings.py new file mode 100644 index 000000000..0804b059a --- /dev/null +++ b/espn_api/baseball/settings.py @@ -0,0 +1,14 @@ +from ..base_settings import BaseSettings +from .constant import POSITION_MAP + + +class Settings(BaseSettings): + def __init__(self, data): + super().__init__(data) + lineup_slot_counts = data.get('rosterSettings', {}).get('lineupSlotCounts', {}) + # slot IDs not in POSITION_MAP (e.g. bench, IR) are intentionally excluded + self.position_slot_counts = { + POSITION_MAP[int(slot_id)]: count + for slot_id, count in lineup_slot_counts.items() + if int(slot_id) in POSITION_MAP + } diff --git a/espn_api/baseball/team.py b/espn_api/baseball/team.py index 5288d1867..25aaf582f 100644 --- a/espn_api/baseball/team.py +++ b/espn_api/baseball/team.py @@ -1,4 +1,3 @@ -import pdb from .player import Player from .matchup import Matchup from .constant import STATS_MAP @@ -16,6 +15,22 @@ def __init__(self, data, roster, schedule, year, **kwargs): self.wins = data['record']['overall']['wins'] self.losses = data['record']['overall']['losses'] self.ties = data['record']['overall']['ties'] + self.points_for = data['record']['overall']['pointsFor'] + self.points_against = data['record']['overall']['pointsAgainst'] + self.streak_length = data['record']['overall']['streakLength'] + self.streak_type = data['record']['overall']['streakType'] + self.home_wins = data['record']['home']['wins'] + self.home_losses = data['record']['home']['losses'] + self.home_ties = data['record']['home']['ties'] + self.away_wins = data['record']['away']['wins'] + self.away_losses = data['record']['away']['losses'] + self.away_ties = data['record']['away']['ties'] + self.division_wins = data['record']['division']['wins'] + self.division_losses = data['record']['division']['losses'] + self.division_ties = data['record']['division']['ties'] + self.current_projected_rank = data.get('currentProjectedRank') + self.waiver_rank = data.get('waiverRank') + self.points = data.get('points', 0) self.logo_url = '' self.standing = data['playoffSeed'] self.final_standing = data.get('rankFinal') or data.get('rankCalculatedFinal') diff --git a/espn_api/baseball/transaction.py b/espn_api/baseball/transaction.py new file mode 100644 index 000000000..608be08d0 --- /dev/null +++ b/espn_api/baseball/transaction.py @@ -0,0 +1,41 @@ +from datetime import datetime +from typing import Any, Callable, Dict + +class Transaction(object): + def __init__(self, data: dict, player_map: Dict[int, str], get_team_data: Callable[[int], Any]): + self.team_id = data['teamId'] + self.team = get_team_data(self.team_id) + self.type = data['type'] + self.status = data.get('status') + self.scoring_period = data['scoringPeriodId'] + raw_date = data.get('processDate') or data.get('proposedDate') + self.date = datetime.fromtimestamp(raw_date / 1000) if raw_date else None + self.bid_amount = data.get('bidAmount') + self.is_pending = data.get('isPending', self.status == 'PENDING') + self.rating = data.get('rating') + self.execution_type = data.get('executionType') + self.related_transaction_id = data.get('relatedTransactionId') + self.comment = data.get('comment') + self.member_id = data.get('memberId') + self.items = [] + for item in data.get('items', []): + self.items.append(TransactionItem(item, player_map)) + + def __repr__(self): + items = ', '.join([str(item) for item in self.items]) + team_name = self.team.team_name if self.team else f'Team({self.team_id})' + return f'Transaction({team_name} {self.type} {items})' + +class TransactionItem(object): + def __init__(self, data, player_map): + self.type = data['type'] + self.player_name = player_map.get(data['playerId'], 'Unknown') + self.from_team_id = data.get('fromTeamId') + self.to_team_id = data.get('toTeamId') + self.from_lineup_slot_id = data.get('fromLineupSlotId') + self.to_lineup_slot_id = data.get('toLineupSlotId') + self.is_keeper = data.get('isKeeper', False) + self.overall_pick_number = data.get('overallPickNumber') + + def __repr__(self): + return f'{self.type} {self.player_name}' diff --git a/tests/baseball/unit/test_box_score.py b/tests/baseball/unit/test_box_score.py new file mode 100644 index 000000000..c3b375da4 --- /dev/null +++ b/tests/baseball/unit/test_box_score.py @@ -0,0 +1,160 @@ +from unittest import TestCase, mock + +from espn_api.baseball.box_score import BoxScore, H2HPointsBoxScore, RotoBoxScore +from espn_api.baseball.constant import STATS_MAP + + +def _make_roto_team(team_id, total_points=50.0, stat_scores=None, entries=None): + """Build a single team entry as returned by the ESPN ROTO schedule API.""" + stat_scores = stat_scores or {5: (10.0, 8.0), 20: (5.0, 6.0)} # stat_id: (score, rank) + score_by_stat = { + str(stat_id): {'score': score, 'rank': rank, 'result': None, 'ineligible': False} + for stat_id, (score, rank) in stat_scores.items() + } + return { + 'teamId': team_id, + 'totalPoints': total_points, + 'cumulativeScore': {'scoreByStat': score_by_stat}, + 'rosterForCurrentScoringPeriod': {'entries': entries or []}, + } + + +def _make_roto_data(matchup_period=1, teams=None): + """Build a roto schedule entry (one per matchup period, all teams included).""" + if teams is None: + teams = [_make_roto_team(1), _make_roto_team(2, total_points=40.0)] + return {'matchupPeriodId': matchup_period, 'teams': teams} + + +class RotoBoxScoreInitTest(TestCase): + def setUp(self): + self.data = _make_roto_data() + self.roto = RotoBoxScore(self.data, pro_schedule={}, year=2026) + + def test_matchup_period_set(self): + self.assertEqual(self.roto.matchup_period, 1) + + def test_teams_length(self): + self.assertEqual(len(self.roto.teams), 2) + + def test_team_ids_stored(self): + ids = [e['team'] for e in self.roto.teams] + self.assertIn(1, ids) + self.assertIn(2, ids) + + def test_total_points(self): + entry = next(e for e in self.roto.teams if e['team'] == 1) + self.assertEqual(entry['total_points'], 50.0) + + def test_stats_dict_keyed_by_name(self): + entry = self.roto.teams[0] + # stat id 5 → 'HR', stat id 20 → 'R' per STATS_MAP + for stat_name in entry['stats']: + self.assertIsInstance(stat_name, str) + + def test_stat_has_score_and_rank(self): + entry = self.roto.teams[0] + for stat in entry['stats'].values(): + self.assertIn('score', stat) + self.assertIn('rank', stat) + + def test_stat_values_correct(self): + entry = next(e for e in self.roto.teams if e['team'] == 1) + hr_name = STATS_MAP[5] + self.assertEqual(entry['stats'][hr_name]['score'], 10.0) + self.assertEqual(entry['stats'][hr_name]['rank'], 8.0) + + def test_lineup_is_list(self): + for entry in self.roto.teams: + self.assertIsInstance(entry['lineup'], list) + + def test_repr(self): + self.assertEqual(repr(self.roto), 'Roto Box Score(period:1)') + + def test_inherits_from_box_score(self): + self.assertIsInstance(self.roto, BoxScore) + + def test_home_away_winner_are_none(self): + # Roto has no head-to-head concept; these stub fields exist only so + # that isinstance() checks and attribute access don't crash. + self.assertIsNone(self.roto.home_team) + self.assertIsNone(self.roto.away_team) + self.assertIsNone(self.roto.winner) + + +class RotoBoxScoreTotalPointsLiveTest(TestCase): + def test_live_score_takes_precedence(self): + team = _make_roto_team(1, total_points=40.0) + team['totalPointsLive'] = 55.5 + data = _make_roto_data(teams=[team]) + roto = RotoBoxScore(data, pro_schedule={}, year=2026) + self.assertEqual(roto.teams[0]['total_points'], 55.5) + + def test_falls_back_to_total_points(self): + team = _make_roto_team(1, total_points=40.0) + # no totalPointsLive key + data = _make_roto_data(teams=[team]) + roto = RotoBoxScore(data, pro_schedule={}, year=2026) + self.assertEqual(roto.teams[0]['total_points'], 40.0) + + +class RotoBoxScoreInfinityTest(TestCase): + def test_infinity_string_converted_to_float_inf(self): + team = _make_roto_team(1, stat_scores={41: ('Infinity', 5.0)}) # 41 → WHIP + data = _make_roto_data(teams=[team]) + roto = RotoBoxScore(data, pro_schedule={}, year=2026) + whip_name = STATS_MAP[41] + score = roto.teams[0]['stats'][whip_name]['score'] + self.assertEqual(score, float('inf')) + + def test_normal_score_unchanged(self): + team = _make_roto_team(1, stat_scores={5: (12.0, 9.0)}) + data = _make_roto_data(teams=[team]) + roto = RotoBoxScore(data, pro_schedule={}, year=2026) + hr_name = STATS_MAP[5] + self.assertEqual(roto.teams[0]['stats'][hr_name]['score'], 12.0) + + +class RotoBoxScoreEmptyTeamsTest(TestCase): + def test_empty_teams_list(self): + data = _make_roto_data(teams=[]) + roto = RotoBoxScore(data, pro_schedule={}, year=2026) + self.assertEqual(roto.teams, []) + + def test_missing_teams_key(self): + data = {'matchupPeriodId': 1} + roto = RotoBoxScore(data, pro_schedule={}, year=2026) + self.assertEqual(roto.teams, []) + + def test_missing_matchup_period(self): + data = _make_roto_data(teams=[]) + del data['matchupPeriodId'] + roto = RotoBoxScore(data, pro_schedule={}, year=2026) + self.assertIsNone(roto.matchup_period) + + +class RotoBoxScoreProcessTeamTest(TestCase): + def test_process_team_noop(self): + # _process_team is required by the abstract base but unused — calling it + # directly should be a no-op and leave instance state untouched. + roto = RotoBoxScore(_make_roto_data(teams=[]), pro_schedule={}, year=2026) + self.assertIsNone(roto._process_team({'teamId': 99}, True)) + self.assertIsNone(roto.home_team) + self.assertIsNone(roto.away_team) + + +class H2HPointsBoxScoreByeWeekTest(TestCase): + def test_missing_away_returns_bye_defaults(self): + data = { + 'winner': 'HOME', + 'home': { + 'teamId': 1, + 'totalPoints': 42.5, + 'rosterForCurrentScoringPeriod': {'entries': []}, + }, + } + box = H2HPointsBoxScore(data, pro_schedule={}, year=2026) + self.assertEqual(box.away_team, 0) + self.assertEqual(box.away_score, 0) + self.assertEqual(box.away_projected, -1) + self.assertEqual(box.away_lineup, []) diff --git a/tests/baseball/unit/test_league.py b/tests/baseball/unit/test_league.py index 34734a82c..628541369 100644 --- a/tests/baseball/unit/test_league.py +++ b/tests/baseball/unit/test_league.py @@ -2,10 +2,58 @@ from unittest import TestCase, mock from espn_api.baseball import League +from espn_api.baseball.activity import Activity +from espn_api.baseball.box_score import RotoBoxScore +from espn_api.baseball.matchup import Matchup +from espn_api.baseball.player import Player from espn_api.baseball.constant import POSITION_MAP from espn_api.requests.espn_requests import EspnFantasyRequests +def _make_matchup_data(matchup_period_id=3, home_team_id=1, away_team_id=2): + return { + 'matchupPeriodId': matchup_period_id, + 'home': {'teamId': home_team_id, 'totalPoints': 10.0}, + 'away': {'teamId': away_team_id, 'totalPoints': 8.0}, + 'winner': 'HOME', + } + + +def _make_player_card_data(player_id=1001): + return { + 'keeperValue': 5, + 'keeperValueFuture': 10, + 'lineupLocked': False, + 'rosterLocked': False, + 'tradeLocked': False, + 'onTeamId': 1, + 'player': { + 'fullName': 'Test Player', + 'id': player_id, + 'defaultPositionId': 1, + 'eligibleSlots': [0], + 'firstName': 'Test', + 'lastName': 'Player', + 'injuryStatus': 'ACTIVE', + 'injured': False, + 'active': True, + 'droppable': True, + 'jersey': '42', + 'laterality': 'RIGHT', + 'stance': 'RIGHT', + 'lastNewsDate': None, + 'seasonOutlook': '', + 'proTeamId': 10, + 'ownership': { + 'percentOwned': 50.0, + 'percentStarted': 30.0, + }, + 'draftRanksByRankType': {}, + 'stats': [], + }, + } + + class FreeAgentsPositionFilterTest(TestCase): """Tests that free_agents(position=...) correctly filters by slot ID.""" @@ -71,3 +119,362 @@ def test_all_position_strings_resolve_to_their_int_id(self, mock_league_get): sent_filter = json.loads(headers['x-fantasy-filter']) slot_ids = sent_filter['players']['filterSlotIds']['value'] self.assertEqual(slot_ids, [slot_id]) + + +class ScoreboardTest(TestCase): + def setUp(self): + with mock.patch.object(League, 'fetch_league'): + self.league = League(league_id=1, year=2021) + self.league.currentMatchupPeriod = 3 + self.mock_team = mock.Mock() + self.mock_team.team_id = 1 + self.league.teams = [self.mock_team] + + @mock.patch.object(EspnFantasyRequests, 'league_get') + def test_returns_matchups_filtered_by_period(self, mock_get): + mock_get.return_value = { + 'schedule': [ + _make_matchup_data(matchup_period_id=3, home_team_id=1, away_team_id=2), + _make_matchup_data(matchup_period_id=4, home_team_id=3, away_team_id=4), + ] + } + result = self.league.scoreboard() + self.assertEqual(len(result), 1) + self.assertIsInstance(result[0], Matchup) + + @mock.patch.object(EspnFantasyRequests, 'league_get') + def test_explicit_matchup_period(self, mock_get): + mock_get.return_value = { + 'schedule': [ + _make_matchup_data(matchup_period_id=2, home_team_id=1, away_team_id=2), + ] + } + result = self.league.scoreboard(matchupPeriod=2) + self.assertEqual(len(result), 1) + + @mock.patch.object(EspnFantasyRequests, 'league_get') + def test_team_substituted_for_home(self, mock_get): + mock_get.return_value = { + 'schedule': [_make_matchup_data(matchup_period_id=3, home_team_id=1, away_team_id=2)] + } + result = self.league.scoreboard() + self.assertEqual(result[0].home_team, self.mock_team) + + @mock.patch.object(EspnFantasyRequests, 'league_get') + def test_team_substituted_for_away(self, mock_get): + mock_get.return_value = { + 'schedule': [_make_matchup_data(matchup_period_id=3, home_team_id=2, away_team_id=1)] + } + result = self.league.scoreboard() + self.assertEqual(result[0].away_team, self.mock_team) + + +class RecentActivityTest(TestCase): + def setUp(self): + with mock.patch.object(League, 'fetch_league'): + self.league = League(league_id=1, year=2021) + self.league.player_map = {1001: 'Mike Trout'} + mock_team = mock.Mock() + mock_team.team_id = 1 + self.league.teams = [mock_team] + + def test_raises_before_2019(self): + with mock.patch.object(League, 'fetch_league'): + old_league = League(league_id=1, year=2018) + with self.assertRaises(Exception): + old_league.recent_activity() + + @mock.patch.object(EspnFantasyRequests, 'league_get') + def test_returns_activity_list(self, mock_get): + mock_get.return_value = { + 'topics': [ + { + 'date': 1234567890000, + 'messages': [ + {'messageTypeId': 178, 'to': 1, 'targetId': 1001} + ], + } + ] + } + result = self.league.recent_activity() + self.assertEqual(len(result), 1) + self.assertIsInstance(result[0], Activity) + + @mock.patch.object(EspnFantasyRequests, 'league_get') + def test_empty_topics_returns_empty_list(self, mock_get): + mock_get.return_value = {'topics': []} + result = self.league.recent_activity() + self.assertEqual(result, []) + + @mock.patch.object(EspnFantasyRequests, 'league_get') + def test_msg_type_filter_applied(self, mock_get): + mock_get.return_value = {'topics': []} + self.league.recent_activity(msg_type='ADD') + headers_arg = mock_get.call_args.kwargs.get('headers') or mock_get.call_args[1].get('headers') + sent_filter = json.loads(headers_arg['x-fantasy-filter']) + self.assertIn('filterIncludeMessageTypeIds', sent_filter['topics']) + + +class BoxScoresTest(TestCase): + def setUp(self): + with mock.patch.object(League, 'fetch_league'): + self.league = League(league_id=1, year=2021) + self.league.currentMatchupPeriod = 3 + self.league.current_week = 5 + mock_team = mock.Mock() + mock_team.team_id = 1 + self.league.teams = [mock_team] + self.league._box_score_class = mock.Mock(return_value=mock.Mock(home_team=1, away_team=2)) + + def test_raises_before_2019(self): + with mock.patch.object(League, 'fetch_league'): + old_league = League(league_id=1, year=2018) + old_league._box_score_class = mock.Mock() + with self.assertRaises(Exception): + old_league.box_scores() + + @mock.patch('espn_api.baseball.league.League._get_pro_schedule', return_value={}) + @mock.patch.object(EspnFantasyRequests, 'league_get') + def test_returns_box_score_list(self, mock_get, mock_pro): + mock_get.return_value = {'schedule': [{'dummy': True}]} + result = self.league.box_scores() + self.assertEqual(len(result), 1) + + @mock.patch('espn_api.baseball.league.League._get_pro_schedule', return_value={}) + @mock.patch.object(EspnFantasyRequests, 'league_get') + def test_explicit_matchup_and_scoring_period(self, mock_get, mock_pro): + mock_get.return_value = {'schedule': []} + self.league.box_scores(matchup_period=2, scoring_period=10) + params = mock_get.call_args.kwargs.get('params') or mock_get.call_args[1].get('params') + self.assertEqual(params['scoringPeriodId'], 10) + + @mock.patch('espn_api.baseball.league.League._get_pro_schedule', return_value={}) + @mock.patch.object(EspnFantasyRequests, 'league_get') + def test_matchup_period_only_uses_earlier_period(self, mock_get, mock_pro): + mock_get.return_value = {'schedule': []} + self.league.box_scores(matchup_period=1) + filters_header = mock_get.call_args.kwargs.get('headers') or mock_get.call_args[1].get('headers') + sent_filter = json.loads(filters_header['x-fantasy-filter']) + self.assertEqual(sent_filter['schedule']['filterMatchupPeriodIds']['value'], [1]) + + @mock.patch('espn_api.baseball.league.League._get_pro_schedule', return_value={}) + @mock.patch.object(EspnFantasyRequests, 'league_get') + def test_team_substituted_in_box_score(self, mock_get, mock_pro): + mock_team = self.league.teams[0] + mock_box = mock.Mock() + mock_box.home_team = 1 + mock_box.away_team = 2 + self.league._box_score_class.return_value = mock_box + mock_get.return_value = {'schedule': [{'dummy': True}]} + self.league.box_scores() + self.assertEqual(mock_box.home_team, mock_team) + + +def _make_roto_schedule_entry(team_ids=(1, 2), matchup_period=1): + teams = [ + { + 'teamId': tid, + 'totalPoints': 50.0, + 'cumulativeScore': { + 'scoreByStat': { + '5': {'score': 10.0, 'rank': float(i + 1), 'result': None, 'ineligible': False}, + } + }, + 'rosterForCurrentScoringPeriod': {'entries': []}, + } + for i, tid in enumerate(team_ids) + ] + return {'matchupPeriodId': matchup_period, 'teams': teams} + + +class BoxScoresRotoTest(TestCase): + def setUp(self): + with mock.patch.object(League, 'fetch_league'): + self.league = League(league_id=1, year=2021) + self.league.currentMatchupPeriod = 1 + self.league.current_week = 1 + self.league.scoring_type = 'ROTO' + self.league._box_score_class = RotoBoxScore + mock_team = mock.Mock() + mock_team.team_id = 1 + self.league.teams = [mock_team] + + @mock.patch('espn_api.baseball.league.League._get_pro_schedule', return_value={}) + @mock.patch.object(EspnFantasyRequests, 'league_get') + def test_returns_roto_box_score_instances(self, mock_get, mock_pro): + mock_get.return_value = {'schedule': [_make_roto_schedule_entry()]} + result = self.league.box_scores() + self.assertEqual(len(result), 1) + self.assertIsInstance(result[0], RotoBoxScore) + + @mock.patch('espn_api.baseball.league.League._get_pro_schedule', return_value={}) + @mock.patch.object(EspnFantasyRequests, 'league_get') + def test_team_id_replaced_with_team_object(self, mock_get, mock_pro): + mock_get.return_value = {'schedule': [_make_roto_schedule_entry(team_ids=(1, 2))]} + result = self.league.box_scores() + roto = result[0] + mock_team = self.league.teams[0] + matched = [e for e in roto.teams if e['team'] == mock_team] + self.assertEqual(len(matched), 1) + + @mock.patch('espn_api.baseball.league.League._get_pro_schedule', return_value={}) + @mock.patch.object(EspnFantasyRequests, 'league_get') + def test_unknown_team_id_left_as_int(self, mock_get, mock_pro): + mock_get.return_value = {'schedule': [_make_roto_schedule_entry(team_ids=(1, 99))]} + result = self.league.box_scores() + roto = result[0] + unmapped = [e for e in roto.teams if e['team'] == 99] + self.assertEqual(len(unmapped), 1) + + @mock.patch('espn_api.baseball.league.League._get_pro_schedule', return_value={}) + @mock.patch.object(EspnFantasyRequests, 'league_get') + def test_all_teams_present_in_snapshot(self, mock_get, mock_pro): + mock_get.return_value = {'schedule': [_make_roto_schedule_entry(team_ids=(1, 2))]} + result = self.league.box_scores() + self.assertEqual(len(result[0].teams), 2) + + @mock.patch('espn_api.baseball.league.League._get_pro_schedule', return_value={}) + @mock.patch.object(EspnFantasyRequests, 'league_get') + def test_stats_and_rank_accessible(self, mock_get, mock_pro): + mock_get.return_value = {'schedule': [_make_roto_schedule_entry(team_ids=(1,))]} + result = self.league.box_scores() + entry = result[0].teams[0] + self.assertIn('stats', entry) + for stat in entry['stats'].values(): + self.assertIn('score', stat) + self.assertIn('rank', stat) + + +class PlayerInfoTest(TestCase): + def setUp(self): + with mock.patch.object(League, 'fetch_league'): + self.league = League(league_id=1, year=2021) + self.league.player_map = {'Mike Trout': 1001} + self.league.finalScoringPeriod = 162 + + @mock.patch.object(EspnFantasyRequests, 'league_get') + def test_by_name_returns_single_player(self, mock_get): + mock_get.return_value = {'players': [_make_player_card_data(1001)]} + result = self.league.player_info(name='Mike Trout') + self.assertIsInstance(result, Player) + + def test_by_name_not_found_returns_none(self): + result = self.league.player_info(name='Unknown Player') + self.assertIsNone(result) + + @mock.patch.object(EspnFantasyRequests, 'league_get') + def test_unknown_name_does_not_clobber_explicit_player_id(self, mock_get): + mock_get.return_value = {'players': [_make_player_card_data(1001)]} + result = self.league.player_info(name='Unknown Player', playerId=1001) + self.assertIsInstance(result, Player) + + def test_no_args_returns_none(self): + result = self.league.player_info() + self.assertIsNone(result) + + @mock.patch.object(EspnFantasyRequests, 'league_get') + def test_by_player_id_int_returns_single_player(self, mock_get): + mock_get.return_value = {'players': [_make_player_card_data(1001)]} + result = self.league.player_info(playerId=1001) + self.assertIsInstance(result, Player) + + @mock.patch.object(EspnFantasyRequests, 'league_get') + def test_by_player_id_list_returns_list(self, mock_get): + mock_get.return_value = { + 'players': [_make_player_card_data(1001), _make_player_card_data(1002)] + } + result = self.league.player_info(playerId=[1001, 1002]) + self.assertIsInstance(result, list) + self.assertEqual(len(result), 2) + + @mock.patch.object(EspnFantasyRequests, 'league_get') + def test_split_filters_included_in_request(self, mock_get): + mock_get.return_value = {'players': [_make_player_card_data(1001)]} + self.league.player_info(playerId=1001) + headers = mock_get.call_args.kwargs.get('headers') or mock_get.call_args[1].get('headers') + sent_filter = json.loads(headers['x-fantasy-filter']) + additional = sent_filter['players']['filterStatsForTopScoringPeriodIds']['additionalValue'] + self.assertIn('012021', additional) + self.assertIn('022021', additional) + self.assertIn('032021', additional) + + +class RefreshTest(TestCase): + def setUp(self): + with mock.patch.object(League, 'fetch_league'): + self.league = League(league_id=1, year=2021) + self.league.teams = [] + + @mock.patch.object(League, '_fetch_teams') + @mock.patch('espn_api.baseball.league.BaseLeague._fetch_league') + def test_refresh_updates_scoring_type(self, mock_fetch_league, mock_fetch_teams): + mock_fetch_league.return_value = { + 'settings': {'scoringSettings': {'scoringType': 'H2H_CATEGORY'}} + } + self.league.refresh() + self.assertEqual(self.league.scoring_type, 'H2H_CATEGORY') + + @mock.patch.object(League, '_fetch_teams') + @mock.patch('espn_api.baseball.league.BaseLeague._fetch_league') + def test_refresh_calls_fetch_teams(self, mock_fetch_league, mock_fetch_teams): + mock_fetch_league.return_value = { + 'settings': {'scoringSettings': {'scoringType': 'H2H_CATEGORY'}} + } + self.league.refresh() + mock_fetch_teams.assert_called_once() + + +class LoadRosterWeekTest(TestCase): + def setUp(self): + with mock.patch.object(League, 'fetch_league'): + self.league = League(league_id=1, year=2021) + self.mock_team = mock.Mock() + self.mock_team.team_id = 1 + self.league.teams = [self.mock_team] + + @mock.patch.object(EspnFantasyRequests, 'league_get') + def test_calls_fetch_roster_on_each_team(self, mock_get): + mock_get.return_value = { + 'teams': [{'id': 1, 'roster': {'entries': []}}] + } + self.league.load_roster_week(week=5) + self.mock_team._fetch_roster.assert_called_once_with({'entries': []}, 2021) + + @mock.patch.object(EspnFantasyRequests, 'league_get') + def test_uses_correct_scoring_period(self, mock_get): + mock_get.return_value = {'teams': [{'id': 1, 'roster': {'entries': []}}]} + self.league.load_roster_week(week=7) + params = mock_get.call_args.kwargs.get('params') or mock_get.call_args[1].get('params') + self.assertEqual(params['scoringPeriodId'], 7) + + +class StandingsTest(TestCase): + def _make_team(self, team_id, final_standing, standing): + t = mock.Mock() + t.team_id = team_id + t.final_standing = final_standing + t.standing = standing + return t + + def setUp(self): + with mock.patch.object(League, 'fetch_league'): + self.league = League(league_id=1, year=2021) + + def test_sorted_by_final_standing(self): + t1 = self._make_team(1, final_standing=3, standing=3) + t2 = self._make_team(2, final_standing=1, standing=1) + t3 = self._make_team(3, final_standing=2, standing=2) + self.league.teams = [t1, t2, t3] + result = self.league.standings() + self.assertEqual([t.team_id for t in result], [2, 3, 1]) + + def test_zero_final_standing_falls_back_to_standing(self): + t1 = self._make_team(1, final_standing=0, standing=2) + t2 = self._make_team(2, final_standing=0, standing=1) + self.league.teams = [t1, t2] + result = self.league.standings() + self.assertEqual(result[0].team_id, 2) + + def test_returns_all_teams(self): + self.league.teams = [self._make_team(i, final_standing=i, standing=i) for i in range(1, 6)] + self.assertEqual(len(self.league.standings()), 5) diff --git a/tests/baseball/unit/test_player.py b/tests/baseball/unit/test_player.py index af7589ae7..ced1abf35 100644 --- a/tests/baseball/unit/test_player.py +++ b/tests/baseball/unit/test_player.py @@ -1,28 +1,104 @@ +from datetime import datetime from unittest import TestCase -from espn_api.baseball.constant import DEFAULT_POSITION_MAP, POSITION_MAP +from espn_api.baseball.constant import DEFAULT_POSITION_MAP, POSITION_MAP, STAT_SPLIT_MAP from espn_api.baseball.player import Player -def _make_player_data(default_position_id, lineup_slot_id=0, eligible_slots=None): - """Minimal data structure that Player.__init__ expects.""" +def _make_player_data(default_position_id=2, lineup_slot_id=0, eligible_slots=None, + ownership=None, pool_entry_extras=None, player_extras=None): + """Roster-style data: pool entry nested under 'playerPoolEntry'. Default position is C (catcher, ID=2).""" return { 'defaultPositionId': default_position_id, 'lineupSlotId': lineup_slot_id, 'eligibleSlots': eligible_slots or [lineup_slot_id], 'acquisitionType': 'DRAFT', + 'acquisitionDate': 1700000000000, 'proTeamId': 10, 'injuryStatus': 'ACTIVE', 'status': 'ONTEAM', 'playerPoolEntry': { + 'keeperValue': 5, + 'keeperValueFuture': 10, + 'lineupLocked': False, + 'rosterLocked': False, + 'tradeLocked': False, + 'onTeamId': 3, + **(pool_entry_extras or {}), 'player': { 'fullName': 'Test Player', 'id': 1234, + 'firstName': 'Test', + 'lastName': 'Player', 'injuryStatus': 'ACTIVE', 'injured': False, - 'ownership': {'percentOwned': 50.0, 'percentStarted': 30.0}, + 'active': True, + 'droppable': True, + 'jersey': '42', + 'laterality': 'RIGHT', + 'stance': 'RIGHT', + 'lastNewsDate': 1700000000000, + 'seasonOutlook': 'Looking good.', + 'ownership': { + 'percentOwned': 50.0, + 'percentStarted': 30.0, + 'percentChange': 1.5, + 'averageDraftPosition': 100.0, + 'averageDraftPositionPercentChange': 0.5, + 'auctionValueAverage': 12.0, + 'auctionValueAverageChange': -1.0, + **(ownership or {}), + }, + 'draftRanksByRankType': { + 'STANDARD': {'rank': 50, 'auctionValue': 20, 'rankSourceId': 0, 'rankType': 'STANDARD', 'slotId': 0, 'published': True}, + 'ROTO': {'rank': 45, 'auctionValue': 18, 'rankSourceId': 0, 'rankType': 'ROTO', 'slotId': 0, 'published': True}, + }, 'stats': [], - } + **(player_extras or {}), + }, + }, + } + + +def _make_player_card_data(player_id=4424862): + """Player card style: flat structure, no 'playerPoolEntry' wrapper.""" + return { + 'keeperValue': 7, + 'keeperValueFuture': 14, + 'lineupLocked': True, + 'rosterLocked': False, + 'tradeLocked': True, + 'onTeamId': 5, + 'player': { + 'fullName': 'Card Player', + 'id': player_id, + 'defaultPositionId': 1, + 'eligibleSlots': [0], + 'firstName': 'Card', + 'lastName': 'Player', + 'injuryStatus': 'ACTIVE', + 'injured': False, + 'active': True, + 'droppable': False, + 'jersey': '99', + 'laterality': 'LEFT', + 'stance': 'SWITCH', + 'lastNewsDate': 1700000000000, + 'seasonOutlook': 'Card outlook.', + 'proTeamId': 15, + 'ownership': { + 'percentOwned': 75.0, + 'percentStarted': 60.0, + 'percentChange': 2.0, + 'averageDraftPosition': 50.0, + 'averageDraftPositionPercentChange': 1.0, + 'auctionValueAverage': 25.0, + 'auctionValueAverageChange': 3.0, + }, + 'draftRanksByRankType': { + 'STANDARD': {'rank': 20, 'auctionValue': 30, 'rankSourceId': 0, 'rankType': 'STANDARD', 'slotId': 0, 'published': True}, + }, + 'stats': [], }, } @@ -31,22 +107,6 @@ class PlayerPositionTest(TestCase): """Tests that Player.position uses DEFAULT_POSITION_MAP (defaultPositionId), not POSITION_MAP (lineupSlotId).""" - def test_sp_default_position(self): - """defaultPositionId=1 should resolve to 'SP', not 'C' (the old off-by-one bug).""" - data = _make_player_data(default_position_id=1) - player = Player(data, year=2021) - self.assertEqual(player.position, 'SP') - - def test_catcher_default_position(self): - data = _make_player_data(default_position_id=2) - player = Player(data, year=2021) - self.assertEqual(player.position, 'C') - - def test_rp_default_position(self): - data = _make_player_data(default_position_id=11) - player = Player(data, year=2021) - self.assertEqual(player.position, 'RP') - def test_unknown_default_position_returns_string_id(self): """Unknown defaultPositionId should return a string of the ID, not crash.""" data = _make_player_data(default_position_id=99) @@ -66,3 +126,297 @@ def test_all_default_positions_covered(self): data = _make_player_data(default_position_id=pos_id) player = Player(data, year=2021) self.assertEqual(player.position, expected) + + +class PlayerMetadataTest(TestCase): + def setUp(self): + self.player = Player(_make_player_data(), year=2021) + + def test_basic_identity(self): + self.assertEqual(self.player.name, 'Test Player') + self.assertEqual(self.player.first_name, 'Test') + self.assertEqual(self.player.last_name, 'Player') + + def test_active_and_droppable(self): + self.assertTrue(self.player.active) + self.assertTrue(self.player.droppable) + + def test_jersey(self): + self.assertEqual(self.player.jersey, '42') + + def test_laterality_and_stance(self): + self.assertEqual(self.player.laterality, 'RIGHT') + self.assertEqual(self.player.stance, 'RIGHT') + + def test_last_news_date_is_datetime(self): + self.assertIsInstance(self.player.last_news_date, datetime) + + def test_last_news_date_none_when_missing(self): + data = _make_player_data() + del data['playerPoolEntry']['player']['lastNewsDate'] + player = Player(data, year=2021) + self.assertIsNone(player.last_news_date) + + def test_season_outlook(self): + self.assertEqual(self.player.season_outlook, 'Looking good.') + + def test_acquisition_date(self): + from datetime import datetime + self.assertIsInstance(self.player.acquisitionDate, datetime) + + +class PlayerPoolEntryTest(TestCase): + def setUp(self): + self.player = Player(_make_player_data(), year=2021) + + def test_keeper_values(self): + self.assertEqual(self.player.keeper_value, 5) + self.assertEqual(self.player.keeper_value_future, 10) + + def test_lock_flags(self): + self.assertFalse(self.player.lineup_locked) + self.assertFalse(self.player.roster_locked) + self.assertFalse(self.player.trade_locked) + + def test_lock_flags_true(self): + data = _make_player_data(pool_entry_extras={ + 'lineupLocked': True, 'rosterLocked': True, 'tradeLocked': True, + }) + player = Player(data, year=2021) + self.assertTrue(player.lineup_locked) + self.assertTrue(player.roster_locked) + self.assertTrue(player.trade_locked) + + def test_on_team_id(self): + self.assertEqual(self.player.on_team_id, 3) + + +class PlayerOwnershipTest(TestCase): + def setUp(self): + self.player = Player(_make_player_data(), year=2021) + + def test_percent_owned_and_started(self): + self.assertAlmostEqual(self.player.percent_owned, 50.0) + self.assertAlmostEqual(self.player.percent_started, 30.0) + + def test_percent_owned_change(self): + self.assertAlmostEqual(self.player.percent_owned_change, 1.5) + + def test_adp(self): + self.assertAlmostEqual(self.player.adp, 100.0) + + def test_adp_change(self): + self.assertAlmostEqual(self.player.adp_change, 0.5) + + def test_auction_value(self): + self.assertAlmostEqual(self.player.auction_value, 12.0) + + def test_auction_value_change(self): + self.assertAlmostEqual(self.player.auction_value_change, -1.0) + + def test_missing_ownership_fields_are_none(self): + data = _make_player_data(ownership={ + 'percentOwned': 10.0, + 'percentStarted': 5.0, + }) + # Remove the extra ownership keys by overriding entirely + data['playerPoolEntry']['player']['ownership'] = { + 'percentOwned': 10.0, + 'percentStarted': 5.0, + } + player = Player(data, year=2021) + self.assertIsNone(player.adp) + self.assertIsNone(player.adp_change) + self.assertIsNone(player.auction_value) + self.assertIsNone(player.auction_value_change) + self.assertIsNone(player.percent_owned_change) + + +class PlayerDraftRanksTest(TestCase): + def setUp(self): + self.player = Player(_make_player_data(), year=2021) + + def test_draft_ranks_keys(self): + self.assertIn('STANDARD', self.player.draft_ranks) + self.assertIn('ROTO', self.player.draft_ranks) + + def test_draft_ranks_values(self): + self.assertEqual(self.player.draft_ranks['STANDARD']['rank'], 50) + self.assertEqual(self.player.draft_ranks['STANDARD']['auction_value'], 20) + self.assertEqual(self.player.draft_ranks['ROTO']['rank'], 45) + + def test_empty_draft_ranks(self): + data = _make_player_data(player_extras={'draftRanksByRankType': {}}) + player = Player(data, year=2021) + self.assertEqual(player.draft_ranks, {}) + + +class PlayerCardStructureTest(TestCase): + """Player card data is flat (no playerPoolEntry wrapper) — verify fields still populate.""" + + def setUp(self): + self.player = Player(_make_player_card_data(), year=2021) + + def test_name(self): + self.assertEqual(self.player.name, 'Card Player') + + def test_keeper_values_from_top_level(self): + self.assertEqual(self.player.keeper_value, 7) + self.assertEqual(self.player.keeper_value_future, 14) + + def test_lock_flags_from_top_level(self): + self.assertTrue(self.player.lineup_locked) + self.assertFalse(self.player.roster_locked) + self.assertTrue(self.player.trade_locked) + + def test_on_team_id_from_top_level(self): + self.assertEqual(self.player.on_team_id, 5) + + def test_jersey_laterality_stance(self): + self.assertEqual(self.player.jersey, '99') + self.assertEqual(self.player.laterality, 'LEFT') + self.assertEqual(self.player.stance, 'SWITCH') + + def test_adp_and_auction_value(self): + self.assertAlmostEqual(self.player.adp, 50.0) + self.assertAlmostEqual(self.player.adp_change, 1.0) + self.assertAlmostEqual(self.player.auction_value, 25.0) + self.assertAlmostEqual(self.player.auction_value_change, 3.0) + + def test_draft_ranks(self): + self.assertIn('STANDARD', self.player.draft_ranks) + self.assertEqual(self.player.draft_ranks['STANDARD']['rank'], 20) + + +def _make_stat(split_type_id, scoring_period=0, season_id=2021, + stat_source_id=0, applied_total=10.0, stats=None): + return { + 'statSplitTypeId': split_type_id, + 'scoringPeriodId': scoring_period, + 'seasonId': season_id, + 'statSourceId': stat_source_id, + 'appliedTotal': applied_total, + 'stats': stats or {'5': 1.0}, # stat key '5' = HR + } + + +class PlayerStatSplitsTest(TestCase): + def _player_with_stats(self, stat_list): + data = _make_player_data(player_extras={'stats': stat_list}) + return Player(data, year=2021) + + def test_stats_splits_keys_present(self): + player = self._player_with_stats([]) + for label in STAT_SPLIT_MAP.values(): + self.assertIn(label, player.stats_splits) + + def test_season_split_populates_stats(self): + player = self._player_with_stats([_make_stat(0, applied_total=50.0)]) + self.assertIn(0, player.stats['season'] if 'season' in player.stats else player.stats) + self.assertIn(0, player.stats_splits['season']) + self.assertEqual(player.stats_splits['season'][0]['points'], 50.0) + + def test_season_split_also_in_stats_dict(self): + """split_type=0 (season) must still appear in player.stats for backwards compat.""" + player = self._player_with_stats([_make_stat(0, applied_total=30.0)]) + self.assertIn(0, player.stats) + self.assertEqual(player.stats[0]['points'], 30.0) + + def test_box_score_split_in_stats_dict(self): + """split_type=5 (box_score) must still appear in player.stats.""" + player = self._player_with_stats([_make_stat(5, scoring_period=3, applied_total=7.0)]) + self.assertIn(3, player.stats) + self.assertEqual(player.stats[3]['points'], 7.0) + + def test_last7_split_not_in_stats_dict(self): + """split_type=1 (last_7) should NOT appear in player.stats.""" + player = self._player_with_stats([_make_stat(1, applied_total=20.0)]) + self.assertNotIn(0, player.stats) + self.assertIn(0, player.stats_splits['last_7']) + + def test_last15_and_last30_splits(self): + player = self._player_with_stats([ + _make_stat(2, applied_total=15.0), + _make_stat(3, applied_total=30.0), + ]) + self.assertEqual(player.stats_splits['last_15'][0]['points'], 15.0) + self.assertEqual(player.stats_splits['last_30'][0]['points'], 30.0) + + def test_wrong_season_ignored(self): + player = self._player_with_stats([_make_stat(0, season_id=2020, applied_total=99.0)]) + self.assertEqual(player.stats, {}) + for split in player.stats_splits.values(): + self.assertEqual(split, {}) + + def test_projected_split(self): + player = self._player_with_stats([_make_stat(0, stat_source_id=1, applied_total=25.0)]) + self.assertIn(0, player.stats_splits['season']) + self.assertEqual(player.stats_splits['season'][0]['projected_points'], 25.0) + self.assertNotIn('points', player.stats_splits['season'][0]) + + def test_breakdown_keys_mapped(self): + player = self._player_with_stats([_make_stat(0, stats={'5': 2.0})]) + self.assertEqual(player.stats_splits['season'][0]['breakdown']['HR'], 2.0) + + def test_total_points_from_season_stats(self): + player = self._player_with_stats([_make_stat(0, applied_total=42.0)]) + self.assertAlmostEqual(player.total_points, 42.0) + + def test_projected_total_points_from_projected_split(self): + player = self._player_with_stats([_make_stat(0, stat_source_id=1, applied_total=38.5)]) + self.assertAlmostEqual(player.projected_total_points, 38.5) + + def test_total_points_zero_when_no_stats(self): + player = self._player_with_stats([]) + self.assertEqual(player.total_points, 0) + + def test_batter_stats_filtered_for_pitchers(self): + """Pitcher (SP, ID=1) should not include batter-only stats like HR (ID=5).""" + pitcher_data = _make_player_data(default_position_id=1, eligible_slots=[14], player_extras={'stats': [_make_stat(0, stats={'5': 2.0, '48': 10.0})]}) + pitcher = Player(pitcher_data, year=2021) + breakdown = pitcher.stats_splits['season'][0]['breakdown'] + self.assertNotIn('HR', breakdown) # HR is a batter-only stat + self.assertIn('K', breakdown) # K (strikeout) is a pitcher stat + + def test_pitcher_stats_filtered_for_batters(self): + """Batter (C, ID=2) should not include pitcher-only stats like ERA (ID=47).""" + batter_data = _make_player_data(default_position_id=2, eligible_slots=[0], player_extras={'stats': [_make_stat(0, stats={'5': 2.0, '47': 3.45})]}) + batter = Player(batter_data, year=2021) + breakdown = batter.stats_splits['season'][0]['breakdown'] + self.assertIn('HR', breakdown) # HR is a batter stat + self.assertNotIn('ERA', breakdown) # ERA is a pitcher-only stat + + def test_multi_position_player_keeps_all_stats(self): + """Ohtani-like player (eligible for both SP and DH) should keep both pitcher and batter stats.""" + multi_data = _make_player_data(eligible_slots=[0, 14], player_extras={'stats': [_make_stat(0, stats={'5': 2.0, '48': 10.0})]}) + multi = Player(multi_data, year=2021) + breakdown = multi.stats_splits['season'][0]['breakdown'] + self.assertIn('HR', breakdown) # HR is a batter stat + self.assertIn('K', breakdown) # K is a pitcher stat + + +class PlayerMiscTest(TestCase): + def test_repr(self): + player = Player(_make_player_data(), year=2021) + self.assertEqual(repr(player), 'Player(Test Player)') + + def test_eligible_slots_mapped(self): + data = _make_player_data(eligible_slots=[0, 14]) + player = Player(data, year=2021) + self.assertIn('C', player.eligibleSlots) + self.assertIn('SP', player.eligibleSlots) + + def test_status_field(self): + player = Player(_make_player_data(), year=2021) + self.assertEqual(player.status, 'ONTEAM') + + def test_pro_team_mapped(self): + from espn_api.baseball.constant import PRO_TEAM_MAP + for pro_id, pro_name in PRO_TEAM_MAP.items(): + with self.subTest(pro_id=pro_id): + data = _make_player_data(player_extras={'proTeamId': pro_id}) + # proTeamId in player_extras goes into player dict, but proTeam reads from + # the outer roster entry's proTeamId — patch that instead + data['proTeamId'] = pro_id + player = Player(data, year=2021) + self.assertEqual(player.proTeam, pro_name) diff --git a/tests/baseball/unit/test_settings.py b/tests/baseball/unit/test_settings.py new file mode 100644 index 000000000..8553fe611 --- /dev/null +++ b/tests/baseball/unit/test_settings.py @@ -0,0 +1,184 @@ +from unittest import TestCase + +from espn_api.base_settings import BaseSettings +from espn_api.baseball.settings import Settings + + +def _make_settings_data(lineup_slot_counts=None, scoring_type='H2H_CATEGORY', + scoring_enhancement_type='NONE'): + return { + 'name': 'Test League', + 'size': 10, + 'scoringSettings': { + 'scoringType': scoring_type, + 'scoringEnhancementType': scoring_enhancement_type, + 'matchupTieRule': 'NONE', + 'playoffMatchupTieRule': 'NONE', + }, + 'scheduleSettings': { + 'matchupPeriodCount': 20, + 'matchupPeriods': {}, + 'playoffTeamCount': 4, + 'playoffMatchupPeriodLength': 1, + 'playoffSeedingRule': 'WINS', + 'divisions': [], + }, + 'tradeSettings': { + 'vetoVotesRequired': 4, + 'revisionHours': 48, + }, + 'draftSettings': { + 'keeperCount': 0, + }, + 'acquisitionSettings': { + 'isUsingAcquisitionBudget': True, + 'acquisitionBudget': 100, + 'acquisitionLimit': 50, + 'matchupAcquisitionLimit': 5, + 'matchupLimitPerScoringPeriod': True, + 'minimumBid': 1, + 'waiverProcessDays': ['MONDAY', 'THURSDAY'], + 'waiverProcessHour': 3, + }, + 'rosterSettings': { + 'lineupSlotCounts': lineup_slot_counts or {}, + }, + } + + +class SettingsPositionSlotCountsTest(TestCase): + def test_known_slots_are_mapped(self): + data = _make_settings_data(lineup_slot_counts={ + '14': 2, # SP + '15': 3, # RP + '0': 1, # C + }) + settings = Settings(data) + self.assertEqual(settings.position_slot_counts['SP'], 2) + self.assertEqual(settings.position_slot_counts['RP'], 3) + self.assertEqual(settings.position_slot_counts['C'], 1) + + def test_unknown_slot_ids_are_excluded(self): + """Slot IDs not in POSITION_MAP (e.g. 18, 21) should be silently dropped.""" + data = _make_settings_data(lineup_slot_counts={'18': 1, '14': 1}) + settings = Settings(data) + self.assertNotIn(18, settings.position_slot_counts) + self.assertIn('SP', settings.position_slot_counts) + + def test_empty_roster_settings(self): + data = _make_settings_data(lineup_slot_counts={}) + settings = Settings(data) + self.assertEqual(settings.position_slot_counts, {}) + + +class SettingsAcquisitionTest(TestCase): + def setUp(self): + self.settings = Settings(_make_settings_data()) + + def test_faab(self): + self.assertTrue(self.settings.faab) + + def test_acquisition_budget(self): + self.assertEqual(self.settings.acquisition_budget, 100) + + def test_acquisition_limit(self): + self.assertEqual(self.settings.acquisition_limit, 50) + + def test_matchup_acquisition_limit(self): + self.assertEqual(self.settings.matchup_acquisition_limit, 5) + + def test_matchup_limit_per_scoring_period(self): + self.assertTrue(self.settings.matchup_limit_per_scoring_period) + + def test_minimum_bid(self): + self.assertEqual(self.settings.minimum_bid, 1) + + def test_waiver_process_days(self): + self.assertEqual(self.settings.waiver_process_days, ['MONDAY', 'THURSDAY']) + + def test_waiver_process_hour(self): + self.assertEqual(self.settings.waiver_process_hour, 3) + + def test_trade_revision_hours(self): + self.assertEqual(self.settings.trade_revision_hours, 48) + + def test_missing_acquisition_fields_default_to_none(self): + data = _make_settings_data() + data['acquisitionSettings'] = {'isUsingAcquisitionBudget': False, 'acquisitionBudget': 0} + data['tradeSettings'] = {'vetoVotesRequired': 4} + settings = Settings(data) + self.assertIsNone(settings.acquisition_limit) + self.assertIsNone(settings.matchup_acquisition_limit) + self.assertIsNone(settings.waiver_process_hour) + self.assertIsNone(settings.trade_revision_hours) + self.assertEqual(settings.waiver_process_days, []) + + +class BaseSettingsCoreFieldsTest(TestCase): + def setUp(self): + self.settings = Settings(_make_settings_data()) + + def test_name(self): + self.assertEqual(self.settings.name, 'Test League') + + def test_team_count(self): + self.assertEqual(self.settings.team_count, 10) + + def test_reg_season_count(self): + self.assertEqual(self.settings.reg_season_count, 20) + + def test_playoff_team_count(self): + self.assertEqual(self.settings.playoff_team_count, 4) + + def test_playoff_matchup_period_length(self): + self.assertEqual(self.settings.playoff_matchup_period_length, 1) + + def test_keeper_count(self): + self.assertEqual(self.settings.keeper_count, 0) + + def test_veto_votes_required(self): + self.assertEqual(self.settings.veto_votes_required, 4) + + def test_scoring_type(self): + self.assertEqual(self.settings.scoring_type, 'H2H_CATEGORY') + + def test_median_scoring_false(self): + self.assertFalse(self.settings.median_scoring) + + def test_median_scoring_true(self): + data = _make_settings_data(scoring_enhancement_type='WIN_BONUS_TOP_HALF') + settings = Settings(data) + self.assertTrue(settings.median_scoring) + + def test_tie_rule(self): + self.assertEqual(self.settings.tie_rule, 'NONE') + + def test_playoff_tie_rule(self): + self.assertEqual(self.settings.playoff_tie_rule, 'NONE') + + def test_playoff_seed_tie_rule(self): + self.assertEqual(self.settings.playoff_seed_tie_rule, 'WINS') + + def test_repr(self): + self.assertEqual(repr(self.settings), 'Settings(Test League)') + + def test_trade_deadline_zero_when_missing(self): + self.assertEqual(self.settings.trade_deadline, 0) + + def test_trade_deadline_set_when_present(self): + data = _make_settings_data() + data['tradeSettings']['deadlineDate'] = 1234567890 + settings = Settings(data) + self.assertEqual(settings.trade_deadline, 1234567890) + + def test_division_map_empty_by_default(self): + self.assertEqual(self.settings.division_map, {}) + + def test_division_map_populated(self): + data = _make_settings_data() + data['scheduleSettings']['divisions'] = [ + {'id': 0, 'name': 'East'}, + {'id': 1, 'name': 'West'}, + ] + settings = Settings(data) + self.assertEqual(settings.division_map, {0: 'East', 1: 'West'}) diff --git a/tests/baseball/unit/test_team.py b/tests/baseball/unit/test_team.py new file mode 100644 index 000000000..c270d5f34 --- /dev/null +++ b/tests/baseball/unit/test_team.py @@ -0,0 +1,103 @@ +from unittest import TestCase, mock + +from espn_api.baseball.team import Team + + +def _make_record(wins=5, losses=3, ties=0, points_for=100.0, points_against=80.0, + streak_length=2, streak_type='WIN'): + return { + 'wins': wins, 'losses': losses, 'ties': ties, + 'pointsFor': points_for, 'pointsAgainst': points_against, + 'streakLength': streak_length, 'streakType': streak_type, + 'gamesBack': 0.0, 'percentage': 0.625, + } + + +def _make_team_data(team_id=1, wins=5, losses=3): + return { + 'id': team_id, + 'abbrev': 'TST', + 'name': 'Test Team', + 'divisionId': 0, + 'playoffSeed': 1, + 'rankCalculatedFinal': 1, + 'currentProjectedRank': 2, + 'waiverRank': 4, + 'points': 55.5, + 'record': { + 'overall': _make_record(wins=wins, losses=losses), + 'home': _make_record(wins=3, losses=1), + 'away': _make_record(wins=2, losses=2), + 'division': _make_record(wins=1, losses=1), + }, + } + + +def _make_team(data=None): + data = data or _make_team_data() + roster = {'entries': []} + schedule = [] + with mock.patch('espn_api.baseball.team.Player'), \ + mock.patch('espn_api.baseball.team.Matchup'): + return Team(data, roster, schedule, year=2026) + + +class TeamRecordTest(TestCase): + def setUp(self): + self.team = _make_team() + + def test_overall_record(self): + self.assertEqual(self.team.wins, 5) + self.assertEqual(self.team.losses, 3) + self.assertEqual(self.team.ties, 0) + + def test_points_for_and_against(self): + self.assertAlmostEqual(self.team.points_for, 100.0) + self.assertAlmostEqual(self.team.points_against, 80.0) + + def test_streak(self): + self.assertEqual(self.team.streak_length, 2) + self.assertEqual(self.team.streak_type, 'WIN') + + def test_home_record(self): + self.assertEqual(self.team.home_wins, 3) + self.assertEqual(self.team.home_losses, 1) + self.assertEqual(self.team.home_ties, 0) + + def test_away_record(self): + self.assertEqual(self.team.away_wins, 2) + self.assertEqual(self.team.away_losses, 2) + self.assertEqual(self.team.away_ties, 0) + + def test_division_record(self): + self.assertEqual(self.team.division_wins, 1) + self.assertEqual(self.team.division_losses, 1) + self.assertEqual(self.team.division_ties, 0) + + +class TeamMetadataTest(TestCase): + def setUp(self): + self.team = _make_team() + + def test_current_projected_rank(self): + self.assertEqual(self.team.current_projected_rank, 2) + + def test_waiver_rank(self): + self.assertEqual(self.team.waiver_rank, 4) + + def test_points(self): + self.assertAlmostEqual(self.team.points, 55.5) + + def test_optional_fields_default_to_none(self): + data = _make_team_data() + del data['currentProjectedRank'] + del data['waiverRank'] + team = _make_team(data) + self.assertIsNone(team.current_projected_rank) + self.assertIsNone(team.waiver_rank) + + def test_points_defaults_to_zero(self): + data = _make_team_data() + del data['points'] + team = _make_team(data) + self.assertEqual(team.points, 0) diff --git a/tests/baseball/unit/test_transaction.py b/tests/baseball/unit/test_transaction.py new file mode 100644 index 000000000..b782f4a96 --- /dev/null +++ b/tests/baseball/unit/test_transaction.py @@ -0,0 +1,251 @@ +from datetime import datetime +from unittest import TestCase, mock + +from espn_api.baseball import League, Transaction +from espn_api.baseball.constant import TRANSACTION_TYPES +from espn_api.requests.espn_requests import EspnFantasyRequests + + +def _make_transaction_data(type_='FREEAGENT', status='EXECUTED', team_id=1, + scoring_period=1, player_id=1001, item_type='ADD', + is_pending=False): + return { + 'teamId': team_id, + 'type': type_, + 'status': status, + 'isPending': is_pending, + 'scoringPeriodId': scoring_period, + 'processDate': 1234567890000, + 'bidAmount': None, + 'rating': 3, + 'executionType': 'PROCESS', + 'relatedTransactionId': None, + 'comment': '', + 'memberId': '{abc-123}', + 'items': [{ + 'type': item_type, + 'playerId': player_id, + 'fromTeamId': 0, + 'toTeamId': team_id, + 'fromLineupSlotId': -1, + 'toLineupSlotId': 16, + 'isKeeper': False, + 'overallPickNumber': None, + }], + } + + +class TransactionClassTest(TestCase): + def setUp(self): + self.mock_team = mock.Mock() + self.mock_team.team_name = 'Test Team' + self.player_map = {1001: 'Mike Trout'} + self.get_team_data = mock.Mock(return_value=self.mock_team) + + def test_basic_attributes(self): + data = _make_transaction_data() + t = Transaction(data, self.player_map, self.get_team_data) + self.assertEqual(t.type, 'FREEAGENT') + self.assertEqual(t.status, 'EXECUTED') + self.assertEqual(t.scoring_period, 1) + self.assertFalse(t.is_pending) + self.assertEqual(len(t.items), 1) + + def test_pending_true_from_api_field(self): + data = _make_transaction_data(is_pending=True) + t = Transaction(data, self.player_map, self.get_team_data) + self.assertTrue(t.is_pending) + + def test_pending_false_from_api_field(self): + data = _make_transaction_data(is_pending=False, status='PENDING') + t = Transaction(data, self.player_map, self.get_team_data) + self.assertFalse(t.is_pending) + + def test_pending_falls_back_to_status(self): + data = _make_transaction_data(status='PENDING') + del data['isPending'] + t = Transaction(data, self.player_map, self.get_team_data) + self.assertTrue(t.is_pending) + + def test_item_player_name_resolved(self): + data = _make_transaction_data(player_id=1001) + t = Transaction(data, self.player_map, self.get_team_data) + self.assertEqual(t.items[0].player_name, 'Mike Trout') + + def test_item_unknown_player_name(self): + data = _make_transaction_data(player_id=9999) + t = Transaction(data, self.player_map, self.get_team_data) + self.assertEqual(t.items[0].player_name, 'Unknown') + + def test_repr(self): + data = _make_transaction_data() + t = Transaction(data, self.player_map, self.get_team_data) + self.assertIn('FREEAGENT', repr(t)) + + def test_date_is_datetime(self): + data = _make_transaction_data() + t = Transaction(data, self.player_map, self.get_team_data) + self.assertIsInstance(t.date, datetime) + + def test_date_falls_back_to_proposed(self): + data = _make_transaction_data() + del data['processDate'] + data['proposedDate'] = 1234567890000 + t = Transaction(data, self.player_map, self.get_team_data) + self.assertIsInstance(t.date, datetime) + + def test_date_none_when_missing(self): + data = _make_transaction_data() + del data['processDate'] + t = Transaction(data, self.player_map, self.get_team_data) + self.assertIsNone(t.date) + + def test_rating_and_execution_type(self): + data = _make_transaction_data() + t = Transaction(data, self.player_map, self.get_team_data) + self.assertEqual(t.rating, 3) + self.assertEqual(t.execution_type, 'PROCESS') + + def test_rating_defaults_to_none(self): + data = _make_transaction_data() + del data['rating'] + t = Transaction(data, self.player_map, self.get_team_data) + self.assertIsNone(t.rating) + + def test_item_lineup_slot_ids(self): + data = _make_transaction_data() + t = Transaction(data, self.player_map, self.get_team_data) + self.assertEqual(t.items[0].from_lineup_slot_id, -1) + self.assertEqual(t.items[0].to_lineup_slot_id, 16) + + def test_item_is_keeper(self): + data = _make_transaction_data() + t = Transaction(data, self.player_map, self.get_team_data) + self.assertFalse(t.items[0].is_keeper) + + def test_item_is_keeper_true(self): + data = _make_transaction_data() + data['items'][0]['isKeeper'] = True + t = Transaction(data, self.player_map, self.get_team_data) + self.assertTrue(t.items[0].is_keeper) + + def test_item_overall_pick_number(self): + data = _make_transaction_data() + data['items'][0]['overallPickNumber'] = 42 + t = Transaction(data, self.player_map, self.get_team_data) + self.assertEqual(t.items[0].overall_pick_number, 42) + + +class LeagueTransactionsTest(TestCase): + def setUp(self): + with mock.patch.object(League, 'fetch_league'): + self.league = League(league_id=1, year=2021) + self.league.scoringPeriodId = 5 + mock_team = mock.Mock() + mock_team.team_name = 'Test Team' + mock_team.team_id = 1 + self.league.teams = [mock_team] + self.league.player_map = {1001: 'Mike Trout'} + + @mock.patch.object(EspnFantasyRequests, 'league_get') + def test_returns_transaction_list(self, mock_get): + mock_get.return_value = { + 'transactions': [_make_transaction_data()] + } + result = self.league.transactions() + self.assertEqual(len(result), 1) + self.assertIsInstance(result[0], Transaction) + + @mock.patch.object(EspnFantasyRequests, 'league_get') + def test_empty_response(self, mock_get): + mock_get.return_value = {} + result = self.league.transactions() + self.assertEqual(result, []) + + @mock.patch.object(EspnFantasyRequests, 'league_get') + def test_uses_current_scoring_period_by_default(self, mock_get): + mock_get.return_value = {'transactions': []} + self.league.transactions() + params = mock_get.call_args.kwargs.get('params') or mock_get.call_args[1].get('params') + self.assertEqual(params['scoringPeriodId'], 5) + + @mock.patch.object(EspnFantasyRequests, 'league_get') + def test_explicit_scoring_period(self, mock_get): + mock_get.return_value = {'transactions': []} + self.league.transactions(scoring_period=3) + params = mock_get.call_args.kwargs.get('params') or mock_get.call_args[1].get('params') + self.assertEqual(params['scoringPeriodId'], 3) + + def test_invalid_type_raises(self): + with self.assertRaises(ValueError) as ctx: + self.league.transactions(types={'BOGUS_TYPE'}) + self.assertIn('BOGUS_TYPE', str(ctx.exception)) + + def test_valid_types_accepted(self): + """All entries in TRANSACTION_TYPES should be accepted without error when mocked.""" + with mock.patch.object(EspnFantasyRequests, 'league_get', return_value={'transactions': []}): + for t in TRANSACTION_TYPES: + with self.subTest(type=t): + self.league.transactions(types={t}) + + @mock.patch.object(EspnFantasyRequests, 'league_get') + def test_types_filter_sent_in_header(self, mock_get): + mock_get.return_value = {'transactions': []} + self.league.transactions(types={'FREEAGENT'}) + headers = mock_get.call_args.kwargs.get('headers') or mock_get.call_args[1].get('headers') + import json + sent_filter = json.loads(headers['x-fantasy-filter']) + self.assertEqual(sent_filter['transactions']['filterType']['value'], ['FREEAGENT']) + + +class TransactionOptionalFieldsTest(TestCase): + def setUp(self): + self.player_map = {1001: 'Mike Trout'} + self.get_team_data = mock.Mock(return_value=mock.Mock(team_name='Test Team')) + + def test_bid_amount(self): + data = _make_transaction_data() + data['bidAmount'] = 15 + t = Transaction(data, self.player_map, self.get_team_data) + self.assertEqual(t.bid_amount, 15) + + def test_bid_amount_none(self): + t = Transaction(_make_transaction_data(), self.player_map, self.get_team_data) + self.assertIsNone(t.bid_amount) + + def test_comment(self): + data = _make_transaction_data() + data['comment'] = 'Picking up the best player' + t = Transaction(data, self.player_map, self.get_team_data) + self.assertEqual(t.comment, 'Picking up the best player') + + def test_comment_defaults_to_none_when_missing(self): + data = _make_transaction_data() + del data['comment'] + t = Transaction(data, self.player_map, self.get_team_data) + self.assertIsNone(t.comment) + + def test_member_id_defaults_to_none_when_missing(self): + data = _make_transaction_data() + del data['memberId'] + t = Transaction(data, self.player_map, self.get_team_data) + self.assertIsNone(t.member_id) + + def test_member_id(self): + t = Transaction(_make_transaction_data(), self.player_map, self.get_team_data) + self.assertEqual(t.member_id, '{abc-123}') + + def test_related_transaction_id_none(self): + t = Transaction(_make_transaction_data(), self.player_map, self.get_team_data) + self.assertIsNone(t.related_transaction_id) + + def test_team_resolved_via_callback(self): + mock_team = mock.Mock(team_name='Resolved Team') + get_team = mock.Mock(return_value=mock_team) + t = Transaction(_make_transaction_data(team_id=5), self.player_map, get_team) + get_team.assert_called_once_with(5) + self.assertEqual(t.team.team_name, 'Resolved Team') + + def test_item_repr(self): + t = Transaction(_make_transaction_data(item_type='ADD', player_id=1001), self.player_map, self.get_team_data) + self.assertEqual(repr(t.items[0]), 'ADD Mike Trout')