diff --git a/PRE_LAUNCH_ROADMAP.md b/PRE_LAUNCH_ROADMAP.md index ff8043f..7c2e5d2 100644 --- a/PRE_LAUNCH_ROADMAP.md +++ b/PRE_LAUNCH_ROADMAP.md @@ -1,8 +1,8 @@ # Discord Bot v2.0 - Pre-Launch Roadmap -**Last Updated:** September 2025 +**Last Updated:** January 2025 **Target Launch:** TBD -**Current Status:** Core functionality complete, utility commands needed +**Current Status:** Core functionality complete including trading system, remaining utility commands needed ## ๐ŸŽฏ Overview @@ -14,7 +14,9 @@ This document outlines the remaining functionality required before the Discord B - **Team Management** (`/team`, `/teams`, `/roster`) - Team information and roster breakdown - **League Operations** (`/league`, `/standings`, `/schedule`) - League-wide information - **Transaction Management** (`/mymoves`, `/legal`) - Player transaction tracking +- **Trading System** (`/trade`) - Full interactive trading with validation and dedicated channels - **Voice Channels** (`/voice-channel`) - Automatic gameplay channel creation with cleanup +- **Custom Commands** (`/custom-command`) - User-created custom text commands - **Admin Commands** - League administration and management tools - **Background Services** - Automated cleanup, monitoring, and maintenance @@ -22,37 +24,40 @@ This document outlines the remaining functionality required before the Discord B ### ๐Ÿ”ง Critical Fixes Required -#### 1. Custom Command Backend Support **[HIGH PRIORITY]** -- **Status**: Currently failing -- **Issue**: Custom commands system is not functioning properly -- **Impact**: Users cannot create/manage custom text commands +#### 1. Custom Command Backend Support **โœ… COMPLETED** +- **Status**: Complete and functional +- **Implementation**: Custom commands system fully operational +- **Features**: Users can create/manage custom text commands - **Files**: `commands/custom_commands/`, `services/custom_command_service.py` -- **Dependencies**: Database integration, permissions system -- **Estimated Effort**: 2-3 hours (debugging + fixes) +- **Completed**: January 2025 ### ๐ŸŽฎ Utility Commands -#### 2. Weather Command -- **Command**: `/weather [location]` -- **Description**: Display current weather conditions and forecast -- **Features**: - - Current conditions (temp, humidity, conditions) - - 3-day forecast - - Location autocomplete/search - - Weather icons in embeds -- **API Integration**: OpenWeatherMap or similar service -- **Estimated Effort**: 3-4 hours +#### 2. Weather Command **โœ… COMPLETED** +- **Command**: `/weather [team_abbrev]` +- **Status**: Complete and fully functional +- **Implementation**: Ballpark weather rolling system for gameplay +- **Features Implemented**: + - Smart team resolution (explicit param โ†’ channel name โ†’ user owned team) + - Season display (Spring/Summer/Fall based on week) + - Time of day logic (division weeks, games played tracking) + - D20 weather roll with formatted display + - Stadium image and team color styling +- **Completed**: January 2025 -#### 3. Charts Display System +#### 3. Charts Display System **โœ… COMPLETED** - **Command**: `/charts ` -- **Description**: Display league charts and infographics by URL -- **Features**: - - Predefined chart library (standings, stats, schedules) - - URL-based image display in embeds - - Chart category organization - - Admin management of chart URLs -- **Data Storage**: JSON config file or database entries -- **Estimated Effort**: 2-3 hours +- **Status**: Complete and fully functional +- **Implementation**: Chart display and management system +- **Features Implemented**: + - Autocomplete chart selection with category display + - Multi-image chart support + - JSON-based chart library (12 charts migrated from legacy bot) + - Admin commands for chart management (add, remove, list, update) + - Category organization (gameplay, defense, reference, stats) + - Proper embed formatting with descriptions +- **Data Storage**: `storage/charts.json` with JSON persistence +- **Completed**: January 2025 #### 4. League Resources Links - **Command**: `/links ` @@ -104,36 +109,37 @@ This document outlines the remaining functionality required before the Discord B - **Complexity**: Custom probability algorithms - **Estimated Effort**: 3-4 hours -#### 8. Trading System +#### 8. Trading System **โœ… COMPLETED** - **Command**: `/trade [parameters]` -- **Description**: Interactive trading interface and management -- **Features**: - - Trade proposal system - - Multi-party trade support - - Trade validation (roster limits, salary caps) +- **Status**: Complete and fully functional +- **Implementation**: Full interactive trading system with comprehensive features +- **Features Implemented**: + - Trade proposal system with interactive UI + - Multi-party trade support (up to 2 teams) + - Trade validation (roster limits, salary caps, sWAR tracking) - Trade history and tracking - Integration with transaction system -- **Complexity**: High - multi-user interactions, complex validation -- **Database**: Trade proposals, approvals, completions -- **Estimated Effort**: 6-8 hours + - Dedicated trade discussion channels with smart permissions + - Pre-existing transaction awareness for accurate projections +- **Completed**: January 2025 ## ๐Ÿ“‹ Implementation Priority -### Phase 1: Critical Fixes (Week 1) -1. **Custom Command Backend** - Fix existing broken functionality +### Phase 1: Critical Fixes โœ… COMPLETED +1. โœ… **Custom Command Backend** - Fixed and fully operational (January 2025) -### Phase 2: Core Utilities (Week 1-2) -2. **Weather Command** - Popular utility feature -3. **Charts System** - Essential for league management +### Phase 2: Core Utilities +2. โœ… **Weather Command** - Complete with smart team resolution (January 2025) +3. โœ… **Charts System** - Complete with admin management and 12 charts (January 2025) 4. **Links System** - Administrative convenience ### Phase 3: User Features (Week 2) 5. **Image Management** - User profile customization 6. **Meme Commands** - Community engagement -### Phase 4: Advanced Features (Week 3) +### Phase 4: Advanced Features 7. **Scout Command** - Complex gaming mechanics -8. **Trade Command** - Most complex system +8. โœ… **Trade Command** - Complete with comprehensive features (January 2025) ## ๐Ÿ—๏ธ Architecture Considerations @@ -157,13 +163,13 @@ commands/ - **ResourceService**: Chart and link management - **ProfileService**: User image management - **ScoutingService**: Dice mechanics and probability -- **TradingService**: Complex trade logic and validation +- โœ… **TradingService**: Complete - complex trade logic and validation implemented (January 2025) ### Database Schema Updates -- **Custom commands**: Fix existing schema issues +- โœ… **Custom commands**: Complete and operational (January 2025) - **Resources**: Chart/link storage tables - **Player images**: Image URL fields -- **Trades**: Trade proposal and history tables +- โœ… **Trades**: Complete - trade proposal and history tables implemented (January 2025) ## ๐Ÿงช Testing Requirements @@ -177,7 +183,7 @@ commands/ - **Weather**: API mocking, error handling, rate limiting - **Charts/Links**: URL validation, admin permissions - **Images**: URL validation, permission checks -- **Trading**: Complex multi-user scenarios, validation logic +- โœ… **Trading**: Complete - complex multi-user scenarios, validation logic all tested (January 2025) ## ๐Ÿ“š Documentation Updates @@ -252,18 +258,24 @@ commands/ - **Documentation**: Thorough README files and architectural docs ### Technical Debt Considerations -- **Custom Commands**: Address existing backend issues +- โœ… **Custom Commands**: Resolved - backend fully operational (January 2025) +- โœ… **Trading System**: Complete with comprehensive validation (January 2025) - **Database Performance**: Monitor query performance with new features - **External Dependencies**: Manage API dependencies and rate limits - **Cache Management**: Implement caching for expensive operations ### Resource Requirements -- **Development Time**: Estimated 20-25 hours total -- **API Costs**: Weather API subscription required +- **Development Time**: ~6-9 hours remaining (reduced from 20-25 hours) + - โœ… Custom Commands: Complete (saved 2-3 hours) + - โœ… Trading System: Complete (saved 6-8 hours) + - โœ… Weather Command: Complete (saved 3-4 hours) + - โœ… Charts System: Complete (saved 2-3 hours) + - Remaining: Links (2-3h), Images (2-3h), Memes (1-2h), Scout (3-4h) +- **API Costs**: None required (weather is gameplay dice rolling, not real weather) - **Database Storage**: Minimal increase for new features - **Hosting Resources**: Current infrastructure sufficient --- -**Target Timeline: 2-3 weeks for complete pre-launch readiness** -**Next Steps: Begin with Custom Command backend fixes, then proceed with utility commands** \ No newline at end of file +**Target Timeline: 1 week for complete pre-launch readiness** +**Next Steps: Proceed with Links system, then user features (image management, meme commands)** \ No newline at end of file diff --git a/bot.py b/bot.py index a9dd86d..e2e1959 100644 --- a/bot.py +++ b/bot.py @@ -116,7 +116,8 @@ class SBABot(commands.Bot): from commands.transactions import setup_transactions from commands.dice import setup_dice from commands.voice import setup_voice - + from commands.utilities import setup_utilities + # Define command packages to load command_packages = [ ("players", setup_players), @@ -127,6 +128,7 @@ class SBABot(commands.Bot): ("transactions", setup_transactions), ("dice", setup_dice), ("voice", setup_voice), + ("utilities", setup_utilities), ] total_successful = 0 diff --git a/commands/transactions/trade_channel_tracker.py b/commands/transactions/trade_channel_tracker.py index 58b6ff3..9bd41b3 100644 --- a/commands/transactions/trade_channel_tracker.py +++ b/commands/transactions/trade_channel_tracker.py @@ -27,7 +27,7 @@ class TradeChannelTracker: - Automatic stale entry removal """ - def __init__(self, data_file: str = "data/trade_channels.json"): + def __init__(self, data_file: str = "storage/trade_channels.json"): """ Initialize the trade channel tracker. diff --git a/commands/utilities/README.md b/commands/utilities/README.md new file mode 100644 index 0000000..6b5ce5f --- /dev/null +++ b/commands/utilities/README.md @@ -0,0 +1,235 @@ +# Utility Commands + +This directory contains general utility commands that enhance the user experience for the SBA Discord bot. + +## Commands + +### `/weather [team_abbrev]` + +**Description**: Roll ballpark weather for gameplay. + +**Usage**: +- `/weather` - Roll weather for your team or current channel's team +- `/weather NYY` - Roll weather for a specific team + +**Features**: +- **Smart Team Resolution** (3-tier priority): + 1. Explicit team abbreviation parameter + 2. Channel name parsing (e.g., `NYY-Yankee Stadium` โ†’ `NYY`) + 3. User's owned team (fallback) + +- **Season Display**: + - Weeks 1-5: ๐ŸŒผ Spring + - Weeks 6-14: ๐Ÿ–๏ธ Summer + - Weeks 15+: ๐Ÿ‚ Fall + +- **Time of Day Logic**: + - Based on games played this week + - Division weeks: [1, 3, 6, 14, 16, 18] + - 0/2 games OR (1 game in division week): ๐ŸŒ™ Night + - 1/3 games: ๐ŸŒž Day + - 4+ games: ๐Ÿ•ธ๏ธ Spidey Time (special case) + +- **Weather Roll**: Random d20 (1-20) displayed in markdown format + +**Embed Layout**: +``` +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ ๐ŸŒค๏ธ Weather Check โ”‚ +โ”‚ [Team Colors] โ”‚ +โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค +โ”‚ Season: ๐ŸŒผ Spring โ”‚ +โ”‚ Time of Day: ๐ŸŒ™ Night โ”‚ +โ”‚ Week: 5 | Games Played: 2/4 โ”‚ +โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค +โ”‚ Weather Roll โ”‚ +โ”‚ ```md โ”‚ +โ”‚ # 14 โ”‚ +โ”‚ Details: [1d20 (14)] โ”‚ +โ”‚ ``` โ”‚ +โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค +โ”‚ [Stadium Image] โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +``` + +**Implementation Details**: +- **File**: `commands/utilities/weather.py` +- **Service Dependencies**: + - `LeagueService` - Current league state + - `ScheduleService` - Week schedule and games + - `TeamService` - Team resolution +- **Logging**: Uses `@logged_command` decorator for automatic logging +- **Error Handling**: Graceful fallback with user-friendly error messages + +**Examples**: + +1. In a team channel (`#NYY-Yankee-Stadium`): + ``` + /weather + โ†’ Automatically uses NYY from channel name + ``` + +2. Explicit team: + ``` + /weather BOS + โ†’ Shows weather for Boston team + ``` + +3. As team owner: + ``` + /weather + โ†’ Defaults to your owned team if not in a team channel + ``` + +## Architecture + +### Command Pattern + +All utility commands follow the standard bot architecture: + +```python +@discord.app_commands.command(name="command") +@discord.app_commands.describe(param="Description") +@logged_command("/command") +async def command_handler(self, interaction, param: str): + await interaction.response.defer() + # Command logic using services + await interaction.followup.send(embed=embed) +``` + +### Service Layer + +Utility commands leverage the service layer for all data access: +- **No direct database calls** - all data through services +- **Async operations** - proper async/await patterns +- **Error handling** - graceful degradation with user feedback + +### Embed Templates + +Use `EmbedTemplate` from `views.embeds` for consistent styling: +- Team colors via `team.color` +- Standard error/success/info templates +- Image support (thumbnails and full images) + +## Testing + +All utility commands have comprehensive test coverage: + +**Weather Command** (`tests/test_commands_weather.py` - 20 tests): +- Team resolution (3-tier priority) +- Season calculation +- Time of day logic (including division weeks) +- Weather roll randomization +- Embed formatting and layout +- Error handling scenarios + +**Charts Command** (`tests/test_commands_charts.py` - 26 tests): +- Chart service operations (loading, adding, updating, removing) +- Chart display (single and multi-image) +- Autocomplete functionality +- Admin command operations +- Error handling (invalid charts, categories) +- JSON persistence + +### `/charts ` + +**Description**: Display gameplay charts and infographics from the league library. + +**Usage**: +- `/charts rest` - Display pitcher rest chart +- `/charts defense` - Display defense chart +- `/charts hit-and-run` - Display hit and run strategy chart + +**Features**: +- **Autocomplete**: Smart chart name suggestions with category display +- **Multi-image Support**: Automatically sends multiple images for complex charts +- **Categorized Library**: Charts organized by gameplay, defense, reference, and stats +- **Proper Embeds**: Charts displayed in formatted Discord embeds with descriptions + +**Available Charts** (12 total): +- **Gameplay**: rest, sac-bunt, squeeze-bunt, hit-and-run, g1, g2, g3, groundball, fly-b +- **Defense**: rob-hr, defense, block-plate + +**Admin Commands**: + +Administrators can manage the chart library using these commands: + +- `/chart-add [description]` - Add a new chart +- `/chart-remove ` - Remove a chart from the library +- `/chart-list [category]` - List all charts (optionally filtered by category) +- `/chart-update [name] [category] [url] [description]` - Update chart properties + +**Implementation Details**: +- **Files**: + - `commands/utilities/charts.py` - Command handlers + - `services/chart_service.py` - Chart management service + - `storage/charts.json` - Chart definitions storage +- **Service**: `ChartService` - Manages chart loading, saving, and retrieval +- **Categories**: gameplay, defense, reference, stats +- **Logging**: Uses `@logged_command` decorator for automatic logging + +**Examples**: + +1. Display a single-image chart: + ``` + /charts defense + โ†’ Shows defense chart embed with image + ``` + +2. Display multi-image chart: + ``` + /charts hit-and-run + โ†’ Shows first image in response, additional images in followups + ``` + +3. Admin: Add new chart: + ``` + /chart-add steal-chart "Steal Chart" gameplay https://example.com/steal.png + โ†’ Adds new chart to the library + ``` + +4. Admin: List charts by category: + ``` + /chart-list gameplay + โ†’ Shows all gameplay charts + ``` + +**Data Structure** (`storage/charts.json`): +```json +{ + "charts": { + "chart-key": { + "name": "Display Name", + "category": "gameplay", + "description": "Chart description", + "urls": ["https://example.com/image.png"] + } + }, + "categories": { + "gameplay": "Gameplay Mechanics", + "defense": "Defensive Play" + } +} +``` + +## Future Commands + +Planned utility commands (see PRE_LAUNCH_ROADMAP.md): + +- `/links ` - Quick access to league resources + +## Development Guidelines + +When adding new utility commands: + +1. **Follow existing patterns** - Use weather.py as a reference +2. **Use @logged_command** - Automatic logging and error handling +3. **Service layer only** - No direct database access +4. **Comprehensive tests** - Cover all edge cases +5. **User-friendly errors** - Clear, actionable error messages +6. **Document in README** - Update this file with new commands + +--- + +**Last Updated**: January 2025 +**Maintainer**: Major Domo Bot Development Team diff --git a/commands/utilities/__init__.py b/commands/utilities/__init__.py new file mode 100644 index 0000000..29dc0e1 --- /dev/null +++ b/commands/utilities/__init__.py @@ -0,0 +1,47 @@ +""" +Utility commands package for Discord Bot v2.0 + +This package contains general utility commands that enhance user experience. +""" +import logging +from discord.ext import commands + +from .weather import WeatherCommands +from .charts import ChartCommands, ChartAdminCommands + +__all__ = ['WeatherCommands', 'ChartCommands', 'ChartAdminCommands', 'setup_utilities'] + +logger = logging.getLogger(__name__) + + +async def setup_utilities(bot: commands.Bot) -> tuple[int, int, list[str]]: + """ + Setup function for utilities commands. + + Args: + bot: The Discord bot instance + + Returns: + Tuple of (successful_count, failed_count, failed_module_names) + """ + successful = 0 + failed = 0 + failed_modules = [] + + cog_classes = [ + WeatherCommands, + ChartCommands, + ChartAdminCommands, + ] + + for cog_class in cog_classes: + try: + await bot.add_cog(cog_class(bot)) + logger.info(f"Loaded cog: {cog_class.__name__}") + successful += 1 + except Exception as e: + logger.error(f"Failed to load cog {cog_class.__name__}: {e}", exc_info=True) + failed += 1 + failed_modules.append(cog_class.__name__) + + return successful, failed, failed_modules diff --git a/commands/utilities/charts.py b/commands/utilities/charts.py new file mode 100644 index 0000000..d869de4 --- /dev/null +++ b/commands/utilities/charts.py @@ -0,0 +1,353 @@ +""" +Chart display and management commands. + +Provides commands for displaying gameplay charts and admin commands +for managing the chart library. +""" +import discord +from discord import app_commands +from discord.ext import commands +from typing import List, Optional + +from utils.decorators import logged_command +from utils.logging import get_contextual_logger, set_discord_context +from services.chart_service import get_chart_service, Chart +from views.embeds import EmbedTemplate, EmbedColors +from exceptions import BotException + + +class ChartCommands(commands.Cog): + """Chart display command handlers.""" + + def __init__(self, bot: commands.Bot): + self.bot = bot + self.logger = get_contextual_logger(f'{__name__}.ChartCommands') + self.chart_service = get_chart_service() + + async def chart_autocomplete( + self, + interaction: discord.Interaction, + current: str + ) -> List[app_commands.Choice[str]]: + """Autocomplete for chart names.""" + chart_keys = self.chart_service.get_chart_keys() + + # Filter based on current input + filtered = [ + key for key in chart_keys + if current.lower() in key.lower() + ][:25] # Discord limit + + # Get chart objects for display names + choices = [] + for key in filtered: + chart = self.chart_service.get_chart(key) + if chart: + choices.append( + app_commands.Choice( + name=f"{chart.name} ({chart.category})", + value=key + ) + ) + + return choices + + @app_commands.command( + name="charts", + description="Display a gameplay chart or infographic" + ) + @app_commands.describe(chart_name="Name of the chart to display") + @app_commands.autocomplete(chart_name=chart_autocomplete) + @logged_command("/charts") + async def charts( + self, + interaction: discord.Interaction, + chart_name: str + ): + """Display a gameplay chart or infographic.""" + set_discord_context( + interaction=interaction, + command="/charts", + chart_name=chart_name + ) + + # Get chart + chart = self.chart_service.get_chart(chart_name) + if chart is None: + raise BotException(f"Chart '{chart_name}' not found") + + # Get category display name + categories = self.chart_service.get_categories() + category_display = categories.get(chart.category, chart.category) + + # Create embed for first image + embed = EmbedTemplate.create_base_embed( + title=f"๐Ÿ“Š {chart.name}", + description=chart.description if chart.description else None, + color=EmbedColors.PRIMARY + ) + embed.add_field(name="Category", value=category_display, inline=True) + + if len(chart.urls) > 1: + embed.add_field( + name="Images", + value=f"{len(chart.urls)} images in this chart", + inline=True + ) + + # Set first image + if chart.urls: + embed.set_image(url=chart.urls[0]) + + # Send response + await interaction.response.send_message(embed=embed) + + # Send additional images as followups + if len(chart.urls) > 1: + for url in chart.urls[1:]: + followup_embed = EmbedTemplate.create_base_embed( + title=f"๐Ÿ“Š {chart.name} (continued)", + color=EmbedColors.PRIMARY + ) + followup_embed.set_image(url=url) + await interaction.followup.send(embed=followup_embed) + + +class ChartAdminCommands(commands.Cog): + """Chart management command handlers for administrators.""" + + def __init__(self, bot: commands.Bot): + self.bot = bot + self.logger = get_contextual_logger(f'{__name__}.ChartAdminCommands') + self.chart_service = get_chart_service() + + @app_commands.command( + name="chart-add", + description="[Admin] Add a new chart to the library" + ) + @app_commands.describe( + key="Unique identifier for the chart (e.g., 'rest', 'sac-bunt')", + name="Display name for the chart", + category="Category (gameplay, defense, reference, stats)", + url="Image URL for the chart", + description="Optional description of the chart" + ) + @app_commands.checks.has_permissions(administrator=True) + @logged_command("/chart-add") + async def chart_add( + self, + interaction: discord.Interaction, + key: str, + name: str, + category: str, + url: str, + description: Optional[str] = None + ): + """Add a new chart to the library.""" + set_discord_context( + interaction=interaction, + command="/chart-add", + chart_key=key, + chart_name=name + ) + + # Validate category + valid_categories = list(self.chart_service.get_categories().keys()) + if category not in valid_categories: + raise BotException( + f"Invalid category. Must be one of: {', '.join(valid_categories)}" + ) + + # Add chart (service will handle duplicate key check) + self.chart_service.add_chart( + key=key, + name=name, + category=category, + urls=[url], + description=description or "" + ) + + # Success response + embed = EmbedTemplate.success( + title="โœ… Chart Added", + description=f"Successfully added chart '{name}'" + ) + embed.add_field(name="Key", value=key, inline=True) + embed.add_field(name="Category", value=category, inline=True) + embed.set_image(url=url) + + await interaction.response.send_message(embed=embed, ephemeral=True) + + @app_commands.command( + name="chart-remove", + description="[Admin] Remove a chart from the library" + ) + @app_commands.describe(key="Chart key to remove") + @app_commands.checks.has_permissions(administrator=True) + @logged_command("/chart-remove") + async def chart_remove( + self, + interaction: discord.Interaction, + key: str + ): + """Remove a chart from the library.""" + set_discord_context( + interaction=interaction, + command="/chart-remove", + chart_key=key + ) + + # Get chart before removing (for confirmation message) + chart = self.chart_service.get_chart(key) + if chart is None: + raise BotException(f"Chart '{key}' not found") + + # Remove chart + self.chart_service.remove_chart(key) + + # Success response + embed = EmbedTemplate.success( + title="โœ… Chart Removed", + description=f"Successfully removed chart '{chart.name}'" + ) + embed.add_field(name="Key", value=key, inline=True) + embed.add_field(name="Category", value=chart.category, inline=True) + + await interaction.response.send_message(embed=embed, ephemeral=True) + + @app_commands.command( + name="chart-list", + description="[Admin] List all available charts" + ) + @app_commands.describe(category="Filter by category (optional)") + @app_commands.checks.has_permissions(administrator=True) + @logged_command("/chart-list") + async def chart_list( + self, + interaction: discord.Interaction, + category: Optional[str] = None + ): + """List all available charts.""" + set_discord_context( + interaction=interaction, + command="/chart-list", + category=category + ) + + # Get charts + if category: + charts = self.chart_service.get_charts_by_category(category) + title = f"๐Ÿ“Š Charts in '{category}'" + else: + charts = self.chart_service.get_all_charts() + title = "๐Ÿ“Š All Available Charts" + + if not charts: + raise BotException("No charts found") + + # Group by category + categories = self.chart_service.get_categories() + charts_by_category = {} + for chart in charts: + if chart.category not in charts_by_category: + charts_by_category[chart.category] = [] + charts_by_category[chart.category].append(chart) + + # Create embed + embed = EmbedTemplate.create_base_embed( + title=title, + description=f"Total: {len(charts)} chart(s)", + color=EmbedColors.PRIMARY + ) + + # Add fields by category + for cat_key in sorted(charts_by_category.keys()): + cat_charts = charts_by_category[cat_key] + cat_display = categories.get(cat_key, cat_key) + + chart_list = "\n".join([ + f"โ€ข `{chart.key}` - {chart.name}" + for chart in sorted(cat_charts, key=lambda c: c.key) + ]) + + embed.add_field( + name=f"{cat_display} ({len(cat_charts)})", + value=chart_list, + inline=False + ) + + await interaction.response.send_message(embed=embed, ephemeral=True) + + @app_commands.command( + name="chart-update", + description="[Admin] Update a chart's properties" + ) + @app_commands.describe( + key="Chart key to update", + name="New display name (optional)", + category="New category (optional)", + url="New image URL (optional)", + description="New description (optional)" + ) + @app_commands.checks.has_permissions(administrator=True) + @logged_command("/chart-update") + async def chart_update( + self, + interaction: discord.Interaction, + key: str, + name: Optional[str] = None, + category: Optional[str] = None, + url: Optional[str] = None, + description: Optional[str] = None + ): + """Update a chart's properties.""" + set_discord_context( + interaction=interaction, + command="/chart-update", + chart_key=key + ) + + # Validate at least one field to update + if not any([name, category, url, description]): + raise BotException("Must provide at least one field to update") + + # Validate category if provided + if category: + valid_categories = list(self.chart_service.get_categories().keys()) + if category not in valid_categories: + raise BotException( + f"Invalid category. Must be one of: {', '.join(valid_categories)}" + ) + + # Update chart + self.chart_service.update_chart( + key=key, + name=name, + category=category, + urls=[url] if url else None, + description=description + ) + + # Get updated chart + chart = self.chart_service.get_chart(key) + if chart is None: + raise BotException(f"Chart '{key}' not found after update") + + # Success response + embed = EmbedTemplate.success( + title="โœ… Chart Updated", + description=f"Successfully updated chart '{chart.name}'" + ) + embed.add_field(name="Key", value=key, inline=True) + embed.add_field(name="Category", value=chart.category, inline=True) + + if url: + embed.set_image(url=url) + + await interaction.response.send_message(embed=embed, ephemeral=True) + + +async def setup(bot: commands.Bot): + """Setup function for chart commands.""" + await bot.add_cog(ChartCommands(bot)) + await bot.add_cog(ChartAdminCommands(bot)) diff --git a/commands/utilities/weather.py b/commands/utilities/weather.py new file mode 100644 index 0000000..a12cd79 --- /dev/null +++ b/commands/utilities/weather.py @@ -0,0 +1,268 @@ +""" +Weather command for Discord Bot v2.0 + +Provides ballpark weather checks with dice rolls for gameplay. +""" +import random +from typing import Optional, Tuple + +import discord +from discord.ext import commands + +from services import team_service, league_service, schedule_service +from models.team import Team +from models.current import Current +from models.game import Game +from utils.logging import get_contextual_logger +from utils.decorators import logged_command +from utils.team_utils import get_user_major_league_team +from views.embeds import EmbedTemplate, EmbedColors + + +class WeatherCommands(commands.Cog): + """Weather command handlers.""" + + # Division weeks where time of day logic differs + DIVISION_WEEKS = [1, 3, 6, 14, 16, 18] + + def __init__(self, bot: commands.Bot): + self.bot = bot + self.logger = get_contextual_logger(f'{__name__}.WeatherCommands') + self.logger.info("WeatherCommands cog initialized") + + @discord.app_commands.command(name="weather", description="Roll ballpark weather for a team") + @discord.app_commands.describe( + team_abbrev="Team abbreviation (optional - defaults to channel or your team)" + ) + @logged_command("/weather") + async def weather(self, interaction: discord.Interaction, team_abbrev: Optional[str] = None): + """Display weather check for a team's ballpark.""" + await interaction.response.defer() + + # Get current league state + current = await league_service.get_current_state() + if current is None: + embed = EmbedTemplate.error( + title="League State Unavailable", + description="Could not retrieve current league state. Please try again later." + ) + await interaction.followup.send(embed=embed) + return + + # Resolve team using 3-tier resolution + team = await self._resolve_team(interaction, team_abbrev, current.season) + + if team is None: + embed = EmbedTemplate.error( + title="Team Not Found", + description=( + f"Could not find a team for you. Try:\n" + f"โ€ข Provide a team abbreviation: `/weather NYY`\n" + f"โ€ข Use this command in a team channel\n" + f"โ€ข Make sure you own a team" + ) + ) + await interaction.followup.send(embed=embed) + return + + # Get games for this team in current week + week_schedule = await schedule_service.get_week_schedule(current.season, current.week) + team_games = [ + game for game in week_schedule + if game.away_team.abbrev.upper() == team.abbrev.upper() + or game.home_team.abbrev.upper() == team.abbrev.upper() + ] + + # Calculate season, time of day, and roll weather + season_display = self._get_season_display(current.week) + time_of_day = self._get_time_of_day(team_games, current.week) + weather_roll = self._roll_weather() + + # Create and send embed + embed = self._create_weather_embed( + team=team, + current=current, + season_display=season_display, + time_of_day=time_of_day, + weather_roll=weather_roll, + games_played=sum(1 for g in team_games if g.is_completed), + total_games=len(team_games), + username=interaction.user.name + ) + + await interaction.followup.send(embed=embed) + + async def _resolve_team( + self, + interaction: discord.Interaction, + team_abbrev: Optional[str], + season: int + ) -> Optional[Team]: + """ + Resolve team using 3-tier priority: + 1. Explicit team_abbrev parameter + 2. Channel name parsing (format: -) + 3. User's owned team + + Args: + interaction: Discord interaction + team_abbrev: Explicit team abbreviation from user + season: Current season number + + Returns: + Team object or None if not found + """ + # Priority 1: Explicit parameter + if team_abbrev: + team = await team_service.get_team_by_abbrev(team_abbrev.upper(), season) + if team: + self.logger.info("Team resolved via explicit parameter", team_abbrev=team_abbrev) + return team + + # Priority 2: Channel name parsing + if isinstance(interaction.channel, discord.TextChannel): + channel_name = interaction.channel.name + # Parse channel name: "NYY-Yankee Stadium" -> "NYY" + channel_abbrev = channel_name.split('-')[0].upper() + team = await team_service.get_team_by_abbrev(channel_abbrev, season) + if team: + self.logger.info("Team resolved via channel name", channel_name=channel_name, abbrev=channel_abbrev) + return team + + # Priority 3: User's owned Major League team + team = await get_user_major_league_team(interaction.user.id, season) + if team: + self.logger.info("Team resolved via user ownership", user_id=interaction.user.id) + else: + self.logger.info("Team could not be resolved", user_id=interaction.user.id) + + return team + + def _get_season_display(self, week: int) -> str: + """ + Get season display with emoji based on week. + + Args: + week: Current week number + + Returns: + Season string with emoji + """ + if week <= 5: + return "๐ŸŒผ Spring" + elif week <= 14: + return "๐Ÿ–๏ธ Summer" + else: + return "๐Ÿ‚ Fall" + + def _get_time_of_day(self, games: list[Game], week: int) -> str: + """ + Calculate time of day based on games played. + + Logic: + - Division weeks: [1, 3, 6, 14, 16, 18] + - 0/2 games played OR (1 game in div week): Night ๐ŸŒ™ + - 1/3 games played: Day ๐ŸŒž + - 4+ games played: "Spidey Time" (special case) + - No games scheduled: Show pattern for all 4 games + + Args: + games: List of games for this team this week + week: Current week number + + Returns: + Time of day string with emoji + """ + night_str = "๐ŸŒ™ Night" + day_str = "๐ŸŒž Day" + is_div_week = week in self.DIVISION_WEEKS + + if not games: + # No games scheduled - show the pattern + if is_div_week: + return f"{night_str} / {night_str} / {night_str} / {day_str}" + else: + return f"{night_str} / {day_str} / {night_str} / {day_str}" + + # Count completed games + played_games = sum(1 for g in games if g.is_completed) + + if played_games in [0, 2] or (played_games == 1 and is_div_week): + return night_str + elif played_games in [1, 3]: + return day_str + else: + # 4+ games - special case (shouldn't happen normally) + # Try to get custom emoji, fallback to text + penni = self.bot.get_emoji(1338227310201016370) + if penni: + return f"{penni} Spidey Time" + else: + return "๐Ÿ•ธ๏ธ Spidey Time" + + def _roll_weather(self) -> int: + """ + Roll a d20 for weather. + + Returns: + Random integer between 1 and 20 + """ + return random.randint(1, 20) + + def _create_weather_embed( + self, + team: Team, + current: Current, + season_display: str, + time_of_day: str, + weather_roll: int, + games_played: int, + total_games: int, + username: str + ) -> discord.Embed: + """ + Create the weather check embed. + + Args: + team: Team object + current: Current league state + season_display: Season string with emoji + time_of_day: Time of day string with emoji + weather_roll: The d20 roll result + games_played: Number of completed games + total_games: Total games scheduled + username: User who requested the weather + + Returns: + Formatted Discord embed + """ + # Create base embed with team colors + color = int(team.color, 16) if team.color else EmbedColors.PRIMARY + embed = EmbedTemplate.create_base_embed( + title="๐ŸŒค๏ธ Weather Check", + color=color + ) + + # Add season, time of day, and week info as inline fields + embed.add_field(name="Season", value=season_display, inline=True) + embed.add_field(name="Time of Day", value=time_of_day, inline=True) + embed.add_field( + name="Week", + value=f"{current.week} | Games Played: {games_played}/{total_games}", + inline=True + ) + + # Add weather roll in markdown code block + roll_text = f"```md\n# {weather_roll}\nDetails: [1d20 ({weather_roll})]\n```" + embed.add_field(name=f"Weather roll for {username}", value=roll_text, inline=False) + + # Set stadium image at bottom + if team.stadium: + embed.set_image(url=team.stadium) + + return embed + + +async def setup(bot: commands.Bot): + """Setup function for loading the cog.""" + await bot.add_cog(WeatherCommands(bot)) diff --git a/commands/voice/README.md b/commands/voice/README.md index db7d712..94b0ad5 100644 --- a/commands/voice/README.md +++ b/commands/voice/README.md @@ -146,7 +146,7 @@ if hasattr(self.bot, 'voice_cleanup_service'): ### Cleanup Service Settings - **`cleanup_interval`**: How often to check channels (default: 60 seconds) - **`empty_threshold`**: Minutes empty before deletion (default: 5 minutes) -- **`data_file`**: JSON persistence file path (default: "data/voice_channels.json") +- **`data_file`**: JSON persistence file path (default: "storage/voice_channels.json") ### Channel Categories - Channels are created in the "Voice Channels" category if it exists diff --git a/commands/voice/cleanup_service.py b/commands/voice/cleanup_service.py index 737cb26..f4a314b 100644 --- a/commands/voice/cleanup_service.py +++ b/commands/voice/cleanup_service.py @@ -25,7 +25,7 @@ class VoiceChannelCleanupService: - Stale entry removal and recovery """ - def __init__(self, data_file: str = "data/voice_channels.json"): + def __init__(self, data_file: str = "storage/voice_channels.json"): """ Initialize the cleanup service. diff --git a/commands/voice/tracker.py b/commands/voice/tracker.py index 5ca4359..fb54145 100644 --- a/commands/voice/tracker.py +++ b/commands/voice/tracker.py @@ -25,7 +25,7 @@ class VoiceChannelTracker: - Automatic stale entry removal """ - def __init__(self, data_file: str = "data/voice_channels.json"): + def __init__(self, data_file: str = "storage/voice_channels.json"): """ Initialize the voice channel tracker. diff --git a/services/__init__.py b/services/__init__.py index 431f014..dc2ca19 100644 --- a/services/__init__.py +++ b/services/__init__.py @@ -7,6 +7,7 @@ Service layer providing clean interfaces to data operations. from .team_service import TeamService, team_service from .player_service import PlayerService, player_service from .league_service import LeagueService, league_service +from .schedule_service import ScheduleService, schedule_service # Wire services together for dependency injection player_service._team_service = team_service @@ -14,5 +15,6 @@ player_service._team_service = team_service __all__ = [ 'TeamService', 'team_service', 'PlayerService', 'player_service', - 'LeagueService', 'league_service' + 'LeagueService', 'league_service', + 'ScheduleService', 'schedule_service' ] \ No newline at end of file diff --git a/services/chart_service.py b/services/chart_service.py new file mode 100644 index 0000000..ebd4a3c --- /dev/null +++ b/services/chart_service.py @@ -0,0 +1,257 @@ +""" +Chart Service for managing gameplay charts and infographics. + +This service handles loading, saving, and managing chart definitions +from the JSON configuration file. +""" +import json +import logging +from pathlib import Path +from typing import Optional, Dict, List, Any +from dataclasses import dataclass + +from exceptions import BotException + +logger = logging.getLogger(__name__) + + +@dataclass +class Chart: + """Represents a gameplay chart or infographic.""" + key: str + name: str + category: str + description: str + urls: List[str] + + def to_dict(self) -> Dict[str, Any]: + """Convert chart to dictionary (excluding key).""" + return { + 'name': self.name, + 'category': self.category, + 'description': self.description, + 'urls': self.urls + } + + +class ChartService: + """Service for managing gameplay charts and infographics.""" + + CHARTS_FILE = Path(__file__).parent.parent / 'storage' / 'charts.json' + + def __init__(self): + """Initialize the chart service.""" + self._charts: Dict[str, Chart] = {} + self._categories: Dict[str, str] = {} + self._load_charts() + + def _load_charts(self) -> None: + """Load charts from JSON file.""" + try: + if not self.CHARTS_FILE.exists(): + logger.warning(f"Charts file not found: {self.CHARTS_FILE}") + self._charts = {} + self._categories = {} + return + + with open(self.CHARTS_FILE, 'r') as f: + data = json.load(f) + + # Load categories + self._categories = data.get('categories', {}) + + # Load charts + charts_data = data.get('charts', {}) + for key, chart_data in charts_data.items(): + self._charts[key] = Chart( + key=key, + name=chart_data['name'], + category=chart_data['category'], + description=chart_data.get('description', ''), + urls=chart_data.get('urls', []) + ) + + logger.info(f"Loaded {len(self._charts)} charts from {self.CHARTS_FILE}") + + except Exception as e: + logger.error(f"Failed to load charts: {e}", exc_info=True) + self._charts = {} + self._categories = {} + + def _save_charts(self) -> None: + """Save charts to JSON file.""" + try: + # Ensure data directory exists + self.CHARTS_FILE.parent.mkdir(parents=True, exist_ok=True) + + # Build data structure + data = { + 'charts': { + key: chart.to_dict() + for key, chart in self._charts.items() + }, + 'categories': self._categories + } + + # Write to file + with open(self.CHARTS_FILE, 'w') as f: + json.dump(data, f, indent=2) + + logger.info(f"Saved {len(self._charts)} charts to {self.CHARTS_FILE}") + + except Exception as e: + logger.error(f"Failed to save charts: {e}", exc_info=True) + raise BotException(f"Failed to save charts: {str(e)}") + + def get_chart(self, chart_key: str) -> Optional[Chart]: + """ + Get a chart by its key. + + Args: + chart_key: The chart key/identifier + + Returns: + Chart object if found, None otherwise + """ + return self._charts.get(chart_key) + + def get_all_charts(self) -> List[Chart]: + """ + Get all available charts. + + Returns: + List of all Chart objects + """ + return list(self._charts.values()) + + def get_charts_by_category(self, category: str) -> List[Chart]: + """ + Get all charts in a specific category. + + Args: + category: The category to filter by + + Returns: + List of charts in the specified category + """ + return [ + chart for chart in self._charts.values() + if chart.category == category + ] + + def get_chart_keys(self) -> List[str]: + """ + Get all chart keys for autocomplete. + + Returns: + Sorted list of chart keys + """ + return sorted(self._charts.keys()) + + def get_categories(self) -> Dict[str, str]: + """ + Get all categories. + + Returns: + Dictionary mapping category keys to display names + """ + return self._categories.copy() + + def add_chart(self, key: str, name: str, category: str, + urls: List[str], description: str = "") -> None: + """ + Add a new chart. + + Args: + key: Unique identifier for the chart + name: Display name for the chart + category: Category the chart belongs to + urls: List of image URLs for the chart + description: Optional description of the chart + + Raises: + BotException: If chart key already exists + """ + if key in self._charts: + raise BotException(f"Chart '{key}' already exists") + + self._charts[key] = Chart( + key=key, + name=name, + category=category, + description=description, + urls=urls + ) + self._save_charts() + logger.info(f"Added chart: {key}") + + def update_chart(self, key: str, name: Optional[str] = None, + category: Optional[str] = None, urls: Optional[List[str]] = None, + description: Optional[str] = None) -> None: + """ + Update an existing chart. + + Args: + key: Chart key to update + name: New name (optional) + category: New category (optional) + urls: New URLs (optional) + description: New description (optional) + + Raises: + BotException: If chart doesn't exist + """ + if key not in self._charts: + raise BotException(f"Chart '{key}' not found") + + chart = self._charts[key] + + if name is not None: + chart.name = name + if category is not None: + chart.category = category + if urls is not None: + chart.urls = urls + if description is not None: + chart.description = description + + self._save_charts() + logger.info(f"Updated chart: {key}") + + def remove_chart(self, key: str) -> None: + """ + Remove a chart. + + Args: + key: Chart key to remove + + Raises: + BotException: If chart doesn't exist + """ + if key not in self._charts: + raise BotException(f"Chart '{key}' not found") + + del self._charts[key] + self._save_charts() + logger.info(f"Removed chart: {key}") + + def reload_charts(self) -> None: + """Reload charts from the JSON file.""" + self._load_charts() + + +# Global chart service instance +_chart_service: Optional[ChartService] = None + + +def get_chart_service() -> ChartService: + """ + Get the global chart service instance. + + Returns: + ChartService instance + """ + global _chart_service + if _chart_service is None: + _chart_service = ChartService() + return _chart_service diff --git a/tests/README.md b/tests/README.md index 7113328..0d13403 100644 --- a/tests/README.md +++ b/tests/README.md @@ -21,7 +21,7 @@ tests/ ## Key Testing Patterns -### 1. HTTP Testing with aioresponses +### 1. HTTP Testing with aioresponsesf **โœ… Recommended Approach:** ```python diff --git a/tests/factories.py b/tests/factories.py index a446427..84b6658 100644 --- a/tests/factories.py +++ b/tests/factories.py @@ -10,6 +10,8 @@ from typing import Optional, Dict, Any from models.player import Player from models.team import Team, RosterType from models.transaction import Transaction +from models.game import Game +from models.current import Current class PlayerFactory: @@ -166,6 +168,93 @@ class TransactionFactory: return Transaction(**defaults) +class GameFactory: + """Factory for creating Game test instances.""" + + @staticmethod + def create( + id: int = 1, + season: int = 12, + week: int = 1, + game_num: int = 1, + season_type: str = "regular", + away_team: Optional[Team] = None, + home_team: Optional[Team] = None, + away_score: Optional[int] = None, + home_score: Optional[int] = None, + **kwargs + ) -> Game: + """Create a Game instance with sensible defaults.""" + # Use default teams if none provided + if away_team is None: + away_team = TeamFactory.create(id=1, abbrev="AWY", sname="Away", lname="Away Team") + if home_team is None: + home_team = TeamFactory.create(id=2, abbrev="HOM", sname="Home", lname="Home Team") + + defaults = { + "id": id, + "season": season, + "week": week, + "game_num": game_num, + "season_type": season_type, + "away_team": away_team, + "home_team": home_team, + "away_score": away_score, + "home_score": home_score, + } + defaults.update(kwargs) + return Game(**defaults) + + @staticmethod + def completed( + id: int = 1, + away_score: int = 5, + home_score: int = 3, + **kwargs + ) -> Game: + """Create a completed game with scores.""" + return GameFactory.create( + id=id, + away_score=away_score, + home_score=home_score, + **kwargs + ) + + @staticmethod + def upcoming(id: int = 1, **kwargs) -> Game: + """Create an upcoming game (no scores).""" + return GameFactory.create( + id=id, + away_score=None, + home_score=None, + **kwargs + ) + + +class CurrentFactory: + """Factory for creating Current league state instances.""" + + @staticmethod + def create( + week: int = 10, + season: int = 12, + freeze: bool = False, + trade_deadline: int = 14, + playoffs_begin: int = 19, + **kwargs + ) -> Current: + """Create a Current instance with sensible defaults.""" + defaults = { + "week": week, + "season": season, + "freeze": freeze, + "trade_deadline": trade_deadline, + "playoffs_begin": playoffs_begin, + } + defaults.update(kwargs) + return Current(**defaults) + + # Convenience functions for common test scenarios def create_player_list(count: int = 3, **kwargs) -> list[Player]: """Create a list of test players.""" diff --git a/tests/test_commands_charts.py b/tests/test_commands_charts.py new file mode 100644 index 0000000..a07faa7 --- /dev/null +++ b/tests/test_commands_charts.py @@ -0,0 +1,415 @@ +""" +Tests for chart display and management commands. +""" +import pytest +import json +import tempfile +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, patch +from discord import app_commands + +from commands.utilities.charts import ChartCommands, ChartAdminCommands +from services.chart_service import ChartService, Chart, get_chart_service +from exceptions import BotException + + +@pytest.fixture +def temp_charts_file(): + """Create a temporary charts.json file for testing.""" + with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f: + charts_data = { + "charts": { + "rest": { + "name": "Pitcher Rest", + "category": "gameplay", + "description": "Pitcher rest and endurance rules", + "urls": ["https://example.com/rest.png"] + }, + "defense": { + "name": "Defense Chart", + "category": "defense", + "description": "General defensive play chart", + "urls": ["https://example.com/defense.png"] + }, + "multi-image": { + "name": "Multi Image Chart", + "category": "gameplay", + "description": "Chart with multiple images", + "urls": [ + "https://example.com/image1.png", + "https://example.com/image2.png", + "https://example.com/image3.png" + ] + } + }, + "categories": { + "gameplay": "Gameplay Mechanics", + "defense": "Defensive Play", + "reference": "Reference Charts" + } + } + json.dump(charts_data, f) + temp_path = Path(f.name) + + yield temp_path + + # Cleanup + if temp_path.exists(): + temp_path.unlink() + + +@pytest.fixture +def chart_service(temp_charts_file): + """Create a chart service with test data.""" + with patch.object(ChartService, 'CHARTS_FILE', temp_charts_file): + service = ChartService() + return service + + +@pytest.fixture +def mock_interaction(): + """Create a mock Discord interaction.""" + interaction = AsyncMock() + interaction.response = AsyncMock() + interaction.followup = AsyncMock() + interaction.user = MagicMock() + interaction.user.id = 123456789 + interaction.user.display_name = "TestUser" + interaction.guild = MagicMock() + interaction.guild.id = 987654321 + interaction.channel = MagicMock() + interaction.channel.name = "test-channel" + return interaction + + +class TestChartService: + """Tests for ChartService class.""" + + def test_load_charts(self, chart_service): + """Test loading charts from file.""" + assert len(chart_service._charts) == 3 + assert 'rest' in chart_service._charts + assert 'defense' in chart_service._charts + assert 'multi-image' in chart_service._charts + + def test_get_chart(self, chart_service): + """Test getting a chart by key.""" + chart = chart_service.get_chart('rest') + assert chart is not None + assert chart.key == 'rest' + assert chart.name == 'Pitcher Rest' + assert chart.category == 'gameplay' + assert len(chart.urls) == 1 + + def test_get_chart_not_found(self, chart_service): + """Test getting a non-existent chart.""" + chart = chart_service.get_chart('nonexistent') + assert chart is None + + def test_get_all_charts(self, chart_service): + """Test getting all charts.""" + charts = chart_service.get_all_charts() + assert len(charts) == 3 + assert all(isinstance(c, Chart) for c in charts) + + def test_get_charts_by_category(self, chart_service): + """Test getting charts by category.""" + gameplay_charts = chart_service.get_charts_by_category('gameplay') + assert len(gameplay_charts) == 2 + assert all(c.category == 'gameplay' for c in gameplay_charts) + + defense_charts = chart_service.get_charts_by_category('defense') + assert len(defense_charts) == 1 + assert defense_charts[0].key == 'defense' + + def test_get_chart_keys(self, chart_service): + """Test getting chart keys for autocomplete.""" + keys = chart_service.get_chart_keys() + assert keys == ['defense', 'multi-image', 'rest'] # Sorted + + def test_get_categories(self, chart_service): + """Test getting categories.""" + categories = chart_service.get_categories() + assert 'gameplay' in categories + assert 'defense' in categories + assert categories['gameplay'] == 'Gameplay Mechanics' + + def test_add_chart(self, chart_service): + """Test adding a new chart.""" + chart_service.add_chart( + key='new-chart', + name='New Chart', + category='reference', + urls=['https://example.com/new.png'], + description='A new chart' + ) + + chart = chart_service.get_chart('new-chart') + assert chart is not None + assert chart.name == 'New Chart' + assert chart.category == 'reference' + + def test_add_duplicate_chart(self, chart_service): + """Test adding a duplicate chart raises exception.""" + with pytest.raises(BotException, match="already exists"): + chart_service.add_chart( + key='rest', # Already exists + name='Duplicate', + category='gameplay', + urls=['https://example.com/dup.png'] + ) + + def test_update_chart(self, chart_service): + """Test updating an existing chart.""" + chart_service.update_chart( + key='rest', + name='Updated Rest Chart', + description='Updated description' + ) + + chart = chart_service.get_chart('rest') + assert chart.name == 'Updated Rest Chart' + assert chart.description == 'Updated description' + assert chart.category == 'gameplay' # Unchanged + + def test_update_nonexistent_chart(self, chart_service): + """Test updating a non-existent chart raises exception.""" + with pytest.raises(BotException, match="not found"): + chart_service.update_chart( + key='nonexistent', + name='New Name' + ) + + def test_remove_chart(self, chart_service): + """Test removing a chart.""" + chart_service.remove_chart('rest') + assert chart_service.get_chart('rest') is None + assert len(chart_service._charts) == 2 + + def test_remove_nonexistent_chart(self, chart_service): + """Test removing a non-existent chart raises exception.""" + with pytest.raises(BotException, match="not found"): + chart_service.remove_chart('nonexistent') + + +class TestChartCommands: + """Tests for ChartCommands class.""" + + @pytest.fixture + def chart_cog(self, chart_service): + """Create ChartCommands cog with mocked service.""" + bot = AsyncMock() + cog = ChartCommands(bot) + + with patch.object(cog, 'chart_service', chart_service): + yield cog + + @pytest.mark.asyncio + async def test_charts_command_single_image(self, chart_cog, mock_interaction): + """Test displaying a chart with a single image.""" + await chart_cog.charts.callback(chart_cog, mock_interaction, 'rest') + + # Verify response was sent with embed + mock_interaction.response.send_message.assert_called_once() + call_kwargs = mock_interaction.response.send_message.call_args[1] + embed = call_kwargs['embed'] + + assert '๐Ÿ“Š Pitcher Rest' in embed.title + assert embed.description == 'Pitcher rest and endurance rules' + assert embed.image.url == 'https://example.com/rest.png' + + # Verify no followups for single image + mock_interaction.followup.send.assert_not_called() + + @pytest.mark.asyncio + async def test_charts_command_multiple_images(self, chart_cog, mock_interaction): + """Test displaying a chart with multiple images.""" + await chart_cog.charts.callback(chart_cog, mock_interaction, 'multi-image') + + # Verify initial response + mock_interaction.response.send_message.assert_called_once() + call_kwargs = mock_interaction.response.send_message.call_args[1] + embed = call_kwargs['embed'] + + assert '๐Ÿ“Š Multi Image Chart' in embed.title + assert embed.image.url == 'https://example.com/image1.png' + + # Verify followup messages for additional images + assert mock_interaction.followup.send.call_count == 2 + + @pytest.mark.asyncio + async def test_charts_command_not_found(self, chart_cog, mock_interaction): + """Test displaying a non-existent chart.""" + with pytest.raises(BotException, match="not found"): + await chart_cog.charts.callback(chart_cog, mock_interaction, 'nonexistent') + + @pytest.mark.asyncio + async def test_chart_autocomplete(self, chart_cog, mock_interaction): + """Test chart autocomplete functionality.""" + # Test with empty current input + choices = await chart_cog.chart_autocomplete(mock_interaction, '') + assert len(choices) == 3 + + # Test with partial match + choices = await chart_cog.chart_autocomplete(mock_interaction, 'def') + assert len(choices) == 1 + assert choices[0].value == 'defense' + + # Test with no match + choices = await chart_cog.chart_autocomplete(mock_interaction, 'xyz') + assert len(choices) == 0 + + +class TestChartAdminCommands: + """Tests for ChartAdminCommands class.""" + + @pytest.fixture + def admin_cog(self, chart_service): + """Create ChartAdminCommands cog with mocked service.""" + bot = AsyncMock() + cog = ChartAdminCommands(bot) + + with patch.object(cog, 'chart_service', chart_service): + yield cog + + @pytest.mark.asyncio + async def test_chart_add_command(self, admin_cog, mock_interaction): + """Test adding a new chart via command.""" + await admin_cog.chart_add.callback( + admin_cog, + mock_interaction, + key='new-chart', + name='New Chart', + category='gameplay', + url='https://example.com/new.png', + description='Test chart' + ) + + # Verify success response + mock_interaction.response.send_message.assert_called_once() + call_kwargs = mock_interaction.response.send_message.call_args[1] + embed = call_kwargs['embed'] + + assert 'โœ… Chart Added' in embed.title + assert call_kwargs['ephemeral'] is True + + # Verify chart was added + chart = admin_cog.chart_service.get_chart('new-chart') + assert chart is not None + assert chart.name == 'New Chart' + + @pytest.mark.asyncio + async def test_chart_add_invalid_category(self, admin_cog, mock_interaction): + """Test adding a chart with invalid category.""" + with pytest.raises(BotException, match="Invalid category"): + await admin_cog.chart_add.callback( + admin_cog, + mock_interaction, + key='new-chart', + name='New Chart', + category='invalid-category', + url='https://example.com/new.png', + description=None + ) + + @pytest.mark.asyncio + async def test_chart_remove_command(self, admin_cog, mock_interaction): + """Test removing a chart via command.""" + await admin_cog.chart_remove.callback(admin_cog, mock_interaction, 'rest') + + # Verify success response + mock_interaction.response.send_message.assert_called_once() + call_kwargs = mock_interaction.response.send_message.call_args[1] + embed = call_kwargs['embed'] + + assert 'โœ… Chart Removed' in embed.title + assert call_kwargs['ephemeral'] is True + + # Verify chart was removed + chart = admin_cog.chart_service.get_chart('rest') + assert chart is None + + @pytest.mark.asyncio + async def test_chart_remove_not_found(self, admin_cog, mock_interaction): + """Test removing a non-existent chart.""" + with pytest.raises(BotException, match="not found"): + await admin_cog.chart_remove.callback(admin_cog, mock_interaction, 'nonexistent') + + @pytest.mark.asyncio + async def test_chart_list_all(self, admin_cog, mock_interaction): + """Test listing all charts.""" + await admin_cog.chart_list.callback(admin_cog, mock_interaction, category=None) + + # Verify response + mock_interaction.response.send_message.assert_called_once() + call_kwargs = mock_interaction.response.send_message.call_args[1] + embed = call_kwargs['embed'] + + assert '๐Ÿ“Š All Available Charts' in embed.title + assert 'Total: 3 chart(s)' in embed.description + + @pytest.mark.asyncio + async def test_chart_list_by_category(self, admin_cog, mock_interaction): + """Test listing charts by category.""" + await admin_cog.chart_list.callback(admin_cog, mock_interaction, category='gameplay') + + # Verify response + mock_interaction.response.send_message.assert_called_once() + call_kwargs = mock_interaction.response.send_message.call_args[1] + embed = call_kwargs['embed'] + + assert "Charts in 'gameplay'" in embed.title + assert 'Total: 2 chart(s)' in embed.description + + @pytest.mark.asyncio + async def test_chart_update_command(self, admin_cog, mock_interaction): + """Test updating a chart via command.""" + await admin_cog.chart_update.callback( + admin_cog, + mock_interaction, + key='rest', + name='Updated Rest Chart', + category=None, + url=None, + description='Updated description' + ) + + # Verify success response + mock_interaction.response.send_message.assert_called_once() + call_kwargs = mock_interaction.response.send_message.call_args[1] + embed = call_kwargs['embed'] + + assert 'โœ… Chart Updated' in embed.title + + # Verify chart was updated + chart = admin_cog.chart_service.get_chart('rest') + assert chart.name == 'Updated Rest Chart' + assert chart.description == 'Updated description' + + @pytest.mark.asyncio + async def test_chart_update_no_fields(self, admin_cog, mock_interaction): + """Test updating with no fields raises exception.""" + with pytest.raises(BotException, match="Must provide at least one field"): + await admin_cog.chart_update.callback( + admin_cog, + mock_interaction, + key='rest', + name=None, + category=None, + url=None, + description=None + ) + + @pytest.mark.asyncio + async def test_chart_update_invalid_category(self, admin_cog, mock_interaction): + """Test updating with invalid category.""" + with pytest.raises(BotException, match="Invalid category"): + await admin_cog.chart_update.callback( + admin_cog, + mock_interaction, + key='rest', + name=None, + category='invalid-category', + url=None, + description=None + ) diff --git a/tests/test_commands_weather.py b/tests/test_commands_weather.py new file mode 100644 index 0000000..846a24d --- /dev/null +++ b/tests/test_commands_weather.py @@ -0,0 +1,434 @@ +""" +Tests for Weather Command (Discord interactions) + +Validates weather command functionality, team resolution, and embed creation. +""" +import pytest +from unittest.mock import AsyncMock, MagicMock, patch +import discord + +from commands.utilities.weather import WeatherCommands +from tests.factories import TeamFactory, GameFactory, CurrentFactory + + +class TestWeatherCommands: + """Test WeatherCommands Discord command functionality.""" + + @pytest.fixture + def mock_bot(self): + """Create mock Discord bot.""" + bot = MagicMock() + bot.user = MagicMock() + bot.user.id = 123456789 + bot.get_emoji = MagicMock(return_value=None) # Default: no custom emoji + return bot + + @pytest.fixture + def commands_cog(self, mock_bot): + """Create WeatherCommands cog instance.""" + return WeatherCommands(mock_bot) + + @pytest.fixture + def mock_interaction(self): + """Create mock Discord interaction.""" + interaction = AsyncMock() + interaction.user = MagicMock() + interaction.user.id = 258104532423147520 + interaction.user.name = "TestUser" + interaction.response = AsyncMock() + interaction.followup = AsyncMock() + + # Mock text channel + interaction.channel = MagicMock(spec=discord.TextChannel) + interaction.channel.name = "test-channel" + + return interaction + + @pytest.fixture + def mock_team(self): + """Create mock team data.""" + return TeamFactory.create( + id=499, + abbrev='NYY', + sname='Yankees', + lname='New York Yankees', + season=12, + color='a6ce39', + stadium='https://example.com/yankee-stadium.jpg', + thumbnail='https://example.com/yankee-thumbnail.png' + ) + + @pytest.fixture + def mock_current(self): + """Create mock current league state.""" + return CurrentFactory.create( + week=10, + season=12, + freeze=False, + trade_deadline=14, + playoffs_begin=19 + ) + + @pytest.fixture + def mock_games(self): + """Create mock game schedule.""" + # Create teams for the games + yankees = TeamFactory.create(id=499, abbrev='NYY', sname='Yankees', lname='New York Yankees', season=12) + red_sox = TeamFactory.create(id=500, abbrev='BOS', sname='Red Sox', lname='Boston Red Sox', season=12) + + # 2 completed games, 2 upcoming games + games = [ + GameFactory.completed(id=1, season=12, week=10, game_num=1, away_team=yankees, home_team=red_sox, away_score=5, home_score=3), + GameFactory.completed(id=2, season=12, week=10, game_num=2, away_team=yankees, home_team=red_sox, away_score=2, home_score=7), + GameFactory.upcoming(id=3, season=12, week=10, game_num=3, away_team=yankees, home_team=red_sox), + GameFactory.upcoming(id=4, season=12, week=10, game_num=4, away_team=yankees, home_team=red_sox), + ] + return games + + @pytest.mark.asyncio + async def test_weather_explicit_team(self, commands_cog, mock_interaction, mock_team, mock_current, mock_games): + """Test weather command with explicit team abbreviation.""" + with patch('commands.utilities.weather.league_service') as mock_league_service, \ + patch('commands.utilities.weather.schedule_service') as mock_schedule_service, \ + patch('commands.utilities.weather.team_service') as mock_team_service: + + # Mock service responses + mock_league_service.get_current_state = AsyncMock(return_value=mock_current) + mock_schedule_service.get_week_schedule = AsyncMock(return_value=mock_games) + mock_team_service.get_team_by_abbrev = AsyncMock(return_value=mock_team) + + # Execute command + await commands_cog.weather.callback(commands_cog, mock_interaction, team_abbrev='NYY') + + # Verify interaction flow + mock_interaction.response.defer.assert_called_once() + mock_interaction.followup.send.assert_called_once() + + # Verify team lookup + mock_team_service.get_team_by_abbrev.assert_called_once_with('NYY', 12) + + # Check embed was sent + embed_call = mock_interaction.followup.send.call_args + assert 'embed' in embed_call.kwargs + embed = embed_call.kwargs['embed'] + assert embed.title == "๐ŸŒค๏ธ Weather Check" + + @pytest.mark.asyncio + async def test_weather_channel_name_resolution(self, commands_cog, mock_interaction, mock_team, mock_current, mock_games): + """Test weather command resolving team from channel name.""" + # Set channel name to format: - + mock_interaction.channel.name = "NYY-Yankee-Stadium" + + with patch('commands.utilities.weather.league_service') as mock_league_service, \ + patch('commands.utilities.weather.schedule_service') as mock_schedule_service, \ + patch('commands.utilities.weather.team_service') as mock_team_service, \ + patch('commands.utilities.weather.get_user_major_league_team') as mock_get_team: + + mock_league_service.get_current_state = AsyncMock(return_value=mock_current) + mock_schedule_service.get_week_schedule = AsyncMock(return_value=mock_games) + mock_team_service.get_team_by_abbrev = AsyncMock(return_value=mock_team) + mock_get_team.return_value = None + + # Execute without explicit team parameter + await commands_cog.weather.callback(commands_cog, mock_interaction, team_abbrev=None) + + # Should resolve team from channel name "NYY-Yankee-Stadium" -> "NYY" + mock_team_service.get_team_by_abbrev.assert_called_once_with('NYY', 12) + mock_interaction.followup.send.assert_called_once() + + @pytest.mark.asyncio + async def test_weather_user_owned_team_fallback(self, commands_cog, mock_interaction, mock_team, mock_current, mock_games): + """Test weather command falling back to user's owned team.""" + # Set channel name that won't match a team + mock_interaction.channel.name = "general-chat" + + with patch('commands.utilities.weather.league_service') as mock_league_service, \ + patch('commands.utilities.weather.schedule_service') as mock_schedule_service, \ + patch('commands.utilities.weather.team_service') as mock_team_service, \ + patch('commands.utilities.weather.get_user_major_league_team') as mock_get_team: + + mock_league_service.get_current_state = AsyncMock(return_value=mock_current) + mock_schedule_service.get_week_schedule = AsyncMock(return_value=mock_games) + mock_team_service.get_team_by_abbrev = AsyncMock(return_value=None) + mock_get_team.return_value = mock_team + + await commands_cog.weather.callback(commands_cog, mock_interaction, team_abbrev=None) + + # Should fall back to user ownership + mock_get_team.assert_called_once_with(258104532423147520, 12) + mock_interaction.followup.send.assert_called_once() + + @pytest.mark.asyncio + async def test_weather_no_team_found(self, commands_cog, mock_interaction, mock_current): + """Test weather command when no team can be resolved.""" + with patch('commands.utilities.weather.league_service') as mock_league_service, \ + patch('commands.utilities.weather.team_service') as mock_team_service, \ + patch('commands.utilities.weather.get_user_major_league_team') as mock_get_team: + + mock_league_service.get_current_state = AsyncMock(return_value=mock_current) + mock_team_service.get_team_by_abbrev = AsyncMock(return_value=None) + mock_get_team.return_value = None + + await commands_cog.weather.callback(commands_cog, mock_interaction, team_abbrev=None) + + # Should send error message + embed_call = mock_interaction.followup.send.call_args + embed = embed_call.kwargs['embed'] + assert "Team Not Found" in embed.title + assert "Could not find a team" in embed.description + + @pytest.mark.asyncio + async def test_weather_league_state_unavailable(self, commands_cog, mock_interaction): + """Test weather command when league state is unavailable.""" + with patch('commands.utilities.weather.league_service') as mock_league_service: + mock_league_service.get_current_state = AsyncMock(return_value=None) + + await commands_cog.weather.callback(commands_cog, mock_interaction) + + # Should send error about league state + embed_call = mock_interaction.followup.send.call_args + embed = embed_call.kwargs['embed'] + assert "League State Unavailable" in embed.title + + @pytest.mark.asyncio + async def test_season_display_spring(self, commands_cog): + """Test season display for spring (weeks 1-5).""" + assert commands_cog._get_season_display(1) == "๐ŸŒผ Spring" + assert commands_cog._get_season_display(3) == "๐ŸŒผ Spring" + assert commands_cog._get_season_display(5) == "๐ŸŒผ Spring" + + @pytest.mark.asyncio + async def test_season_display_summer(self, commands_cog): + """Test season display for summer (weeks 6-14).""" + assert commands_cog._get_season_display(6) == "๐Ÿ–๏ธ Summer" + assert commands_cog._get_season_display(10) == "๐Ÿ–๏ธ Summer" + assert commands_cog._get_season_display(14) == "๐Ÿ–๏ธ Summer" + + @pytest.mark.asyncio + async def test_season_display_fall(self, commands_cog): + """Test season display for fall (weeks 15+).""" + assert commands_cog._get_season_display(15) == "๐Ÿ‚ Fall" + assert commands_cog._get_season_display(18) == "๐Ÿ‚ Fall" + assert commands_cog._get_season_display(20) == "๐Ÿ‚ Fall" + + @pytest.mark.asyncio + async def test_time_of_day_zero_games_played(self, commands_cog, mock_games): + """Test time of day when 0 games have been played (non-division week).""" + # Filter to only upcoming games (no scores) + upcoming_games = [g for g in mock_games if not g.is_completed] + + time_of_day = commands_cog._get_time_of_day(upcoming_games, week=10) + assert time_of_day == "๐ŸŒ™ Night" + + @pytest.mark.asyncio + async def test_time_of_day_one_game_played(self, commands_cog, mock_games): + """Test time of day when 1 game has been played (non-division week).""" + # Take first game only (completed) + one_game = [mock_games[0]] + + time_of_day = commands_cog._get_time_of_day(one_game, week=10) + assert time_of_day == "๐ŸŒž Day" + + @pytest.mark.asyncio + async def test_time_of_day_two_games_played(self, commands_cog, mock_games): + """Test time of day when 2 games have been played.""" + # Take first two games (both completed) + two_games = mock_games[:2] + + time_of_day = commands_cog._get_time_of_day(two_games, week=10) + assert time_of_day == "๐ŸŒ™ Night" + + @pytest.mark.asyncio + async def test_time_of_day_three_games_played(self, commands_cog, mock_games): + """Test time of day when 3 games have been played.""" + # Mark third game as completed + games = list(mock_games) + games[2].away_score = 4 + games[2].home_score = 2 + + time_of_day = commands_cog._get_time_of_day(games, week=10) + assert time_of_day == "๐ŸŒž Day" + + @pytest.mark.asyncio + async def test_time_of_day_division_week(self, commands_cog, mock_games): + """Test time of day logic in division week.""" + # Division week 6, 1 game played + one_game = [mock_games[0]] + + time_of_day = commands_cog._get_time_of_day(one_game, week=6) + # In division week, 1 game played = Night (not Day) + assert time_of_day == "๐ŸŒ™ Night" + + @pytest.mark.asyncio + async def test_time_of_day_no_games_scheduled(self, commands_cog): + """Test time of day when no games are scheduled.""" + # Regular week + time_of_day = commands_cog._get_time_of_day([], week=10) + assert time_of_day == "๐ŸŒ™ Night / ๐ŸŒž Day / ๐ŸŒ™ Night / ๐ŸŒž Day" + + # Division week + time_of_day = commands_cog._get_time_of_day([], week=6) + assert time_of_day == "๐ŸŒ™ Night / ๐ŸŒ™ Night / ๐ŸŒ™ Night / ๐ŸŒž Day" + + @pytest.mark.asyncio + async def test_weather_roll(self, commands_cog): + """Test weather roll generates valid d20 result.""" + # Test multiple rolls to ensure they're all in valid range + for _ in range(100): + roll = commands_cog._roll_weather() + assert 1 <= roll <= 20 + + @pytest.mark.asyncio + async def test_create_weather_embed(self, commands_cog, mock_team, mock_current): + """Test weather embed creation.""" + embed = commands_cog._create_weather_embed( + team=mock_team, + current=mock_current, + season_display="๐Ÿ–๏ธ Summer", + time_of_day="๐ŸŒ™ Night", + weather_roll=14, + games_played=2, + total_games=4, + username="TestUser" + ) + + # Check embed basics + assert isinstance(embed, discord.Embed) + assert embed.title == "๐ŸŒค๏ธ Weather Check" + assert embed.color.value == int(mock_team.color, 16) + + # Check fields + field_names = [field.name for field in embed.fields] + assert "Season" in field_names + assert "Time of Day" in field_names + assert "Week" in field_names + assert "Weather roll for TestUser" in field_names + + # Check field values + season_field = next(f for f in embed.fields if f.name == "Season") + assert season_field.value == "๐Ÿ–๏ธ Summer" + + time_field = next(f for f in embed.fields if f.name == "Time of Day") + assert time_field.value == "๐ŸŒ™ Night" + + week_field = next(f for f in embed.fields if f.name == "Week") + assert "10" in week_field.value + assert "2/4" in week_field.value + + roll_field = next(f for f in embed.fields if "Weather roll" in f.name) + assert "14" in roll_field.value + assert "1d20" in roll_field.value + + # Check stadium image + assert embed.image.url == mock_team.stadium + + @pytest.mark.asyncio + async def test_full_weather_workflow(self, commands_cog, mock_interaction, mock_team, mock_current, mock_games): + """Test complete weather workflow with realistic data.""" + with patch('commands.utilities.weather.league_service') as mock_league_service, \ + patch('commands.utilities.weather.schedule_service') as mock_schedule_service, \ + patch('commands.utilities.weather.team_service') as mock_team_service: + + mock_league_service.get_current_state = AsyncMock(return_value=mock_current) + mock_schedule_service.get_week_schedule = AsyncMock(return_value=mock_games) + mock_team_service.get_team_by_abbrev = AsyncMock(return_value=mock_team) + + await commands_cog.weather.callback(commands_cog, mock_interaction, team_abbrev='NYY') + + # Verify complete flow + mock_interaction.response.defer.assert_called_once() + mock_league_service.get_current_state.assert_called_once() + mock_schedule_service.get_week_schedule.assert_called_once_with(12, 10) + mock_team_service.get_team_by_abbrev.assert_called_once_with('NYY', 12) + + # Check final embed + embed_call = mock_interaction.followup.send.call_args + embed = embed_call.kwargs['embed'] + + # Validate embed structure + assert "Weather Check" in embed.title + assert len(embed.fields) == 4 # Season, Time, Week, Roll + assert embed.image.url == mock_team.stadium + assert embed.color.value == int(mock_team.color, 16) + + @pytest.mark.asyncio + async def test_team_resolution_priority(self, commands_cog, mock_interaction, mock_current): + """Test that team resolution follows correct priority order.""" + team1 = TeamFactory.create(id=1, abbrev='NYY', sname='Yankees', lname='New York Yankees', season=12) + team2 = TeamFactory.create(id=2, abbrev='BOS', sname='Red Sox', lname='Boston Red Sox', season=12) + team3 = TeamFactory.create(id=3, abbrev='LAD', sname='Dodgers', lname='Los Angeles Dodgers', season=12) + + # Test Priority 1: Explicit parameter (should return team1) + with patch('commands.utilities.weather.team_service') as mock_team_service: + mock_team_service.get_team_by_abbrev = AsyncMock(return_value=team1) + + result = await commands_cog._resolve_team(mock_interaction, 'NYY', 12) + assert result.abbrev == 'NYY' + assert result.id == 1 + + # Test Priority 2: Channel name (should return team2) + mock_interaction.channel.name = "BOS-Fenway-Park" + with patch('commands.utilities.weather.team_service') as mock_team_service: + mock_team_service.get_team_by_abbrev = AsyncMock(return_value=team2) + + result = await commands_cog._resolve_team(mock_interaction, None, 12) + assert result.abbrev == 'BOS' + assert result.id == 2 + + # Test Priority 3: User ownership (should return team3) + mock_interaction.channel.name = "general" + with patch('commands.utilities.weather.team_service') as mock_team_service, \ + patch('commands.utilities.weather.get_user_major_league_team') as mock_get_team: + mock_team_service.get_team_by_abbrev = AsyncMock(return_value=None) + mock_get_team.return_value = team3 + + result = await commands_cog._resolve_team(mock_interaction, None, 12) + assert result.abbrev == 'LAD' + assert result.id == 3 + + +class TestWeatherCommandsIntegration: + """Integration tests for weather command with realistic scenarios.""" + + @pytest.fixture + def mock_bot(self): + """Create mock Discord bot for integration tests.""" + bot = MagicMock() + bot.get_emoji = MagicMock(return_value=None) + return bot + + @pytest.fixture + def commands_cog(self, mock_bot): + """Create WeatherCommands cog for integration tests.""" + return WeatherCommands(mock_bot) + + @pytest.fixture + def mock_games(self): + """Create mock game schedule for integration tests.""" + yankees = TeamFactory.create(id=499, abbrev='NYY', sname='Yankees', lname='New York Yankees', season=12) + red_sox = TeamFactory.create(id=500, abbrev='BOS', sname='Red Sox', lname='Boston Red Sox', season=12) + + # 1 completed game for division week testing + games = [ + GameFactory.completed(id=1, season=12, week=10, game_num=1, away_team=yankees, home_team=red_sox, away_score=5, home_score=3) + ] + return games + + @pytest.mark.asyncio + async def test_all_division_weeks(self, commands_cog, mock_games): + """Test that all division weeks are handled correctly.""" + division_weeks = [1, 3, 6, 14, 16, 18] + + for week in division_weeks: + # 1 game played in division week should be Night + one_game = [mock_games[0]] + time_of_day = commands_cog._get_time_of_day(one_game, week) + assert "Night" in time_of_day, f"Week {week} should be Night with 1 game in division week" + + @pytest.mark.asyncio + async def test_season_transitions(self, commands_cog): + """Test season display transitions at boundaries.""" + assert "Spring" in commands_cog._get_season_display(5) + assert "Summer" in commands_cog._get_season_display(6) # Transition + assert "Summer" in commands_cog._get_season_display(14) + assert "Fall" in commands_cog._get_season_display(15) # Transition