CLAUDE: Add weather and charts commands with utility infrastructure
- Add /weather command with smart team resolution and D20 rolling system - Add /charts command with autocomplete and category organization - Implement ChartService for JSON-based chart management - Add comprehensive test coverage for new commands - Update test factories with complete model fixtures - Enhance voice channel tracker with improved logging - Update PRE_LAUNCH_ROADMAP.md to reflect completed features - Minor improvements to imports and service initialization 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
5c7f2d916b
commit
7d422f50c5
@ -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 <chart-name>`
|
||||
- **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 <resource-name>`
|
||||
@ -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**
|
||||
**Target Timeline: 1 week for complete pre-launch readiness**
|
||||
**Next Steps: Proceed with Links system, then user features (image management, meme commands)**
|
||||
4
bot.py
4
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
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
235
commands/utilities/README.md
Normal file
235
commands/utilities/README.md
Normal file
@ -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 <chart-name>`
|
||||
|
||||
**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 <key> <name> <category> <url> [description]` - Add a new chart
|
||||
- `/chart-remove <key>` - Remove a chart from the library
|
||||
- `/chart-list [category]` - List all charts (optionally filtered by category)
|
||||
- `/chart-update <key> [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 <resource-name>` - 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
|
||||
47
commands/utilities/__init__.py
Normal file
47
commands/utilities/__init__.py
Normal file
@ -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
|
||||
353
commands/utilities/charts.py
Normal file
353
commands/utilities/charts.py
Normal file
@ -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))
|
||||
268
commands/utilities/weather.py
Normal file
268
commands/utilities/weather.py
Normal file
@ -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: <abbrev>-<park name>)
|
||||
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))
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -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'
|
||||
]
|
||||
257
services/chart_service.py
Normal file
257
services/chart_service.py
Normal file
@ -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
|
||||
@ -21,7 +21,7 @@ tests/
|
||||
|
||||
## Key Testing Patterns
|
||||
|
||||
### 1. HTTP Testing with aioresponses
|
||||
### 1. HTTP Testing with aioresponsesf
|
||||
|
||||
**✅ Recommended Approach:**
|
||||
```python
|
||||
|
||||
@ -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."""
|
||||
|
||||
415
tests/test_commands_charts.py
Normal file
415
tests/test_commands_charts.py
Normal file
@ -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
|
||||
)
|
||||
434
tests/test_commands_weather.py
Normal file
434
tests/test_commands_weather.py
Normal file
@ -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: <abbrev>-<park name>
|
||||
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
|
||||
Loading…
Reference in New Issue
Block a user