diff --git a/services/roster_service.py b/services/roster_service.py index 6b88454..de03731 100644 --- a/services/roster_service.py +++ b/services/roster_service.py @@ -3,6 +3,7 @@ Roster service for Discord Bot v2.0 Handles roster operations and validation. """ + import logging from typing import Optional, List, Dict @@ -12,79 +13,80 @@ from models.player import Player from models.transaction import RosterValidation from exceptions import APIException -logger = logging.getLogger(f'{__name__}.RosterService') +logger = logging.getLogger(f"{__name__}.RosterService") class RosterService: """Service for roster operations and validation.""" - + def __init__(self): """Initialize roster service.""" from api.client import get_global_client + self._get_client = get_global_client logger.debug("RosterService initialized") - + async def get_client(self): """Get the API client.""" return await self._get_client() - + async def get_team_roster( - self, - team_id: int, - week_type: str = "current" + self, team_id: int, week_type: str = "current" ) -> Optional[TeamRoster]: """ Get team roster for current or next week. - + Args: team_id: Team ID from database week_type: "current" or "next" - + Returns: TeamRoster object or None if not found """ try: client = await self.get_client() - + # Use the team roster endpoint - roster_data = await client.get(f'teams/{team_id}/roster/{week_type}') - + roster_data = await client.get(f"teams/{team_id}/roster/{week_type}") + if not roster_data: - logger.warning(f"No roster data found for team {team_id}, week {week_type}") + logger.warning( + f"No roster data found for team {team_id}, week {week_type}" + ) return None - + # Add team metadata if not present - if 'team_id' not in roster_data: - roster_data['team_id'] = team_id - + if "team_id" not in roster_data: + roster_data["team_id"] = team_id + # Determine week number (this might need adjustment based on API) - roster_data.setdefault('week', 0) # Will need current week info - roster_data.setdefault('season', 12) # Will need current season info - + roster_data.setdefault("week", 0) # Will need current week info + roster_data.setdefault("season", 12) # Will need current season info + roster = TeamRoster.from_api_data(roster_data) - + logger.debug(f"Retrieved roster for team {team_id}, {week_type} week") return roster - + except Exception as e: logger.error(f"Error getting roster for team {team_id}: {e}") raise APIException(f"Failed to retrieve roster: {e}") - + async def get_current_roster(self, team_id: int) -> Optional[TeamRoster]: """Get current week roster.""" return await self.get_team_roster(team_id, "current") - + async def get_next_roster(self, team_id: int) -> Optional[TeamRoster]: - """Get next week roster.""" + """Get next week roster.""" return await self.get_team_roster(team_id, "next") - + async def validate_roster(self, roster: TeamRoster) -> RosterValidation: """ Validate roster for legality according to league rules. - + Args: roster: TeamRoster to validate - + Returns: RosterValidation with results """ @@ -95,49 +97,66 @@ class RosterService: active_players=roster.active_count, il_players=roster.il_count, minor_league_players=roster.minor_league_count, - total_wara=roster.total_wara + total_sWAR=roster.total_wara, ) - + # Validate active roster size (typical limits) if roster.active_count > 25: # Adjust based on league rules validation.is_legal = False - validation.errors.append(f"Too many active players: {roster.active_count}/25") + validation.errors.append( + f"Too many active players: {roster.active_count}/25" + ) elif roster.active_count < 20: # Minimum active roster - validation.warnings.append(f"Low active player count: {roster.active_count}") - + validation.warnings.append( + f"Low active player count: {roster.active_count}" + ) + # Validate total roster size if roster.total_players > 50: # Adjust based on league rules validation.is_legal = False - validation.errors.append(f"Total roster too large: {roster.total_players}/50") - + validation.errors.append( + f"Total roster too large: {roster.total_players}/50" + ) + # Position requirements validation position_counts = self._count_positions(roster.active_players) - + # Check catcher requirement (at least 2 catchers) - if position_counts.get('C', 0) < 2: + if position_counts.get("C", 0) < 2: validation.warnings.append("Fewer than 2 catchers on active roster") - + # Check pitcher requirements (at least 10 pitchers) - pitcher_count = position_counts.get('SP', 0) + position_counts.get('RP', 0) + position_counts.get('P', 0) + pitcher_count = ( + position_counts.get("SP", 0) + + position_counts.get("RP", 0) + + position_counts.get("P", 0) + ) if pitcher_count < 10: - validation.warnings.append(f"Fewer than 10 pitchers on active roster: {pitcher_count}") - + validation.warnings.append( + f"Fewer than 10 pitchers on active roster: {pitcher_count}" + ) + # WARA validation (if there are limits) - if validation.total_wara > 100: # Adjust based on league rules - validation.warnings.append(f"High WARA total: {validation.total_wara:.2f}") - elif validation.total_wara < 20: - validation.warnings.append(f"Low WARA total: {validation.total_wara:.2f}") - - logger.debug(f"Validated roster: legal={validation.is_legal}, {len(validation.errors)} errors, {len(validation.warnings)} warnings") + if validation.total_sWAR > 100: # Adjust based on league rules + validation.warnings.append( + f"High WARA total: {validation.total_sWAR:.2f}" + ) + elif validation.total_sWAR < 20: + validation.warnings.append( + f"Low WARA total: {validation.total_sWAR:.2f}" + ) + + logger.debug( + f"Validated roster: legal={validation.is_legal}, {len(validation.errors)} errors, {len(validation.warnings)} warnings" + ) return validation - + except Exception as e: logger.error(f"Error validating roster: {e}") return RosterValidation( - is_legal=False, - errors=[f"Validation error: {str(e)}"] + is_legal=False, errors=[f"Validation error: {str(e)}"] ) - + def _count_positions(self, players: List[Player]) -> Dict[str, int]: """Count players by position.""" position_counts = {} @@ -145,48 +164,52 @@ class RosterService: pos = player.primary_position position_counts[pos] = position_counts.get(pos, 0) + 1 return position_counts - + async def get_roster_summary(self, roster: TeamRoster) -> Dict[str, any]: """ Get a summary of roster composition. - + Args: roster: TeamRoster to summarize - + Returns: Dictionary with roster summary information """ try: position_counts = self._count_positions(roster.active_players) - + # Group positions - catchers = position_counts.get('C', 0) - infielders = sum(position_counts.get(pos, 0) for pos in ['1B', '2B', '3B', 'SS', 'IF']) - outfielders = sum(position_counts.get(pos, 0) for pos in ['LF', 'CF', 'RF', 'OF']) - pitchers = sum(position_counts.get(pos, 0) for pos in ['SP', 'RP', 'P']) - dh = position_counts.get('DH', 0) - + catchers = position_counts.get("C", 0) + infielders = sum( + position_counts.get(pos, 0) for pos in ["1B", "2B", "3B", "SS", "IF"] + ) + outfielders = sum( + position_counts.get(pos, 0) for pos in ["LF", "CF", "RF", "OF"] + ) + pitchers = sum(position_counts.get(pos, 0) for pos in ["SP", "RP", "P"]) + dh = position_counts.get("DH", 0) + summary = { - 'total_active': roster.active_count, - 'total_il': roster.il_count, - 'total_minor': roster.minor_league_count, - 'total_wara': roster.total_wara, - 'positions': { - 'catchers': catchers, - 'infielders': infielders, - 'outfielders': outfielders, - 'pitchers': pitchers, - 'dh': dh + "total_active": roster.active_count, + "total_il": roster.il_count, + "total_minor": roster.minor_league_count, + "total_wara": roster.total_wara, + "positions": { + "catchers": catchers, + "infielders": infielders, + "outfielders": outfielders, + "pitchers": pitchers, + "dh": dh, }, - 'detailed_positions': position_counts + "detailed_positions": position_counts, } - + return summary - + except Exception as e: logger.error(f"Error creating roster summary: {e}") return {} # Global service instance -roster_service = RosterService() \ No newline at end of file +roster_service = RosterService() diff --git a/tests/test_dropadd_integration.py b/tests/test_dropadd_integration.py index c4cfd0e..f81626b 100644 --- a/tests/test_dropadd_integration.py +++ b/tests/test_dropadd_integration.py @@ -3,6 +3,7 @@ Integration tests for /dropadd functionality Tests complete workflows from command invocation through transaction submission. """ + import pytest from unittest.mock import AsyncMock, MagicMock, patch from datetime import datetime @@ -12,13 +13,10 @@ from services.transaction_builder import ( TransactionBuilder, TransactionMove, get_transaction_builder, - clear_transaction_builder + clear_transaction_builder, ) from models.team import RosterType -from views.transaction_embed import ( - TransactionEmbedView, - SubmitConfirmationModal -) +from views.transaction_embed import TransactionEmbedView, SubmitConfirmationModal from models.team import Team from models.player import Player from models.roster import TeamRoster @@ -29,17 +27,17 @@ from tests.factories import PlayerFactory, TeamFactory class TestDropAddIntegration: """Integration tests for complete /dropadd workflows.""" - + @pytest.fixture def mock_bot(self): """Create mock Discord bot.""" return MagicMock() - + @pytest.fixture def commands_cog(self, mock_bot): """Create DropAddCommands cog instance.""" return DropAddCommands(mock_bot) - + @pytest.fixture def mock_interaction(self): """Create mock Discord interaction.""" @@ -63,24 +61,26 @@ class TestDropAddIntegration: mock_message.embeds[0].title = "📋 Transaction Builder" mock_message.edit = AsyncMock() - interaction.channel.history.return_value.__aiter__ = AsyncMock(return_value=iter([mock_message])) + interaction.channel.history.return_value.__aiter__ = AsyncMock( + return_value=iter([mock_message]) + ) return interaction - + @pytest.fixture def mock_team(self): """Create mock team.""" return TeamFactory.west_virginia() - + @pytest.fixture def mock_players(self): """Create mock players.""" return [ PlayerFactory.mike_trout(), PlayerFactory.ronald_acuna(), - PlayerFactory.mookie_betts() + PlayerFactory.mookie_betts(), ] - + @pytest.fixture def mock_roster(self): """Create mock team roster. @@ -91,75 +91,77 @@ class TestDropAddIntegration: # Create 24 ML players (under limit) ml_players = [] for i in range(24): - ml_players.append(Player( - id=1000 + i, - name=f'ML Player {i}', - wara=3.0 + i * 0.1, - season=13, - team_id=499, - team=None, - image=None, - image2=None, - vanity_card=None, - headshot=None, - pos_1='OF', - pitcher_injury=None, - injury_rating=None, - il_return=None, - demotion_week=None, - last_game=None, - last_game2=None, - strat_code=None, - bbref_id=None, - sbaplayer=None - )) + ml_players.append( + Player( + id=1000 + i, + name=f"ML Player {i}", + wara=1.0, + season=13, + team_id=499, + team=None, + image=None, + image2=None, + vanity_card=None, + headshot=None, + pos_1="OF", + pitcher_injury=None, + injury_rating=None, + il_return=None, + demotion_week=None, + last_game=None, + last_game2=None, + strat_code=None, + bbref_id=None, + sbaplayer=None, + ) + ) # Create 4 MiL players (under 6 limit to allow adding) mil_players = [] for i in range(4): - mil_players.append(Player( - id=2000 + i, - name=f'MiL Player {i}', - wara=1.0 + i * 0.1, - season=13, - team_id=499, - team=None, - image=None, - image2=None, - vanity_card=None, - headshot=None, - pos_1='OF', - pitcher_injury=None, - injury_rating=None, - il_return=None, - demotion_week=None, - last_game=None, - last_game2=None, - strat_code=None, - bbref_id=None, - sbaplayer=None - )) + mil_players.append( + Player( + id=2000 + i, + name=f"MiL Player {i}", + wara=1.0 + i * 0.1, + season=13, + team_id=499, + team=None, + image=None, + image2=None, + vanity_card=None, + headshot=None, + pos_1="OF", + pitcher_injury=None, + injury_rating=None, + il_return=None, + demotion_week=None, + last_game=None, + last_game2=None, + strat_code=None, + bbref_id=None, + sbaplayer=None, + ) + ) return TeamRoster( team_id=499, - team_abbrev='TST', + team_abbrev="TST", week=10, season=13, active_players=ml_players, - minor_league_players=mil_players + minor_league_players=mil_players, ) - + @pytest.fixture def mock_current_state(self): """Create mock current league state.""" - return Current( - week=10, - season=13, - freeze=False - ) - + return Current(week=10, season=13, freeze=False) + @pytest.mark.asyncio - async def test_complete_single_move_workflow(self, commands_cog, mock_interaction, mock_team, mock_players, mock_roster): + async def test_complete_single_move_workflow( + self, commands_cog, mock_interaction, mock_team, mock_players, mock_roster + ): """Test complete workflow for single move transaction. Verifies that when a player and destination are provided to /dropadd, @@ -173,23 +175,42 @@ class TestDropAddIntegration: # Clear any existing builders clear_transaction_builder(mock_interaction.user.id) - with patch('commands.transactions.dropadd.validate_user_has_team') as mock_validate: - with patch('commands.transactions.dropadd.player_service') as mock_player_service: - with patch('services.transaction_builder.roster_service') as mock_roster_service: - with patch('services.transaction_builder.transaction_service') as mock_tx_service: + with patch( + "commands.transactions.dropadd.validate_user_has_team" + ) as mock_validate: + with patch( + "commands.transactions.dropadd.player_service" + ) as mock_player_service: + with patch( + "services.transaction_builder.roster_service" + ) as mock_roster_service: + with patch( + "services.transaction_builder.transaction_service" + ) as mock_tx_service: # Setup mocks - mock_validate.return_value = mock_team # validate_user_has_team returns team - mock_player_service.search_players = AsyncMock(return_value=[mock_players[0]]) # Mike Trout - mock_roster_service.get_current_roster = AsyncMock(return_value=mock_roster) - mock_tx_service.get_team_transactions = AsyncMock(return_value=[]) + mock_validate.return_value = ( + mock_team # validate_user_has_team returns team + ) + mock_player_service.search_players = AsyncMock( + return_value=[mock_players[0]] + ) # Mike Trout + mock_roster_service.get_current_roster = AsyncMock( + return_value=mock_roster + ) + mock_tx_service.get_team_transactions = AsyncMock( + return_value=[] + ) # Mock the new pending transaction check - mock_tx_service.is_player_in_pending_transaction = AsyncMock(return_value=(False, None)) + mock_tx_service.is_player_in_pending_transaction = AsyncMock( + return_value=(False, None) + ) # Execute /dropadd command with quick move - await commands_cog.dropadd.callback(commands_cog, + await commands_cog.dropadd.callback( + commands_cog, mock_interaction, - player='Mike Trout', - destination='ml' + player="Mike Trout", + destination="ml", ) # Verify command execution @@ -197,12 +218,14 @@ class TestDropAddIntegration: mock_interaction.followup.send.assert_called_once() # Get the builder that was created - builder = get_transaction_builder(mock_interaction.user.id, mock_team) + builder = get_transaction_builder( + mock_interaction.user.id, mock_team + ) # Verify the move was added assert builder.move_count == 1 move = builder.moves[0] - assert move.player.name == 'Mike Trout' + assert move.player.name == "Mike Trout" # Note: TransactionMove no longer has 'action' field assert move.to_roster == RosterType.MAJOR_LEAGUE @@ -210,9 +233,11 @@ class TestDropAddIntegration: validation = await builder.validate_transaction() assert validation.is_legal is True assert validation.major_league_count == 25 # 24 + 1 - + @pytest.mark.asyncio - async def test_complete_multi_move_workflow(self, commands_cog, mock_interaction, mock_team, mock_players, mock_roster): + async def test_complete_multi_move_workflow( + self, commands_cog, mock_interaction, mock_team, mock_players, mock_roster + ): """Test complete workflow for multi-move transaction. Verifies that manually adding multiple moves to the transaction builder @@ -220,32 +245,42 @@ class TestDropAddIntegration: """ clear_transaction_builder(mock_interaction.user.id) - with patch('commands.transactions.dropadd.validate_user_has_team') as mock_validate: - with patch('services.transaction_builder.roster_service') as mock_roster_service: - with patch('services.transaction_builder.transaction_service') as mock_tx_service: + with patch( + "commands.transactions.dropadd.validate_user_has_team" + ) as mock_validate: + with patch( + "services.transaction_builder.roster_service" + ) as mock_roster_service: + with patch( + "services.transaction_builder.transaction_service" + ) as mock_tx_service: mock_validate.return_value = mock_team - mock_roster_service.get_current_roster = AsyncMock(return_value=mock_roster) + mock_roster_service.get_current_roster = AsyncMock( + return_value=mock_roster + ) mock_tx_service.get_team_transactions = AsyncMock(return_value=[]) # Start with /dropadd command await commands_cog.dropadd.callback(commands_cog, mock_interaction) # Get the builder - builder = get_transaction_builder(mock_interaction.user.id, mock_team) + builder = get_transaction_builder( + mock_interaction.user.id, mock_team + ) # Manually add multiple moves (simulating UI interactions) add_move = TransactionMove( player=mock_players[0], # Mike Trout from_roster=RosterType.FREE_AGENCY, to_roster=RosterType.MAJOR_LEAGUE, - to_team=mock_team + to_team=mock_team, ) drop_move = TransactionMove( player=mock_players[1], # Ronald Acuna Jr. from_roster=RosterType.MAJOR_LEAGUE, to_roster=RosterType.FREE_AGENCY, - from_team=mock_team + from_team=mock_team, ) await builder.add_move(add_move, check_pending_transactions=False) @@ -256,9 +291,17 @@ class TestDropAddIntegration: validation = await builder.validate_transaction() assert validation.is_legal is True assert validation.major_league_count == 24 # 24 + 1 - 1 = 24 - + @pytest.mark.asyncio - async def test_complete_submission_workflow(self, commands_cog, mock_interaction, mock_team, mock_players, mock_roster, mock_current_state): + async def test_complete_submission_workflow( + self, + commands_cog, + mock_interaction, + mock_team, + mock_players, + mock_roster, + mock_current_state, + ): """Test complete transaction submission workflow. Verifies that submitting a transaction via the builder creates @@ -266,10 +309,16 @@ class TestDropAddIntegration: """ clear_transaction_builder(mock_interaction.user.id) - with patch('services.transaction_builder.roster_service') as mock_roster_service: - with patch('services.transaction_builder.transaction_service') as mock_tx_service: + with patch( + "services.transaction_builder.roster_service" + ) as mock_roster_service: + with patch( + "services.transaction_builder.transaction_service" + ) as mock_tx_service: # Setup mocks - mock_roster_service.get_current_roster = AsyncMock(return_value=mock_roster) + mock_roster_service.get_current_roster = AsyncMock( + return_value=mock_roster + ) mock_tx_service.get_team_transactions = AsyncMock(return_value=[]) # Create builder and add move @@ -278,7 +327,7 @@ class TestDropAddIntegration: player=mock_players[0], from_roster=RosterType.FREE_AGENCY, to_roster=RosterType.MAJOR_LEAGUE, - to_team=mock_team + to_team=mock_team, ) await builder.add_move(move, check_pending_transactions=False) @@ -289,14 +338,15 @@ class TestDropAddIntegration: assert len(transactions) == 1 transaction = transactions[0] assert isinstance(transaction, Transaction) - assert transaction.player.name == 'Mike Trout' + assert transaction.player.name == "Mike Trout" assert transaction.week == 11 assert transaction.season == 13 assert "Season-013-Week-11-" in transaction.moveid - - + @pytest.mark.asyncio - async def test_submission_modal_workflow(self, mock_interaction, mock_team, mock_players, mock_roster, mock_current_state): + async def test_submission_modal_workflow( + self, mock_interaction, mock_team, mock_players, mock_roster, mock_current_state + ): """Test submission confirmation modal workflow. Verifies that the SubmitConfirmationModal properly: @@ -305,42 +355,70 @@ class TestDropAddIntegration: 3. Submits transactions 4. Posts success message - Note: The modal imports services dynamically inside on_submit(), - so we patch them where they're imported from (services.X module). + Note: The modal uses services imported at the top of views/transaction_embed.py, + so we patch them where they're used (views.transaction_embed module). Note: Discord.py's TextInput.value is a read-only property, so we replace the entire confirmation attribute with a MagicMock. """ clear_transaction_builder(mock_interaction.user.id) - with patch('services.transaction_builder.roster_service') as mock_roster_service: - with patch('services.transaction_builder.transaction_service') as mock_tx_service: - with patch('services.league_service.league_service') as mock_league_service: - with patch('services.transaction_service.transaction_service') as mock_view_tx_service: - with patch('utils.transaction_logging.post_transaction_to_log') as mock_post_log: - mock_roster_service.get_current_roster = AsyncMock(return_value=mock_roster) - mock_tx_service.get_team_transactions = AsyncMock(return_value=[]) - mock_league_service.get_current_state = AsyncMock(return_value=mock_current_state) + with patch( + "services.transaction_builder.roster_service" + ) as mock_roster_service: + with patch( + "services.transaction_builder.transaction_service" + ) as mock_tx_service: + with patch( + "views.transaction_embed.league_service" + ) as mock_league_service: + with patch( + "views.transaction_embed.transaction_service" + ) as mock_view_tx_service: + with patch( + "utils.transaction_logging.post_transaction_to_log" + ) as mock_post_log: + mock_roster_service.get_current_roster = AsyncMock( + return_value=mock_roster + ) + mock_tx_service.get_team_transactions = AsyncMock( + return_value=[] + ) + mock_league_service.get_current_state = AsyncMock( + return_value=mock_current_state + ) mock_post_log.return_value = None # Create builder with move - builder = get_transaction_builder(mock_interaction.user.id, mock_team) + builder = get_transaction_builder( + mock_interaction.user.id, mock_team + ) move = TransactionMove( player=mock_players[0], from_roster=RosterType.FREE_AGENCY, to_roster=RosterType.MAJOR_LEAGUE, - to_team=mock_team + to_team=mock_team, + ) + await builder.add_move( + move, check_pending_transactions=False ) - await builder.add_move(move, check_pending_transactions=False) # Submit transactions first to get move IDs - transactions = await builder.submit_transaction(week=mock_current_state.week + 1) - mock_view_tx_service.create_transaction_batch = AsyncMock(return_value=transactions) + transactions = await builder.submit_transaction( + week=mock_current_state.week + 1 + ) + mock_view_tx_service.create_transaction_batch = AsyncMock( + return_value=transactions + ) # Reset the builder and add move again for modal test clear_transaction_builder(mock_interaction.user.id) - builder = get_transaction_builder(mock_interaction.user.id, mock_team) - await builder.add_move(move, check_pending_transactions=False) + builder = get_transaction_builder( + mock_interaction.user.id, mock_team + ) + await builder.add_move( + move, check_pending_transactions=False + ) # Create the modal modal = SubmitConfirmationModal(builder) @@ -348,14 +426,16 @@ class TestDropAddIntegration: # Replace the entire confirmation input with a mock that has .value # Discord.py's TextInput.value is read-only, so we can't patch it mock_confirmation = MagicMock() - mock_confirmation.value = 'CONFIRM' + mock_confirmation.value = "CONFIRM" modal.confirmation = mock_confirmation await modal.on_submit(mock_interaction) # Verify submission process mock_league_service.get_current_state.assert_called() - mock_interaction.response.defer.assert_called_once_with(ephemeral=True) + mock_interaction.response.defer.assert_called_once_with( + ephemeral=True + ) mock_interaction.followup.send.assert_called_once() # Verify success message @@ -363,9 +443,11 @@ class TestDropAddIntegration: success_msg = call_args[0][0] assert "Transaction Submitted Successfully" in success_msg assert "Move ID:" in success_msg - + @pytest.mark.asyncio - async def test_error_handling_workflow(self, commands_cog, mock_interaction, mock_team): + async def test_error_handling_workflow( + self, commands_cog, mock_interaction, mock_team + ): """Test error handling throughout the workflow. Verifies that when validate_user_has_team raises an error, @@ -377,7 +459,9 @@ class TestDropAddIntegration: """ clear_transaction_builder(mock_interaction.user.id) - with patch('commands.transactions.dropadd.validate_user_has_team') as mock_validate: + with patch( + "commands.transactions.dropadd.validate_user_has_team" + ) as mock_validate: # Test API error handling mock_validate.side_effect = Exception("API Error") @@ -390,9 +474,11 @@ class TestDropAddIntegration: # Should still defer (called before error) mock_interaction.response.defer.assert_called_once() - + @pytest.mark.asyncio - async def test_roster_validation_workflow(self, commands_cog, mock_interaction, mock_team, mock_players): + async def test_roster_validation_workflow( + self, commands_cog, mock_interaction, mock_team, mock_players + ): """Test roster validation throughout workflow. Verifies that the transaction builder correctly validates roster limits @@ -403,40 +489,48 @@ class TestDropAddIntegration: # Create roster at limit (26 ML players for week 10) ml_players = [] for i in range(26): - ml_players.append(Player( - id=1000 + i, - name=f'ML Player {i}', - wara=3.0 + i * 0.1, - season=13, - team_id=499, - team=None, - image=None, - image2=None, - vanity_card=None, - headshot=None, - pos_1='OF', - pitcher_injury=None, - injury_rating=None, - il_return=None, - demotion_week=None, - last_game=None, - last_game2=None, - strat_code=None, - bbref_id=None, - sbaplayer=None - )) + ml_players.append( + Player( + id=1000 + i, + name=f"ML Player {i}", + wara=1.0, + season=13, + team_id=499, + team=None, + image=None, + image2=None, + vanity_card=None, + headshot=None, + pos_1="OF", + pitcher_injury=None, + injury_rating=None, + il_return=None, + demotion_week=None, + last_game=None, + last_game2=None, + strat_code=None, + bbref_id=None, + sbaplayer=None, + ) + ) full_roster = TeamRoster( team_id=499, - team_abbrev='TST', + team_abbrev="TST", week=10, season=13, - active_players=ml_players + active_players=ml_players, ) - with patch('services.transaction_builder.roster_service') as mock_roster_service: - with patch('services.transaction_builder.transaction_service') as mock_tx_service: - mock_roster_service.get_current_roster = AsyncMock(return_value=full_roster) + with patch( + "services.transaction_builder.roster_service" + ) as mock_roster_service: + with patch( + "services.transaction_builder.transaction_service" + ) as mock_tx_service: + mock_roster_service.get_current_roster = AsyncMock( + return_value=full_roster + ) mock_tx_service.get_team_transactions = AsyncMock(return_value=[]) # Create builder and try to add player (should exceed limit) @@ -445,7 +539,7 @@ class TestDropAddIntegration: player=mock_players[0], from_roster=RosterType.FREE_AGENCY, to_roster=RosterType.MAJOR_LEAGUE, - to_team=mock_team + to_team=mock_team, ) await builder.add_move(move, check_pending_transactions=False) @@ -457,9 +551,11 @@ class TestDropAddIntegration: assert "27 players (limit: 26)" in validation.errors[0] assert len(validation.suggestions) > 0 assert "Drop 1 ML player" in validation.suggestions[0] - + @pytest.mark.asyncio - async def test_builder_persistence_workflow(self, commands_cog, mock_interaction, mock_team, mock_players, mock_roster): + async def test_builder_persistence_workflow( + self, commands_cog, mock_interaction, mock_team, mock_players, mock_roster + ): """Test that transaction builder persists across command calls. Verifies that calling /dropadd multiple times uses the same @@ -467,33 +563,44 @@ class TestDropAddIntegration: """ clear_transaction_builder(mock_interaction.user.id) - with patch('commands.transactions.dropadd.validate_user_has_team') as mock_validate: - with patch('services.transaction_builder.roster_service') as mock_roster_service: - with patch('services.transaction_builder.transaction_service') as mock_tx_service: + with patch( + "commands.transactions.dropadd.validate_user_has_team" + ) as mock_validate: + with patch( + "services.transaction_builder.roster_service" + ) as mock_roster_service: + with patch( + "services.transaction_builder.transaction_service" + ) as mock_tx_service: mock_validate.return_value = mock_team - mock_roster_service.get_current_roster = AsyncMock(return_value=mock_roster) + mock_roster_service.get_current_roster = AsyncMock( + return_value=mock_roster + ) mock_tx_service.get_team_transactions = AsyncMock(return_value=[]) # First command call await commands_cog.dropadd.callback(commands_cog, mock_interaction) - builder1 = get_transaction_builder(mock_interaction.user.id, mock_team) + builder1 = get_transaction_builder( + mock_interaction.user.id, mock_team + ) # Add a move move = TransactionMove( player=mock_players[0], from_roster=RosterType.FREE_AGENCY, to_roster=RosterType.MAJOR_LEAGUE, - to_team=mock_team + to_team=mock_team, ) await builder1.add_move(move, check_pending_transactions=False) assert builder1.move_count == 1 # Second command call should get same builder await commands_cog.dropadd.callback(commands_cog, mock_interaction) - builder2 = get_transaction_builder(mock_interaction.user.id, mock_team) + builder2 = get_transaction_builder( + mock_interaction.user.id, mock_team + ) # Should be same instance with same moves assert builder1 is builder2 assert builder2.move_count == 1 - assert builder2.moves[0].player.name == 'Mike Trout' - + assert builder2.moves[0].player.name == "Mike Trout" diff --git a/tests/test_views_transaction_embed.py b/tests/test_views_transaction_embed.py index b868862..c21e2b4 100644 --- a/tests/test_views_transaction_embed.py +++ b/tests/test_views_transaction_embed.py @@ -3,6 +3,7 @@ Tests for Transaction Embed Views Validates Discord UI components, modals, and interactive elements. """ + import pytest from unittest.mock import AsyncMock, MagicMock, patch import discord @@ -12,12 +13,12 @@ from views.transaction_embed import ( RemoveMoveView, RemoveMoveSelect, SubmitConfirmationModal, - create_transaction_embed + create_transaction_embed, ) from services.transaction_builder import ( TransactionBuilder, TransactionMove, - RosterValidationResult + RosterValidationResult, ) from models.team import Team, RosterType from models.player import Player @@ -25,11 +26,17 @@ from models.player import Player class TestTransactionEmbedView: """Test TransactionEmbedView Discord UI component.""" - + @pytest.fixture def mock_builder(self): """Create mock TransactionBuilder.""" - team = Team(id=499, abbrev='WV', sname='Black Bears', lname='West Virginia Black Bears', season=12) + team = Team( + id=499, + abbrev="WV", + sname="Black Bears", + lname="West Virginia Black Bears", + season=12, + ) builder = MagicMock(spec=TransactionBuilder) builder.team = team builder.user_id = 123456789 @@ -40,9 +47,9 @@ class TestTransactionEmbedView: builder.created_at = MagicMock() builder.created_at.strftime.return_value = "10:30:15" return builder - + # Don't create view as fixture - create in test methods to ensure event loop is running - + @pytest.fixture def mock_interaction(self): """Create mock Discord interaction.""" @@ -54,154 +61,184 @@ class TestTransactionEmbedView: interaction.client = MagicMock() interaction.channel = MagicMock() return interaction - + @pytest.mark.asyncio async def test_interaction_check_correct_user(self, mock_builder, mock_interaction): """Test interaction check passes for correct user.""" view = TransactionEmbedView(mock_builder, user_id=123456789) result = await view.interaction_check(mock_interaction) assert result is True - + @pytest.mark.asyncio async def test_interaction_check_wrong_user(self, mock_builder, mock_interaction): """Test interaction check fails for wrong user.""" view = TransactionEmbedView(mock_builder, user_id=123456789) mock_interaction.user.id = 999999999 # Different user - + result = await view.interaction_check(mock_interaction) - + assert result is False mock_interaction.response.send_message.assert_called_once() call_args = mock_interaction.response.send_message.call_args assert "don't have permission" in call_args[0][0] - assert call_args[1]['ephemeral'] is True - + assert call_args[1]["ephemeral"] is True + @pytest.mark.asyncio - async def test_remove_move_button_empty_builder(self, mock_builder, mock_interaction): + async def test_remove_move_button_empty_builder( + self, mock_builder, mock_interaction + ): """Test remove move button with empty builder.""" view = TransactionEmbedView(mock_builder, user_id=123456789) view.builder.is_empty = True - + await view.remove_move_button.callback(mock_interaction) - + mock_interaction.response.send_message.assert_called_once() call_args = mock_interaction.response.send_message.call_args assert "No moves to remove" in call_args[0][0] - assert call_args[1]['ephemeral'] is True - + assert call_args[1]["ephemeral"] is True + @pytest.mark.asyncio async def test_remove_move_button_with_moves(self, mock_builder, mock_interaction): """Test remove move button with moves available.""" view = TransactionEmbedView(mock_builder, user_id=123456789) view.builder.is_empty = False - - with patch('views.transaction_embed.create_transaction_embed') as mock_create_embed: + + with patch( + "views.transaction_embed.create_transaction_embed" + ) as mock_create_embed: mock_create_embed.return_value = MagicMock() - + await view.remove_move_button.callback(mock_interaction) - + mock_interaction.response.edit_message.assert_called_once() - + # Check that view is RemoveMoveView call_args = mock_interaction.response.edit_message.call_args - view_arg = call_args[1]['view'] + view_arg = call_args[1]["view"] assert isinstance(view_arg, RemoveMoveView) - + @pytest.mark.asyncio async def test_submit_button_empty_builder(self, mock_builder, mock_interaction): """Test submit button with empty builder.""" view = TransactionEmbedView(mock_builder, user_id=123456789) view.builder.is_empty = True - + await view.submit_button.callback(mock_interaction) - + mock_interaction.response.send_message.assert_called_once() call_args = mock_interaction.response.send_message.call_args assert "Cannot submit empty transaction" in call_args[0][0] - assert call_args[1]['ephemeral'] is True - + assert call_args[1]["ephemeral"] is True + @pytest.mark.asyncio - async def test_submit_button_illegal_transaction(self, mock_builder, mock_interaction): + async def test_submit_button_illegal_transaction( + self, mock_builder, mock_interaction + ): """Test submit button with illegal transaction.""" view = TransactionEmbedView(mock_builder, user_id=123456789) view.builder.is_empty = False - view.builder.validate_transaction = AsyncMock(return_value=RosterValidationResult( - is_legal=False, - major_league_count=26, - minor_league_count=10, - warnings=[], - errors=["Too many players"], - suggestions=["Drop 1 player"] - )) - - await view.submit_button.callback(mock_interaction) - + view.builder.validate_transaction = AsyncMock( + return_value=RosterValidationResult( + is_legal=False, + major_league_count=26, + minor_league_count=10, + warnings=[], + errors=["Too many players"], + suggestions=["Drop 1 player"], + ) + ) + + with patch("views.transaction_embed.league_service") as mock_league_service: + mock_current_state = MagicMock() + mock_current_state.week = 10 + mock_league_service.get_current_state = AsyncMock( + return_value=mock_current_state + ) + await view.submit_button.callback(mock_interaction) + mock_interaction.response.send_message.assert_called_once() call_args = mock_interaction.response.send_message.call_args message = call_args[0][0] assert "Cannot submit illegal transaction" in message assert "Too many players" in message assert "Drop 1 player" in message - assert call_args[1]['ephemeral'] is True - + assert call_args[1]["ephemeral"] is True + @pytest.mark.asyncio - async def test_submit_button_legal_transaction(self, mock_builder, mock_interaction): + async def test_submit_button_legal_transaction( + self, mock_builder, mock_interaction + ): """Test submit button with legal transaction.""" view = TransactionEmbedView(mock_builder, user_id=123456789) view.builder.is_empty = False - view.builder.validate_transaction = AsyncMock(return_value=RosterValidationResult( - is_legal=True, - major_league_count=25, - minor_league_count=10, - warnings=[], - errors=[], - suggestions=[] - )) - - await view.submit_button.callback(mock_interaction) - + view.builder.validate_transaction = AsyncMock( + return_value=RosterValidationResult( + is_legal=True, + major_league_count=25, + minor_league_count=10, + warnings=[], + errors=[], + suggestions=[], + ) + ) + + with patch("views.transaction_embed.league_service") as mock_league_service: + mock_current_state = MagicMock() + mock_current_state.week = 10 + mock_league_service.get_current_state = AsyncMock( + return_value=mock_current_state + ) + await view.submit_button.callback(mock_interaction) + # Should send confirmation modal mock_interaction.response.send_modal.assert_called_once() modal_arg = mock_interaction.response.send_modal.call_args[0][0] assert isinstance(modal_arg, SubmitConfirmationModal) - + @pytest.mark.asyncio async def test_cancel_button(self, mock_builder, mock_interaction): """Test cancel button clears moves and disables view.""" view = TransactionEmbedView(mock_builder, user_id=123456789) - with patch('views.transaction_embed.create_transaction_embed') as mock_create_embed: + with patch( + "views.transaction_embed.create_transaction_embed" + ) as mock_create_embed: mock_create_embed.return_value = MagicMock() - + await view.cancel_button.callback(mock_interaction) - + # Should clear moves view.builder.clear_moves.assert_called_once() - + # Should edit message with disabled view mock_interaction.response.edit_message.assert_called_once() call_args = mock_interaction.response.edit_message.call_args - assert "Transaction cancelled" in call_args[1]['content'] - - + assert "Transaction cancelled" in call_args[1]["content"] class TestSubmitConfirmationModal: """Test SubmitConfirmationModal functionality.""" - + @pytest.fixture def mock_builder(self): """Create mock TransactionBuilder.""" - team = Team(id=499, abbrev='WV', sname='Black Bears', lname='West Virginia Black Bears', season=12) + team = Team( + id=499, + abbrev="WV", + sname="Black Bears", + lname="West Virginia Black Bears", + season=12, + ) builder = MagicMock(spec=TransactionBuilder) builder.team = team builder.moves = [] return builder - + @pytest.fixture def modal(self, mock_builder): """Create SubmitConfirmationModal instance.""" return SubmitConfirmationModal(mock_builder) - + @pytest.fixture def mock_interaction(self): """Create mock Discord interaction.""" @@ -212,72 +249,94 @@ class TestSubmitConfirmationModal: interaction.followup = AsyncMock() interaction.client = MagicMock() interaction.channel = MagicMock() - + # Mock message history mock_message = MagicMock() mock_message.author = interaction.client.user mock_message.embeds = [MagicMock()] mock_message.embeds[0].title = "📋 Transaction Builder" mock_message.edit = AsyncMock() - - interaction.channel.history.return_value.__aiter__ = AsyncMock(return_value=iter([mock_message])) - + + interaction.channel.history.return_value.__aiter__ = AsyncMock( + return_value=iter([mock_message]) + ) + return interaction - + @pytest.mark.asyncio - async def test_modal_submit_wrong_confirmation(self, mock_builder, mock_interaction): + async def test_modal_submit_wrong_confirmation( + self, mock_builder, mock_interaction + ): """Test modal submission with wrong confirmation text.""" modal = SubmitConfirmationModal(mock_builder) # Mock the TextInput values modal.confirmation = MagicMock() - modal.confirmation.value = 'WRONG' - + modal.confirmation.value = "WRONG" + await modal.on_submit(mock_interaction) - + mock_interaction.response.send_message.assert_called_once() call_args = mock_interaction.response.send_message.call_args assert "must type 'CONFIRM' exactly" in call_args[0][0] - assert call_args[1]['ephemeral'] is True - + assert call_args[1]["ephemeral"] is True + @pytest.mark.asyncio - async def test_modal_submit_correct_confirmation(self, mock_builder, mock_interaction): + async def test_modal_submit_correct_confirmation( + self, mock_builder, mock_interaction + ): """Test modal submission with correct confirmation.""" modal = SubmitConfirmationModal(mock_builder) # Mock the TextInput values modal.confirmation = MagicMock() - modal.confirmation.value = 'CONFIRM' + modal.confirmation.value = "CONFIRM" mock_transaction = MagicMock() - mock_transaction.moveid = 'Season-012-Week-11-123456789' + mock_transaction.moveid = "Season-012-Week-11-123456789" mock_transaction.week = 11 mock_transaction.frozen = False # Will be set to True - with patch('services.league_service.league_service') as mock_league_service: + with patch("views.transaction_embed.league_service") as mock_league_service: mock_current_state = MagicMock() mock_current_state.week = 10 mock_current_state.freeze = True # Simulate Monday-Friday freeze period - mock_league_service.get_current_state = AsyncMock(return_value=mock_current_state) + mock_league_service.get_current_state = AsyncMock( + return_value=mock_current_state + ) - modal.builder.submit_transaction = AsyncMock(return_value=[mock_transaction]) + modal.builder.submit_transaction = AsyncMock( + return_value=[mock_transaction] + ) - with patch('services.transaction_service.transaction_service') as mock_transaction_service: + with patch( + "views.transaction_embed.transaction_service" + ) as mock_transaction_service: # Mock the create_transaction_batch call - mock_transaction_service.create_transaction_batch = AsyncMock(return_value=[mock_transaction]) + mock_transaction_service.create_transaction_batch = AsyncMock( + return_value=[mock_transaction] + ) - with patch('utils.transaction_logging.post_transaction_to_log') as mock_post_log: + with patch( + "utils.transaction_logging.post_transaction_to_log" + ) as mock_post_log: mock_post_log.return_value = AsyncMock() - with patch('services.transaction_builder.clear_transaction_builder') as mock_clear: + with patch( + "views.transaction_embed.clear_transaction_builder" + ) as mock_clear: await modal.on_submit(mock_interaction) # Should defer response - mock_interaction.response.defer.assert_called_once_with(ephemeral=True) + mock_interaction.response.defer.assert_called_once_with( + ephemeral=True + ) # Should get current state mock_league_service.get_current_state.assert_called_once() # Should submit transaction for next week - modal.builder.submit_transaction.assert_called_once_with(week=11) + modal.builder.submit_transaction.assert_called_once_with( + week=11 + ) # Should mark transaction as frozen (based on current_state.freeze) assert mock_transaction.frozen is True @@ -293,9 +352,11 @@ class TestSubmitConfirmationModal: call_args = mock_interaction.followup.send.call_args assert "Transaction Submitted Successfully" in call_args[0][0] assert mock_transaction.moveid in call_args[0][0] - + @pytest.mark.asyncio - async def test_modal_submit_during_thaw_period(self, mock_builder, mock_interaction): + async def test_modal_submit_during_thaw_period( + self, mock_builder, mock_interaction + ): """Test modal submission during thaw period (Saturday-Sunday) sets frozen=False. When freeze=False (Saturday-Sunday), transactions should NOT be frozen @@ -305,29 +366,41 @@ class TestSubmitConfirmationModal: modal = SubmitConfirmationModal(mock_builder) # Mock the TextInput values modal.confirmation = MagicMock() - modal.confirmation.value = 'CONFIRM' + modal.confirmation.value = "CONFIRM" mock_transaction = MagicMock() - mock_transaction.moveid = 'Season-012-Week-11-123456789' + mock_transaction.moveid = "Season-012-Week-11-123456789" mock_transaction.week = 11 mock_transaction.frozen = True # Will be set to False during thaw - with patch('services.league_service.league_service') as mock_league_service: + with patch("views.transaction_embed.league_service") as mock_league_service: mock_current_state = MagicMock() mock_current_state.week = 10 mock_current_state.freeze = False # Simulate Saturday-Sunday thaw period - mock_league_service.get_current_state = AsyncMock(return_value=mock_current_state) + mock_league_service.get_current_state = AsyncMock( + return_value=mock_current_state + ) - modal.builder.submit_transaction = AsyncMock(return_value=[mock_transaction]) + modal.builder.submit_transaction = AsyncMock( + return_value=[mock_transaction] + ) - with patch('services.transaction_service.transaction_service') as mock_transaction_service: + with patch( + "views.transaction_embed.transaction_service" + ) as mock_transaction_service: # Mock the create_transaction_batch call - mock_transaction_service.create_transaction_batch = AsyncMock(return_value=[mock_transaction]) + mock_transaction_service.create_transaction_batch = AsyncMock( + return_value=[mock_transaction] + ) - with patch('utils.transaction_logging.post_transaction_to_log') as mock_post_log: + with patch( + "utils.transaction_logging.post_transaction_to_log" + ) as mock_post_log: mock_post_log.return_value = AsyncMock() - with patch('services.transaction_builder.clear_transaction_builder') as mock_clear: + with patch( + "views.transaction_embed.clear_transaction_builder" + ) as mock_clear: await modal.on_submit(mock_interaction) # Should mark transaction as NOT frozen (thaw period) @@ -339,9 +412,9 @@ class TestSubmitConfirmationModal: modal = SubmitConfirmationModal(mock_builder) # Mock the TextInput values modal.confirmation = MagicMock() - modal.confirmation.value = 'CONFIRM' + modal.confirmation.value = "CONFIRM" - with patch('services.league_service.league_service') as mock_league_service: + with patch("views.transaction_embed.league_service") as mock_league_service: mock_league_service.get_current_state = AsyncMock(return_value=None) await modal.on_submit(mock_interaction) @@ -349,16 +422,22 @@ class TestSubmitConfirmationModal: mock_interaction.followup.send.assert_called_once() call_args = mock_interaction.followup.send.call_args assert "Could not get current league state" in call_args[0][0] - assert call_args[1]['ephemeral'] is True + assert call_args[1]["ephemeral"] is True class TestEmbedCreation: """Test embed creation functions.""" - + @pytest.fixture def mock_builder_empty(self): """Create empty mock TransactionBuilder.""" - team = Team(id=499, abbrev='WV', sname='Black Bears', lname='West Virginia Black Bears', season=12) + team = Team( + id=499, + abbrev="WV", + sname="Black Bears", + lname="West Virginia Black Bears", + season=12, + ) builder = MagicMock(spec=TransactionBuilder) builder.team = team builder.is_empty = True @@ -366,92 +445,121 @@ class TestEmbedCreation: builder.moves = [] builder.created_at = MagicMock() builder.created_at.strftime.return_value = "10:30:15" - builder.validate_transaction = AsyncMock(return_value=RosterValidationResult( - is_legal=True, - major_league_count=24, - minor_league_count=10, - warnings=[], - errors=[], - suggestions=["Add player moves to build your transaction"] - )) + builder.validate_transaction = AsyncMock( + return_value=RosterValidationResult( + is_legal=True, + major_league_count=24, + minor_league_count=10, + warnings=[], + errors=[], + suggestions=["Add player moves to build your transaction"], + ) + ) return builder - + @pytest.fixture def mock_builder_with_moves(self): """Create mock TransactionBuilder with moves.""" - team = Team(id=499, abbrev='WV', sname='Black Bears', lname='West Virginia Black Bears', season=12) + team = Team( + id=499, + abbrev="WV", + sname="Black Bears", + lname="West Virginia Black Bears", + season=12, + ) builder = MagicMock(spec=TransactionBuilder) builder.team = team builder.is_empty = False builder.move_count = 2 - + mock_moves = [] for i in range(2): move = MagicMock() move.description = f"Move {i+1}: Player → Team" mock_moves.append(move) builder.moves = mock_moves - + builder.created_at = MagicMock() builder.created_at.strftime.return_value = "10:30:15" - builder.validate_transaction = AsyncMock(return_value=RosterValidationResult( - is_legal=False, - major_league_count=26, - minor_league_count=10, - warnings=["Warning message"], - errors=["Error message"], - suggestions=["Suggestion message"] - )) + builder.validate_transaction = AsyncMock( + return_value=RosterValidationResult( + is_legal=False, + major_league_count=26, + minor_league_count=10, + warnings=["Warning message"], + errors=["Error message"], + suggestions=["Suggestion message"], + ) + ) return builder - + @pytest.mark.asyncio async def test_create_transaction_embed_empty(self, mock_builder_empty): """Test creating embed for empty transaction.""" - embed = await create_transaction_embed(mock_builder_empty) - + with patch("views.transaction_embed.league_service") as mock_league_service: + mock_current_state = MagicMock() + mock_current_state.week = 10 + mock_league_service.get_current_state = AsyncMock( + return_value=mock_current_state + ) + embed = await create_transaction_embed(mock_builder_empty) + assert isinstance(embed, discord.Embed) assert "Transaction Builder - WV" in embed.title assert "📋" in embed.title - + # Should have fields for empty state field_names = [field.name for field in embed.fields] assert "Current Moves" in field_names assert "Roster Status" in field_names assert "Suggestions" in field_names - + # Check empty moves message - moves_field = next(field for field in embed.fields if field.name == "Current Moves") + moves_field = next( + field for field in embed.fields if field.name == "Current Moves" + ) assert "No moves yet" in moves_field.value # Check for Add More Moves instruction field field_names = [field.name for field in embed.fields] assert "➕ Add More Moves" in field_names - add_moves_field = next(field for field in embed.fields if field.name == "➕ Add More Moves") + add_moves_field = next( + field for field in embed.fields if field.name == "➕ Add More Moves" + ) assert "/dropadd" in add_moves_field.value - + @pytest.mark.asyncio async def test_create_transaction_embed_with_moves(self, mock_builder_with_moves): """Test creating embed for transaction with moves.""" - embed = await create_transaction_embed(mock_builder_with_moves) - + with patch("views.transaction_embed.league_service") as mock_league_service: + mock_current_state = MagicMock() + mock_current_state.week = 10 + mock_league_service.get_current_state = AsyncMock( + return_value=mock_current_state + ) + embed = await create_transaction_embed(mock_builder_with_moves) + assert isinstance(embed, discord.Embed) assert "Transaction Builder - WV" in embed.title - + # Should have all fields field_names = [field.name for field in embed.fields] assert "Current Moves (2)" in field_names assert "Roster Status" in field_names assert "❌ Errors" in field_names assert "Suggestions" in field_names - + # Check moves content - moves_field = next(field for field in embed.fields if "Current Moves" in field.name) + moves_field = next( + field for field in embed.fields if "Current Moves" in field.name + ) assert "Move 1: Player → Team" in moves_field.value assert "Move 2: Player → Team" in moves_field.value # Check for Add More Moves instruction field field_names = [field.name for field in embed.fields] assert "➕ Add More Moves" in field_names - add_moves_field = next(field for field in embed.fields if field.name == "➕ Add More Moves") + add_moves_field = next( + field for field in embed.fields if field.name == "➕ Add More Moves" + ) assert "/dropadd" in add_moves_field.value - \ No newline at end of file diff --git a/views/transaction_embed.py b/views/transaction_embed.py index ceea559..cca7153 100644 --- a/views/transaction_embed.py +++ b/views/transaction_embed.py @@ -3,11 +3,19 @@ Interactive Transaction Embed Views Handles the Discord embed and button interfaces for the transaction builder. """ + import discord from typing import Optional, List from datetime import datetime -from services.transaction_builder import TransactionBuilder, RosterValidationResult +from services.transaction_builder import ( + TransactionBuilder, + RosterValidationResult, + clear_transaction_builder, +) +from services.league_service import league_service +from services.transaction_service import transaction_service +from services.player_service import player_service from views.embeds import EmbedColors, EmbedTemplate from utils.transaction_logging import post_transaction_to_log @@ -15,7 +23,13 @@ from utils.transaction_logging import post_transaction_to_log class TransactionEmbedView(discord.ui.View): """Interactive view for the transaction builder embed.""" - def __init__(self, builder: TransactionBuilder, user_id: int, submission_handler: str = "scheduled", command_name: str = "/dropadd"): + def __init__( + self, + builder: TransactionBuilder, + user_id: int, + submission_handler: str = "scheduled", + command_name: str = "/dropadd", + ): """ Initialize the transaction embed view. @@ -30,69 +44,83 @@ class TransactionEmbedView(discord.ui.View): self.user_id = user_id self.submission_handler = submission_handler self.command_name = command_name - + async def interaction_check(self, interaction: discord.Interaction) -> bool: """Check if user has permission to interact with this view.""" if interaction.user.id != self.user_id: await interaction.response.send_message( "❌ You don't have permission to use this transaction builder.", - ephemeral=True + ephemeral=True, ) return False return True - + async def on_timeout(self) -> None: """Handle view timeout.""" # Disable all buttons when timeout occurs for item in self.children: if isinstance(item, discord.ui.Button): item.disabled = True - + @discord.ui.button(label="Remove Move", style=discord.ButtonStyle.red, emoji="➖") - async def remove_move_button(self, interaction: discord.Interaction, button: discord.ui.Button): + async def remove_move_button( + self, interaction: discord.Interaction, button: discord.ui.Button + ): """Handle remove move button click.""" if self.builder.is_empty: await interaction.response.send_message( - "❌ No moves to remove. Add some moves first!", - ephemeral=True + "❌ No moves to remove. Add some moves first!", ephemeral=True ) return - + # Create select menu for move removal select_view = RemoveMoveView(self.builder, self.user_id, self.command_name) embed = await create_transaction_embed(self.builder, self.command_name) await interaction.response.edit_message(embed=embed, view=select_view) - - @discord.ui.button(label="Submit Transaction", style=discord.ButtonStyle.primary, emoji="📤") - async def submit_button(self, interaction: discord.Interaction, button: discord.ui.Button): + + @discord.ui.button( + label="Submit Transaction", style=discord.ButtonStyle.primary, emoji="📤" + ) + async def submit_button( + self, interaction: discord.Interaction, button: discord.ui.Button + ): """Handle submit transaction button click.""" if self.builder.is_empty: await interaction.response.send_message( "❌ Cannot submit empty transaction. Add some moves first!", - ephemeral=True + ephemeral=True, ) return - - # Validate before submission - validation = await self.builder.validate_transaction() + + # Validate before submission (include pre-existing transactions for scheduled moves) + if self.submission_handler == "scheduled": + current_state = await league_service.get_current_state() + next_week = current_state.week + 1 if current_state else None + validation = await self.builder.validate_transaction(next_week=next_week) + else: + validation = await self.builder.validate_transaction() if not validation.is_legal: error_msg = "❌ **Cannot submit illegal transaction:**\n" error_msg += "\n".join([f"• {error}" for error in validation.errors]) - + if validation.suggestions: error_msg += "\n\n**Suggestions:**\n" - error_msg += "\n".join([f"💡 {suggestion}" for suggestion in validation.suggestions]) - + error_msg += "\n".join( + [f"💡 {suggestion}" for suggestion in validation.suggestions] + ) + await interaction.response.send_message(error_msg, ephemeral=True) return - + # Show confirmation modal modal = SubmitConfirmationModal(self.builder, self.submission_handler) await interaction.response.send_modal(modal) - + @discord.ui.button(label="Cancel", style=discord.ButtonStyle.secondary, emoji="❌") - async def cancel_button(self, interaction: discord.Interaction, button: discord.ui.Button): + async def cancel_button( + self, interaction: discord.Interaction, button: discord.ui.Button + ): """Handle cancel button click.""" self.builder.clear_moves() embed = await create_transaction_embed(self.builder, self.command_name) @@ -103,9 +131,7 @@ class TransactionEmbedView(discord.ui.View): item.disabled = True await interaction.response.edit_message( - content="❌ **Transaction cancelled and cleared.**", - embed=embed, - view=self + content="❌ **Transaction cancelled and cleared.**", embed=embed, view=self ) self.stop() @@ -113,7 +139,9 @@ class TransactionEmbedView(discord.ui.View): class RemoveMoveView(discord.ui.View): """View for selecting which move to remove.""" - def __init__(self, builder: TransactionBuilder, user_id: int, command_name: str = "/dropadd"): + def __init__( + self, builder: TransactionBuilder, user_id: int, command_name: str = "/dropadd" + ): super().__init__(timeout=300.0) # 5 minute timeout self.builder = builder self.user_id = user_id @@ -124,18 +152,24 @@ class RemoveMoveView(discord.ui.View): self.add_item(RemoveMoveSelect(builder, command_name)) # Add back button - back_button = discord.ui.Button(label="Back", style=discord.ButtonStyle.secondary, emoji="⬅️") + back_button = discord.ui.Button( + label="Back", style=discord.ButtonStyle.secondary, emoji="⬅️" + ) back_button.callback = self.back_callback self.add_item(back_button) async def back_callback(self, interaction: discord.Interaction): """Handle back button to return to main view.""" # Determine submission_handler from command_name - submission_handler = "immediate" if self.command_name == "/ilmove" else "scheduled" - main_view = TransactionEmbedView(self.builder, self.user_id, submission_handler, self.command_name) + submission_handler = ( + "immediate" if self.command_name == "/ilmove" else "scheduled" + ) + main_view = TransactionEmbedView( + self.builder, self.user_id, submission_handler, self.command_name + ) embed = await create_transaction_embed(self.builder, self.command_name) await interaction.response.edit_message(embed=embed, view=main_view) - + async def interaction_check(self, interaction: discord.Interaction) -> bool: """Check if user has permission to interact with this view.""" return interaction.user.id == self.user_id @@ -151,17 +185,19 @@ class RemoveMoveSelect(discord.ui.Select): # Create options from current moves options = [] for i, move in enumerate(builder.moves[:25]): # Discord limit of 25 options - options.append(discord.SelectOption( - label=f"{move.player.name}", - description=move.description[:100], # Discord description limit - value=str(move.player.id) - )) + options.append( + discord.SelectOption( + label=f"{move.player.name}", + description=move.description[:100], # Discord description limit + value=str(move.player.id), + ) + ) super().__init__( placeholder="Select a move to remove...", min_values=1, max_values=1, - options=options + options=options, ) async def callback(self, interaction: discord.Interaction): @@ -172,73 +208,74 @@ class RemoveMoveSelect(discord.ui.Select): if move: self.builder.remove_move(player_id) await interaction.response.send_message( - f"✅ Removed: {move.description}", - ephemeral=True + f"✅ Removed: {move.description}", ephemeral=True ) # Update the embed # Determine submission_handler from command_name - submission_handler = "immediate" if self.command_name == "/ilmove" else "scheduled" - main_view = TransactionEmbedView(self.builder, interaction.user.id, submission_handler, self.command_name) + submission_handler = ( + "immediate" if self.command_name == "/ilmove" else "scheduled" + ) + main_view = TransactionEmbedView( + self.builder, interaction.user.id, submission_handler, self.command_name + ) embed = await create_transaction_embed(self.builder, self.command_name) # Edit the original message await interaction.edit_original_response(embed=embed, view=main_view) else: await interaction.response.send_message( - "❌ Could not find that move to remove.", - ephemeral=True + "❌ Could not find that move to remove.", ephemeral=True ) - class SubmitConfirmationModal(discord.ui.Modal): """Modal for confirming transaction submission.""" - def __init__(self, builder: TransactionBuilder, submission_handler: str = "scheduled"): + def __init__( + self, builder: TransactionBuilder, submission_handler: str = "scheduled" + ): super().__init__(title="Confirm Transaction Submission") self.builder = builder self.submission_handler = submission_handler - + self.confirmation = discord.ui.TextInput( label="Type 'CONFIRM' to submit", placeholder="CONFIRM", required=True, - max_length=7 + max_length=7, ) - + self.add_item(self.confirmation) - + async def on_submit(self, interaction: discord.Interaction): """Handle confirmation submission.""" if self.confirmation.value.upper() != "CONFIRM": await interaction.response.send_message( "❌ Transaction not submitted. You must type 'CONFIRM' exactly.", - ephemeral=True + ephemeral=True, ) return await interaction.response.defer(ephemeral=True) try: - from services.league_service import league_service - from services.transaction_service import transaction_service - from services.player_service import player_service - # Get current league state current_state = await league_service.get_current_state() if not current_state: await interaction.followup.send( "❌ Could not get current league state. Please try again later.", - ephemeral=True + ephemeral=True, ) return if self.submission_handler == "scheduled": # SCHEDULED SUBMISSION (/dropadd behavior) # Submit the transaction for NEXT week - transactions = await self.builder.submit_transaction(week=current_state.week + 1) + transactions = await self.builder.submit_transaction( + week=current_state.week + 1 + ) # Set frozen flag based on current freeze state: # - During freeze (Mon-Fri): frozen=True -> wait for Saturday conflict resolution @@ -247,13 +284,17 @@ class SubmitConfirmationModal(discord.ui.Modal): txn.frozen = current_state.freeze # POST transactions to database - created_transactions = await transaction_service.create_transaction_batch(transactions) + created_transactions = ( + await transaction_service.create_transaction_batch(transactions) + ) # Post to #transaction-log channel (only when league is NOT frozen) # During freeze period, transactions are hidden until Saturday processing if not current_state.freeze: bot = interaction.client - await post_transaction_to_log(bot, created_transactions, team=self.builder.team) + await post_transaction_to_log( + bot, created_transactions, team=self.builder.team + ) # Create success message success_msg = f"✅ **Transaction Submitted Successfully!**\n\n" @@ -277,27 +318,28 @@ class SubmitConfirmationModal(discord.ui.Modal): # Submit the transaction for THIS week # Don't check existing transactions - they're already in DB and would cause double-counting transactions = await self.builder.submit_transaction( - week=current_state.week, - check_existing_transactions=False + week=current_state.week, check_existing_transactions=False ) # POST transactions to database - created_transactions = await transaction_service.create_transaction_batch(transactions) + created_transactions = ( + await transaction_service.create_transaction_batch(transactions) + ) # Update each player's team assignment player_updates = [] for txn in created_transactions: updated_player = await player_service.update_player_team( - txn.player.id, - txn.newteam.id, - dem_week=current_state.week + txn.player.id, txn.newteam.id, dem_week=current_state.week ) player_updates.append(updated_player) # Post to #transaction-log channel # IL moves always post immediately - they're intra-team and don't need freeze hiding bot = interaction.client - await post_transaction_to_log(bot, created_transactions, team=self.builder.team) + await post_transaction_to_log( + bot, created_transactions, team=self.builder.team + ) # Create success message success_msg = f"✅ **IL Move Executed Successfully!**\n\n" @@ -314,15 +356,18 @@ class SubmitConfirmationModal(discord.ui.Modal): await interaction.followup.send(success_msg, ephemeral=True) # Clear the builder after successful submission - from services.transaction_builder import clear_transaction_builder clear_transaction_builder(interaction.user.id) # Update the original embed to show completion - completion_title = "✅ Transaction Submitted" if self.submission_handler == "scheduled" else "✅ IL Move Executed" + completion_title = ( + "✅ Transaction Submitted" + if self.submission_handler == "scheduled" + else "✅ IL Move Executed" + ) completion_embed = discord.Embed( title=completion_title, description=f"Your transaction has been processed successfully!", - color=0x00ff00 + color=0x00FF00, ) # Disable all buttons @@ -330,9 +375,9 @@ class SubmitConfirmationModal(discord.ui.Modal): try: # Find and update the original message - async for message in interaction.channel.history(limit=50): # type: ignore + async for message in interaction.channel.history(limit=50): # type: ignore if message.author == interaction.client.user and message.embeds: - if "Transaction Builder" in message.embeds[0].title: # type: ignore + if "Transaction Builder" in message.embeds[0].title: # type: ignore await message.edit(embed=completion_embed, view=view) break except: @@ -340,12 +385,13 @@ class SubmitConfirmationModal(discord.ui.Modal): except Exception as e: await interaction.followup.send( - f"❌ Error submitting transaction: {str(e)}", - ephemeral=True + f"❌ Error submitting transaction: {str(e)}", ephemeral=True ) -async def create_transaction_embed(builder: TransactionBuilder, command_name: str = "/dropadd") -> discord.Embed: +async def create_transaction_embed( + builder: TransactionBuilder, command_name: str = "/dropadd" +) -> discord.Embed: """ Create the main transaction builder embed. @@ -365,84 +411,75 @@ async def create_transaction_embed(builder: TransactionBuilder, command_name: st embed = EmbedTemplate.create_base_embed( title=f"📋 Transaction Builder - {builder.team.abbrev}", description=description, - color=EmbedColors.PRIMARY + color=EmbedColors.PRIMARY, ) - + # Add current moves section if builder.is_empty: embed.add_field( name="Current Moves", value="*No moves yet. Use the buttons below to build your transaction.*", - inline=False + inline=False, ) else: moves_text = "" for i, move in enumerate(builder.moves[:10], 1): # Limit display moves_text += f"{i}. {move.description}\n" - + if len(builder.moves) > 10: moves_text += f"... and {len(builder.moves) - 10} more moves" - + embed.add_field( - name=f"Current Moves ({builder.move_count})", - value=moves_text, - inline=False + name=f"Current Moves ({builder.move_count})", value=moves_text, inline=False ) - - # Add roster validation - validation = await builder.validate_transaction() - - roster_status = f"{validation.major_league_status}\n{validation.minor_league_status}" - - embed.add_field( - name="Roster Status", - value=roster_status, - inline=False + + # Add roster validation (include pre-existing transactions for scheduled moves) + if command_name != "/ilmove": + current_state = await league_service.get_current_state() + next_week = current_state.week + 1 if current_state else None + validation = await builder.validate_transaction(next_week=next_week) + else: + validation = await builder.validate_transaction() + + roster_status = ( + f"{validation.major_league_status}\n{validation.minor_league_status}" ) + embed.add_field(name="Roster Status", value=roster_status, inline=False) + # Add sWAR status - swar_status = f"{validation.major_league_swar_status}\n{validation.minor_league_swar_status}" - embed.add_field( - name="Team sWAR", - value=swar_status, - inline=False + swar_status = ( + f"{validation.major_league_swar_status}\n{validation.minor_league_swar_status}" ) + embed.add_field(name="Team sWAR", value=swar_status, inline=False) # Add pre-existing transactions note if applicable if validation.pre_existing_transactions_note: embed.add_field( name="📋 Transaction Context", value=validation.pre_existing_transactions_note, - inline=False + inline=False, ) # Add suggestions/errors if validation.errors: error_text = "\n".join([f"• {error}" for error in validation.errors]) - embed.add_field( - name="❌ Errors", - value=error_text, - inline=False - ) - + embed.add_field(name="❌ Errors", value=error_text, inline=False) + if validation.suggestions: - suggestion_text = "\n".join([f"💡 {suggestion}" for suggestion in validation.suggestions]) - embed.add_field( - name="Suggestions", - value=suggestion_text, - inline=False + suggestion_text = "\n".join( + [f"💡 {suggestion}" for suggestion in validation.suggestions] ) + embed.add_field(name="Suggestions", value=suggestion_text, inline=False) # Add instructions for adding more moves embed.add_field( name="➕ Add More Moves", value=f"Use `{command_name}` to add more moves", - inline=False + inline=False, ) # Add footer with timestamp embed.set_footer(text=f"Created at {builder.created_at.strftime('%H:%M:%S')}") return embed - -