From 313c3f857b44cd4ea122bc603dce62e775fa948d Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Thu, 19 Feb 2026 21:19:06 -0600 Subject: [PATCH 1/2] fix: ContextualLogger crashes when callers pass exc_info=True ContextualLogger methods forwarded all **kwargs as extra={} to Python's standard logger. When callers passed exc_info=True, it landed in the extra dict and Python's LogRecord raised KeyError("Attempt to overwrite 'exc_info' in LogRecord") since exc_info is a reserved attribute. This caused /submit-scorecard to crash after game data was already posted, masking the original error and preventing proper rollback. Fix: Extract exc_info and stack_info from kwargs before passing as extra, forwarding them as proper logging parameters instead. Also fix direct callers in submit_scorecard.py and views/players.py to use error=e. Co-Authored-By: Claude Opus 4.6 --- commands/league/submit_scorecard.py | 130 +++++++-------- utils/logging.py | 249 ++++++++++++++++------------ views/players.py | 190 +++++++++------------ 3 files changed, 282 insertions(+), 287 deletions(-) diff --git a/commands/league/submit_scorecard.py b/commands/league/submit_scorecard.py index ef2aefb..e70c895 100644 --- a/commands/league/submit_scorecard.py +++ b/commands/league/submit_scorecard.py @@ -4,6 +4,7 @@ Scorecard Submission Commands Implements the /submit-scorecard command for submitting Google Sheets scorecards with play-by-play data, pitching decisions, and game results. """ + from typing import Optional, List import discord @@ -36,24 +37,18 @@ class SubmitScorecardCommands(commands.Cog): def __init__(self, bot: commands.Bot): self.bot = bot - self.logger = get_contextual_logger(f'{__name__}.SubmitScorecardCommands') + self.logger = get_contextual_logger(f"{__name__}.SubmitScorecardCommands") self.sheets_service = SheetsService() # Will use config automatically self.logger.info("SubmitScorecardCommands cog initialized") @app_commands.command( name="submit-scorecard", - description="Submit a Google Sheets scorecard with game results and play data" - ) - @app_commands.describe( - sheet_url="Full URL to the Google Sheets scorecard" + description="Submit a Google Sheets scorecard with game results and play data", ) + @app_commands.describe(sheet_url="Full URL to the Google Sheets scorecard") @app_commands.checks.has_any_role(get_config().sba_players_role_name) @logged_command("/submit-scorecard") - async def submit_scorecard( - self, - interaction: discord.Interaction, - sheet_url: str - ): + async def submit_scorecard(self, interaction: discord.Interaction, sheet_url: str): """ Submit scorecard with full transaction rollback support. @@ -97,7 +92,7 @@ class SubmitScorecardCommands(commands.Cog): setup_data = await self.sheets_service.read_setup_data(scorecard) # Validate scorecard version - if setup_data['version'] != current.bet_week: + if setup_data["version"] != current.bet_week: await interaction.edit_original_response( content=( f"❌ This scorecard appears out of date (version {setup_data['version']}, " @@ -113,12 +108,10 @@ class SubmitScorecardCommands(commands.Cog): ) away_team = await team_service.get_team_by_abbrev( - setup_data['away_team_abbrev'], - current.season + setup_data["away_team_abbrev"], current.season ) home_team = await team_service.get_team_by_abbrev( - setup_data['home_team_abbrev'], - current.season + setup_data["home_team_abbrev"], current.season ) if not away_team or not home_team: @@ -129,18 +122,15 @@ class SubmitScorecardCommands(commands.Cog): # Match managers away_manager = self._match_manager( - away_team, - setup_data['away_manager_name'] + away_team, setup_data["away_manager_name"] ) home_manager = self._match_manager( - home_team, - setup_data['home_manager_name'] + home_team, setup_data["home_manager_name"] ) # Phase 3: Permission Check user_team = await get_user_major_league_team( - interaction.user.id, - current.season + interaction.user.id, current.season ) if user_team is None: @@ -160,30 +150,26 @@ class SubmitScorecardCommands(commands.Cog): # Phase 4: Duplicate Game Check duplicate_game = await game_service.find_duplicate_game( current.season, - setup_data['week'], - setup_data['game_num'], + setup_data["week"], + setup_data["game_num"], away_team.id, - home_team.id + home_team.id, ) if duplicate_game: - view = ConfirmationView( - responders=[interaction.user], - timeout=30.0 - ) + view = ConfirmationView(responders=[interaction.user], timeout=30.0) await interaction.edit_original_response( content=( f"⚠️ This game has already been played!\n" f"Would you like me to wipe the old one and re-submit?" ), - view=view + view=view, ) await view.wait() if view.confirmed: await interaction.edit_original_response( - content="🗑️ Wiping old game data...", - view=None + content="🗑️ Wiping old game data...", view=None ) # Delete old data @@ -193,7 +179,9 @@ class SubmitScorecardCommands(commands.Cog): pass # May not exist try: - await decision_service.delete_decisions_for_game(duplicate_game.id) + await decision_service.delete_decisions_for_game( + duplicate_game.id + ) except: pass # May not exist @@ -202,16 +190,13 @@ class SubmitScorecardCommands(commands.Cog): else: await interaction.edit_original_response( content="❌ You think on it some more and get back to me later.", - view=None + view=None, ) return # Phase 5: Find Scheduled Game scheduled_game = await game_service.find_scheduled_game( - current.season, - setup_data['week'], - away_team.id, - home_team.id + current.season, setup_data["week"], away_team.id, home_team.id ) if not scheduled_game: @@ -234,7 +219,7 @@ class SubmitScorecardCommands(commands.Cog): # Add game_id to each play for play in plays_data: - play['game_id'] = game_id + play["game_id"] = game_id # Phase 7: POST Plays await interaction.edit_original_response( @@ -244,7 +229,9 @@ class SubmitScorecardCommands(commands.Cog): try: if not DRY_RUN: await play_service.create_plays_batch(plays_data) - self.logger.info(f'Posting plays_data (1 and 2): {plays_data[0]} / {plays_data[1]}') + self.logger.info( + f"Posting plays_data (1 and 2): {plays_data[0]} / {plays_data[1]}" + ) rollback_state = "PLAYS_POSTED" except APIException as e: await interaction.edit_original_response( @@ -269,14 +256,16 @@ class SubmitScorecardCommands(commands.Cog): if not DRY_RUN: await game_service.update_game_result( game_id, - box_score['away'][0], # Runs - box_score['home'][0], # Runs + box_score["away"][0], # Runs + box_score["home"][0], # Runs away_manager.id, home_manager.id, - setup_data['game_num'], - sheet_url + setup_data["game_num"], + sheet_url, ) - self.logger.info(f'Updating game ID {game_id}, {box_score['away'][0]} @ {box_score['home'][0]}, {away_manager.id} vs {home_manager.id}') + self.logger.info( + f"Updating game ID {game_id}, {box_score['away'][0]} @ {box_score['home'][0]}, {away_manager.id} vs {home_manager.id}" + ) rollback_state = "GAME_PATCHED" except APIException as e: # Rollback plays @@ -287,18 +276,21 @@ class SubmitScorecardCommands(commands.Cog): return # Phase 10: Read Pitching Decisions - decisions_data = await self.sheets_service.read_pitching_decisions(scorecard) + decisions_data = await self.sheets_service.read_pitching_decisions( + scorecard + ) # Add game metadata to each decision for decision in decisions_data: - decision['game_id'] = game_id - decision['season'] = current.season - decision['week'] = setup_data['week'] - decision['game_num'] = setup_data['game_num'] + decision["game_id"] = game_id + decision["season"] = current.season + decision["week"] = setup_data["week"] + decision["game_num"] = setup_data["game_num"] # Validate WP and LP exist and fetch Player objects - wp, lp, sv, holders, _blown_saves = \ + wp, lp, sv, holders, _blown_saves = ( await decision_service.find_winning_losing_pitchers(decisions_data) + ) if wp is None or lp is None: # Rollback @@ -333,9 +325,7 @@ class SubmitScorecardCommands(commands.Cog): return # Phase 12: Create Results Embed - await interaction.edit_original_response( - content="📰 Posting results..." - ) + await interaction.edit_original_response(content="📰 Posting results...") results_embed = await self._create_results_embed( away_team, @@ -348,7 +338,7 @@ class SubmitScorecardCommands(commands.Cog): lp, sv, holders, - game_id + game_id, ) # Phase 13: Post to News Channel @@ -356,13 +346,11 @@ class SubmitScorecardCommands(commands.Cog): self.bot, get_config().sba_network_news_channel, content=None, - embed=results_embed + embed=results_embed, ) # Phase 14: Recalculate Standings - await interaction.edit_original_response( - content="📊 Tallying standings..." - ) + await interaction.edit_original_response(content="📊 Tallying standings...") try: await standings_service.recalculate_standings(current.season) @@ -371,13 +359,11 @@ class SubmitScorecardCommands(commands.Cog): self.logger.error("Failed to recalculate standings") # Success! - await interaction.edit_original_response( - content="✅ You are all set!" - ) + await interaction.edit_original_response(content="✅ You are all set!") except Exception as e: # Unexpected error - attempt rollback - self.logger.error(f"Unexpected error in scorecard submission: {e}", exc_info=True) + self.logger.error(f"Unexpected error in scorecard submission: {e}", error=e) if rollback_state and game_id: try: @@ -421,7 +407,7 @@ class SubmitScorecardCommands(commands.Cog): lp: Optional[Player], sv: Optional[Player], holders: List[Player], - game_id: int + game_id: int, ): """ Create rich embed with game results. @@ -444,8 +430,8 @@ class SubmitScorecardCommands(commands.Cog): """ # Determine winner and loser - away_score = box_score['away'][0] - home_score = box_score['home'][0] + away_score = box_score["away"][0] + home_score = box_score["home"][0] if away_score > home_score: winning_team = away_team @@ -465,7 +451,7 @@ class SubmitScorecardCommands(commands.Cog): # Create embed embed = EmbedTemplate.create_base_embed( title=f"{winner_abbrev} defeats {loser_abbrev} {winner_score}-{loser_score}", - description=f"Season {current.season}, Week {setup_data['week']}, Game {setup_data['game_num']}" + description=f"Season {current.season}, Week {setup_data['week']}, Game {setup_data['game_num']}", ) embed.color = winning_team.get_color_int() if winning_team.thumbnail: @@ -498,13 +484,13 @@ class SubmitScorecardCommands(commands.Cog): decisions_text += f"**SV:** {sv.display_name}\n" if decisions_text: - embed.add_field(name="Pitching Decisions", value=decisions_text, inline=True) + embed.add_field( + name="Pitching Decisions", value=decisions_text, inline=True + ) # Add scorecard link embed.add_field( - name="Scorecard", - value=f"[View Full Scorecard]({sheet_url})", - inline=True + name="Scorecard", value=f"[View Full Scorecard]({sheet_url})", inline=True ) # Try to get key plays (non-critical) @@ -513,7 +499,9 @@ class SubmitScorecardCommands(commands.Cog): if key_plays: key_plays_text = format_key_plays(key_plays, away_team, home_team) if key_plays_text: - embed.add_field(name="Key Plays", value=key_plays_text, inline=False) + embed.add_field( + name="Key Plays", value=key_plays_text, inline=False + ) except Exception as e: self.logger.warning(f"Failed to get key plays: {e}") diff --git a/utils/logging.py b/utils/logging.py index ecb575c..2b16f26 100644 --- a/utils/logging.py +++ b/utils/logging.py @@ -4,6 +4,7 @@ Enhanced Logging Utilities Provides structured logging with contextual information for Discord bot debugging. Implements hybrid approach: human-readable console + structured JSON files. """ + import contextvars import json import logging @@ -13,66 +14,82 @@ from datetime import datetime from typing import Dict, Any, Optional, Union # Context variable for request tracking across async calls -log_context: contextvars.ContextVar[Dict[str, Any]] = contextvars.ContextVar('log_context', default={}) +log_context: contextvars.ContextVar[Dict[str, Any]] = contextvars.ContextVar( + "log_context", default={} +) -logger = logging.getLogger(f'{__name__}.logging_utils') +logger = logging.getLogger(f"{__name__}.logging_utils") JSONValue = Union[ - str, - int, - float, - bool, - None, - dict[str, Any], # nested object - list[Any] # arrays + str, int, float, bool, None, dict[str, Any], list[Any] # nested object # arrays ] class JSONFormatter(logging.Formatter): """Custom JSON formatter for structured file logging.""" - + def format(self, record) -> str: """Format log record as JSON with context information.""" # Base log object log_obj: dict[str, JSONValue] = { - 'timestamp': datetime.now().isoformat() + 'Z', - 'level': record.levelname, - 'logger': record.name, - 'message': record.getMessage() + "timestamp": datetime.now().isoformat() + "Z", + "level": record.levelname, + "logger": record.name, + "message": record.getMessage(), } - + # Add function/line info if available - if hasattr(record, 'funcName') and record.funcName: - log_obj['function'] = record.funcName - if hasattr(record, 'lineno') and record.lineno: - log_obj['line'] = record.lineno - + if hasattr(record, "funcName") and record.funcName: + log_obj["function"] = record.funcName + if hasattr(record, "lineno") and record.lineno: + log_obj["line"] = record.lineno + # Add exception info if present if record.exc_info: - log_obj['exception'] = { - 'type': record.exc_info[0].__name__ if record.exc_info[0] else 'Unknown', - 'message': str(record.exc_info[1]) if record.exc_info[1] else 'No message', - 'traceback': self.formatException(record.exc_info) + log_obj["exception"] = { + "type": ( + record.exc_info[0].__name__ if record.exc_info[0] else "Unknown" + ), + "message": ( + str(record.exc_info[1]) if record.exc_info[1] else "No message" + ), + "traceback": self.formatException(record.exc_info), } - + # Add context from contextvars context = log_context.get({}) if context: - log_obj['context'] = context.copy() - + log_obj["context"] = context.copy() + # Promote trace_id to standard key if available in context - if 'trace_id' in context: - log_obj['trace_id'] = context['trace_id'] - + if "trace_id" in context: + log_obj["trace_id"] = context["trace_id"] + # Add custom fields from extra parameter excluded_keys = { - 'name', 'msg', 'args', 'levelname', 'levelno', 'pathname', - 'filename', 'module', 'lineno', 'funcName', 'created', - 'msecs', 'relativeCreated', 'thread', 'threadName', - 'processName', 'process', 'getMessage', 'exc_info', - 'exc_text', 'stack_info' + "name", + "msg", + "args", + "levelname", + "levelno", + "pathname", + "filename", + "module", + "lineno", + "funcName", + "created", + "msecs", + "relativeCreated", + "thread", + "threadName", + "processName", + "process", + "getMessage", + "exc_info", + "exc_text", + "stack_info", } - + extra_data = {} for key, value in record.__dict__.items(): if key not in excluded_keys: @@ -82,56 +99,56 @@ class JSONFormatter(logging.Formatter): extra_data[key] = value except (TypeError, ValueError): extra_data[key] = str(value) - + if extra_data: - log_obj['extra'] = extra_data - - return json.dumps(log_obj, ensure_ascii=False) + '\n' + log_obj["extra"] = extra_data + + return json.dumps(log_obj, ensure_ascii=False) + "\n" class ContextualLogger: """ Logger wrapper that provides contextual information and structured logging. - + Automatically includes Discord context (user, guild, command) in all log messages. """ - + def __init__(self, logger_name: str): """ Initialize contextual logger. - + Args: logger_name: Name for the underlying logger """ self.logger = logging.getLogger(logger_name) self._start_time: Optional[float] = None - + def start_operation(self, operation_name: Optional[str] = None) -> str: """ Start timing an operation and generate a trace ID. - + Args: operation_name: Optional name for the operation being tracked - + Returns: Generated trace ID for this operation """ self._start_time = time.time() trace_id = str(uuid.uuid4())[:8] - + # Add trace_id to context current_context = log_context.get({}) - current_context['trace_id'] = trace_id + current_context["trace_id"] = trace_id if operation_name: - current_context['operation'] = operation_name + current_context["operation"] = operation_name log_context.set(current_context) - + return trace_id - + def end_operation(self, trace_id: str, operation_result: str = "completed") -> None: """ End an operation and log the final duration. - + Args: trace_id: The trace ID returned by start_operation operation_result: Result status (e.g., "completed", "failed", "cancelled") @@ -139,83 +156,99 @@ class ContextualLogger: if self._start_time is None: self.warning("end_operation called without corresponding start_operation") return - + duration_ms = int((time.time() - self._start_time) * 1000) - + # Get current context current_context = log_context.get({}) - + # Log operation completion - self.info(f"Operation {operation_result}", - trace_id=trace_id, - final_duration_ms=duration_ms, - operation_result=operation_result) - + self.info( + f"Operation {operation_result}", + trace_id=trace_id, + final_duration_ms=duration_ms, + operation_result=operation_result, + ) + # Clear operation-specific context - if 'operation' in current_context: - current_context.pop('operation', None) - if 'trace_id' in current_context and current_context['trace_id'] == trace_id: - current_context.pop('trace_id', None) + if "operation" in current_context: + current_context.pop("operation", None) + if "trace_id" in current_context and current_context["trace_id"] == trace_id: + current_context.pop("trace_id", None) log_context.set(current_context) - + # Reset start time self._start_time = None - + def _get_duration_ms(self) -> Optional[int]: """Get operation duration in milliseconds if start_operation was called.""" if self._start_time: return int((time.time() - self._start_time) * 1000) return None - + + @staticmethod + def _extract_logging_params(kwargs: dict) -> dict: + """Extract standard logging parameters from kwargs to prevent LogRecord conflicts. + + Python's LogRecord raises KeyError if reserved attributes like 'exc_info' or + 'stack_info' are passed via the 'extra' dict. This extracts them so they can + be passed as direct parameters to the underlying logger instead. + """ + return { + key: kwargs.pop(key) for key in ("exc_info", "stack_info") if key in kwargs + } + def debug(self, message: str, **kwargs): """Log debug message with context.""" duration = self._get_duration_ms() if duration is not None: - kwargs['duration_ms'] = duration - self.logger.debug(message, extra=kwargs) - + kwargs["duration_ms"] = duration + log_params = self._extract_logging_params(kwargs) + self.logger.debug(message, extra=kwargs, **log_params) + def info(self, message: str, **kwargs): """Log info message with context.""" duration = self._get_duration_ms() if duration is not None: - kwargs['duration_ms'] = duration - self.logger.info(message, extra=kwargs) - + kwargs["duration_ms"] = duration + log_params = self._extract_logging_params(kwargs) + self.logger.info(message, extra=kwargs, **log_params) + def warning(self, message: str, **kwargs): """Log warning message with context.""" duration = self._get_duration_ms() if duration is not None: - kwargs['duration_ms'] = duration - self.logger.warning(message, extra=kwargs) - + kwargs["duration_ms"] = duration + log_params = self._extract_logging_params(kwargs) + self.logger.warning(message, extra=kwargs, **log_params) + def error(self, message: str, error: Optional[Exception] = None, **kwargs): """ Log error message with context and exception information. - + Args: message: Error message error: Optional exception object - **kwargs: Additional context + **kwargs: Additional context (exc_info=True is supported) """ duration = self._get_duration_ms() if duration is not None: - kwargs['duration_ms'] = duration - + kwargs["duration_ms"] = duration + log_params = self._extract_logging_params(kwargs) + if error: - kwargs['error'] = { - 'type': type(error).__name__, - 'message': str(error) - } + kwargs["error"] = {"type": type(error).__name__, "message": str(error)} self.logger.error(message, exc_info=True, extra=kwargs) else: - self.logger.error(message, extra=kwargs) - + self.logger.error(message, extra=kwargs, **log_params) + def exception(self, message: str, **kwargs): """Log exception with full traceback and context.""" duration = self._get_duration_ms() if duration is not None: - kwargs['duration_ms'] = duration - self.logger.exception(message, extra=kwargs) + kwargs["duration_ms"] = duration + log_params = self._extract_logging_params(kwargs) + self.logger.exception(message, extra=kwargs, **log_params) def set_discord_context( @@ -224,45 +257,45 @@ def set_discord_context( guild_id: Optional[Union[str, int]] = None, channel_id: Optional[Union[str, int]] = None, command: Optional[str] = None, - **additional_context + **additional_context, ): """ Set Discord-specific context for logging. - + Args: interaction: Discord interaction object (will extract user/guild/channel) user_id: Discord user ID - guild_id: Discord guild ID + guild_id: Discord guild ID channel_id: Discord channel ID command: Command name (e.g., '/player') **additional_context: Any additional context to include """ context = log_context.get({}).copy() - + # Extract from interaction if provided if interaction: - context['user_id'] = str(interaction.user.id) + context["user_id"] = str(interaction.user.id) if interaction.guild: - context['guild_id'] = str(interaction.guild.id) - context['guild_name'] = interaction.guild.name + context["guild_id"] = str(interaction.guild.id) + context["guild_name"] = interaction.guild.name if interaction.channel: - context['channel_id'] = str(interaction.channel.id) - if hasattr(interaction, 'command') and interaction.command: - context['command'] = f"/{interaction.command.name}" - + context["channel_id"] = str(interaction.channel.id) + if hasattr(interaction, "command") and interaction.command: + context["command"] = f"/{interaction.command.name}" + # Override with explicit parameters if user_id: - context['user_id'] = str(user_id) + context["user_id"] = str(user_id) if guild_id: - context['guild_id'] = str(guild_id) + context["guild_id"] = str(guild_id) if channel_id: - context['channel_id'] = str(channel_id) + context["channel_id"] = str(channel_id) if command: - context['command'] = command - + context["command"] = command + # Add any additional context context.update(additional_context) - + log_context.set(context) @@ -274,11 +307,11 @@ def clear_context(): def get_contextual_logger(logger_name: str) -> ContextualLogger: """ Get a contextual logger instance. - + Args: logger_name: Name for the logger (typically __name__) - + Returns: ContextualLogger instance """ - return ContextualLogger(logger_name) \ No newline at end of file + return ContextualLogger(logger_name) diff --git a/views/players.py b/views/players.py index 1c0eafe..b5bed53 100644 --- a/views/players.py +++ b/views/players.py @@ -3,6 +3,7 @@ Player View Components Interactive Discord UI components for player information display with toggleable statistics. """ + from typing import Optional, TYPE_CHECKING import discord @@ -34,11 +35,11 @@ class PlayerStatsView(BaseView): def __init__( self, - player: 'Player', + player: "Player", season: int, - batting_stats: Optional['BattingStats'] = None, - pitching_stats: Optional['PitchingStats'] = None, - user_id: Optional[int] = None + batting_stats: Optional["BattingStats"] = None, + pitching_stats: Optional["PitchingStats"] = None, + user_id: Optional[int] = None, ): """ Initialize the player stats view. @@ -50,7 +51,9 @@ class PlayerStatsView(BaseView): pitching_stats: Pitching statistics (if available) user_id: Discord user ID who can interact with this view """ - super().__init__(timeout=300.0, user_id=user_id, logger_name=f'{__name__}.PlayerStatsView') + super().__init__( + timeout=300.0, user_id=user_id, logger_name=f"{__name__}.PlayerStatsView" + ) self.player = player self.season = season @@ -69,36 +72,37 @@ class PlayerStatsView(BaseView): self.remove_item(self.toggle_pitching_button) self.logger.debug("No pitching stats available, pitching button hidden") - self.logger.info("PlayerStatsView initialized", - player_id=player.id, - player_name=player.name, - season=season, - has_batting=bool(batting_stats), - has_pitching=bool(pitching_stats), - user_id=user_id) + self.logger.info( + "PlayerStatsView initialized", + player_id=player.id, + player_name=player.name, + season=season, + has_batting=bool(batting_stats), + has_pitching=bool(pitching_stats), + user_id=user_id, + ) @discord.ui.button( - label="Show Batting Stats", - style=discord.ButtonStyle.primary, - emoji="💥", - row=0 + label="Show Batting Stats", style=discord.ButtonStyle.primary, emoji="💥", row=0 ) async def toggle_batting_button( - self, - interaction: discord.Interaction, - button: discord.ui.Button + self, interaction: discord.Interaction, button: discord.ui.Button ): """Toggle batting statistics visibility.""" self.increment_interaction_count() self.show_batting = not self.show_batting # Update button label - button.label = "Hide Batting Stats" if self.show_batting else "Show Batting Stats" + button.label = ( + "Hide Batting Stats" if self.show_batting else "Show Batting Stats" + ) - self.logger.info("Batting stats toggled", - player_id=self.player.id, - show_batting=self.show_batting, - user_id=interaction.user.id) + self.logger.info( + "Batting stats toggled", + player_id=self.player.id, + show_batting=self.show_batting, + user_id=interaction.user.id, + ) # Rebuild and update embed await self._update_embed(interaction) @@ -107,24 +111,26 @@ class PlayerStatsView(BaseView): label="Show Pitching Stats", style=discord.ButtonStyle.primary, emoji="⚾", - row=0 + row=0, ) async def toggle_pitching_button( - self, - interaction: discord.Interaction, - button: discord.ui.Button + self, interaction: discord.Interaction, button: discord.ui.Button ): """Toggle pitching statistics visibility.""" self.increment_interaction_count() self.show_pitching = not self.show_pitching # Update button label - button.label = "Hide Pitching Stats" if self.show_pitching else "Show Pitching Stats" + button.label = ( + "Hide Pitching Stats" if self.show_pitching else "Show Pitching Stats" + ) - self.logger.info("Pitching stats toggled", - player_id=self.player.id, - show_pitching=self.show_pitching, - user_id=interaction.user.id) + self.logger.info( + "Pitching stats toggled", + player_id=self.player.id, + show_pitching=self.show_pitching, + user_id=interaction.user.id, + ) # Rebuild and update embed await self._update_embed(interaction) @@ -143,20 +149,24 @@ class PlayerStatsView(BaseView): # Update the message with new embed await interaction.response.edit_message(embed=embed, view=self) - self.logger.debug("Embed updated successfully", - show_batting=self.show_batting, - show_pitching=self.show_pitching) + self.logger.debug( + "Embed updated successfully", + show_batting=self.show_batting, + show_pitching=self.show_pitching, + ) except Exception as e: - self.logger.error("Failed to update embed", error=e, exc_info=True) + self.logger.error("Failed to update embed", error=e) # Try to send error message try: error_embed = EmbedTemplate.error( title="Update Failed", - description="Failed to update player statistics. Please try again." + description="Failed to update player statistics. Please try again.", + ) + await interaction.response.send_message( + embed=error_embed, ephemeral=True ) - await interaction.response.send_message(embed=error_embed, ephemeral=True) except Exception: self.logger.error("Failed to send error message", exc_info=True) @@ -181,23 +191,16 @@ class PlayerStatsView(BaseView): # Add injury indicator emoji if player is injured title = f"🤕 {player.name}" if player.il_return is not None else player.name - embed = EmbedTemplate.create_base_embed( - title=title, - color=embed_color - ) + embed = EmbedTemplate.create_base_embed(title=title, color=embed_color) # Basic info section (always visible) - embed.add_field( - name="Position", - value=player.primary_position, - inline=True - ) + embed.add_field(name="Position", value=player.primary_position, inline=True) - if hasattr(player, 'team') and player.team: + if hasattr(player, "team") and player.team: embed.add_field( name="Team", value=f"{player.team.abbrev} - {player.team.sname}", - inline=True + inline=True, ) # Add Major League affiliate if this is a Minor League team @@ -205,55 +208,33 @@ class PlayerStatsView(BaseView): major_affiliate = await player.team.major_league_affiliate() if major_affiliate: embed.add_field( - name="Major Affiliate", - value=major_affiliate, - inline=True + name="Major Affiliate", value=major_affiliate, inline=True ) - embed.add_field( - name="sWAR", - value=f"{player.wara:.2f}", - inline=True - ) + embed.add_field(name="sWAR", value=f"{player.wara:.2f}", inline=True) - embed.add_field( - name="Player ID", - value=str(player.id), - inline=True - ) + embed.add_field(name="Player ID", value=str(player.id), inline=True) # All positions if multiple if len(player.positions) > 1: embed.add_field( - name="Positions", - value=", ".join(player.positions), - inline=True + name="Positions", value=", ".join(player.positions), inline=True ) - embed.add_field( - name="Season", - value=str(season), - inline=True - ) + embed.add_field(name="Season", value=str(season), inline=True) # Always show injury rating embed.add_field( - name="Injury Rating", - value=player.injury_rating or "N/A", - inline=True + name="Injury Rating", value=player.injury_rating or "N/A", inline=True ) # Show injury return date only if player is currently injured if player.il_return: - embed.add_field( - name="Injury Return", - value=player.il_return, - inline=True - ) + embed.add_field(name="Injury Return", value=player.il_return, inline=True) # Add batting stats if visible and available if self.show_batting and self.batting_stats: - embed.add_field(name='', value='', inline=False) + embed.add_field(name="", value="", inline=False) self.logger.debug("Adding batting statistics to embed") batting_stats = self.batting_stats @@ -269,11 +250,7 @@ class PlayerStatsView(BaseView): "╰─────────────╯\n" "```" ) - embed.add_field( - name="Rate Stats", - value=rate_stats, - inline=True - ) + embed.add_field(name="Rate Stats", value=rate_stats, inline=True) count_stats = ( "```\n" @@ -288,15 +265,11 @@ class PlayerStatsView(BaseView): "╰───────────╯\n" "```" ) - embed.add_field( - name='Counting Stats', - value=count_stats, - inline=True - ) + embed.add_field(name="Counting Stats", value=count_stats, inline=True) # Add pitching stats if visible and available if self.show_pitching and self.pitching_stats: - embed.add_field(name='', value='', inline=False) + embed.add_field(name="", value="", inline=False) self.logger.debug("Adding pitching statistics to embed") pitching_stats = self.pitching_stats @@ -313,11 +286,7 @@ class PlayerStatsView(BaseView): "╰─────────────╯\n" "```" ) - embed.add_field( - name="Record Stats", - value=record_stats, - inline=True - ) + embed.add_field(name="Record Stats", value=record_stats, inline=True) strikeout_stats = ( "```\n" @@ -329,11 +298,7 @@ class PlayerStatsView(BaseView): "╰──────────╯\n" "```" ) - embed.add_field( - name='Counting Stats', - value=strikeout_stats, - inline=True - ) + embed.add_field(name="Counting Stats", value=strikeout_stats, inline=True) # Add a note if no stats are visible if not self.show_batting and not self.show_pitching: @@ -341,37 +306,46 @@ class PlayerStatsView(BaseView): embed.add_field( name="📊 Statistics", value="Click the buttons below to show statistics.", - inline=False + inline=False, ) else: embed.add_field( name="📊 Statistics", value="No statistics available for this season.", - inline=False + inline=False, ) # Set player card as main image if player.image: embed.set_image(url=player.image) - self.logger.debug("Player card image added to embed", image_url=player.image) + self.logger.debug( + "Player card image added to embed", image_url=player.image + ) # Set thumbnail with priority: fancycard → headshot → team logo thumbnail_url = None thumbnail_source = None - if hasattr(player, 'vanity_card') and player.vanity_card: + if hasattr(player, "vanity_card") and player.vanity_card: thumbnail_url = player.vanity_card thumbnail_source = "fancycard" - elif hasattr(player, 'headshot') and player.headshot: + elif hasattr(player, "headshot") and player.headshot: thumbnail_url = player.headshot thumbnail_source = "headshot" - elif hasattr(player, 'team') and player.team and hasattr(player.team, 'thumbnail') and player.team.thumbnail: + elif ( + hasattr(player, "team") + and player.team + and hasattr(player.team, "thumbnail") + and player.team.thumbnail + ): thumbnail_url = player.team.thumbnail thumbnail_source = "team logo" if thumbnail_url: embed.set_thumbnail(url=thumbnail_url) - self.logger.debug(f"Thumbnail set from {thumbnail_source}", thumbnail_url=thumbnail_url) + self.logger.debug( + f"Thumbnail set from {thumbnail_source}", thumbnail_url=thumbnail_url + ) # Footer with player ID footer_text = f"Player ID: {player.id}" From 62b058bddfee4217fba960c855a9599ab4a55824 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Thu, 19 Feb 2026 21:19:31 -0600 Subject: [PATCH 2/2] test: fix weather test expecting 4 embed fields instead of 5 The Stadium Image field was added to the weather embed but the test_full_weather_workflow assertion wasn't updated to match. Co-Authored-By: Claude Opus 4.6 --- tests/test_commands_weather.py | 291 +++++++++++++++++++++++---------- 1 file changed, 201 insertions(+), 90 deletions(-) diff --git a/tests/test_commands_weather.py b/tests/test_commands_weather.py index 6b2d9a1..608705d 100644 --- a/tests/test_commands_weather.py +++ b/tests/test_commands_weather.py @@ -3,6 +3,7 @@ Tests for Weather Command (Discord interactions) Validates weather command functionality, team resolution, and embed creation. """ + import pytest from unittest.mock import AsyncMock, MagicMock, patch import discord @@ -53,54 +54,93 @@ class TestWeatherCommands: """Create mock team data.""" return TeamFactory.create( id=499, - abbrev='NYY', - sname='Yankees', - lname='New York Yankees', + abbrev="NYY", + sname="Yankees", + lname="New York Yankees", season=13, - color='a6ce39', - stadium='https://example.com/yankee-stadium.jpg', - thumbnail='https://example.com/yankee-thumbnail.png' + color="a6ce39", + stadium="https://example.com/yankee-stadium.jpg", + thumbnail="https://example.com/yankee-thumbnail.png", ) @pytest.fixture def mock_current(self): """Create mock current league state.""" return CurrentFactory.create( - week=10, - season=13, - freeze=False, - trade_deadline=14, - playoffs_begin=19 + week=10, season=13, freeze=False, trade_deadline=14, playoffs_begin=19 ) @pytest.fixture def mock_games(self): """Create mock game schedule.""" # Create teams for the games - yankees = TeamFactory.create(id=499, abbrev='NYY', sname='Yankees', lname='New York Yankees', season=13) - red_sox = TeamFactory.create(id=500, abbrev='BOS', sname='Red Sox', lname='Boston Red Sox', season=13) + yankees = TeamFactory.create( + id=499, abbrev="NYY", sname="Yankees", lname="New York Yankees", season=13 + ) + red_sox = TeamFactory.create( + id=500, abbrev="BOS", sname="Red Sox", lname="Boston Red Sox", season=13 + ) # 2 completed games, 2 upcoming games games = [ - GameFactory.completed(id=1, season=13, week=10, game_num=1, away_team=yankees, home_team=red_sox, away_score=5, home_score=3), - GameFactory.completed(id=2, season=13, week=10, game_num=2, away_team=yankees, home_team=red_sox, away_score=2, home_score=7), - GameFactory.upcoming(id=3, season=13, week=10, game_num=3, away_team=yankees, home_team=red_sox), - GameFactory.upcoming(id=4, season=13, week=10, game_num=4, away_team=yankees, home_team=red_sox), + GameFactory.completed( + id=1, + season=13, + week=10, + game_num=1, + away_team=yankees, + home_team=red_sox, + away_score=5, + home_score=3, + ), + GameFactory.completed( + id=2, + season=13, + week=10, + game_num=2, + away_team=yankees, + home_team=red_sox, + away_score=2, + home_score=7, + ), + GameFactory.upcoming( + id=3, + season=13, + week=10, + game_num=3, + away_team=yankees, + home_team=red_sox, + ), + GameFactory.upcoming( + id=4, + season=13, + week=10, + game_num=4, + away_team=yankees, + home_team=red_sox, + ), ] return games @pytest.mark.asyncio - async def test_weather_explicit_team(self, commands_cog, mock_interaction, mock_team, mock_current, mock_games): + async def test_weather_explicit_team( + self, commands_cog, mock_interaction, mock_team, mock_current, mock_games + ): """Test weather command with explicit team abbreviation.""" - with patch('utils.permissions.get_user_team') as mock_get_user_team, \ - patch('commands.utilities.weather.league_service') as mock_league_service, \ - patch('commands.utilities.weather.schedule_service') as mock_schedule_service, \ - patch('commands.utilities.weather.team_service') as mock_team_service: + with patch("utils.permissions.get_user_team") as mock_get_user_team, patch( + "commands.utilities.weather.league_service" + ) as mock_league_service, patch( + "commands.utilities.weather.schedule_service" + ) as mock_schedule_service, patch( + "commands.utilities.weather.team_service" + ) as mock_team_service: # Mock @requires_team decorator lookup mock_get_user_team.return_value = { - 'id': mock_team.id, 'name': mock_team.lname, - 'abbrev': mock_team.abbrev, 'season': mock_team.season + "id": mock_team.id, + "name": mock_team.lname, + "abbrev": mock_team.abbrev, + "season": mock_team.season, } # Mock service responses @@ -109,37 +149,47 @@ class TestWeatherCommands: mock_team_service.get_team_by_abbrev = AsyncMock(return_value=mock_team) # Execute command - await commands_cog.weather.callback(commands_cog, mock_interaction, team_abbrev='NYY') + await commands_cog.weather.callback( + commands_cog, mock_interaction, team_abbrev="NYY" + ) # Verify interaction flow mock_interaction.response.defer.assert_called_once() mock_interaction.followup.send.assert_called_once() # Verify team lookup - mock_team_service.get_team_by_abbrev.assert_called_once_with('NYY', 13) + mock_team_service.get_team_by_abbrev.assert_called_once_with("NYY", 13) # Check embed was sent embed_call = mock_interaction.followup.send.call_args - assert 'embed' in embed_call.kwargs - embed = embed_call.kwargs['embed'] + assert "embed" in embed_call.kwargs + embed = embed_call.kwargs["embed"] assert embed.title == "🌤️ Weather Check" @pytest.mark.asyncio - async def test_weather_channel_name_resolution(self, commands_cog, mock_interaction, mock_team, mock_current, mock_games): + async def test_weather_channel_name_resolution( + self, commands_cog, mock_interaction, mock_team, mock_current, mock_games + ): """Test weather command resolving team from channel name.""" # Set channel name to format: - mock_interaction.channel.name = "NYY-Yankee-Stadium" - with patch('utils.permissions.get_user_team') as mock_get_user_team, \ - patch('commands.utilities.weather.league_service') as mock_league_service, \ - patch('commands.utilities.weather.schedule_service') as mock_schedule_service, \ - patch('commands.utilities.weather.team_service') as mock_team_service, \ - patch('commands.utilities.weather.get_user_major_league_team') as mock_get_team: + with patch("utils.permissions.get_user_team") as mock_get_user_team, patch( + "commands.utilities.weather.league_service" + ) as mock_league_service, patch( + "commands.utilities.weather.schedule_service" + ) as mock_schedule_service, patch( + "commands.utilities.weather.team_service" + ) as mock_team_service, patch( + "commands.utilities.weather.get_user_major_league_team" + ) as mock_get_team: # Mock @requires_team decorator lookup mock_get_user_team.return_value = { - 'id': mock_team.id, 'name': mock_team.lname, - 'abbrev': mock_team.abbrev, 'season': mock_team.season + "id": mock_team.id, + "name": mock_team.lname, + "abbrev": mock_team.abbrev, + "season": mock_team.season, } mock_league_service.get_current_state = AsyncMock(return_value=mock_current) @@ -148,28 +198,38 @@ class TestWeatherCommands: mock_get_team.return_value = None # Execute without explicit team parameter - await commands_cog.weather.callback(commands_cog, mock_interaction, team_abbrev=None) + await commands_cog.weather.callback( + commands_cog, mock_interaction, team_abbrev=None + ) # Should resolve team from channel name "NYY-Yankee-Stadium" -> "NYY" - mock_team_service.get_team_by_abbrev.assert_called_once_with('NYY', 13) + mock_team_service.get_team_by_abbrev.assert_called_once_with("NYY", 13) mock_interaction.followup.send.assert_called_once() @pytest.mark.asyncio - async def test_weather_user_owned_team_fallback(self, commands_cog, mock_interaction, mock_team, mock_current, mock_games): + async def test_weather_user_owned_team_fallback( + self, commands_cog, mock_interaction, mock_team, mock_current, mock_games + ): """Test weather command falling back to user's owned team.""" # Set channel name that won't match a team mock_interaction.channel.name = "general-chat" - with patch('utils.permissions.get_user_team') as mock_get_user_team, \ - patch('commands.utilities.weather.league_service') as mock_league_service, \ - patch('commands.utilities.weather.schedule_service') as mock_schedule_service, \ - patch('commands.utilities.weather.team_service') as mock_team_service, \ - patch('commands.utilities.weather.get_user_major_league_team') as mock_get_team: + with patch("utils.permissions.get_user_team") as mock_get_user_team, patch( + "commands.utilities.weather.league_service" + ) as mock_league_service, patch( + "commands.utilities.weather.schedule_service" + ) as mock_schedule_service, patch( + "commands.utilities.weather.team_service" + ) as mock_team_service, patch( + "commands.utilities.weather.get_user_major_league_team" + ) as mock_get_team: # Mock @requires_team decorator lookup mock_get_user_team.return_value = { - 'id': mock_team.id, 'name': mock_team.lname, - 'abbrev': mock_team.abbrev, 'season': mock_team.season + "id": mock_team.id, + "name": mock_team.lname, + "abbrev": mock_team.abbrev, + "season": mock_team.season, } mock_league_service.get_current_state = AsyncMock(return_value=mock_current) @@ -177,48 +237,64 @@ class TestWeatherCommands: mock_team_service.get_team_by_abbrev = AsyncMock(return_value=None) mock_get_team.return_value = mock_team - await commands_cog.weather.callback(commands_cog, mock_interaction, team_abbrev=None) + await commands_cog.weather.callback( + commands_cog, mock_interaction, team_abbrev=None + ) # Should fall back to user ownership mock_get_team.assert_called_once_with(258104532423147520, 13) mock_interaction.followup.send.assert_called_once() @pytest.mark.asyncio - async def test_weather_no_team_found(self, commands_cog, mock_interaction, mock_current, mock_team): + async def test_weather_no_team_found( + self, commands_cog, mock_interaction, mock_current, mock_team + ): """Test weather command when no team can be resolved.""" - with patch('utils.permissions.get_user_team') as mock_get_user_team, \ - patch('commands.utilities.weather.league_service') as mock_league_service, \ - patch('commands.utilities.weather.team_service') as mock_team_service, \ - patch('commands.utilities.weather.get_user_major_league_team') as mock_get_team: + with patch("utils.permissions.get_user_team") as mock_get_user_team, patch( + "commands.utilities.weather.league_service" + ) as mock_league_service, patch( + "commands.utilities.weather.team_service" + ) as mock_team_service, patch( + "commands.utilities.weather.get_user_major_league_team" + ) as mock_get_team: # Mock @requires_team decorator lookup - user has a team so decorator passes mock_get_user_team.return_value = { - 'id': mock_team.id, 'name': mock_team.lname, - 'abbrev': mock_team.abbrev, 'season': mock_team.season + "id": mock_team.id, + "name": mock_team.lname, + "abbrev": mock_team.abbrev, + "season": mock_team.season, } mock_league_service.get_current_state = AsyncMock(return_value=mock_current) mock_team_service.get_team_by_abbrev = AsyncMock(return_value=None) mock_get_team.return_value = None - await commands_cog.weather.callback(commands_cog, mock_interaction, team_abbrev=None) + await commands_cog.weather.callback( + commands_cog, mock_interaction, team_abbrev=None + ) # Should send error message embed_call = mock_interaction.followup.send.call_args - embed = embed_call.kwargs['embed'] + embed = embed_call.kwargs["embed"] assert "Team Not Found" in embed.title assert "Could not find a team" in embed.description @pytest.mark.asyncio - async def test_weather_league_state_unavailable(self, commands_cog, mock_interaction, mock_team): + async def test_weather_league_state_unavailable( + self, commands_cog, mock_interaction, mock_team + ): """Test weather command when league state is unavailable.""" - with patch('utils.permissions.get_user_team') as mock_get_user_team, \ - patch('commands.utilities.weather.league_service') as mock_league_service: + with patch("utils.permissions.get_user_team") as mock_get_user_team, patch( + "commands.utilities.weather.league_service" + ) as mock_league_service: # Mock @requires_team decorator lookup mock_get_user_team.return_value = { - 'id': mock_team.id, 'name': mock_team.lname, - 'abbrev': mock_team.abbrev, 'season': mock_team.season + "id": mock_team.id, + "name": mock_team.lname, + "abbrev": mock_team.abbrev, + "season": mock_team.season, } mock_league_service.get_current_state = AsyncMock(return_value=None) @@ -227,7 +303,7 @@ class TestWeatherCommands: # Should send error about league state embed_call = mock_interaction.followup.send.call_args - embed = embed_call.kwargs['embed'] + embed = embed_call.kwargs["embed"] assert "League State Unavailable" in embed.title @pytest.mark.asyncio @@ -329,7 +405,7 @@ class TestWeatherCommands: weather_roll=14, games_played=2, total_games=4, - username="TestUser" + username="TestUser", ) # Check embed basics @@ -363,74 +439,94 @@ class TestWeatherCommands: assert embed.image.url == mock_team.stadium @pytest.mark.asyncio - async def test_full_weather_workflow(self, commands_cog, mock_interaction, mock_team, mock_current, mock_games): + async def test_full_weather_workflow( + self, commands_cog, mock_interaction, mock_team, mock_current, mock_games + ): """Test complete weather workflow with realistic data.""" - with patch('utils.permissions.get_user_team') as mock_get_user_team, \ - patch('commands.utilities.weather.league_service') as mock_league_service, \ - patch('commands.utilities.weather.schedule_service') as mock_schedule_service, \ - patch('commands.utilities.weather.team_service') as mock_team_service: + with patch("utils.permissions.get_user_team") as mock_get_user_team, patch( + "commands.utilities.weather.league_service" + ) as mock_league_service, patch( + "commands.utilities.weather.schedule_service" + ) as mock_schedule_service, patch( + "commands.utilities.weather.team_service" + ) as mock_team_service: # Mock @requires_team decorator lookup mock_get_user_team.return_value = { - 'id': mock_team.id, 'name': mock_team.lname, - 'abbrev': mock_team.abbrev, 'season': mock_team.season + "id": mock_team.id, + "name": mock_team.lname, + "abbrev": mock_team.abbrev, + "season": mock_team.season, } mock_league_service.get_current_state = AsyncMock(return_value=mock_current) mock_schedule_service.get_week_schedule = AsyncMock(return_value=mock_games) mock_team_service.get_team_by_abbrev = AsyncMock(return_value=mock_team) - await commands_cog.weather.callback(commands_cog, mock_interaction, team_abbrev='NYY') + await commands_cog.weather.callback( + commands_cog, mock_interaction, team_abbrev="NYY" + ) # Verify complete flow mock_interaction.response.defer.assert_called_once() mock_league_service.get_current_state.assert_called_once() mock_schedule_service.get_week_schedule.assert_called_once_with(13, 10) - mock_team_service.get_team_by_abbrev.assert_called_once_with('NYY', 13) + mock_team_service.get_team_by_abbrev.assert_called_once_with("NYY", 13) # Check final embed embed_call = mock_interaction.followup.send.call_args - embed = embed_call.kwargs['embed'] + embed = embed_call.kwargs["embed"] # Validate embed structure assert "Weather Check" in embed.title - assert len(embed.fields) == 4 # Season, Time, Week, Roll + assert len(embed.fields) == 5 # Season, Time, Week, Roll, Stadium Image assert embed.image.url == mock_team.stadium assert embed.color.value == int(mock_team.color, 16) @pytest.mark.asyncio - async def test_team_resolution_priority(self, commands_cog, mock_interaction, mock_current): + async def test_team_resolution_priority( + self, commands_cog, mock_interaction, mock_current + ): """Test that team resolution follows correct priority order.""" - team1 = TeamFactory.create(id=1, abbrev='NYY', sname='Yankees', lname='New York Yankees', season=12) - team2 = TeamFactory.create(id=2, abbrev='BOS', sname='Red Sox', lname='Boston Red Sox', season=12) - team3 = TeamFactory.create(id=3, abbrev='LAD', sname='Dodgers', lname='Los Angeles Dodgers', season=12) + team1 = TeamFactory.create( + id=1, abbrev="NYY", sname="Yankees", lname="New York Yankees", season=12 + ) + team2 = TeamFactory.create( + id=2, abbrev="BOS", sname="Red Sox", lname="Boston Red Sox", season=12 + ) + team3 = TeamFactory.create( + id=3, abbrev="LAD", sname="Dodgers", lname="Los Angeles Dodgers", season=12 + ) # Test Priority 1: Explicit parameter (should return team1) - with patch('commands.utilities.weather.team_service') as mock_team_service: + with patch("commands.utilities.weather.team_service") as mock_team_service: mock_team_service.get_team_by_abbrev = AsyncMock(return_value=team1) - result = await commands_cog._resolve_team(mock_interaction, 'NYY', 12) - assert result.abbrev == 'NYY' + result = await commands_cog._resolve_team(mock_interaction, "NYY", 12) + assert result.abbrev == "NYY" assert result.id == 1 # Test Priority 2: Channel name (should return team2) mock_interaction.channel.name = "BOS-Fenway-Park" - with patch('commands.utilities.weather.team_service') as mock_team_service: + with patch("commands.utilities.weather.team_service") as mock_team_service: mock_team_service.get_team_by_abbrev = AsyncMock(return_value=team2) result = await commands_cog._resolve_team(mock_interaction, None, 12) - assert result.abbrev == 'BOS' + assert result.abbrev == "BOS" assert result.id == 2 # Test Priority 3: User ownership (should return team3) mock_interaction.channel.name = "general" - with patch('commands.utilities.weather.team_service') as mock_team_service, \ - patch('commands.utilities.weather.get_user_major_league_team') as mock_get_team: + with patch( + "commands.utilities.weather.team_service" + ) as mock_team_service, patch( + "commands.utilities.weather.get_user_major_league_team" + ) as mock_get_team: mock_team_service.get_team_by_abbrev = AsyncMock(return_value=None) mock_get_team.return_value = team3 result = await commands_cog._resolve_team(mock_interaction, None, 12) - assert result.abbrev == 'LAD' + assert result.abbrev == "LAD" assert result.id == 3 @@ -452,12 +548,25 @@ class TestWeatherCommandsIntegration: @pytest.fixture def mock_games(self): """Create mock game schedule for integration tests.""" - yankees = TeamFactory.create(id=499, abbrev='NYY', sname='Yankees', lname='New York Yankees', season=12) - red_sox = TeamFactory.create(id=500, abbrev='BOS', sname='Red Sox', lname='Boston Red Sox', season=12) + yankees = TeamFactory.create( + id=499, abbrev="NYY", sname="Yankees", lname="New York Yankees", season=12 + ) + red_sox = TeamFactory.create( + id=500, abbrev="BOS", sname="Red Sox", lname="Boston Red Sox", season=12 + ) # 1 completed game for division week testing games = [ - GameFactory.completed(id=1, season=12, week=10, game_num=1, away_team=yankees, home_team=red_sox, away_score=5, home_score=3) + GameFactory.completed( + id=1, + season=12, + week=10, + game_num=1, + away_team=yankees, + home_team=red_sox, + away_score=5, + home_score=3, + ) ] return games @@ -470,7 +579,9 @@ class TestWeatherCommandsIntegration: # 1 game played in division week should be Night one_game = [mock_games[0]] time_of_day = commands_cog._get_time_of_day(one_game, week) - assert "Night" in time_of_day, f"Week {week} should be Night with 1 game in division week" + assert ( + "Night" in time_of_day + ), f"Week {week} should be Night with 1 game in division week" @pytest.mark.asyncio async def test_season_transitions(self, commands_cog):