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

View File

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

View File

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

View File

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