CLAUDE: Enhance /dropadd workflow and remove redundant commands

This commit implements significant improvements to the transaction management system:

**Major Changes:**
1. Enhanced /dropadd command workflow:
   - All responses are now ephemeral for better user privacy
   - Every call now shows the transaction builder (not just first move)
   - Improved autocomplete with team context prioritization (user's players first)

2. Added team ownership validation:
   - Prevents adding players from other teams (except Free Agents with abbrev='FA')
   - Enhanced error messages with specific team information
   - Changed _add_quick_move return type to tuple[bool, str] for better error handling

3. Removed redundant /transactionstatus command:
   - Command functionality fully replaced by /dropadd without parameters
   - Simplified workflow reduces user confusion
   - All related tests updated accordingly

**Technical Improvements:**
- Enhanced player autocomplete with team prioritization and better formatting
- Improved roster detection logic checking actual roster data vs team_id
- Better error handling with specific, actionable error messages
- Updated all test cases to handle new tuple return types
- Added comprehensive test coverage for team ownership validation

**Test Coverage:**
- All 30 tests passing (17 dropadd + 13 transaction embed tests)
- New tests for team ownership validation scenarios
- Updated existing tests for new return type patterns

The /dropadd command now serves as the single point of entry for all transaction operations while providing a cleaner, more intuitive user experience.

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Cal Corum 2025-10-02 11:33:53 -05:00
parent e20677ec72
commit 25a8b55fca
2 changed files with 340 additions and 232 deletions

View File

@ -38,26 +38,69 @@ class DropAddCommands(commands.Cog):
current: str current: str
) -> List[app_commands.Choice[str]]: ) -> List[app_commands.Choice[str]]:
""" """
Autocomplete for player names. Autocomplete for player names with team context prioritization.
Args: Args:
interaction: Discord interaction interaction: Discord interaction
current: Current input from user current: Current input from user
Returns: Returns:
List of player name choices List of player name choices (user's team players first)
""" """
if len(current) < 2: if len(current) < 2:
return [] return []
try:
# Search for players using the new dedicated search endpoint
players = await player_service.search_players(current, limit=25, season=SBA_CURRENT_SEASON)
# Format choices for Discord autocomplete try:
choices = [] # Get user's team for prioritization
user_team = None
try:
major_league_teams = await team_service.get_teams_by_owner(
interaction.user.id,
SBA_CURRENT_SEASON,
roster_type="ml"
)
if major_league_teams:
user_team = major_league_teams[0]
except Exception:
# If we can't get user's team, continue without prioritization
pass
# Search for players using the search endpoint
players = await player_service.search_players(current, limit=50, season=SBA_CURRENT_SEASON)
# Separate players by team (user's team vs others)
user_team_players = []
other_players = []
for player in players: for player in players:
# Format: "Player Name (POS - TEAM)" # Check if player belongs to user's team (any roster section)
is_users_player = False
if user_team and hasattr(player, 'team') and player.team:
# Check if player is from user's major league team or has same base team
if (player.team.id == user_team.id or
(hasattr(player, 'team_id') and player.team_id == user_team.id)):
is_users_player = True
if is_users_player:
user_team_players.append(player)
else:
other_players.append(player)
# Format choices with team context
choices = []
# Add user's team players first (prioritized)
for player in user_team_players[:15]: # Limit user team players
team_info = f"{player.primary_position}"
if hasattr(player, 'team') and player.team:
team_info += f" - {player.team.abbrev}"
choice_name = f"{player.name} ({team_info})"
choices.append(app_commands.Choice(name=choice_name, value=player.name))
# Add other players (remaining slots)
remaining_slots = 25 - len(choices)
for player in other_players[:remaining_slots]:
team_info = f"{player.primary_position}" team_info = f"{player.primary_position}"
if hasattr(player, 'team') and player.team: if hasattr(player, 'team') and player.team:
team_info += f" - {player.team.abbrev}" team_info += f" - {player.team.abbrev}"
@ -66,7 +109,7 @@ class DropAddCommands(commands.Cog):
choices.append(app_commands.Choice(name=choice_name, value=player.name)) choices.append(app_commands.Choice(name=choice_name, value=player.name))
return choices return choices
except Exception as e: except Exception as e:
self.logger.error(f"Error in player autocomplete: {e}") self.logger.error(f"Error in player autocomplete: {e}")
return [] return []
@ -76,7 +119,7 @@ class DropAddCommands(commands.Cog):
description="Interactive transaction builder for player moves" description="Interactive transaction builder for player moves"
) )
@app_commands.describe( @app_commands.describe(
player="Player name (optional - can add later)", player="Player name (use autocomplete for best results)",
destination="Where to move the player: Major League, Minor League, Injured List, or Free Agency" destination="Where to move the player: Major League, Minor League, Injured List, or Free Agency"
) )
@app_commands.autocomplete(player=player_autocomplete) @app_commands.autocomplete(player=player_autocomplete)
@ -94,8 +137,8 @@ class DropAddCommands(commands.Cog):
destination: Optional[str] = None destination: Optional[str] = None
): ):
"""Interactive transaction builder for complex player moves.""" """Interactive transaction builder for complex player moves."""
await interaction.response.defer() await interaction.response.defer(ephemeral=True)
# Get user's major league team # Get user's major league team
major_league_teams = await team_service.get_teams_by_owner( major_league_teams = await team_service.get_teams_by_owner(
interaction.user.id, interaction.user.id,
@ -111,47 +154,74 @@ class DropAddCommands(commands.Cog):
return return
team = major_league_teams[0] # Use first major league team team = major_league_teams[0] # Use first major league team
# Get or create transaction builder # Get or create transaction builder
builder = get_transaction_builder(interaction.user.id, team) builder = get_transaction_builder(interaction.user.id, team)
# If player and destination provided, try to add the move immediately # Handle different scenarios based on builder state and parameters
if player and destination: if player and destination:
success = await self._add_quick_move(builder, player, destination) # User provided both parameters - try to add the move
success, error_message = await self._add_quick_move(builder, player, destination)
if success: if success:
self.logger.info(f"Quick move added for {team.abbrev}: {player}{destination}") # Move added successfully - show updated transaction builder
embed = await create_transaction_embed(builder)
view = TransactionEmbedView(builder, interaction.user.id)
success_msg = f"✅ **Added {player}{destination.upper()}**"
if builder.move_count > 1:
success_msg += f"\n📊 Transaction now has {builder.move_count} moves"
await interaction.followup.send(
content=success_msg,
embed=embed,
view=view,
ephemeral=True
)
self.logger.info(f"Move added for {team.abbrev}: {player}{destination}")
else: else:
self.logger.warning(f"Failed to add quick move: {player}{destination}") # Failed to add move - still show current transaction state
embed = await create_transaction_embed(builder)
# Create and display interactive embed view = TransactionEmbedView(builder, interaction.user.id)
embed = await create_transaction_embed(builder)
view = TransactionEmbedView(builder, interaction.user.id) await interaction.followup.send(
content=f"❌ **{error_message}**\n"
await interaction.followup.send(embed=embed, view=view) f"💡 Try using autocomplete for player names",
embed=embed,
view=view,
ephemeral=True
)
self.logger.warning(f"Failed to add move: {player}{destination}: {error_message}")
else:
# No parameters or incomplete parameters - show current transaction state
embed = await create_transaction_embed(builder)
view = TransactionEmbedView(builder, interaction.user.id)
await interaction.followup.send(embed=embed, view=view, ephemeral=True)
async def _add_quick_move( async def _add_quick_move(
self, self,
builder: TransactionBuilder, builder: TransactionBuilder,
player_name: str, player_name: str,
destination_str: str destination_str: str
) -> bool: ) -> tuple[bool, str]:
""" """
Add a move quickly from command parameters by auto-determining the action. Add a move quickly from command parameters by auto-determining the action.
Args: Args:
builder: TransactionBuilder instance builder: TransactionBuilder instance
player_name: Name of player to move player_name: Name of player to move
destination_str: Destination string (ml, mil, fa) destination_str: Destination string (ml, mil, fa)
Returns: Returns:
True if move was added successfully Tuple of (success: bool, error_message: str)
""" """
try: try:
# Find player using the new search endpoint # Find player using the new search endpoint
players = await player_service.search_players(player_name, limit=10, season=SBA_CURRENT_SEASON) players = await player_service.search_players(player_name, limit=10, season=SBA_CURRENT_SEASON)
if not players: if not players:
self.logger.error(f"Player not found: {player_name}") self.logger.error(f"Player not found: {player_name}")
return False return False, f"Player '{player_name}' not found"
# Use exact match if available, otherwise first result # Use exact match if available, otherwise first result
player = None player = None
@ -159,9 +229,20 @@ class DropAddCommands(commands.Cog):
if p.name.lower() == player_name.lower(): if p.name.lower() == player_name.lower():
player = p player = p
break break
if not player: if not player:
player = players[0] # Use first match player = players[0] # Use first match
# Check if player belongs to another team (not user's team and not Free Agency)
if player.team and hasattr(player.team, 'abbrev'):
# Player belongs to another team if:
# 1. They have a team assigned AND
# 2. That team is not Free Agency (abbrev != 'FA') AND
# 3. That team is not the user's team
if (player.team.abbrev != 'FA' and
player.team.id != builder.team.id):
self.logger.warning(f"Player {player.name} belongs to {player.team.abbrev}, cannot add to {builder.team.abbrev} transaction")
return False, f"{player.name} belongs to {player.team.abbrev} and cannot be added to your transaction"
# Parse destination # Parse destination
destination_map = { destination_map = {
@ -174,17 +255,36 @@ class DropAddCommands(commands.Cog):
to_roster = destination_map.get(destination_str.lower()) to_roster = destination_map.get(destination_str.lower())
if not to_roster: if not to_roster:
self.logger.error(f"Invalid destination: {destination_str}") self.logger.error(f"Invalid destination: {destination_str}")
return False return False, f"Invalid destination: {destination_str}"
# Determine player's current roster status based on their team and roster type # Determine player's current roster status by checking actual roster data
if player.team_id == builder.team.id: # Note: Minor League players have different team_id than Major League team
# Player is on the user's team - need to determine which roster self.logger.debug(f"Player {player.name} team_id: {player.team_id}, Builder team_id: {builder.team.id}")
# This would need to be enhanced to check actual roster data
# For now, we'll assume they're coming from Major League await builder.load_roster_data()
from_roster = RosterType.MAJOR_LEAGUE if builder._current_roster:
# Check which roster section the player is on (regardless of team_id)
player_on_active = any(p.id == player.id for p in builder._current_roster.active_players)
player_on_minor = any(p.id == player.id for p in builder._current_roster.minor_league_players)
player_on_il = any(p.id == player.id for p in builder._current_roster.il_players)
if player_on_active:
from_roster = RosterType.MAJOR_LEAGUE
self.logger.debug(f"Player {player.name} found on active roster (Major League)")
elif player_on_minor:
from_roster = RosterType.MINOR_LEAGUE
self.logger.debug(f"Player {player.name} found on minor league roster")
elif player_on_il:
from_roster = RosterType.INJURED_LIST
self.logger.debug(f"Player {player.name} found on injured list")
else:
# Player not found on user's roster - they're from another team or free agency
from_roster = RosterType.FREE_AGENCY
self.logger.debug(f"Player {player.name} not found on user's roster, treating as free agency")
else: else:
# Player is on another team or free agency # Couldn't load roster data, assume free agency as safest fallback
from_roster = RosterType.FREE_AGENCY from_roster = RosterType.FREE_AGENCY
self.logger.warning(f"Could not load roster data, assuming {player.name} is free agency")
# Create move # Create move
move = TransactionMove( move = TransactionMove(
@ -198,11 +298,12 @@ class DropAddCommands(commands.Cog):
success, error_message = builder.add_move(move) success, error_message = builder.add_move(move)
if not success: if not success:
self.logger.warning(f"Failed to add quick move: {error_message}") self.logger.warning(f"Failed to add quick move: {error_message}")
return success return False, error_message
return True, ""
except Exception as e: except Exception as e:
self.logger.error(f"Error adding quick move: {e}") self.logger.error(f"Error adding quick move: {e}")
return False return False, f"Error adding move: {str(e)}"
@app_commands.command( @app_commands.command(
name="cleartransaction", name="cleartransaction",
@ -218,56 +319,6 @@ class DropAddCommands(commands.Cog):
ephemeral=True ephemeral=True
) )
@app_commands.command(
name="transactionstatus",
description="Show your current transaction builder status"
)
@logged_command("/transactionstatus")
async def transaction_status(self, interaction: discord.Interaction):
"""Show the current status of user's transaction builder."""
await interaction.response.defer(ephemeral=True)
# Get user's major league team
major_league_teams = await team_service.get_teams_by_owner(
interaction.user.id,
SBA_CURRENT_SEASON,
roster_type="ml"
)
if not major_league_teams:
await interaction.followup.send(
"❌ You don't appear to own a major league team in the current season.",
ephemeral=True
)
return
team = major_league_teams[0]
builder = get_transaction_builder(interaction.user.id, team)
if builder.is_empty:
await interaction.followup.send(
"📋 Your transaction builder is empty. Use `/dropadd` to start building!",
ephemeral=True
)
return
# Show current status
validation = await builder.validate_transaction()
status_msg = f"📋 **Transaction Builder Status - {team.abbrev}**\n\n"
status_msg += f"**Moves:** {builder.move_count}\n"
status_msg += f"**Status:** {'✅ Legal' if validation.is_legal else '❌ Illegal'}\n\n"
if validation.errors:
status_msg += "**Errors:**\n"
status_msg += "\n".join([f"{error}" for error in validation.errors])
status_msg += "\n\n"
if validation.suggestions:
status_msg += "**Suggestions:**\n"
status_msg += "\n".join([f"💡 {suggestion}" for suggestion in validation.suggestions])
await interaction.followup.send(status_msg, ephemeral=True)
async def setup(bot): async def setup(bot):

View File

@ -13,6 +13,7 @@ from services.transaction_builder import TransactionBuilder
from models.team import RosterType from models.team import RosterType
from models.team import Team from models.team import Team
from models.player import Player from models.player import Player
from tests.factories import PlayerFactory, TeamFactory
class TestDropAddCommands: class TestDropAddCommands:
@ -47,34 +48,23 @@ class TestDropAddCommands:
@pytest.fixture @pytest.fixture
def mock_team(self): def mock_team(self):
"""Create mock team data.""" """Create mock team data."""
return Team( return TeamFactory.west_virginia()
id=499,
abbrev='WV',
sname='Black Bears',
lname='West Virginia Black Bears',
season=12
)
@pytest.fixture @pytest.fixture
def mock_player(self): def mock_player(self):
"""Create mock player data.""" """Create mock player data."""
return Player( return PlayerFactory.mike_trout()
id=12472,
name='Mike Trout',
season=12,
primary_position='CF'
)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_player_autocomplete_success(self, commands_cog, mock_interaction): async def test_player_autocomplete_success(self, commands_cog, mock_interaction):
"""Test successful player autocomplete.""" """Test successful player autocomplete."""
mock_players = [ mock_players = [
Player(id=1, name='Mike Trout', season=12, primary_position='CF'), PlayerFactory.mike_trout(id=1),
Player(id=2, name='Ronald Acuna Jr.', season=12, primary_position='OF') PlayerFactory.ronald_acuna(id=2)
] ]
with patch('commands.transactions.dropadd.player_service') as mock_service: with patch('commands.transactions.dropadd.player_service') as mock_service:
mock_service.get_players_by_name.return_value = mock_players mock_service.search_players = AsyncMock(return_value=mock_players)
choices = await commands_cog.player_autocomplete(mock_interaction, 'Trout') choices = await commands_cog.player_autocomplete(mock_interaction, 'Trout')
@ -87,18 +77,12 @@ class TestDropAddCommands:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_player_autocomplete_with_team(self, commands_cog, mock_interaction): async def test_player_autocomplete_with_team(self, commands_cog, mock_interaction):
"""Test player autocomplete with team information.""" """Test player autocomplete with team information."""
mock_team = Team(id=499, abbrev='LAA', sname='Angels', lname='Los Angeles Angels', season=12) mock_team = TeamFactory.create(id=499, abbrev='LAA', sname='Angels', lname='Los Angeles Angels')
mock_player = Player( mock_player = PlayerFactory.mike_trout(id=1)
id=1,
name='Mike Trout',
season=12,
primary_position='CF'
)
mock_player.team = mock_team # Add team info mock_player.team = mock_team # Add team info
with patch('commands.transactions.dropadd.player_service') as mock_service: with patch('commands.transactions.dropadd.player_service') as mock_service:
mock_service.get_players_by_name.return_value = [mock_player] mock_service.search_players = AsyncMock(return_value=[mock_player])
choices = await commands_cog.player_autocomplete(mock_interaction, 'Trout') choices = await commands_cog.player_autocomplete(mock_interaction, 'Trout')
assert len(choices) == 1 assert len(choices) == 1
@ -115,7 +99,7 @@ class TestDropAddCommands:
async def test_player_autocomplete_error_handling(self, commands_cog, mock_interaction): async def test_player_autocomplete_error_handling(self, commands_cog, mock_interaction):
"""Test player autocomplete error handling.""" """Test player autocomplete error handling."""
with patch('commands.transactions.dropadd.player_service') as mock_service: with patch('commands.transactions.dropadd.player_service') as mock_service:
mock_service.get_players_by_name.side_effect = Exception("API Error") mock_service.search_players.side_effect = Exception("API Error")
choices = await commands_cog.player_autocomplete(mock_interaction, 'Trout') choices = await commands_cog.player_autocomplete(mock_interaction, 'Trout')
assert len(choices) == 0 assert len(choices) == 0
@ -124,16 +108,15 @@ class TestDropAddCommands:
async def test_dropadd_command_no_team(self, commands_cog, mock_interaction): async def test_dropadd_command_no_team(self, commands_cog, mock_interaction):
"""Test /dropadd command when user has no team.""" """Test /dropadd command when user has no team."""
with patch('commands.transactions.dropadd.team_service') as mock_service: with patch('commands.transactions.dropadd.team_service') as mock_service:
mock_service.get_teams_by_owner.return_value = [] mock_service.get_teams_by_owner = AsyncMock(return_value=[])
await commands_cog.dropadd.callback(commands_cog, mock_interaction)
await commands_cog.dropadd(mock_interaction)
mock_interaction.response.defer.assert_called_once() mock_interaction.response.defer.assert_called_once()
mock_interaction.followup.send.assert_called_once() mock_interaction.followup.send.assert_called_once()
# Check error message # Check error message
call_args = mock_interaction.followup.send.call_args call_args = mock_interaction.followup.send.call_args
assert "don't appear to own a team" in call_args[0][0] assert "don't appear to own" in call_args[0][0]
assert call_args[1]['ephemeral'] is True assert call_args[1]['ephemeral'] is True
@pytest.mark.asyncio @pytest.mark.asyncio
@ -142,7 +125,7 @@ class TestDropAddCommands:
with patch('commands.transactions.dropadd.team_service') as mock_team_service: with patch('commands.transactions.dropadd.team_service') as mock_team_service:
with patch('commands.transactions.dropadd.get_transaction_builder') as mock_get_builder: with patch('commands.transactions.dropadd.get_transaction_builder') as mock_get_builder:
with patch('commands.transactions.dropadd.create_transaction_embed') as mock_create_embed: with patch('commands.transactions.dropadd.create_transaction_embed') as mock_create_embed:
mock_team_service.get_teams_by_owner.return_value = [mock_team] mock_team_service.get_teams_by_owner = AsyncMock(return_value=[mock_team])
mock_builder = MagicMock() mock_builder = MagicMock()
mock_builder.team = mock_team mock_builder.team = mock_team
@ -151,12 +134,12 @@ class TestDropAddCommands:
mock_embed = MagicMock() mock_embed = MagicMock()
mock_create_embed.return_value = mock_embed mock_create_embed.return_value = mock_embed
await commands_cog.dropadd(mock_interaction) await commands_cog.dropadd.callback(commands_cog, mock_interaction)
# Verify flow # Verify flow
mock_interaction.response.defer.assert_called_once() mock_interaction.response.defer.assert_called_once()
mock_team_service.get_teams_by_owner.assert_called_once_with( mock_team_service.get_teams_by_owner.assert_called_once_with(
mock_interaction.user.id, 12 mock_interaction.user.id, 12, roster_type='ml'
) )
mock_get_builder.assert_called_once_with(mock_interaction.user.id, mock_team) mock_get_builder.assert_called_once_with(mock_interaction.user.id, mock_team)
mock_create_embed.assert_called_once_with(mock_builder) mock_create_embed.assert_called_once_with(mock_builder)
@ -169,23 +152,23 @@ class TestDropAddCommands:
with patch('commands.transactions.dropadd.get_transaction_builder') as mock_get_builder: with patch('commands.transactions.dropadd.get_transaction_builder') as mock_get_builder:
with patch.object(commands_cog, '_add_quick_move') as mock_add_quick: with patch.object(commands_cog, '_add_quick_move') as mock_add_quick:
with patch('commands.transactions.dropadd.create_transaction_embed') as mock_create_embed: with patch('commands.transactions.dropadd.create_transaction_embed') as mock_create_embed:
mock_team_service.get_teams_by_owner.return_value = [mock_team] mock_team_service.get_teams_by_owner = AsyncMock(return_value=[mock_team])
mock_builder = MagicMock() mock_builder = MagicMock()
mock_builder.move_count = 1
mock_get_builder.return_value = mock_builder mock_get_builder.return_value = mock_builder
mock_add_quick.return_value = True mock_add_quick.return_value = (True, "")
mock_create_embed.return_value = MagicMock() mock_create_embed.return_value = MagicMock()
await commands_cog.dropadd( await commands_cog.dropadd.callback(commands_cog,
mock_interaction, mock_interaction,
player='Mike Trout', player='Mike Trout',
action='add',
destination='ml' destination='ml'
) )
# Verify quick move was attempted # Verify quick move was attempted
mock_add_quick.assert_called_once_with( mock_add_quick.assert_called_once_with(
mock_builder, 'Mike Trout', 'add', 'ml' mock_builder, 'Mike Trout', 'ml'
) )
@pytest.mark.asyncio @pytest.mark.asyncio
@ -193,17 +176,23 @@ class TestDropAddCommands:
"""Test successful quick move addition.""" """Test successful quick move addition."""
mock_builder = MagicMock() mock_builder = MagicMock()
mock_builder.team = mock_team mock_builder.team = mock_team
mock_builder.add_move.return_value = True mock_builder.add_move.return_value = (True, "")
mock_builder.load_roster_data = AsyncMock()
mock_builder._current_roster = MagicMock()
mock_builder._current_roster.active_players = []
mock_builder._current_roster.minor_league_players = []
mock_builder._current_roster.il_players = []
with patch('commands.transactions.dropadd.player_service') as mock_service: with patch('commands.transactions.dropadd.player_service') as mock_service:
mock_service.get_players_by_name.return_value = [mock_player] mock_service.search_players = AsyncMock(return_value=[mock_player])
success = await commands_cog._add_quick_move( success, error_message = await commands_cog._add_quick_move(
mock_builder, 'Mike Trout', 'add', 'ml' mock_builder, 'Mike Trout', 'ml'
) )
assert success is True assert success is True
mock_service.get_players_by_name.assert_called_once_with('Mike Trout', 12) assert error_message == ""
mock_service.search_players.assert_called_once_with('Mike Trout', limit=10, season=12)
mock_builder.add_move.assert_called_once() mock_builder.add_move.assert_called_once()
@pytest.mark.asyncio @pytest.mark.asyncio
@ -213,26 +202,86 @@ class TestDropAddCommands:
mock_builder.team = mock_team mock_builder.team = mock_team
with patch('commands.transactions.dropadd.player_service') as mock_service: with patch('commands.transactions.dropadd.player_service') as mock_service:
mock_service.get_players_by_name.return_value = [] mock_service.search_players = AsyncMock(return_value=[])
success = await commands_cog._add_quick_move( success, error_message = await commands_cog._add_quick_move(
mock_builder, 'Nonexistent Player', 'add', 'ml' mock_builder, 'Nonexistent Player', 'ml'
) )
assert success is False assert success is False
assert "not found" in error_message
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_add_quick_move_invalid_action(self, commands_cog, mock_team): async def test_add_quick_move_invalid_action(self, commands_cog, mock_team, mock_player):
"""Test quick move with invalid action.""" """Test quick move with invalid action."""
mock_builder = MagicMock() mock_builder = MagicMock()
mock_builder.team = mock_team mock_builder.team = mock_team
success = await commands_cog._add_quick_move( with patch('commands.transactions.dropadd.player_service') as mock_service:
mock_builder, 'Mike Trout', 'invalid_action', 'ml' mock_service.search_players = AsyncMock(return_value=[mock_player])
)
success, error_message = await commands_cog._add_quick_move(
assert success is False mock_builder, 'Mike Trout', 'invalid_destination'
)
assert success is False
assert "Invalid destination" in error_message
@pytest.mark.asyncio
async def test_add_quick_move_player_from_other_team(self, commands_cog, mock_team):
"""Test quick move when player belongs to another team."""
mock_builder = MagicMock()
mock_builder.team = mock_team # West Virginia (team_id=499)
# Create a player from another team (not Free Agency)
other_team = MagicMock()
other_team.id = 100 # Different team
other_team.abbrev = "LAA" # Not 'FA'
mock_player = MagicMock()
mock_player.name = "Mike Trout"
mock_player.team = other_team
with patch('commands.transactions.dropadd.player_service') as mock_service:
mock_service.search_players = AsyncMock(return_value=[mock_player])
success, error_message = await commands_cog._add_quick_move(
mock_builder, 'Mike Trout', 'ml'
)
assert success is False
assert "belongs to LAA" in error_message
assert "cannot be added to your transaction" in error_message
@pytest.mark.asyncio
async def test_add_quick_move_free_agent_allowed(self, commands_cog, mock_team):
"""Test quick move when player is a Free Agent (should be allowed)."""
from tests.factories import PlayerFactory, TeamFactory
mock_builder = MagicMock()
mock_builder.team = mock_team
mock_builder.add_move.return_value = (True, "")
mock_builder.load_roster_data = AsyncMock()
mock_builder._current_roster = MagicMock()
mock_builder._current_roster.active_players = []
mock_builder._current_roster.minor_league_players = []
mock_builder._current_roster.il_players = []
# Create a Free Agent team and player
fa_team = TeamFactory.create(id=1, abbrev="FA", sname="Free Agency", lname="Free Agency")
fa_player = PlayerFactory.create(id=12472, name="Mike Trout", team_id=1)
fa_player.team = fa_team
with patch('commands.transactions.dropadd.player_service') as mock_service:
mock_service.search_players = AsyncMock(return_value=[fa_player])
success, error_message = await commands_cog._add_quick_move(
mock_builder, 'Mike Trout', 'ml'
)
assert success is True
assert error_message == ""
# TODO: These tests are for obsolete MoveAction-based functionality # TODO: These tests are for obsolete MoveAction-based functionality
# The transaction system now uses from_roster/to_roster directly # The transaction system now uses from_roster/to_roster directly
# def test_determine_roster_types_add(self, commands_cog): # def test_determine_roster_types_add(self, commands_cog):
@ -245,7 +294,7 @@ class TestDropAddCommands:
async def test_clear_transaction_command(self, commands_cog, mock_interaction): async def test_clear_transaction_command(self, commands_cog, mock_interaction):
"""Test /cleartransaction command.""" """Test /cleartransaction command."""
with patch('commands.transactions.dropadd.clear_transaction_builder') as mock_clear: with patch('commands.transactions.dropadd.clear_transaction_builder') as mock_clear:
await commands_cog.clear_transaction(mock_interaction) await commands_cog.clear_transaction.callback(commands_cog, mock_interaction)
mock_clear.assert_called_once_with(mock_interaction.user.id) mock_clear.assert_called_once_with(mock_interaction.user.id)
mock_interaction.response.send_message.assert_called_once() mock_interaction.response.send_message.assert_called_once()
@ -255,64 +304,79 @@ class TestDropAddCommands:
assert "transaction builder has been cleared" in call_args[0][0] assert "transaction builder has been cleared" in call_args[0][0]
assert call_args[1]['ephemeral'] is True assert call_args[1]['ephemeral'] is True
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_transaction_status_no_team(self, commands_cog, mock_interaction): async def test_dropadd_first_move_shows_full_embed(self, commands_cog, mock_interaction, mock_team):
"""Test /transactionstatus when user has no team.""" """Test /dropadd command with first move shows full interactive embed."""
with patch('commands.transactions.dropadd.team_service') as mock_service:
mock_service.get_teams_by_owner.return_value = []
await commands_cog.transaction_status(mock_interaction)
mock_interaction.response.defer.assert_called_once_with(ephemeral=True)
mock_interaction.followup.send.assert_called_once()
call_args = mock_interaction.followup.send.call_args
assert "don't appear to own a team" in call_args[0][0]
@pytest.mark.asyncio
async def test_transaction_status_empty_builder(self, commands_cog, mock_interaction, mock_team):
"""Test /transactionstatus with empty builder."""
with patch('commands.transactions.dropadd.team_service') as mock_team_service: with patch('commands.transactions.dropadd.team_service') as mock_team_service:
with patch('commands.transactions.dropadd.get_transaction_builder') as mock_get_builder: with patch('commands.transactions.dropadd.get_transaction_builder') as mock_get_builder:
mock_team_service.get_teams_by_owner.return_value = [mock_team] with patch.object(commands_cog, '_add_quick_move') as mock_add_quick:
with patch('commands.transactions.dropadd.create_transaction_embed') as mock_create_embed:
mock_builder = MagicMock() mock_team_service.get_teams_by_owner = AsyncMock(return_value=[mock_team])
mock_builder.is_empty = True
mock_get_builder.return_value = mock_builder # Create empty builder (first move)
mock_builder = MagicMock()
await commands_cog.transaction_status(mock_interaction) mock_builder.is_empty = True
mock_builder.move_count = 1
call_args = mock_interaction.followup.send.call_args mock_get_builder.return_value = mock_builder
assert "transaction builder is empty" in call_args[0][0] mock_add_quick.return_value = (True, "")
mock_create_embed.return_value = MagicMock()
await commands_cog.dropadd.callback(commands_cog,
mock_interaction,
player='Mike Trout',
destination='ml'
)
# Should show full embed with view (now ephemeral)
mock_interaction.followup.send.assert_called_once()
call_args = mock_interaction.followup.send.call_args
assert call_args[1]['ephemeral'] is True
assert 'embed' in call_args[1]
assert 'view' in call_args[1]
assert 'content' in call_args[1]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_transaction_status_with_moves(self, commands_cog, mock_interaction, mock_team): async def test_dropadd_append_mode_shows_confirmation(self, commands_cog, mock_interaction, mock_team):
"""Test /transactionstatus with moves in builder.""" """Test /dropadd command in append mode shows ephemeral confirmation."""
from services.transaction_builder import RosterValidationResult
with patch('commands.transactions.dropadd.team_service') as mock_team_service: with patch('commands.transactions.dropadd.team_service') as mock_team_service:
with patch('commands.transactions.dropadd.get_transaction_builder') as mock_get_builder: with patch('commands.transactions.dropadd.get_transaction_builder') as mock_get_builder:
mock_team_service.get_teams_by_owner.return_value = [mock_team] with patch.object(commands_cog, '_add_quick_move') as mock_add_quick:
mock_team_service.get_teams_by_owner = AsyncMock(return_value=[mock_team])
mock_builder = MagicMock()
mock_builder.is_empty = False # Create builder with existing moves (append mode)
mock_builder.move_count = 2 mock_builder = MagicMock()
mock_builder.validate_transaction = AsyncMock(return_value=RosterValidationResult( mock_builder.is_empty = False
is_legal=True, mock_builder.move_count = 2
major_league_count=25, mock_builder.validate_transaction = AsyncMock(return_value=MagicMock(
minor_league_count=10, is_legal=True,
warnings=[], major_league_count=25,
errors=[], minor_league_count=10,
suggestions=[] warnings=[],
)) errors=[],
mock_get_builder.return_value = mock_builder suggestions=[]
))
await commands_cog.transaction_status(mock_interaction) mock_get_builder.return_value = mock_builder
mock_add_quick.return_value = (True, "")
call_args = mock_interaction.followup.send.call_args
status_msg = call_args[0][0] with patch('commands.transactions.dropadd.create_transaction_embed') as mock_create_embed:
assert "Moves:** 2" in status_msg mock_create_embed.return_value = MagicMock()
assert "✅ Legal" in status_msg
await commands_cog.dropadd.callback(commands_cog,
mock_interaction,
player='Kevin Ginkel',
destination='ml'
)
# Should show embed with ephemeral confirmation
mock_interaction.followup.send.assert_called_once()
call_args = mock_interaction.followup.send.call_args
assert call_args[1]['ephemeral'] is True
assert 'embed' in call_args[1]
assert 'view' in call_args[1]
content = call_args[1]['content']
assert "Added Kevin Ginkel → ML" in content
assert "Transaction now has 2 moves" in content
class TestDropAddCommandsIntegration: class TestDropAddCommandsIntegration:
@ -334,38 +398,30 @@ class TestDropAddCommandsIntegration:
mock_interaction = AsyncMock() mock_interaction = AsyncMock()
mock_interaction.user.id = 123456789 mock_interaction.user.id = 123456789
mock_team = Team( mock_team = TeamFactory.west_virginia()
id=499,
abbrev='WV',
sname='Black Bears',
lname='West Virginia Black Bears',
season=12
)
mock_player = Player( mock_player = PlayerFactory.mike_trout(id=12472)
id=12472,
name='Mike Trout',
season=12,
primary_position='CF'
)
with patch('commands.transactions.dropadd.team_service') as mock_team_service: with patch('commands.transactions.dropadd.team_service') as mock_team_service:
with patch('commands.transactions.dropadd.player_service') as mock_player_service: with patch('commands.transactions.dropadd.player_service') as mock_player_service:
with patch('commands.transactions.dropadd.get_transaction_builder') as mock_get_builder: with patch('commands.transactions.dropadd.get_transaction_builder') as mock_get_builder:
with patch('commands.transactions.dropadd.create_transaction_embed') as mock_create_embed: with patch('commands.transactions.dropadd.create_transaction_embed') as mock_create_embed:
# Setup mocks # Setup mocks
mock_team_service.get_teams_by_owner.return_value = [mock_team] mock_team_service.get_teams_by_owner = AsyncMock(return_value=[mock_team])
mock_player_service.get_players_by_name.return_value = [mock_player] mock_player_service.search_players = AsyncMock(return_value=[mock_player])
mock_builder = TransactionBuilder(mock_team, 123456789, 12) mock_builder = TransactionBuilder(mock_team, 123456789, 12)
mock_get_builder.return_value = mock_builder mock_get_builder.return_value = mock_builder
mock_create_embed.return_value = MagicMock()
# Mock the async function
async def mock_create_embed_func(builder):
return MagicMock()
mock_create_embed.side_effect = mock_create_embed_func
# Execute command with parameters # Execute command with parameters
await commands_cog.dropadd( await commands_cog.dropadd.callback(commands_cog,
mock_interaction, mock_interaction,
player='Mike Trout', player='Mike Trout',
action='add',
destination='ml' destination='ml'
) )
@ -384,10 +440,11 @@ class TestDropAddCommandsIntegration:
with patch('commands.transactions.dropadd.team_service') as mock_service: with patch('commands.transactions.dropadd.team_service') as mock_service:
# Simulate API error # Simulate API error
mock_service.get_teams_by_owner.side_effect = Exception("API Error") mock_service.get_teams_by_owner = AsyncMock(side_effect=Exception("API Error"))
# Should not raise exception, should handle gracefully # Exception should be raised (logged_command decorator re-raises)
await commands_cog.dropadd(mock_interaction) with pytest.raises(Exception, match="API Error"):
await commands_cog.dropadd.callback(commands_cog, mock_interaction)
# Should have deferred and attempted to send error (which will also fail gracefully)
# Should have deferred before the error occurred
mock_interaction.response.defer.assert_called_once() mock_interaction.response.defer.assert_called_once()