fix: roster validation now includes pending trades and fixes sWAR field name
Some checks failed
Build Docker Image / build (pull_request) Failing after 15s

RosterValidation used total_wara instead of total_sWAR, causing /legal
to silently fail. Transaction embed and submit validation now pass
next_week to validate_transaction() so pending trades are included in
roster count projections. Moved lazy imports to top-level in
transaction_embed.py. Fixed dropadd integration test fixtures that
exceeded sWAR cap.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Cal Corum 2026-02-15 22:50:01 -06:00
parent 25ba45e529
commit f29cccd3ab
4 changed files with 764 additions and 489 deletions

View File

@ -3,6 +3,7 @@ Roster service for Discord Bot v2.0
Handles roster operations and validation. Handles roster operations and validation.
""" """
import logging import logging
from typing import Optional, List, Dict from typing import Optional, List, Dict
@ -12,79 +13,80 @@ from models.player import Player
from models.transaction import RosterValidation from models.transaction import RosterValidation
from exceptions import APIException from exceptions import APIException
logger = logging.getLogger(f'{__name__}.RosterService') logger = logging.getLogger(f"{__name__}.RosterService")
class RosterService: class RosterService:
"""Service for roster operations and validation.""" """Service for roster operations and validation."""
def __init__(self): def __init__(self):
"""Initialize roster service.""" """Initialize roster service."""
from api.client import get_global_client from api.client import get_global_client
self._get_client = get_global_client self._get_client = get_global_client
logger.debug("RosterService initialized") logger.debug("RosterService initialized")
async def get_client(self): async def get_client(self):
"""Get the API client.""" """Get the API client."""
return await self._get_client() return await self._get_client()
async def get_team_roster( async def get_team_roster(
self, self, team_id: int, week_type: str = "current"
team_id: int,
week_type: str = "current"
) -> Optional[TeamRoster]: ) -> Optional[TeamRoster]:
""" """
Get team roster for current or next week. Get team roster for current or next week.
Args: Args:
team_id: Team ID from database team_id: Team ID from database
week_type: "current" or "next" week_type: "current" or "next"
Returns: Returns:
TeamRoster object or None if not found TeamRoster object or None if not found
""" """
try: try:
client = await self.get_client() client = await self.get_client()
# Use the team roster endpoint # 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: 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 return None
# Add team metadata if not present # Add team metadata if not present
if 'team_id' not in roster_data: if "team_id" not in roster_data:
roster_data['team_id'] = team_id roster_data["team_id"] = team_id
# Determine week number (this might need adjustment based on API) # Determine week number (this might need adjustment based on API)
roster_data.setdefault('week', 0) # Will need current week info roster_data.setdefault("week", 0) # Will need current week info
roster_data.setdefault('season', 12) # Will need current season info roster_data.setdefault("season", 12) # Will need current season info
roster = TeamRoster.from_api_data(roster_data) roster = TeamRoster.from_api_data(roster_data)
logger.debug(f"Retrieved roster for team {team_id}, {week_type} week") logger.debug(f"Retrieved roster for team {team_id}, {week_type} week")
return roster return roster
except Exception as e: except Exception as e:
logger.error(f"Error getting roster for team {team_id}: {e}") logger.error(f"Error getting roster for team {team_id}: {e}")
raise APIException(f"Failed to retrieve roster: {e}") raise APIException(f"Failed to retrieve roster: {e}")
async def get_current_roster(self, team_id: int) -> Optional[TeamRoster]: async def get_current_roster(self, team_id: int) -> Optional[TeamRoster]:
"""Get current week roster.""" """Get current week roster."""
return await self.get_team_roster(team_id, "current") return await self.get_team_roster(team_id, "current")
async def get_next_roster(self, team_id: int) -> Optional[TeamRoster]: 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") return await self.get_team_roster(team_id, "next")
async def validate_roster(self, roster: TeamRoster) -> RosterValidation: async def validate_roster(self, roster: TeamRoster) -> RosterValidation:
""" """
Validate roster for legality according to league rules. Validate roster for legality according to league rules.
Args: Args:
roster: TeamRoster to validate roster: TeamRoster to validate
Returns: Returns:
RosterValidation with results RosterValidation with results
""" """
@ -95,49 +97,66 @@ class RosterService:
active_players=roster.active_count, active_players=roster.active_count,
il_players=roster.il_count, il_players=roster.il_count,
minor_league_players=roster.minor_league_count, minor_league_players=roster.minor_league_count,
total_wara=roster.total_wara total_sWAR=roster.total_wara,
) )
# Validate active roster size (typical limits) # Validate active roster size (typical limits)
if roster.active_count > 25: # Adjust based on league rules if roster.active_count > 25: # Adjust based on league rules
validation.is_legal = False 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 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 # Validate total roster size
if roster.total_players > 50: # Adjust based on league rules if roster.total_players > 50: # Adjust based on league rules
validation.is_legal = False 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 requirements validation
position_counts = self._count_positions(roster.active_players) position_counts = self._count_positions(roster.active_players)
# Check catcher requirement (at least 2 catchers) # 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") validation.warnings.append("Fewer than 2 catchers on active roster")
# Check pitcher requirements (at least 10 pitchers) # 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: 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) # WARA validation (if there are limits)
if validation.total_wara > 100: # Adjust based on league rules if validation.total_sWAR > 100: # Adjust based on league rules
validation.warnings.append(f"High WARA total: {validation.total_wara:.2f}") validation.warnings.append(
elif validation.total_wara < 20: f"High WARA total: {validation.total_sWAR:.2f}"
validation.warnings.append(f"Low WARA total: {validation.total_wara:.2f}") )
elif validation.total_sWAR < 20:
logger.debug(f"Validated roster: legal={validation.is_legal}, {len(validation.errors)} errors, {len(validation.warnings)} warnings") 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 return validation
except Exception as e: except Exception as e:
logger.error(f"Error validating roster: {e}") logger.error(f"Error validating roster: {e}")
return RosterValidation( return RosterValidation(
is_legal=False, is_legal=False, errors=[f"Validation error: {str(e)}"]
errors=[f"Validation error: {str(e)}"]
) )
def _count_positions(self, players: List[Player]) -> Dict[str, int]: def _count_positions(self, players: List[Player]) -> Dict[str, int]:
"""Count players by position.""" """Count players by position."""
position_counts = {} position_counts = {}
@ -145,48 +164,52 @@ class RosterService:
pos = player.primary_position pos = player.primary_position
position_counts[pos] = position_counts.get(pos, 0) + 1 position_counts[pos] = position_counts.get(pos, 0) + 1
return position_counts return position_counts
async def get_roster_summary(self, roster: TeamRoster) -> Dict[str, any]: async def get_roster_summary(self, roster: TeamRoster) -> Dict[str, any]:
""" """
Get a summary of roster composition. Get a summary of roster composition.
Args: Args:
roster: TeamRoster to summarize roster: TeamRoster to summarize
Returns: Returns:
Dictionary with roster summary information Dictionary with roster summary information
""" """
try: try:
position_counts = self._count_positions(roster.active_players) position_counts = self._count_positions(roster.active_players)
# Group positions # Group positions
catchers = position_counts.get('C', 0) catchers = position_counts.get("C", 0)
infielders = sum(position_counts.get(pos, 0) for pos in ['1B', '2B', '3B', 'SS', 'IF']) infielders = sum(
outfielders = sum(position_counts.get(pos, 0) for pos in ['LF', 'CF', 'RF', 'OF']) position_counts.get(pos, 0) for pos in ["1B", "2B", "3B", "SS", "IF"]
pitchers = sum(position_counts.get(pos, 0) for pos in ['SP', 'RP', 'P']) )
dh = position_counts.get('DH', 0) 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 = { summary = {
'total_active': roster.active_count, "total_active": roster.active_count,
'total_il': roster.il_count, "total_il": roster.il_count,
'total_minor': roster.minor_league_count, "total_minor": roster.minor_league_count,
'total_wara': roster.total_wara, "total_wara": roster.total_wara,
'positions': { "positions": {
'catchers': catchers, "catchers": catchers,
'infielders': infielders, "infielders": infielders,
'outfielders': outfielders, "outfielders": outfielders,
'pitchers': pitchers, "pitchers": pitchers,
'dh': dh "dh": dh,
}, },
'detailed_positions': position_counts "detailed_positions": position_counts,
} }
return summary return summary
except Exception as e: except Exception as e:
logger.error(f"Error creating roster summary: {e}") logger.error(f"Error creating roster summary: {e}")
return {} return {}
# Global service instance # Global service instance
roster_service = RosterService() roster_service = RosterService()

View File

@ -3,6 +3,7 @@ Integration tests for /dropadd functionality
Tests complete workflows from command invocation through transaction submission. Tests complete workflows from command invocation through transaction submission.
""" """
import pytest import pytest
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
from datetime import datetime from datetime import datetime
@ -12,13 +13,10 @@ from services.transaction_builder import (
TransactionBuilder, TransactionBuilder,
TransactionMove, TransactionMove,
get_transaction_builder, get_transaction_builder,
clear_transaction_builder clear_transaction_builder,
) )
from models.team import RosterType from models.team import RosterType
from views.transaction_embed import ( from views.transaction_embed import TransactionEmbedView, SubmitConfirmationModal
TransactionEmbedView,
SubmitConfirmationModal
)
from models.team import Team from models.team import Team
from models.player import Player from models.player import Player
from models.roster import TeamRoster from models.roster import TeamRoster
@ -29,17 +27,17 @@ from tests.factories import PlayerFactory, TeamFactory
class TestDropAddIntegration: class TestDropAddIntegration:
"""Integration tests for complete /dropadd workflows.""" """Integration tests for complete /dropadd workflows."""
@pytest.fixture @pytest.fixture
def mock_bot(self): def mock_bot(self):
"""Create mock Discord bot.""" """Create mock Discord bot."""
return MagicMock() return MagicMock()
@pytest.fixture @pytest.fixture
def commands_cog(self, mock_bot): def commands_cog(self, mock_bot):
"""Create DropAddCommands cog instance.""" """Create DropAddCommands cog instance."""
return DropAddCommands(mock_bot) return DropAddCommands(mock_bot)
@pytest.fixture @pytest.fixture
def mock_interaction(self): def mock_interaction(self):
"""Create mock Discord interaction.""" """Create mock Discord interaction."""
@ -63,24 +61,26 @@ class TestDropAddIntegration:
mock_message.embeds[0].title = "📋 Transaction Builder" mock_message.embeds[0].title = "📋 Transaction Builder"
mock_message.edit = AsyncMock() 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 return interaction
@pytest.fixture @pytest.fixture
def mock_team(self): def mock_team(self):
"""Create mock team.""" """Create mock team."""
return TeamFactory.west_virginia() return TeamFactory.west_virginia()
@pytest.fixture @pytest.fixture
def mock_players(self): def mock_players(self):
"""Create mock players.""" """Create mock players."""
return [ return [
PlayerFactory.mike_trout(), PlayerFactory.mike_trout(),
PlayerFactory.ronald_acuna(), PlayerFactory.ronald_acuna(),
PlayerFactory.mookie_betts() PlayerFactory.mookie_betts(),
] ]
@pytest.fixture @pytest.fixture
def mock_roster(self): def mock_roster(self):
"""Create mock team roster. """Create mock team roster.
@ -91,75 +91,77 @@ class TestDropAddIntegration:
# Create 24 ML players (under limit) # Create 24 ML players (under limit)
ml_players = [] ml_players = []
for i in range(24): for i in range(24):
ml_players.append(Player( ml_players.append(
id=1000 + i, Player(
name=f'ML Player {i}', id=1000 + i,
wara=3.0 + i * 0.1, name=f"ML Player {i}",
season=13, wara=1.0,
team_id=499, season=13,
team=None, team_id=499,
image=None, team=None,
image2=None, image=None,
vanity_card=None, image2=None,
headshot=None, vanity_card=None,
pos_1='OF', headshot=None,
pitcher_injury=None, pos_1="OF",
injury_rating=None, pitcher_injury=None,
il_return=None, injury_rating=None,
demotion_week=None, il_return=None,
last_game=None, demotion_week=None,
last_game2=None, last_game=None,
strat_code=None, last_game2=None,
bbref_id=None, strat_code=None,
sbaplayer=None bbref_id=None,
)) sbaplayer=None,
)
)
# Create 4 MiL players (under 6 limit to allow adding) # Create 4 MiL players (under 6 limit to allow adding)
mil_players = [] mil_players = []
for i in range(4): for i in range(4):
mil_players.append(Player( mil_players.append(
id=2000 + i, Player(
name=f'MiL Player {i}', id=2000 + i,
wara=1.0 + i * 0.1, name=f"MiL Player {i}",
season=13, wara=1.0 + i * 0.1,
team_id=499, season=13,
team=None, team_id=499,
image=None, team=None,
image2=None, image=None,
vanity_card=None, image2=None,
headshot=None, vanity_card=None,
pos_1='OF', headshot=None,
pitcher_injury=None, pos_1="OF",
injury_rating=None, pitcher_injury=None,
il_return=None, injury_rating=None,
demotion_week=None, il_return=None,
last_game=None, demotion_week=None,
last_game2=None, last_game=None,
strat_code=None, last_game2=None,
bbref_id=None, strat_code=None,
sbaplayer=None bbref_id=None,
)) sbaplayer=None,
)
)
return TeamRoster( return TeamRoster(
team_id=499, team_id=499,
team_abbrev='TST', team_abbrev="TST",
week=10, week=10,
season=13, season=13,
active_players=ml_players, active_players=ml_players,
minor_league_players=mil_players minor_league_players=mil_players,
) )
@pytest.fixture @pytest.fixture
def mock_current_state(self): def mock_current_state(self):
"""Create mock current league state.""" """Create mock current league state."""
return Current( return Current(week=10, season=13, freeze=False)
week=10,
season=13,
freeze=False
)
@pytest.mark.asyncio @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. """Test complete workflow for single move transaction.
Verifies that when a player and destination are provided to /dropadd, Verifies that when a player and destination are provided to /dropadd,
@ -173,23 +175,42 @@ class TestDropAddIntegration:
# Clear any existing builders # Clear any existing builders
clear_transaction_builder(mock_interaction.user.id) clear_transaction_builder(mock_interaction.user.id)
with patch('commands.transactions.dropadd.validate_user_has_team') as mock_validate: with patch(
with patch('commands.transactions.dropadd.player_service') as mock_player_service: "commands.transactions.dropadd.validate_user_has_team"
with patch('services.transaction_builder.roster_service') as mock_roster_service: ) as mock_validate:
with patch('services.transaction_builder.transaction_service') as mock_tx_service: 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 # Setup mocks
mock_validate.return_value = mock_team # validate_user_has_team returns team mock_validate.return_value = (
mock_player_service.search_players = AsyncMock(return_value=[mock_players[0]]) # Mike Trout mock_team # validate_user_has_team returns team
mock_roster_service.get_current_roster = AsyncMock(return_value=mock_roster) )
mock_tx_service.get_team_transactions = AsyncMock(return_value=[]) 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 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 # Execute /dropadd command with quick move
await commands_cog.dropadd.callback(commands_cog, await commands_cog.dropadd.callback(
commands_cog,
mock_interaction, mock_interaction,
player='Mike Trout', player="Mike Trout",
destination='ml' destination="ml",
) )
# Verify command execution # Verify command execution
@ -197,12 +218,14 @@ class TestDropAddIntegration:
mock_interaction.followup.send.assert_called_once() mock_interaction.followup.send.assert_called_once()
# Get the builder that was created # 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 # Verify the move was added
assert builder.move_count == 1 assert builder.move_count == 1
move = builder.moves[0] move = builder.moves[0]
assert move.player.name == 'Mike Trout' assert move.player.name == "Mike Trout"
# Note: TransactionMove no longer has 'action' field # Note: TransactionMove no longer has 'action' field
assert move.to_roster == RosterType.MAJOR_LEAGUE assert move.to_roster == RosterType.MAJOR_LEAGUE
@ -210,9 +233,11 @@ class TestDropAddIntegration:
validation = await builder.validate_transaction() validation = await builder.validate_transaction()
assert validation.is_legal is True assert validation.is_legal is True
assert validation.major_league_count == 25 # 24 + 1 assert validation.major_league_count == 25 # 24 + 1
@pytest.mark.asyncio @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. """Test complete workflow for multi-move transaction.
Verifies that manually adding multiple moves to the transaction builder Verifies that manually adding multiple moves to the transaction builder
@ -220,32 +245,42 @@ class TestDropAddIntegration:
""" """
clear_transaction_builder(mock_interaction.user.id) clear_transaction_builder(mock_interaction.user.id)
with patch('commands.transactions.dropadd.validate_user_has_team') as mock_validate: with patch(
with patch('services.transaction_builder.roster_service') as mock_roster_service: "commands.transactions.dropadd.validate_user_has_team"
with patch('services.transaction_builder.transaction_service') as mock_tx_service: ) 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_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=[]) mock_tx_service.get_team_transactions = AsyncMock(return_value=[])
# Start with /dropadd command # Start with /dropadd command
await commands_cog.dropadd.callback(commands_cog, mock_interaction) await commands_cog.dropadd.callback(commands_cog, mock_interaction)
# Get the builder # 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) # Manually add multiple moves (simulating UI interactions)
add_move = TransactionMove( add_move = TransactionMove(
player=mock_players[0], # Mike Trout player=mock_players[0], # Mike Trout
from_roster=RosterType.FREE_AGENCY, from_roster=RosterType.FREE_AGENCY,
to_roster=RosterType.MAJOR_LEAGUE, to_roster=RosterType.MAJOR_LEAGUE,
to_team=mock_team to_team=mock_team,
) )
drop_move = TransactionMove( drop_move = TransactionMove(
player=mock_players[1], # Ronald Acuna Jr. player=mock_players[1], # Ronald Acuna Jr.
from_roster=RosterType.MAJOR_LEAGUE, from_roster=RosterType.MAJOR_LEAGUE,
to_roster=RosterType.FREE_AGENCY, to_roster=RosterType.FREE_AGENCY,
from_team=mock_team from_team=mock_team,
) )
await builder.add_move(add_move, check_pending_transactions=False) await builder.add_move(add_move, check_pending_transactions=False)
@ -256,9 +291,17 @@ class TestDropAddIntegration:
validation = await builder.validate_transaction() validation = await builder.validate_transaction()
assert validation.is_legal is True assert validation.is_legal is True
assert validation.major_league_count == 24 # 24 + 1 - 1 = 24 assert validation.major_league_count == 24 # 24 + 1 - 1 = 24
@pytest.mark.asyncio @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. """Test complete transaction submission workflow.
Verifies that submitting a transaction via the builder creates Verifies that submitting a transaction via the builder creates
@ -266,10 +309,16 @@ class TestDropAddIntegration:
""" """
clear_transaction_builder(mock_interaction.user.id) clear_transaction_builder(mock_interaction.user.id)
with patch('services.transaction_builder.roster_service') as mock_roster_service: with patch(
with patch('services.transaction_builder.transaction_service') as mock_tx_service: "services.transaction_builder.roster_service"
) as mock_roster_service:
with patch(
"services.transaction_builder.transaction_service"
) as mock_tx_service:
# Setup mocks # 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=[]) mock_tx_service.get_team_transactions = AsyncMock(return_value=[])
# Create builder and add move # Create builder and add move
@ -278,7 +327,7 @@ class TestDropAddIntegration:
player=mock_players[0], player=mock_players[0],
from_roster=RosterType.FREE_AGENCY, from_roster=RosterType.FREE_AGENCY,
to_roster=RosterType.MAJOR_LEAGUE, 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)
@ -289,14 +338,15 @@ class TestDropAddIntegration:
assert len(transactions) == 1 assert len(transactions) == 1
transaction = transactions[0] transaction = transactions[0]
assert isinstance(transaction, Transaction) assert isinstance(transaction, Transaction)
assert transaction.player.name == 'Mike Trout' assert transaction.player.name == "Mike Trout"
assert transaction.week == 11 assert transaction.week == 11
assert transaction.season == 13 assert transaction.season == 13
assert "Season-013-Week-11-" in transaction.moveid assert "Season-013-Week-11-" in transaction.moveid
@pytest.mark.asyncio @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. """Test submission confirmation modal workflow.
Verifies that the SubmitConfirmationModal properly: Verifies that the SubmitConfirmationModal properly:
@ -305,42 +355,70 @@ class TestDropAddIntegration:
3. Submits transactions 3. Submits transactions
4. Posts success message 4. Posts success message
Note: The modal imports services dynamically inside on_submit(), Note: The modal uses services imported at the top of views/transaction_embed.py,
so we patch them where they're imported from (services.X module). 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 Note: Discord.py's TextInput.value is a read-only property, so we
replace the entire confirmation attribute with a MagicMock. replace the entire confirmation attribute with a MagicMock.
""" """
clear_transaction_builder(mock_interaction.user.id) clear_transaction_builder(mock_interaction.user.id)
with patch('services.transaction_builder.roster_service') as mock_roster_service: with patch(
with patch('services.transaction_builder.transaction_service') as mock_tx_service: "services.transaction_builder.roster_service"
with patch('services.league_service.league_service') as mock_league_service: ) as mock_roster_service:
with patch('services.transaction_service.transaction_service') as mock_view_tx_service: with patch(
with patch('utils.transaction_logging.post_transaction_to_log') as mock_post_log: "services.transaction_builder.transaction_service"
mock_roster_service.get_current_roster = AsyncMock(return_value=mock_roster) ) as mock_tx_service:
mock_tx_service.get_team_transactions = AsyncMock(return_value=[]) with patch(
mock_league_service.get_current_state = AsyncMock(return_value=mock_current_state) "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 mock_post_log.return_value = None
# Create builder with move # 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( move = TransactionMove(
player=mock_players[0], player=mock_players[0],
from_roster=RosterType.FREE_AGENCY, from_roster=RosterType.FREE_AGENCY,
to_roster=RosterType.MAJOR_LEAGUE, 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 # Submit transactions first to get move IDs
transactions = await builder.submit_transaction(week=mock_current_state.week + 1) transactions = await builder.submit_transaction(
mock_view_tx_service.create_transaction_batch = AsyncMock(return_value=transactions) 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 # Reset the builder and add move again for modal test
clear_transaction_builder(mock_interaction.user.id) clear_transaction_builder(mock_interaction.user.id)
builder = get_transaction_builder(mock_interaction.user.id, mock_team) builder = get_transaction_builder(
await builder.add_move(move, check_pending_transactions=False) mock_interaction.user.id, mock_team
)
await builder.add_move(
move, check_pending_transactions=False
)
# Create the modal # Create the modal
modal = SubmitConfirmationModal(builder) modal = SubmitConfirmationModal(builder)
@ -348,14 +426,16 @@ class TestDropAddIntegration:
# Replace the entire confirmation input with a mock that has .value # 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 # Discord.py's TextInput.value is read-only, so we can't patch it
mock_confirmation = MagicMock() mock_confirmation = MagicMock()
mock_confirmation.value = 'CONFIRM' mock_confirmation.value = "CONFIRM"
modal.confirmation = mock_confirmation modal.confirmation = mock_confirmation
await modal.on_submit(mock_interaction) await modal.on_submit(mock_interaction)
# Verify submission process # Verify submission process
mock_league_service.get_current_state.assert_called() 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() mock_interaction.followup.send.assert_called_once()
# Verify success message # Verify success message
@ -363,9 +443,11 @@ class TestDropAddIntegration:
success_msg = call_args[0][0] success_msg = call_args[0][0]
assert "Transaction Submitted Successfully" in success_msg assert "Transaction Submitted Successfully" in success_msg
assert "Move ID:" in success_msg assert "Move ID:" in success_msg
@pytest.mark.asyncio @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. """Test error handling throughout the workflow.
Verifies that when validate_user_has_team raises an error, Verifies that when validate_user_has_team raises an error,
@ -377,7 +459,9 @@ class TestDropAddIntegration:
""" """
clear_transaction_builder(mock_interaction.user.id) 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 # Test API error handling
mock_validate.side_effect = Exception("API Error") mock_validate.side_effect = Exception("API Error")
@ -390,9 +474,11 @@ class TestDropAddIntegration:
# Should still defer (called before error) # Should still defer (called before error)
mock_interaction.response.defer.assert_called_once() mock_interaction.response.defer.assert_called_once()
@pytest.mark.asyncio @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. """Test roster validation throughout workflow.
Verifies that the transaction builder correctly validates roster limits 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) # Create roster at limit (26 ML players for week 10)
ml_players = [] ml_players = []
for i in range(26): for i in range(26):
ml_players.append(Player( ml_players.append(
id=1000 + i, Player(
name=f'ML Player {i}', id=1000 + i,
wara=3.0 + i * 0.1, name=f"ML Player {i}",
season=13, wara=1.0,
team_id=499, season=13,
team=None, team_id=499,
image=None, team=None,
image2=None, image=None,
vanity_card=None, image2=None,
headshot=None, vanity_card=None,
pos_1='OF', headshot=None,
pitcher_injury=None, pos_1="OF",
injury_rating=None, pitcher_injury=None,
il_return=None, injury_rating=None,
demotion_week=None, il_return=None,
last_game=None, demotion_week=None,
last_game2=None, last_game=None,
strat_code=None, last_game2=None,
bbref_id=None, strat_code=None,
sbaplayer=None bbref_id=None,
)) sbaplayer=None,
)
)
full_roster = TeamRoster( full_roster = TeamRoster(
team_id=499, team_id=499,
team_abbrev='TST', team_abbrev="TST",
week=10, week=10,
season=13, season=13,
active_players=ml_players active_players=ml_players,
) )
with patch('services.transaction_builder.roster_service') as mock_roster_service: with patch(
with patch('services.transaction_builder.transaction_service') as mock_tx_service: "services.transaction_builder.roster_service"
mock_roster_service.get_current_roster = AsyncMock(return_value=full_roster) ) 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=[]) mock_tx_service.get_team_transactions = AsyncMock(return_value=[])
# Create builder and try to add player (should exceed limit) # Create builder and try to add player (should exceed limit)
@ -445,7 +539,7 @@ class TestDropAddIntegration:
player=mock_players[0], player=mock_players[0],
from_roster=RosterType.FREE_AGENCY, from_roster=RosterType.FREE_AGENCY,
to_roster=RosterType.MAJOR_LEAGUE, 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)
@ -457,9 +551,11 @@ class TestDropAddIntegration:
assert "27 players (limit: 26)" in validation.errors[0] assert "27 players (limit: 26)" in validation.errors[0]
assert len(validation.suggestions) > 0 assert len(validation.suggestions) > 0
assert "Drop 1 ML player" in validation.suggestions[0] assert "Drop 1 ML player" in validation.suggestions[0]
@pytest.mark.asyncio @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. """Test that transaction builder persists across command calls.
Verifies that calling /dropadd multiple times uses the same Verifies that calling /dropadd multiple times uses the same
@ -467,33 +563,44 @@ class TestDropAddIntegration:
""" """
clear_transaction_builder(mock_interaction.user.id) clear_transaction_builder(mock_interaction.user.id)
with patch('commands.transactions.dropadd.validate_user_has_team') as mock_validate: with patch(
with patch('services.transaction_builder.roster_service') as mock_roster_service: "commands.transactions.dropadd.validate_user_has_team"
with patch('services.transaction_builder.transaction_service') as mock_tx_service: ) 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_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=[]) mock_tx_service.get_team_transactions = AsyncMock(return_value=[])
# First command call # First command call
await commands_cog.dropadd.callback(commands_cog, mock_interaction) 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 # Add a move
move = TransactionMove( move = TransactionMove(
player=mock_players[0], player=mock_players[0],
from_roster=RosterType.FREE_AGENCY, from_roster=RosterType.FREE_AGENCY,
to_roster=RosterType.MAJOR_LEAGUE, to_roster=RosterType.MAJOR_LEAGUE,
to_team=mock_team to_team=mock_team,
) )
await builder1.add_move(move, check_pending_transactions=False) await builder1.add_move(move, check_pending_transactions=False)
assert builder1.move_count == 1 assert builder1.move_count == 1
# Second command call should get same builder # Second command call should get same builder
await commands_cog.dropadd.callback(commands_cog, mock_interaction) 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 # Should be same instance with same moves
assert builder1 is builder2 assert builder1 is builder2
assert builder2.move_count == 1 assert builder2.move_count == 1
assert builder2.moves[0].player.name == 'Mike Trout' assert builder2.moves[0].player.name == "Mike Trout"

View File

@ -3,6 +3,7 @@ Tests for Transaction Embed Views
Validates Discord UI components, modals, and interactive elements. Validates Discord UI components, modals, and interactive elements.
""" """
import pytest import pytest
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
import discord import discord
@ -12,12 +13,12 @@ from views.transaction_embed import (
RemoveMoveView, RemoveMoveView,
RemoveMoveSelect, RemoveMoveSelect,
SubmitConfirmationModal, SubmitConfirmationModal,
create_transaction_embed create_transaction_embed,
) )
from services.transaction_builder import ( from services.transaction_builder import (
TransactionBuilder, TransactionBuilder,
TransactionMove, TransactionMove,
RosterValidationResult RosterValidationResult,
) )
from models.team import Team, RosterType from models.team import Team, RosterType
from models.player import Player from models.player import Player
@ -25,11 +26,17 @@ from models.player import Player
class TestTransactionEmbedView: class TestTransactionEmbedView:
"""Test TransactionEmbedView Discord UI component.""" """Test TransactionEmbedView Discord UI component."""
@pytest.fixture @pytest.fixture
def mock_builder(self): def mock_builder(self):
"""Create mock TransactionBuilder.""" """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 = MagicMock(spec=TransactionBuilder)
builder.team = team builder.team = team
builder.user_id = 123456789 builder.user_id = 123456789
@ -40,9 +47,9 @@ class TestTransactionEmbedView:
builder.created_at = MagicMock() builder.created_at = MagicMock()
builder.created_at.strftime.return_value = "10:30:15" builder.created_at.strftime.return_value = "10:30:15"
return builder return builder
# Don't create view as fixture - create in test methods to ensure event loop is running # Don't create view as fixture - create in test methods to ensure event loop is running
@pytest.fixture @pytest.fixture
def mock_interaction(self): def mock_interaction(self):
"""Create mock Discord interaction.""" """Create mock Discord interaction."""
@ -54,154 +61,184 @@ class TestTransactionEmbedView:
interaction.client = MagicMock() interaction.client = MagicMock()
interaction.channel = MagicMock() interaction.channel = MagicMock()
return interaction return interaction
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_interaction_check_correct_user(self, mock_builder, mock_interaction): async def test_interaction_check_correct_user(self, mock_builder, mock_interaction):
"""Test interaction check passes for correct user.""" """Test interaction check passes for correct user."""
view = TransactionEmbedView(mock_builder, user_id=123456789) view = TransactionEmbedView(mock_builder, user_id=123456789)
result = await view.interaction_check(mock_interaction) result = await view.interaction_check(mock_interaction)
assert result is True assert result is True
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_interaction_check_wrong_user(self, mock_builder, mock_interaction): async def test_interaction_check_wrong_user(self, mock_builder, mock_interaction):
"""Test interaction check fails for wrong user.""" """Test interaction check fails for wrong user."""
view = TransactionEmbedView(mock_builder, user_id=123456789) view = TransactionEmbedView(mock_builder, user_id=123456789)
mock_interaction.user.id = 999999999 # Different user mock_interaction.user.id = 999999999 # Different user
result = await view.interaction_check(mock_interaction) result = await view.interaction_check(mock_interaction)
assert result is False assert result is False
mock_interaction.response.send_message.assert_called_once() mock_interaction.response.send_message.assert_called_once()
call_args = mock_interaction.response.send_message.call_args call_args = mock_interaction.response.send_message.call_args
assert "don't have permission" in call_args[0][0] 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 @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.""" """Test remove move button with empty builder."""
view = TransactionEmbedView(mock_builder, user_id=123456789) view = TransactionEmbedView(mock_builder, user_id=123456789)
view.builder.is_empty = True view.builder.is_empty = True
await view.remove_move_button.callback(mock_interaction) await view.remove_move_button.callback(mock_interaction)
mock_interaction.response.send_message.assert_called_once() mock_interaction.response.send_message.assert_called_once()
call_args = mock_interaction.response.send_message.call_args call_args = mock_interaction.response.send_message.call_args
assert "No moves to remove" in call_args[0][0] 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 @pytest.mark.asyncio
async def test_remove_move_button_with_moves(self, mock_builder, mock_interaction): async def test_remove_move_button_with_moves(self, mock_builder, mock_interaction):
"""Test remove move button with moves available.""" """Test remove move button with moves available."""
view = TransactionEmbedView(mock_builder, user_id=123456789) view = TransactionEmbedView(mock_builder, user_id=123456789)
view.builder.is_empty = False 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() mock_create_embed.return_value = MagicMock()
await view.remove_move_button.callback(mock_interaction) await view.remove_move_button.callback(mock_interaction)
mock_interaction.response.edit_message.assert_called_once() mock_interaction.response.edit_message.assert_called_once()
# Check that view is RemoveMoveView # Check that view is RemoveMoveView
call_args = mock_interaction.response.edit_message.call_args 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) assert isinstance(view_arg, RemoveMoveView)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_submit_button_empty_builder(self, mock_builder, mock_interaction): async def test_submit_button_empty_builder(self, mock_builder, mock_interaction):
"""Test submit button with empty builder.""" """Test submit button with empty builder."""
view = TransactionEmbedView(mock_builder, user_id=123456789) view = TransactionEmbedView(mock_builder, user_id=123456789)
view.builder.is_empty = True view.builder.is_empty = True
await view.submit_button.callback(mock_interaction) await view.submit_button.callback(mock_interaction)
mock_interaction.response.send_message.assert_called_once() mock_interaction.response.send_message.assert_called_once()
call_args = mock_interaction.response.send_message.call_args call_args = mock_interaction.response.send_message.call_args
assert "Cannot submit empty transaction" in call_args[0][0] 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 @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.""" """Test submit button with illegal transaction."""
view = TransactionEmbedView(mock_builder, user_id=123456789) view = TransactionEmbedView(mock_builder, user_id=123456789)
view.builder.is_empty = False view.builder.is_empty = False
view.builder.validate_transaction = AsyncMock(return_value=RosterValidationResult( view.builder.validate_transaction = AsyncMock(
is_legal=False, return_value=RosterValidationResult(
major_league_count=26, is_legal=False,
minor_league_count=10, major_league_count=26,
warnings=[], minor_league_count=10,
errors=["Too many players"], warnings=[],
suggestions=["Drop 1 player"] errors=["Too many players"],
)) suggestions=["Drop 1 player"],
)
await view.submit_button.callback(mock_interaction) )
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() mock_interaction.response.send_message.assert_called_once()
call_args = mock_interaction.response.send_message.call_args call_args = mock_interaction.response.send_message.call_args
message = call_args[0][0] message = call_args[0][0]
assert "Cannot submit illegal transaction" in message assert "Cannot submit illegal transaction" in message
assert "Too many players" in message assert "Too many players" in message
assert "Drop 1 player" 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 @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.""" """Test submit button with legal transaction."""
view = TransactionEmbedView(mock_builder, user_id=123456789) view = TransactionEmbedView(mock_builder, user_id=123456789)
view.builder.is_empty = False view.builder.is_empty = False
view.builder.validate_transaction = AsyncMock(return_value=RosterValidationResult( view.builder.validate_transaction = AsyncMock(
is_legal=True, return_value=RosterValidationResult(
major_league_count=25, is_legal=True,
minor_league_count=10, major_league_count=25,
warnings=[], minor_league_count=10,
errors=[], warnings=[],
suggestions=[] errors=[],
)) suggestions=[],
)
await view.submit_button.callback(mock_interaction) )
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 # Should send confirmation modal
mock_interaction.response.send_modal.assert_called_once() mock_interaction.response.send_modal.assert_called_once()
modal_arg = mock_interaction.response.send_modal.call_args[0][0] modal_arg = mock_interaction.response.send_modal.call_args[0][0]
assert isinstance(modal_arg, SubmitConfirmationModal) assert isinstance(modal_arg, SubmitConfirmationModal)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_cancel_button(self, mock_builder, mock_interaction): async def test_cancel_button(self, mock_builder, mock_interaction):
"""Test cancel button clears moves and disables view.""" """Test cancel button clears moves and disables view."""
view = TransactionEmbedView(mock_builder, user_id=123456789) 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() mock_create_embed.return_value = MagicMock()
await view.cancel_button.callback(mock_interaction) await view.cancel_button.callback(mock_interaction)
# Should clear moves # Should clear moves
view.builder.clear_moves.assert_called_once() view.builder.clear_moves.assert_called_once()
# Should edit message with disabled view # Should edit message with disabled view
mock_interaction.response.edit_message.assert_called_once() mock_interaction.response.edit_message.assert_called_once()
call_args = mock_interaction.response.edit_message.call_args 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: class TestSubmitConfirmationModal:
"""Test SubmitConfirmationModal functionality.""" """Test SubmitConfirmationModal functionality."""
@pytest.fixture @pytest.fixture
def mock_builder(self): def mock_builder(self):
"""Create mock TransactionBuilder.""" """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 = MagicMock(spec=TransactionBuilder)
builder.team = team builder.team = team
builder.moves = [] builder.moves = []
return builder return builder
@pytest.fixture @pytest.fixture
def modal(self, mock_builder): def modal(self, mock_builder):
"""Create SubmitConfirmationModal instance.""" """Create SubmitConfirmationModal instance."""
return SubmitConfirmationModal(mock_builder) return SubmitConfirmationModal(mock_builder)
@pytest.fixture @pytest.fixture
def mock_interaction(self): def mock_interaction(self):
"""Create mock Discord interaction.""" """Create mock Discord interaction."""
@ -212,72 +249,94 @@ class TestSubmitConfirmationModal:
interaction.followup = AsyncMock() interaction.followup = AsyncMock()
interaction.client = MagicMock() interaction.client = MagicMock()
interaction.channel = MagicMock() interaction.channel = MagicMock()
# Mock message history # Mock message history
mock_message = MagicMock() mock_message = MagicMock()
mock_message.author = interaction.client.user mock_message.author = interaction.client.user
mock_message.embeds = [MagicMock()] mock_message.embeds = [MagicMock()]
mock_message.embeds[0].title = "📋 Transaction Builder" mock_message.embeds[0].title = "📋 Transaction Builder"
mock_message.edit = AsyncMock() 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 return interaction
@pytest.mark.asyncio @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.""" """Test modal submission with wrong confirmation text."""
modal = SubmitConfirmationModal(mock_builder) modal = SubmitConfirmationModal(mock_builder)
# Mock the TextInput values # Mock the TextInput values
modal.confirmation = MagicMock() modal.confirmation = MagicMock()
modal.confirmation.value = 'WRONG' modal.confirmation.value = "WRONG"
await modal.on_submit(mock_interaction) await modal.on_submit(mock_interaction)
mock_interaction.response.send_message.assert_called_once() mock_interaction.response.send_message.assert_called_once()
call_args = mock_interaction.response.send_message.call_args call_args = mock_interaction.response.send_message.call_args
assert "must type 'CONFIRM' exactly" in call_args[0][0] 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 @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.""" """Test modal submission with correct confirmation."""
modal = SubmitConfirmationModal(mock_builder) modal = SubmitConfirmationModal(mock_builder)
# Mock the TextInput values # Mock the TextInput values
modal.confirmation = MagicMock() modal.confirmation = MagicMock()
modal.confirmation.value = 'CONFIRM' modal.confirmation.value = "CONFIRM"
mock_transaction = MagicMock() 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.week = 11
mock_transaction.frozen = False # Will be set to True 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 = MagicMock()
mock_current_state.week = 10 mock_current_state.week = 10
mock_current_state.freeze = True # Simulate Monday-Friday freeze period 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 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() 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) await modal.on_submit(mock_interaction)
# Should defer response # 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 # Should get current state
mock_league_service.get_current_state.assert_called_once() mock_league_service.get_current_state.assert_called_once()
# Should submit transaction for next week # 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) # Should mark transaction as frozen (based on current_state.freeze)
assert mock_transaction.frozen is True assert mock_transaction.frozen is True
@ -293,9 +352,11 @@ class TestSubmitConfirmationModal:
call_args = mock_interaction.followup.send.call_args call_args = mock_interaction.followup.send.call_args
assert "Transaction Submitted Successfully" in call_args[0][0] assert "Transaction Submitted Successfully" in call_args[0][0]
assert mock_transaction.moveid in call_args[0][0] assert mock_transaction.moveid in call_args[0][0]
@pytest.mark.asyncio @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. """Test modal submission during thaw period (Saturday-Sunday) sets frozen=False.
When freeze=False (Saturday-Sunday), transactions should NOT be frozen When freeze=False (Saturday-Sunday), transactions should NOT be frozen
@ -305,29 +366,41 @@ class TestSubmitConfirmationModal:
modal = SubmitConfirmationModal(mock_builder) modal = SubmitConfirmationModal(mock_builder)
# Mock the TextInput values # Mock the TextInput values
modal.confirmation = MagicMock() modal.confirmation = MagicMock()
modal.confirmation.value = 'CONFIRM' modal.confirmation.value = "CONFIRM"
mock_transaction = MagicMock() 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.week = 11
mock_transaction.frozen = True # Will be set to False during thaw 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 = MagicMock()
mock_current_state.week = 10 mock_current_state.week = 10
mock_current_state.freeze = False # Simulate Saturday-Sunday thaw period 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 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() 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) await modal.on_submit(mock_interaction)
# Should mark transaction as NOT frozen (thaw period) # Should mark transaction as NOT frozen (thaw period)
@ -339,9 +412,9 @@ class TestSubmitConfirmationModal:
modal = SubmitConfirmationModal(mock_builder) modal = SubmitConfirmationModal(mock_builder)
# Mock the TextInput values # Mock the TextInput values
modal.confirmation = MagicMock() 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) mock_league_service.get_current_state = AsyncMock(return_value=None)
await modal.on_submit(mock_interaction) await modal.on_submit(mock_interaction)
@ -349,16 +422,22 @@ class TestSubmitConfirmationModal:
mock_interaction.followup.send.assert_called_once() mock_interaction.followup.send.assert_called_once()
call_args = mock_interaction.followup.send.call_args call_args = mock_interaction.followup.send.call_args
assert "Could not get current league state" in call_args[0][0] 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: class TestEmbedCreation:
"""Test embed creation functions.""" """Test embed creation functions."""
@pytest.fixture @pytest.fixture
def mock_builder_empty(self): def mock_builder_empty(self):
"""Create empty mock TransactionBuilder.""" """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 = MagicMock(spec=TransactionBuilder)
builder.team = team builder.team = team
builder.is_empty = True builder.is_empty = True
@ -366,92 +445,121 @@ class TestEmbedCreation:
builder.moves = [] builder.moves = []
builder.created_at = MagicMock() builder.created_at = MagicMock()
builder.created_at.strftime.return_value = "10:30:15" builder.created_at.strftime.return_value = "10:30:15"
builder.validate_transaction = AsyncMock(return_value=RosterValidationResult( builder.validate_transaction = AsyncMock(
is_legal=True, return_value=RosterValidationResult(
major_league_count=24, is_legal=True,
minor_league_count=10, major_league_count=24,
warnings=[], minor_league_count=10,
errors=[], warnings=[],
suggestions=["Add player moves to build your transaction"] errors=[],
)) suggestions=["Add player moves to build your transaction"],
)
)
return builder return builder
@pytest.fixture @pytest.fixture
def mock_builder_with_moves(self): def mock_builder_with_moves(self):
"""Create mock TransactionBuilder with moves.""" """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 = MagicMock(spec=TransactionBuilder)
builder.team = team builder.team = team
builder.is_empty = False builder.is_empty = False
builder.move_count = 2 builder.move_count = 2
mock_moves = [] mock_moves = []
for i in range(2): for i in range(2):
move = MagicMock() move = MagicMock()
move.description = f"Move {i+1}: Player → Team" move.description = f"Move {i+1}: Player → Team"
mock_moves.append(move) mock_moves.append(move)
builder.moves = mock_moves builder.moves = mock_moves
builder.created_at = MagicMock() builder.created_at = MagicMock()
builder.created_at.strftime.return_value = "10:30:15" builder.created_at.strftime.return_value = "10:30:15"
builder.validate_transaction = AsyncMock(return_value=RosterValidationResult( builder.validate_transaction = AsyncMock(
is_legal=False, return_value=RosterValidationResult(
major_league_count=26, is_legal=False,
minor_league_count=10, major_league_count=26,
warnings=["Warning message"], minor_league_count=10,
errors=["Error message"], warnings=["Warning message"],
suggestions=["Suggestion message"] errors=["Error message"],
)) suggestions=["Suggestion message"],
)
)
return builder return builder
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_create_transaction_embed_empty(self, mock_builder_empty): async def test_create_transaction_embed_empty(self, mock_builder_empty):
"""Test creating embed for empty transaction.""" """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 isinstance(embed, discord.Embed)
assert "Transaction Builder - WV" in embed.title assert "Transaction Builder - WV" in embed.title
assert "📋" in embed.title assert "📋" in embed.title
# Should have fields for empty state # Should have fields for empty state
field_names = [field.name for field in embed.fields] field_names = [field.name for field in embed.fields]
assert "Current Moves" in field_names assert "Current Moves" in field_names
assert "Roster Status" in field_names assert "Roster Status" in field_names
assert "Suggestions" in field_names assert "Suggestions" in field_names
# Check empty moves message # 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 assert "No moves yet" in moves_field.value
# Check for Add More Moves instruction field # Check for Add More Moves instruction field
field_names = [field.name for field in embed.fields] field_names = [field.name for field in embed.fields]
assert " Add More Moves" in field_names 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 assert "/dropadd" in add_moves_field.value
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_create_transaction_embed_with_moves(self, mock_builder_with_moves): async def test_create_transaction_embed_with_moves(self, mock_builder_with_moves):
"""Test creating embed for transaction 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 isinstance(embed, discord.Embed)
assert "Transaction Builder - WV" in embed.title assert "Transaction Builder - WV" in embed.title
# Should have all fields # Should have all fields
field_names = [field.name for field in embed.fields] field_names = [field.name for field in embed.fields]
assert "Current Moves (2)" in field_names assert "Current Moves (2)" in field_names
assert "Roster Status" in field_names assert "Roster Status" in field_names
assert "❌ Errors" in field_names assert "❌ Errors" in field_names
assert "Suggestions" in field_names assert "Suggestions" in field_names
# Check moves content # 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 1: Player → Team" in moves_field.value
assert "Move 2: Player → Team" in moves_field.value assert "Move 2: Player → Team" in moves_field.value
# Check for Add More Moves instruction field # Check for Add More Moves instruction field
field_names = [field.name for field in embed.fields] field_names = [field.name for field in embed.fields]
assert " Add More Moves" in field_names 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 assert "/dropadd" in add_moves_field.value

View File

@ -3,11 +3,19 @@ Interactive Transaction Embed Views
Handles the Discord embed and button interfaces for the transaction builder. Handles the Discord embed and button interfaces for the transaction builder.
""" """
import discord import discord
from typing import Optional, List from typing import Optional, List
from datetime import datetime 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 views.embeds import EmbedColors, EmbedTemplate
from utils.transaction_logging import post_transaction_to_log 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): class TransactionEmbedView(discord.ui.View):
"""Interactive view for the transaction builder embed.""" """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. Initialize the transaction embed view.
@ -30,69 +44,83 @@ class TransactionEmbedView(discord.ui.View):
self.user_id = user_id self.user_id = user_id
self.submission_handler = submission_handler self.submission_handler = submission_handler
self.command_name = command_name self.command_name = command_name
async def interaction_check(self, interaction: discord.Interaction) -> bool: async def interaction_check(self, interaction: discord.Interaction) -> bool:
"""Check if user has permission to interact with this view.""" """Check if user has permission to interact with this view."""
if interaction.user.id != self.user_id: if interaction.user.id != self.user_id:
await interaction.response.send_message( await interaction.response.send_message(
"❌ You don't have permission to use this transaction builder.", "❌ You don't have permission to use this transaction builder.",
ephemeral=True ephemeral=True,
) )
return False return False
return True return True
async def on_timeout(self) -> None: async def on_timeout(self) -> None:
"""Handle view timeout.""" """Handle view timeout."""
# Disable all buttons when timeout occurs # Disable all buttons when timeout occurs
for item in self.children: for item in self.children:
if isinstance(item, discord.ui.Button): if isinstance(item, discord.ui.Button):
item.disabled = True item.disabled = True
@discord.ui.button(label="Remove Move", style=discord.ButtonStyle.red, emoji="") @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.""" """Handle remove move button click."""
if self.builder.is_empty: if self.builder.is_empty:
await interaction.response.send_message( await interaction.response.send_message(
"❌ No moves to remove. Add some moves first!", "❌ No moves to remove. Add some moves first!", ephemeral=True
ephemeral=True
) )
return return
# Create select menu for move removal # Create select menu for move removal
select_view = RemoveMoveView(self.builder, self.user_id, self.command_name) select_view = RemoveMoveView(self.builder, self.user_id, self.command_name)
embed = await create_transaction_embed(self.builder, self.command_name) embed = await create_transaction_embed(self.builder, self.command_name)
await interaction.response.edit_message(embed=embed, view=select_view) await interaction.response.edit_message(embed=embed, view=select_view)
@discord.ui.button(label="Submit Transaction", style=discord.ButtonStyle.primary, emoji="📤") @discord.ui.button(
async def submit_button(self, interaction: discord.Interaction, 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.""" """Handle submit transaction button click."""
if self.builder.is_empty: if self.builder.is_empty:
await interaction.response.send_message( await interaction.response.send_message(
"❌ Cannot submit empty transaction. Add some moves first!", "❌ Cannot submit empty transaction. Add some moves first!",
ephemeral=True ephemeral=True,
) )
return return
# Validate before submission # Validate before submission (include pre-existing transactions for scheduled moves)
validation = await self.builder.validate_transaction() 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: if not validation.is_legal:
error_msg = "❌ **Cannot submit illegal transaction:**\n" error_msg = "❌ **Cannot submit illegal transaction:**\n"
error_msg += "\n".join([f"{error}" for error in validation.errors]) error_msg += "\n".join([f"{error}" for error in validation.errors])
if validation.suggestions: if validation.suggestions:
error_msg += "\n\n**Suggestions:**\n" 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) await interaction.response.send_message(error_msg, ephemeral=True)
return return
# Show confirmation modal # Show confirmation modal
modal = SubmitConfirmationModal(self.builder, self.submission_handler) modal = SubmitConfirmationModal(self.builder, self.submission_handler)
await interaction.response.send_modal(modal) await interaction.response.send_modal(modal)
@discord.ui.button(label="Cancel", style=discord.ButtonStyle.secondary, emoji="") @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.""" """Handle cancel button click."""
self.builder.clear_moves() self.builder.clear_moves()
embed = await create_transaction_embed(self.builder, self.command_name) embed = await create_transaction_embed(self.builder, self.command_name)
@ -103,9 +131,7 @@ class TransactionEmbedView(discord.ui.View):
item.disabled = True item.disabled = True
await interaction.response.edit_message( await interaction.response.edit_message(
content="❌ **Transaction cancelled and cleared.**", content="❌ **Transaction cancelled and cleared.**", embed=embed, view=self
embed=embed,
view=self
) )
self.stop() self.stop()
@ -113,7 +139,9 @@ class TransactionEmbedView(discord.ui.View):
class RemoveMoveView(discord.ui.View): class RemoveMoveView(discord.ui.View):
"""View for selecting which move to remove.""" """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 super().__init__(timeout=300.0) # 5 minute timeout
self.builder = builder self.builder = builder
self.user_id = user_id self.user_id = user_id
@ -124,18 +152,24 @@ class RemoveMoveView(discord.ui.View):
self.add_item(RemoveMoveSelect(builder, command_name)) self.add_item(RemoveMoveSelect(builder, command_name))
# Add back button # 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 back_button.callback = self.back_callback
self.add_item(back_button) self.add_item(back_button)
async def back_callback(self, interaction: discord.Interaction): async def back_callback(self, interaction: discord.Interaction):
"""Handle back button to return to main view.""" """Handle back button to return to main view."""
# Determine submission_handler from command_name # Determine submission_handler from command_name
submission_handler = "immediate" if self.command_name == "/ilmove" else "scheduled" submission_handler = (
main_view = TransactionEmbedView(self.builder, self.user_id, submission_handler, self.command_name) "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) embed = await create_transaction_embed(self.builder, self.command_name)
await interaction.response.edit_message(embed=embed, view=main_view) await interaction.response.edit_message(embed=embed, view=main_view)
async def interaction_check(self, interaction: discord.Interaction) -> bool: async def interaction_check(self, interaction: discord.Interaction) -> bool:
"""Check if user has permission to interact with this view.""" """Check if user has permission to interact with this view."""
return interaction.user.id == self.user_id return interaction.user.id == self.user_id
@ -151,17 +185,19 @@ class RemoveMoveSelect(discord.ui.Select):
# Create options from current moves # Create options from current moves
options = [] options = []
for i, move in enumerate(builder.moves[:25]): # Discord limit of 25 options for i, move in enumerate(builder.moves[:25]): # Discord limit of 25 options
options.append(discord.SelectOption( options.append(
label=f"{move.player.name}", discord.SelectOption(
description=move.description[:100], # Discord description limit label=f"{move.player.name}",
value=str(move.player.id) description=move.description[:100], # Discord description limit
)) value=str(move.player.id),
)
)
super().__init__( super().__init__(
placeholder="Select a move to remove...", placeholder="Select a move to remove...",
min_values=1, min_values=1,
max_values=1, max_values=1,
options=options options=options,
) )
async def callback(self, interaction: discord.Interaction): async def callback(self, interaction: discord.Interaction):
@ -172,73 +208,74 @@ class RemoveMoveSelect(discord.ui.Select):
if move: if move:
self.builder.remove_move(player_id) self.builder.remove_move(player_id)
await interaction.response.send_message( await interaction.response.send_message(
f"✅ Removed: {move.description}", f"✅ Removed: {move.description}", ephemeral=True
ephemeral=True
) )
# Update the embed # Update the embed
# Determine submission_handler from command_name # Determine submission_handler from command_name
submission_handler = "immediate" if self.command_name == "/ilmove" else "scheduled" submission_handler = (
main_view = TransactionEmbedView(self.builder, interaction.user.id, submission_handler, self.command_name) "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) embed = await create_transaction_embed(self.builder, self.command_name)
# Edit the original message # Edit the original message
await interaction.edit_original_response(embed=embed, view=main_view) await interaction.edit_original_response(embed=embed, view=main_view)
else: else:
await interaction.response.send_message( await interaction.response.send_message(
"❌ Could not find that move to remove.", "❌ Could not find that move to remove.", ephemeral=True
ephemeral=True
) )
class SubmitConfirmationModal(discord.ui.Modal): class SubmitConfirmationModal(discord.ui.Modal):
"""Modal for confirming transaction submission.""" """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") super().__init__(title="Confirm Transaction Submission")
self.builder = builder self.builder = builder
self.submission_handler = submission_handler self.submission_handler = submission_handler
self.confirmation = discord.ui.TextInput( self.confirmation = discord.ui.TextInput(
label="Type 'CONFIRM' to submit", label="Type 'CONFIRM' to submit",
placeholder="CONFIRM", placeholder="CONFIRM",
required=True, required=True,
max_length=7 max_length=7,
) )
self.add_item(self.confirmation) self.add_item(self.confirmation)
async def on_submit(self, interaction: discord.Interaction): async def on_submit(self, interaction: discord.Interaction):
"""Handle confirmation submission.""" """Handle confirmation submission."""
if self.confirmation.value.upper() != "CONFIRM": if self.confirmation.value.upper() != "CONFIRM":
await interaction.response.send_message( await interaction.response.send_message(
"❌ Transaction not submitted. You must type 'CONFIRM' exactly.", "❌ Transaction not submitted. You must type 'CONFIRM' exactly.",
ephemeral=True ephemeral=True,
) )
return return
await interaction.response.defer(ephemeral=True) await interaction.response.defer(ephemeral=True)
try: 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 # Get current league state
current_state = await league_service.get_current_state() current_state = await league_service.get_current_state()
if not current_state: if not current_state:
await interaction.followup.send( await interaction.followup.send(
"❌ Could not get current league state. Please try again later.", "❌ Could not get current league state. Please try again later.",
ephemeral=True ephemeral=True,
) )
return return
if self.submission_handler == "scheduled": if self.submission_handler == "scheduled":
# SCHEDULED SUBMISSION (/dropadd behavior) # SCHEDULED SUBMISSION (/dropadd behavior)
# Submit the transaction for NEXT week # 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: # Set frozen flag based on current freeze state:
# - During freeze (Mon-Fri): frozen=True -> wait for Saturday conflict resolution # - 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 txn.frozen = current_state.freeze
# POST transactions to database # 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) # Post to #transaction-log channel (only when league is NOT frozen)
# During freeze period, transactions are hidden until Saturday processing # During freeze period, transactions are hidden until Saturday processing
if not current_state.freeze: if not current_state.freeze:
bot = interaction.client 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 # Create success message
success_msg = f"✅ **Transaction Submitted Successfully!**\n\n" success_msg = f"✅ **Transaction Submitted Successfully!**\n\n"
@ -277,27 +318,28 @@ class SubmitConfirmationModal(discord.ui.Modal):
# Submit the transaction for THIS week # Submit the transaction for THIS week
# Don't check existing transactions - they're already in DB and would cause double-counting # Don't check existing transactions - they're already in DB and would cause double-counting
transactions = await self.builder.submit_transaction( transactions = await self.builder.submit_transaction(
week=current_state.week, week=current_state.week, check_existing_transactions=False
check_existing_transactions=False
) )
# POST transactions to database # 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 # Update each player's team assignment
player_updates = [] player_updates = []
for txn in created_transactions: for txn in created_transactions:
updated_player = await player_service.update_player_team( updated_player = await player_service.update_player_team(
txn.player.id, txn.player.id, txn.newteam.id, dem_week=current_state.week
txn.newteam.id,
dem_week=current_state.week
) )
player_updates.append(updated_player) player_updates.append(updated_player)
# Post to #transaction-log channel # Post to #transaction-log channel
# IL moves always post immediately - they're intra-team and don't need freeze hiding # IL moves always post immediately - they're intra-team and don't need freeze hiding
bot = interaction.client 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 # Create success message
success_msg = f"✅ **IL Move Executed Successfully!**\n\n" 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) await interaction.followup.send(success_msg, ephemeral=True)
# Clear the builder after successful submission # Clear the builder after successful submission
from services.transaction_builder import clear_transaction_builder
clear_transaction_builder(interaction.user.id) clear_transaction_builder(interaction.user.id)
# Update the original embed to show completion # 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( completion_embed = discord.Embed(
title=completion_title, title=completion_title,
description=f"Your transaction has been processed successfully!", description=f"Your transaction has been processed successfully!",
color=0x00ff00 color=0x00FF00,
) )
# Disable all buttons # Disable all buttons
@ -330,9 +375,9 @@ class SubmitConfirmationModal(discord.ui.Modal):
try: try:
# Find and update the original message # 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 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) await message.edit(embed=completion_embed, view=view)
break break
except: except:
@ -340,12 +385,13 @@ class SubmitConfirmationModal(discord.ui.Modal):
except Exception as e: except Exception as e:
await interaction.followup.send( await interaction.followup.send(
f"❌ Error submitting transaction: {str(e)}", f"❌ Error submitting transaction: {str(e)}", ephemeral=True
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. 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( embed = EmbedTemplate.create_base_embed(
title=f"📋 Transaction Builder - {builder.team.abbrev}", title=f"📋 Transaction Builder - {builder.team.abbrev}",
description=description, description=description,
color=EmbedColors.PRIMARY color=EmbedColors.PRIMARY,
) )
# Add current moves section # Add current moves section
if builder.is_empty: if builder.is_empty:
embed.add_field( embed.add_field(
name="Current Moves", name="Current Moves",
value="*No moves yet. Use the buttons below to build your transaction.*", value="*No moves yet. Use the buttons below to build your transaction.*",
inline=False inline=False,
) )
else: else:
moves_text = "" moves_text = ""
for i, move in enumerate(builder.moves[:10], 1): # Limit display for i, move in enumerate(builder.moves[:10], 1): # Limit display
moves_text += f"{i}. {move.description}\n" moves_text += f"{i}. {move.description}\n"
if len(builder.moves) > 10: if len(builder.moves) > 10:
moves_text += f"... and {len(builder.moves) - 10} more moves" moves_text += f"... and {len(builder.moves) - 10} more moves"
embed.add_field( embed.add_field(
name=f"Current Moves ({builder.move_count})", name=f"Current Moves ({builder.move_count})", value=moves_text, inline=False
value=moves_text,
inline=False
) )
# Add roster validation # Add roster validation (include pre-existing transactions for scheduled moves)
validation = await builder.validate_transaction() if command_name != "/ilmove":
current_state = await league_service.get_current_state()
roster_status = f"{validation.major_league_status}\n{validation.minor_league_status}" next_week = current_state.week + 1 if current_state else None
validation = await builder.validate_transaction(next_week=next_week)
embed.add_field( else:
name="Roster Status", validation = await builder.validate_transaction()
value=roster_status,
inline=False 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 # Add sWAR status
swar_status = f"{validation.major_league_swar_status}\n{validation.minor_league_swar_status}" swar_status = (
embed.add_field( f"{validation.major_league_swar_status}\n{validation.minor_league_swar_status}"
name="Team sWAR",
value=swar_status,
inline=False
) )
embed.add_field(name="Team sWAR", value=swar_status, inline=False)
# Add pre-existing transactions note if applicable # Add pre-existing transactions note if applicable
if validation.pre_existing_transactions_note: if validation.pre_existing_transactions_note:
embed.add_field( embed.add_field(
name="📋 Transaction Context", name="📋 Transaction Context",
value=validation.pre_existing_transactions_note, value=validation.pre_existing_transactions_note,
inline=False inline=False,
) )
# Add suggestions/errors # Add suggestions/errors
if validation.errors: if validation.errors:
error_text = "\n".join([f"{error}" for error in validation.errors]) error_text = "\n".join([f"{error}" for error in validation.errors])
embed.add_field( embed.add_field(name="❌ Errors", value=error_text, inline=False)
name="❌ Errors",
value=error_text,
inline=False
)
if validation.suggestions: if validation.suggestions:
suggestion_text = "\n".join([f"💡 {suggestion}" for suggestion in validation.suggestions]) suggestion_text = "\n".join(
embed.add_field( [f"💡 {suggestion}" for suggestion in validation.suggestions]
name="Suggestions",
value=suggestion_text,
inline=False
) )
embed.add_field(name="Suggestions", value=suggestion_text, inline=False)
# Add instructions for adding more moves # Add instructions for adding more moves
embed.add_field( embed.add_field(
name=" Add More Moves", name=" Add More Moves",
value=f"Use `{command_name}` to add more moves", value=f"Use `{command_name}` to add more moves",
inline=False inline=False,
) )
# Add footer with timestamp # Add footer with timestamp
embed.set_footer(text=f"Created at {builder.created_at.strftime('%H:%M:%S')}") embed.set_footer(text=f"Created at {builder.created_at.strftime('%H:%M:%S')}")
return embed return embed