Add all-season player search with deduplicated autocomplete

- PlayerService.search_players() now supports all_seasons=True to search across all 13 seasons
- Autocomplete shows unique player names (most recent season's team) instead of duplicates
- Command defaults to most recent season when no season parameter specified
- Users can specify season parameter for historical data

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Cal Corum 2026-01-23 14:23:16 -06:00
parent 249c17a64c
commit bf079eab83
3 changed files with 481 additions and 287 deletions

View File

@ -3,6 +3,7 @@ Player Information Commands
Implements slash commands for displaying player information and statistics.
"""
from typing import Optional, List
import discord
@ -21,26 +22,50 @@ async def player_name_autocomplete(
interaction: discord.Interaction,
current: str,
) -> List[discord.app_commands.Choice[str]]:
"""Autocomplete for player names."""
"""Autocomplete for player names across all seasons, deduplicated by name.
Returns unique player names only (most recent season's data for display).
Users can specify the `season` parameter to get historical data.
"""
if len(current) < 2:
return []
try:
# Use the dedicated search endpoint to get matching players
players = await player_service.search_players(current, limit=25, season=get_config().sba_season)
# Use the dedicated search endpoint to get matching players across ALL seasons
# Results are ordered by most recent season first
players = await player_service.search_players(
current,
limit=50,
all_seasons=True, # Fetch more to ensure 25 unique names
)
# Convert to discord choices, limiting to 25 (Discord's max)
# Deduplicate by player name, keeping only the first (most recent) occurrence
seen_names: set[str] = set()
choices = []
for player in players[:25]:
for player in players:
# Skip if we've already seen this player name
name_lower = player.name.lower()
if name_lower in seen_names:
continue
seen_names.add(name_lower)
# Format: "Player Name (Position) - Team"
# No season indicator needed since we're showing unique players
display_name = f"{player.name} ({player.primary_position})"
if hasattr(player, 'team') and player.team:
if hasattr(player, "team") and player.team:
display_name += f" - {player.team.abbrev}"
choices.append(discord.app_commands.Choice(
name=display_name,
value=player.name
))
choices.append(
discord.app_commands.Choice(
name=display_name[:100], # Discord limit is 100 chars
value=player.name,
)
)
# Stop once we have 25 unique players (Discord's max)
if len(choices) >= 25:
break
return choices
@ -54,54 +79,89 @@ class PlayerInfoCommands(commands.Cog):
def __init__(self, bot: commands.Bot):
self.bot = bot
self.logger = get_contextual_logger(f'{__name__}.PlayerInfoCommands')
self.logger = get_contextual_logger(f"{__name__}.PlayerInfoCommands")
@discord.app_commands.command(
name="player",
description="Display player information and statistics"
description="Display player information and statistics",
)
@discord.app_commands.describe(
name="Player name to search for",
season="Season to show stats for (defaults to current season)"
season="Season number for historical stats (defaults to most recent)",
)
@discord.app_commands.autocomplete(name=player_name_autocomplete)
@logged_command("/player")
async def player_info(
self,
interaction: discord.Interaction,
name: str,
season: Optional[int] = None
self, interaction: discord.Interaction, name: str, season: Optional[int] = None
):
"""Display player card with statistics."""
# Defer response for potentially slow API calls
await interaction.response.defer()
self.logger.debug("Response deferred")
# Search for player by name (use season parameter or default to current)
search_season = season or get_config().sba_season
self.logger.debug("Starting player search", api_call="get_players_by_name", season=search_season)
players = await player_service.get_players_by_name(name, search_season)
self.logger.info("Player search completed", players_found=len(players), season=search_season)
# Determine search strategy based on whether season was explicitly provided
explicit_season = season is not None
if explicit_season:
# User specified a season - search only that season
search_season = season
self.logger.debug(
"Starting player search (explicit season)",
api_call="get_players_by_name",
season=search_season,
)
players = await player_service.get_players_by_name(name, search_season)
else:
# No season specified - search ALL seasons, prioritize most recent
self.logger.debug(
"Starting player search (all seasons)",
api_call="search_players",
all_seasons=True,
)
players = await player_service.search_players(
name, limit=25, all_seasons=True
)
# Set search_season to the season of the first matching player (most recent)
search_season = players[0].season if players else get_config().sba_season
self.logger.info(
"Player search completed",
players_found=len(players),
season=search_season,
all_seasons=not explicit_season,
)
if not players:
# Try fuzzy search as fallback
self.logger.info("No exact matches found, attempting fuzzy search", search_term=name)
fuzzy_players = await player_service.search_players_fuzzy(name, limit=10)
# Try fuzzy search as fallback (search all seasons)
self.logger.info(
"No exact matches found, attempting fuzzy search", search_term=name
)
fuzzy_players = await player_service.search_players_fuzzy(
name, limit=10, all_seasons=True
)
if not fuzzy_players:
self.logger.warning("No players found even with fuzzy search", search_term=name)
self.logger.warning(
"No players found even with fuzzy search", search_term=name
)
await interaction.followup.send(
f"❌ No players found matching '{name}'.",
ephemeral=True
f"❌ No players found matching '{name}'.", ephemeral=True
)
return
# Show fuzzy search results for user selection
self.logger.info("Fuzzy search results found", fuzzy_results_count=len(fuzzy_players))
fuzzy_list = "\n".join([f"{p.name} ({p.primary_position})" for p in fuzzy_players[:10]])
# Show fuzzy search results for user selection with season info
self.logger.info(
"Fuzzy search results found", fuzzy_results_count=len(fuzzy_players)
)
fuzzy_list = "\n".join(
[
f"{p.name} ({p.primary_position}) [Season {p.season}]"
for p in fuzzy_players[:10]
]
)
await interaction.followup.send(
f"🔍 No exact match found for '{name}'. Did you mean one of these?\n{fuzzy_list}\n\nPlease try again with the exact name.",
ephemeral=True
ephemeral=True,
)
return
@ -109,50 +169,76 @@ class PlayerInfoCommands(commands.Cog):
player = None
if len(players) == 1:
player = players[0]
self.logger.debug("Single player found", player_id=player.id, player_name=player.name)
self.logger.debug(
"Single player found", player_id=player.id, player_name=player.name
)
else:
self.logger.debug("Multiple players found, attempting exact match", candidate_count=len(players))
self.logger.debug(
"Multiple players found, attempting exact match",
candidate_count=len(players),
)
# Try exact match
for p in players:
if p.name.lower() == name.lower():
player = p
self.logger.debug("Exact match found", player_id=player.id, player_name=player.name)
self.logger.debug(
"Exact match found",
player_id=player.id,
player_name=player.name,
)
break
if player is None:
# Show multiple options
candidate_names = [p.name for p in players[:10]]
self.logger.info("Multiple candidates found, requiring user clarification",
candidates=candidate_names)
# Show multiple options with season info
candidate_names = [f"{p.name} (S{p.season})" for p in players[:10]]
self.logger.info(
"Multiple candidates found, requiring user clarification",
candidates=candidate_names,
)
player_list = "\n".join([f"{p.name} ({p.primary_position})" for p in players[:10]])
player_list = "\n".join(
[
f"{p.name} ({p.primary_position}) [Season {p.season}]"
for p in players[:10]
]
)
await interaction.followup.send(
f"🔍 Multiple players found for '{name}':\n{player_list}\n\nPlease be more specific.",
ephemeral=True
f"🔍 Multiple players found for '{name}':\n{player_list}\n\nPlease specify a season with the `season` parameter.",
ephemeral=True,
)
return
# Get player data and statistics concurrently
self.logger.debug("Fetching player data and statistics",
player_id=player.id,
season=search_season)
self.logger.debug(
"Fetching player data and statistics",
player_id=player.id,
season=search_season,
)
# Fetch player data and stats concurrently for better performance
import asyncio
player_with_team, (batting_stats, pitching_stats) = await asyncio.gather(
player_service.get_player(player.id),
stats_service.get_player_stats(player.id, search_season)
stats_service.get_player_stats(player.id, search_season),
)
if player_with_team is None:
self.logger.warning("Failed to get player data, using search result")
player_with_team = player # Fallback to search result
else:
team_info = f"{player_with_team.team.abbrev}" if hasattr(player_with_team, 'team') and player_with_team.team else "No team"
self.logger.debug("Player data retrieved", team=team_info,
batting_stats=bool(batting_stats),
pitching_stats=bool(pitching_stats))
team_info = (
f"{player_with_team.team.abbrev}"
if hasattr(player_with_team, "team") and player_with_team.team
else "No team"
)
self.logger.debug(
"Player data retrieved",
team=team_info,
batting_stats=bool(batting_stats),
pitching_stats=bool(pitching_stats),
)
# Create interactive player view with toggleable statistics
self.logger.debug("Creating PlayerStatsView with toggleable statistics")
@ -161,7 +247,7 @@ class PlayerInfoCommands(commands.Cog):
season=search_season,
batting_stats=batting_stats,
pitching_stats=pitching_stats,
user_id=None # setting to None so any GM can toggle the stats views
user_id=None, # setting to None so any GM can toggle the stats views
)
# Get initial embed with stats hidden

View File

@ -3,6 +3,7 @@ Player service for Discord Bot v2.0
Handles player-related operations with team population and search functionality.
"""
import logging
from typing import Optional, List, TYPE_CHECKING
@ -14,7 +15,7 @@ from exceptions import APIException
if TYPE_CHECKING:
from services.team_service import TeamService
logger = logging.getLogger(f'{__name__}.PlayerService')
logger = logging.getLogger(f"{__name__}.PlayerService")
class PlayerService(BaseService[Player]):
@ -29,9 +30,9 @@ class PlayerService(BaseService[Player]):
- Free agent handling via constants
"""
def __init__(self, team_service: Optional['TeamService'] = None):
def __init__(self, team_service: Optional["TeamService"] = None):
"""Initialize player service."""
super().__init__(Player, 'players')
super().__init__(Player, "players")
self._team_service = team_service
logger.debug("PlayerService initialized")
@ -54,8 +55,9 @@ class PlayerService(BaseService[Player]):
logger.error(f"Unexpected error getting player {player_id}: {e}")
return None
async def get_players_by_team(self, team_id: int, season: int, sort: Optional[str] = None) -> List[Player]:
async def get_players_by_team(
self, team_id: int, season: int, sort: Optional[str] = None
) -> List[Player]:
"""
Get all players for a specific team.
@ -68,22 +70,21 @@ class PlayerService(BaseService[Player]):
List of players on the team, optionally sorted
"""
try:
params = [
('season', str(season)),
('team_id', str(team_id))
]
params = [("season", str(season)), ("team_id", str(team_id))]
# Add sort parameter if specified
if sort:
valid_sorts = ['cost-asc', 'cost-desc', 'name-asc', 'name-desc']
valid_sorts = ["cost-asc", "cost-desc", "name-asc", "name-desc"]
if sort in valid_sorts:
params.append(('sort', sort))
params.append(("sort", sort))
logger.debug(f"Applying sort '{sort}' to team {team_id} players")
else:
logger.warning(f"Invalid sort parameter '{sort}' - ignoring")
players = await self.get_all_items(params=params)
logger.debug(f"Retrieved {len(players)} players for team {team_id} in season {season}")
logger.debug(
f"Retrieved {len(players)} players for team {team_id} in season {season}"
)
return players
except Exception as e:
@ -102,20 +103,21 @@ class PlayerService(BaseService[Player]):
List of matching players
"""
try:
params = [
('season', str(season)),
('name', name)
]
params = [("season", str(season)), ("name", name)]
players = await self.get_all_items(params=params)
logger.debug(f"Found {len(players)} players matching '{name}' in season {season}")
logger.debug(
f"Found {len(players)} players matching '{name}' in season {season}"
)
return players
except Exception as e:
logger.error(f"Failed to search players by name '{name}': {e}")
return []
async def get_player_by_name_exact(self, name: str, season: int) -> Optional[Player]:
async def get_player_by_name_exact(
self, name: str, season: int
) -> Optional[Player]:
"""
Get player by exact name match (case-insensitive).
@ -143,25 +145,37 @@ class PlayerService(BaseService[Player]):
logger.error(f"Error finding exact player match for '{name}': {e}")
return None
async def search_players(self, query: str, limit: int = 10, season: Optional[int] = None) -> List[Player]:
async def search_players(
self,
query: str,
limit: int = 10,
season: Optional[int] = None,
all_seasons: bool = False,
) -> List[Player]:
"""
Search for players using the dedicated /v3/players/search endpoint.
Args:
query: Search query for player name
limit: Maximum number of results to return (1-50)
season: Season to search in (defaults to current season)
season: Season to search in (defaults to current season if all_seasons=False)
all_seasons: If True, search across all seasons (ignores season parameter)
Returns:
List of matching players (up to limit)
"""
try:
params = [('q', query), ('limit', str(limit))]
if season is not None:
params.append(('season', str(season)))
params = [("q", query), ("limit", str(limit))]
if all_seasons:
# Pass season=0 to API to search all seasons
params.append(("season", "0"))
elif season is not None:
params.append(("season", str(season)))
# If neither all_seasons nor season specified, API defaults to current season
client = await self.get_client()
data = await client.get('players/search', params=params)
data = await client.get("players/search", params=params)
if not data:
logger.debug(f"No players found for search query '{query}'")
@ -171,16 +185,50 @@ class PlayerService(BaseService[Player]):
items, count = self._extract_items_and_count_from_response(data)
players = [self.model_class.from_api_data(item) for item in items]
logger.debug(f"Search '{query}' returned {len(players)} of {count} matches")
logger.debug(
f"Search '{query}' returned {len(players)} of {count} matches (all_seasons={all_seasons})"
)
return players
except Exception as e:
logger.error(f"Error in player search for '{query}': {e}")
return []
async def search_players_fuzzy(self, query: str, limit: int = 10, season: Optional[int] = None) -> List[Player]:
async def search_players_fuzzy(
self,
query: str,
limit: int = 10,
season: Optional[int] = None,
all_seasons: bool = False,
) -> List[Player]:
"""
Fuzzy search for players by name with limit using existing name search functionality.
Fuzzy search for players by name with limit using the search endpoint.
Args:
query: Search query
limit: Maximum results to return
season: Season to search in (defaults to current season if all_seasons=False)
all_seasons: If True, search across all seasons
Returns:
List of matching players (up to limit)
"""
try:
# Use the search endpoint which handles fuzzy matching and all-seasons
return await self.search_players(
query, limit=limit, season=season, all_seasons=all_seasons
)
except Exception as e:
logger.error(f"Error in fuzzy search for '{query}': {e}")
return []
async def _search_players_fuzzy_legacy(
self, query: str, limit: int = 10, season: Optional[int] = None
) -> List[Player]:
"""
Legacy fuzzy search for players by name with limit using existing name search functionality.
Kept for backwards compatibility if needed.
Args:
query: Search query
@ -213,14 +261,15 @@ class PlayerService(BaseService[Player]):
results = exact_matches + partial_matches
limited_results = results[:limit]
logger.debug(f"Fuzzy search '{query}' returned {len(limited_results)} of {len(results)} matches")
logger.debug(
f"Fuzzy search '{query}' returned {len(limited_results)} of {len(results)} matches"
)
return limited_results
except Exception as e:
logger.error(f"Error in fuzzy search for '{query}': {e}")
return []
async def get_free_agents(self, season: int) -> List[Player]:
"""
Get all free agent players.
@ -232,7 +281,10 @@ class PlayerService(BaseService[Player]):
List of free agent players
"""
try:
params = [('team_id', get_config().free_agent_team_id), ('season', str(season))]
params = [
("team_id", get_config().free_agent_team_id),
("season", str(season)),
]
players = await self.get_all_items(params=params)
logger.debug(f"Retrieved {len(players)} free agents")
@ -268,7 +320,9 @@ class PlayerService(BaseService[Player]):
try:
free_agents = await self.get_free_agents(season)
# Sort by wara descending and take top N
sorted_fa = sorted(free_agents, key=lambda p: p.wara if p.wara else 0.0, reverse=True)
sorted_fa = sorted(
free_agents, key=lambda p: p.wara if p.wara else 0.0, reverse=True
)
return sorted_fa[:limit]
except Exception as e:
logger.error(f"Failed to get top free agents: {e}")
@ -286,7 +340,7 @@ class PlayerService(BaseService[Player]):
List of players at the position
"""
try:
params = [('position', position), ('season', str(season))]
params = [("position", position), ("season", str(season))]
players = await self.get_all_items(params=params)
logger.debug(f"Retrieved {len(players)} players at position {position}")
@ -296,7 +350,6 @@ class PlayerService(BaseService[Player]):
logger.error(f"Failed to get players by position {position}: {e}")
return []
async def update_player(self, player_id: int, updates: dict) -> Optional[Player]:
"""
Update player information.
@ -318,7 +371,9 @@ class PlayerService(BaseService[Player]):
logger.error(f"Failed to update player {player_id}: {e}")
return None
async def update_player_team(self, player_id: int, new_team_id: int) -> Optional[Player]:
async def update_player_team(
self, player_id: int, new_team_id: int
) -> Optional[Player]:
"""
Update a player's team assignment (for real-time IL moves).
@ -337,14 +392,22 @@ class PlayerService(BaseService[Player]):
"""
try:
logger.info(f"Updating player {player_id} team to {new_team_id}")
updated_player = await self.update_player(player_id, {'team_id': new_team_id})
updated_player = await self.update_player(
player_id, {"team_id": new_team_id}
)
if updated_player:
logger.info(f"Successfully updated player {player_id} to team {new_team_id}")
logger.info(
f"Successfully updated player {player_id} to team {new_team_id}"
)
return updated_player
else:
logger.error(f"Failed to update player {player_id} team - no response from API")
raise APIException(f"Failed to update player {player_id} team assignment")
logger.error(
f"Failed to update player {player_id} team - no response from API"
)
raise APIException(
f"Failed to update player {player_id} team assignment"
)
except Exception as e:
logger.error(f"Error updating player {player_id} team: {e}")

View File

@ -1,6 +1,7 @@
"""
Tests for PlayerService functionality
"""
import pytest
from unittest.mock import AsyncMock
@ -26,16 +27,18 @@ class TestPlayerService:
service._client = mock_client
return service
def create_player_data(self, player_id: int, name: str, team_id: int = 5, position: str = 'C', **kwargs):
def create_player_data(
self, player_id: int, name: str, team_id: int = 5, position: str = "C", **kwargs
):
"""Create complete player data for testing."""
base_data = {
'id': player_id,
'name': name,
'wara': 2.5,
'season': 12,
'team_id': team_id,
'image': f'https://example.com/player{player_id}.jpg',
'pos_1': position,
"id": player_id,
"name": name,
"wara": 2.5,
"season": 12,
"team_id": team_id,
"image": f"https://example.com/player{player_id}.jpg",
"pos_1": position,
}
base_data.update(kwargs)
return base_data
@ -43,29 +46,31 @@ class TestPlayerService:
@pytest.mark.asyncio
async def test_get_player_success(self, player_service_instance, mock_client):
"""Test successful player retrieval."""
mock_data = self.create_player_data(1, 'Test Player', pos_2='1B')
mock_data = self.create_player_data(1, "Test Player", pos_2="1B")
mock_client.get.return_value = mock_data
result = await player_service_instance.get_player(1)
assert isinstance(result, Player)
assert result.name == 'Test Player'
assert result.name == "Test Player"
assert result.wara == 2.5
assert result.season == 12
assert result.primary_position == 'C'
mock_client.get.assert_called_once_with('players', object_id=1)
assert result.primary_position == "C"
mock_client.get.assert_called_once_with("players", object_id=1)
@pytest.mark.asyncio
async def test_get_player_includes_team_data(self, player_service_instance, mock_client):
async def test_get_player_includes_team_data(
self, player_service_instance, mock_client
):
"""Test that get_player returns data with team information (from API)."""
# API returns player data with team information already included
player_data = self.create_player_data(1, 'Test Player', team_id=5)
player_data['team'] = {
'id': 5,
'abbrev': 'TST',
'sname': 'Test Team',
'lname': 'Test Team Long Name',
'season': 12
player_data = self.create_player_data(1, "Test Player", team_id=5)
player_data["team"] = {
"id": 5,
"abbrev": "TST",
"sname": "Test Team",
"lname": "Test Team Long Name",
"season": 12,
}
mock_client.get.return_value = player_data
@ -73,22 +78,22 @@ class TestPlayerService:
result = await player_service_instance.get_player(1)
assert isinstance(result, Player)
assert result.name == 'Test Player'
assert result.name == "Test Player"
assert result.team is not None
assert result.team.sname == 'Test Team'
assert result.team.sname == "Test Team"
# Should call get once for player (team data included in API response)
mock_client.get.assert_called_once_with('players', object_id=1)
mock_client.get.assert_called_once_with("players", object_id=1)
@pytest.mark.asyncio
async def test_get_players_by_team(self, player_service_instance, mock_client):
"""Test getting players by team."""
mock_data = {
'count': 2,
'players': [
self.create_player_data(1, 'Player1', team_id=5),
self.create_player_data(2, 'Player2', team_id=5)
]
"count": 2,
"players": [
self.create_player_data(1, "Player1", team_id=5),
self.create_player_data(2, "Player2", team_id=5),
],
}
mock_client.get.return_value = mock_data
@ -96,81 +101,99 @@ class TestPlayerService:
assert len(result) == 2
assert all(isinstance(p, Player) for p in result)
mock_client.get.assert_called_once_with('players', params=[('season', '12'), ('team_id', '5')])
mock_client.get.assert_called_once_with(
"players", params=[("season", "12"), ("team_id", "5")]
)
@pytest.mark.asyncio
async def test_get_players_by_team_with_sort(self, player_service_instance, mock_client):
async def test_get_players_by_team_with_sort(
self, player_service_instance, mock_client
):
"""Test getting players by team with sort parameter."""
mock_data = {
'count': 2,
'players': [
self.create_player_data(1, 'Player1', team_id=5),
self.create_player_data(2, 'Player2', team_id=5)
]
"count": 2,
"players": [
self.create_player_data(1, "Player1", team_id=5),
self.create_player_data(2, "Player2", team_id=5),
],
}
mock_client.get.return_value = mock_data
# Test with valid sort parameter
result = await player_service_instance.get_players_by_team(5, season=12, sort='cost-asc')
result = await player_service_instance.get_players_by_team(
5, season=12, sort="cost-asc"
)
assert len(result) == 2
assert all(isinstance(p, Player) for p in result)
mock_client.get.assert_called_once_with('players', params=[('season', '12'), ('team_id', '5'), ('sort', 'cost-asc')])
mock_client.get.assert_called_once_with(
"players", params=[("season", "12"), ("team_id", "5"), ("sort", "cost-asc")]
)
# Reset mock for next test
mock_client.reset_mock()
# Test with invalid sort parameter (should be ignored)
result = await player_service_instance.get_players_by_team(5, season=12, sort='invalid-sort')
result = await player_service_instance.get_players_by_team(
5, season=12, sort="invalid-sort"
)
assert len(result) == 2
# Should not include sort parameter when invalid
mock_client.get.assert_called_once_with('players', params=[('season', '12'), ('team_id', '5')])
mock_client.get.assert_called_once_with(
"players", params=[("season", "12"), ("team_id", "5")]
)
@pytest.mark.asyncio
async def test_get_players_by_name(self, player_service_instance, mock_client):
"""Test searching players by name."""
mock_data = {
'count': 1,
'players': [
self.create_player_data(1, 'John Smith', team_id=5)
]
"count": 1,
"players": [self.create_player_data(1, "John Smith", team_id=5)],
}
mock_client.get.return_value = mock_data
result = await player_service_instance.get_players_by_name('John', season=13)
result = await player_service_instance.get_players_by_name("John", season=13)
assert len(result) == 1
assert result[0].name == 'John Smith'
mock_client.get.assert_called_once_with('players', params=[('season', '13'), ('name', 'John')])
assert result[0].name == "John Smith"
mock_client.get.assert_called_once_with(
"players", params=[("season", "13"), ("name", "John")]
)
@pytest.mark.asyncio
async def test_get_player_by_name_exact(self, player_service_instance, mock_client):
"""Test exact name matching."""
mock_data = {
'count': 2,
'players': [
self.create_player_data(1, 'John Smith', team_id=5),
self.create_player_data(2, 'John Doe', team_id=6)
]
"count": 2,
"players": [
self.create_player_data(1, "John Smith", team_id=5),
self.create_player_data(2, "John Doe", team_id=6),
],
}
mock_client.get.return_value = mock_data
result = await player_service_instance.get_player_by_name_exact('John Smith', season=12)
result = await player_service_instance.get_player_by_name_exact(
"John Smith", season=12
)
assert result is not None
assert result.name == 'John Smith'
assert result.name == "John Smith"
assert result.id == 1
@pytest.mark.asyncio
async def test_get_free_agents(self, player_service_instance, mock_client):
"""Test getting free agents."""
mock_data = {
'count': 2,
'players': [
self.create_player_data(1, 'Free Agent 1', team_id=get_config().free_agent_team_id),
self.create_player_data(2, 'Free Agent 2', team_id=get_config().free_agent_team_id)
]
"count": 2,
"players": [
self.create_player_data(
1, "Free Agent 1", team_id=get_config().free_agent_team_id
),
self.create_player_data(
2, "Free Agent 2", team_id=get_config().free_agent_team_id
),
],
}
mock_client.get.return_value = mock_data
@ -178,14 +201,19 @@ class TestPlayerService:
assert len(result) == 2
assert all(p.team_id == get_config().free_agent_team_id for p in result)
mock_client.get.assert_called_once_with('players', params=[('team_id', get_config().free_agent_team_id), ('season', '12')])
mock_client.get.assert_called_once_with(
"players",
params=[("team_id", get_config().free_agent_team_id), ("season", "12")],
)
@pytest.mark.asyncio
async def test_is_free_agent(self, player_service_instance):
"""Test free agent checking."""
# Create test players with all required fields
free_agent_data = self.create_player_data(1, 'Free Agent', team_id=get_config().free_agent_team_id)
regular_player_data = self.create_player_data(2, 'Regular Player', team_id=5)
free_agent_data = self.create_player_data(
1, "Free Agent", team_id=get_config().free_agent_team_id
)
regular_player_data = self.create_player_data(2, "Regular Player", team_id=5)
free_agent = Player.from_api_data(free_agent_data)
regular_player = Player.from_api_data(regular_player_data)
@ -197,86 +225,101 @@ class TestPlayerService:
async def test_search_players(self, player_service_instance, mock_client):
"""Test new search_players functionality using /v3/players/search endpoint."""
mock_players = [
self.create_player_data(1, 'Mike Trout', pos_1='OF'),
self.create_player_data(2, 'Michael Harris', pos_1='OF')
self.create_player_data(1, "Mike Trout", pos_1="OF"),
self.create_player_data(2, "Michael Harris", pos_1="OF"),
]
mock_client.get.return_value = {
'count': 2,
'players': mock_players
}
mock_client.get.return_value = {"count": 2, "players": mock_players}
result = await player_service_instance.search_players('Mike', limit=10, season=12)
result = await player_service_instance.search_players(
"Mike", limit=10, season=12
)
mock_client.get.assert_called_once_with('players/search', params=[('q', 'Mike'), ('limit', '10'), ('season', '12')])
mock_client.get.assert_called_once_with(
"players/search", params=[("q", "Mike"), ("limit", "10"), ("season", "12")]
)
assert len(result) == 2
assert all(isinstance(player, Player) for player in result)
assert result[0].name == 'Mike Trout'
assert result[1].name == 'Michael Harris'
assert result[0].name == "Mike Trout"
assert result[1].name == "Michael Harris"
@pytest.mark.asyncio
async def test_search_players_no_season(self, player_service_instance, mock_client):
"""Test search_players without explicit season."""
mock_players = [self.create_player_data(1, 'Test Player', pos_1='C')]
mock_players = [self.create_player_data(1, "Test Player", pos_1="C")]
mock_client.get.return_value = {
'count': 1,
'players': mock_players
}
mock_client.get.return_value = {"count": 1, "players": mock_players}
result = await player_service_instance.search_players('Test', limit=5)
result = await player_service_instance.search_players("Test", limit=5)
mock_client.get.assert_called_once_with('players/search', params=[('q', 'Test'), ('limit', '5')])
mock_client.get.assert_called_once_with(
"players/search", params=[("q", "Test"), ("limit", "5")]
)
assert len(result) == 1
assert result[0].name == 'Test Player'
assert result[0].name == "Test Player"
@pytest.mark.asyncio
async def test_search_players_empty_result(self, player_service_instance, mock_client):
async def test_search_players_empty_result(
self, player_service_instance, mock_client
):
"""Test search_players with no results."""
mock_client.get.return_value = None
result = await player_service_instance.search_players('NonExistent')
result = await player_service_instance.search_players("NonExistent")
mock_client.get.assert_called_once_with('players/search', params=[('q', 'NonExistent'), ('limit', '10')])
mock_client.get.assert_called_once_with(
"players/search", params=[("q", "NonExistent"), ("limit", "10")]
)
assert result == []
@pytest.mark.asyncio
async def test_search_players_fuzzy(self, player_service_instance, mock_client):
"""Test fuzzy search with relevance sorting."""
"""Test fuzzy search delegates to search_players endpoint.
The fuzzy search now uses the /players/search endpoint which handles
relevance sorting server-side. The limit parameter is passed to the API.
"""
mock_data = {
'count': 3,
'players': [
self.create_player_data(1, 'John Smith', team_id=5), # partial match
self.create_player_data(2, 'John', team_id=6), # exact match
self.create_player_data(3, 'Johnny Doe', team_id=7) # partial match
]
"count": 2,
"total_matches": 3,
"players": [
self.create_player_data(
2, "John", team_id=6
), # exact match first (API sorts)
self.create_player_data(1, "John Smith", team_id=5), # partial match
],
}
mock_client.get.return_value = mock_data
result = await player_service_instance.search_players_fuzzy('John', limit=2)
result = await player_service_instance.search_players_fuzzy("John", limit=2)
# Should return exact match first, then partial matches, limited to 2
# Should return results from the search endpoint, limited by API
assert len(result) == 2
assert result[0].name == 'John' # exact match first
mock_client.get.assert_called_once_with('players', params=[('season', '13'), ('name', 'John')])
assert result[0].name == "John" # exact match first (sorted by API)
# Now uses the search endpoint instead of get_players_by_name
mock_client.get.assert_called_once_with(
"players/search", params=[("q", "John"), ("limit", "2")]
)
@pytest.mark.asyncio
async def test_get_players_by_position(self, player_service_instance, mock_client):
"""Test getting players by position."""
mock_data = {
'count': 2,
'players': [
self.create_player_data(1, 'Catcher 1', position='C', team_id=5),
self.create_player_data(2, 'Catcher 2', position='C', team_id=6)
]
"count": 2,
"players": [
self.create_player_data(1, "Catcher 1", position="C", team_id=5),
self.create_player_data(2, "Catcher 2", position="C", team_id=6),
],
}
mock_client.get.return_value = mock_data
result = await player_service_instance.get_players_by_position('C', season=12)
result = await player_service_instance.get_players_by_position("C", season=12)
assert len(result) == 2
assert all(p.primary_position == 'C' for p in result)
mock_client.get.assert_called_once_with('players', params=[('position', 'C'), ('season', '12')])
assert all(p.primary_position == "C" for p in result)
mock_client.get.assert_called_once_with(
"players", params=[("position", "C"), ("season", "12")]
)
@pytest.mark.asyncio
async def test_error_handling(self, player_service_instance, mock_client):
@ -294,16 +337,18 @@ class TestPlayerService:
class TestPlayerServiceExtras:
"""Additional coverage tests for PlayerService edge cases."""
def create_player_data(self, player_id: int, name: str, team_id: int = 5, position: str = 'C', **kwargs):
def create_player_data(
self, player_id: int, name: str, team_id: int = 5, position: str = "C", **kwargs
):
"""Create complete player data for testing."""
base_data = {
'id': player_id,
'name': name,
'wara': 2.5,
'season': 12,
'team_id': team_id,
'image': f'https://example.com/player{player_id}.jpg',
'pos_1': position,
"id": player_id,
"name": name,
"wara": 2.5,
"season": 12,
"team_id": team_id,
"image": f"https://example.com/player{player_id}.jpg",
"pos_1": position,
}
base_data.update(kwargs)
return base_data
@ -319,11 +364,11 @@ class TestPlayerServiceExtras:
# Test additional functionality
mock_client.get.return_value = {
'count': 1,
'players': [self.create_player_data(1, 'Test Player')]
"count": 1,
"players": [self.create_player_data(1, "Test Player")],
}
result = await player_service.get_players_by_name('Test', season=12)
result = await player_service.get_players_by_name("Test", season=12)
assert len(result) == 1
@ -334,7 +379,7 @@ class TestGlobalPlayerServiceInstance:
"""Test global player service instance."""
assert isinstance(player_service, PlayerService)
assert player_service.model_class == Player
assert player_service.endpoint == 'players'
assert player_service.endpoint == "players"
@pytest.mark.asyncio
async def test_service_independence(self):