commit
4abbb8e6b5
@ -4,9 +4,7 @@ Dice Rolling Commands
|
||||
Implements slash commands for dice rolling functionality required for gameplay.
|
||||
"""
|
||||
import random
|
||||
import re
|
||||
from typing import Optional
|
||||
from dataclasses import dataclass
|
||||
|
||||
import discord
|
||||
from discord.ext import commands
|
||||
@ -18,6 +16,7 @@ from utils.logging import get_contextual_logger
|
||||
from utils.decorators import logged_command
|
||||
from utils.team_utils import get_user_major_league_team
|
||||
from utils.text_utils import split_text_for_fields
|
||||
from utils.dice_utils import DiceRoll, parse_and_roll_multiple_dice, parse_and_roll_single_dice
|
||||
from views.embeds import EmbedColors, EmbedTemplate
|
||||
from .chart_data import (
|
||||
INFIELD_X_CHART,
|
||||
@ -36,16 +35,6 @@ from .chart_data import (
|
||||
PITCHER_ERRORS,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class DiceRoll:
|
||||
"""Represents the result of a dice roll."""
|
||||
dice_notation: str
|
||||
num_dice: int
|
||||
die_sides: int
|
||||
rolls: list[int]
|
||||
total: int
|
||||
|
||||
class DiceRollCommands(commands.Cog):
|
||||
"""Dice rolling command handlers for gameplay."""
|
||||
|
||||
@ -70,7 +59,7 @@ class DiceRollCommands(commands.Cog):
|
||||
await interaction.response.defer()
|
||||
|
||||
# Parse and validate dice notation (supports multiple rolls)
|
||||
roll_results = self._parse_and_roll_multiple_dice(dice)
|
||||
roll_results = parse_and_roll_multiple_dice(dice)
|
||||
if not roll_results:
|
||||
await interaction.followup.send(
|
||||
"❌ Invalid dice notation. Use format like: 2d6, 1d20, or 1d6;2d6;1d20",
|
||||
@ -93,7 +82,7 @@ class DiceRollCommands(commands.Cog):
|
||||
return
|
||||
|
||||
# Parse and validate dice notation (supports multiple rolls)
|
||||
roll_results = self._parse_and_roll_multiple_dice(dice)
|
||||
roll_results = parse_and_roll_multiple_dice(dice)
|
||||
if not roll_results:
|
||||
self.logger.warning("Invalid dice notation provided", dice_input=dice)
|
||||
await ctx.send("❌ Invalid dice notation. Use format like: 2d6, 1d20, or 1d6;2d6;1d20")
|
||||
@ -105,6 +94,32 @@ class DiceRollCommands(commands.Cog):
|
||||
embed = self._create_multi_roll_embed(dice, roll_results, ctx.author)
|
||||
await ctx.send(embed=embed)
|
||||
|
||||
@discord.app_commands.command(
|
||||
name="d20",
|
||||
description="Roll a single d20"
|
||||
)
|
||||
@logged_command("/d20")
|
||||
async def d20_dice(self, interaction: discord.Interaction):
|
||||
"""Roll a single d20."""
|
||||
await interaction.response.defer()
|
||||
embed_color = await self._get_channel_embed_color(interaction)
|
||||
|
||||
# Roll 1d20
|
||||
dice_notation = "1d20"
|
||||
roll_results = parse_and_roll_multiple_dice(dice_notation)
|
||||
|
||||
# Create embed for the roll results
|
||||
embed = self._create_multi_roll_embed(
|
||||
dice_notation,
|
||||
roll_results,
|
||||
interaction.user,
|
||||
set_author=False,
|
||||
embed_color=embed_color
|
||||
)
|
||||
embed.title = f'd20 roll for {interaction.user.display_name}'
|
||||
|
||||
await interaction.followup.send(embed=embed)
|
||||
|
||||
@discord.app_commands.command(
|
||||
name="ab",
|
||||
description="Roll baseball at-bat dice (1d6;2d6;1d20)"
|
||||
@ -117,7 +132,7 @@ class DiceRollCommands(commands.Cog):
|
||||
|
||||
# Use the standard baseball dice combination
|
||||
dice_notation = "1d6;2d6;1d20"
|
||||
roll_results = self._parse_and_roll_multiple_dice(dice_notation)
|
||||
roll_results = parse_and_roll_multiple_dice(dice_notation)
|
||||
|
||||
injury_risk = (roll_results[0].total == 6) and (roll_results[1].total in [7, 8, 9, 10, 11, 12])
|
||||
d6_total = roll_results[1].total
|
||||
@ -126,11 +141,11 @@ class DiceRollCommands(commands.Cog):
|
||||
if roll_results[2].total == 1:
|
||||
embed_title = 'Wild pitch roll'
|
||||
dice_notation = '1d20'
|
||||
roll_results = [self._parse_and_roll_single_dice(dice_notation)]
|
||||
roll_results = [parse_and_roll_single_dice(dice_notation)]
|
||||
elif roll_results[2].total == 2:
|
||||
embed_title = 'PB roll'
|
||||
dice_notation = '1d20'
|
||||
roll_results = [self._parse_and_roll_single_dice(dice_notation)]
|
||||
roll_results = [parse_and_roll_single_dice(dice_notation)]
|
||||
|
||||
# Create embed for the roll results
|
||||
embed = self._create_multi_roll_embed(
|
||||
@ -162,7 +177,7 @@ class DiceRollCommands(commands.Cog):
|
||||
|
||||
# Use the standard baseball dice combination
|
||||
dice_notation = "1d6;2d6;1d20"
|
||||
roll_results = self._parse_and_roll_multiple_dice(dice_notation)
|
||||
roll_results = parse_and_roll_multiple_dice(dice_notation)
|
||||
|
||||
self.logger.info("At Bat dice rolled successfully", roll_count=len(roll_results))
|
||||
|
||||
@ -235,7 +250,7 @@ class DiceRollCommands(commands.Cog):
|
||||
|
||||
# Roll the dice - 1d20 and 3d6
|
||||
dice_notation = "1d20;3d6;1d100"
|
||||
roll_results = self._parse_and_roll_multiple_dice(dice_notation)
|
||||
roll_results = parse_and_roll_multiple_dice(dice_notation)
|
||||
|
||||
# Create fielding embed
|
||||
embed = self._create_fielding_embed(pos_value, roll_results, interaction.user, embed_color)
|
||||
@ -256,9 +271,9 @@ class DiceRollCommands(commands.Cog):
|
||||
await ctx.send("❌ Invalid position. Use: C, 1B, 2B, 3B, SS, LF, CF, RF")
|
||||
return
|
||||
|
||||
# Roll the dice - 1d20 and 3d6
|
||||
dice_notation = "1d20;3d6"
|
||||
roll_results = self._parse_and_roll_multiple_dice(dice_notation)
|
||||
# Roll the dice - 1d20 and 3d6 and 1d100
|
||||
dice_notation = "1d20;3d6;1d100"
|
||||
roll_results = parse_and_roll_multiple_dice(dice_notation)
|
||||
|
||||
self.logger.info("SA Fielding dice rolled successfully", position=parsed_position, d20=roll_results[0].total, d6_total=roll_results[1].total)
|
||||
|
||||
@ -280,7 +295,7 @@ class DiceRollCommands(commands.Cog):
|
||||
check_roll = random.randint(1, 20)
|
||||
|
||||
# Roll 2d6 for jump rating
|
||||
jump_result = self._parse_and_roll_single_dice("2d6")
|
||||
jump_result = parse_and_roll_single_dice("2d6")
|
||||
|
||||
# Roll another 1d20 for pickoff/balk resolution
|
||||
resolution_roll = random.randint(1, 20)
|
||||
@ -309,7 +324,7 @@ class DiceRollCommands(commands.Cog):
|
||||
check_roll = random.randint(1, 20)
|
||||
|
||||
# Roll 2d6 for jump rating
|
||||
jump_result = self._parse_and_roll_single_dice("2d6")
|
||||
jump_result = parse_and_roll_single_dice("2d6")
|
||||
|
||||
# Roll another 1d20 for pickoff/balk resolution
|
||||
resolution_roll = random.randint(1, 20)
|
||||
@ -642,50 +657,6 @@ class DiceRollCommands(commands.Cog):
|
||||
f'**TR3**: {OUTFIELD_X_CHART["tr3"]["rp"]}\n'
|
||||
)
|
||||
|
||||
def _parse_and_roll_multiple_dice(self, dice_notation: str) -> list[DiceRoll]:
|
||||
"""Parse dice notation (supports multiple rolls) and return roll results."""
|
||||
# Split by semicolon for multiple rolls
|
||||
dice_parts = [part.strip() for part in dice_notation.split(';')]
|
||||
results = []
|
||||
|
||||
for dice_part in dice_parts:
|
||||
result = self._parse_and_roll_single_dice(dice_part)
|
||||
if result is None:
|
||||
return [] # Return empty list if any part is invalid
|
||||
results.append(result)
|
||||
|
||||
return results
|
||||
|
||||
def _parse_and_roll_single_dice(self, dice_notation: str) -> DiceRoll:
|
||||
"""Parse single dice notation and return roll results."""
|
||||
# Clean the input
|
||||
dice_notation = dice_notation.strip().lower().replace(' ', '')
|
||||
|
||||
# Pattern: XdY
|
||||
pattern = r'^(\d+)d(\d+)$'
|
||||
match = re.match(pattern, dice_notation)
|
||||
|
||||
if not match:
|
||||
raise ValueError(f'Cannot parse dice string **{dice_notation}**')
|
||||
|
||||
num_dice = int(match.group(1))
|
||||
die_sides = int(match.group(2))
|
||||
|
||||
# Validate reasonable limits
|
||||
if num_dice > 100 or die_sides > 1000 or num_dice < 1 or die_sides < 2:
|
||||
raise ValueError('I don\'t know, bud, that just doesn\'t seem doable.')
|
||||
|
||||
# Roll the dice
|
||||
rolls = [random.randint(1, die_sides) for _ in range(num_dice)]
|
||||
total = sum(rolls)
|
||||
|
||||
return DiceRoll(
|
||||
dice_notation=dice_notation,
|
||||
num_dice=num_dice,
|
||||
die_sides=die_sides,
|
||||
rolls=rolls,
|
||||
total=total
|
||||
)
|
||||
|
||||
def _roll_weighted_scout_dice(self, card_type: str) -> list[DiceRoll]:
|
||||
"""
|
||||
@ -712,10 +683,10 @@ class DiceRollCommands(commands.Cog):
|
||||
)
|
||||
|
||||
# Second roll (2d6) - normal
|
||||
second_result = self._parse_and_roll_single_dice("2d6")
|
||||
second_result = parse_and_roll_single_dice("2d6")
|
||||
|
||||
# Third roll (1d20) - normal
|
||||
third_result = self._parse_and_roll_single_dice("1d20")
|
||||
third_result = parse_and_roll_single_dice("1d20")
|
||||
|
||||
return [first_d6_result, second_result, third_result]
|
||||
|
||||
|
||||
@ -32,6 +32,10 @@ class BotConfig(BaseSettings):
|
||||
sba_color: str = "a6ce39"
|
||||
weeks_per_season: int = 18
|
||||
games_per_week: int = 4
|
||||
playoff_weeks_per_season: int = 3
|
||||
playoff_round_one_games: int = 5
|
||||
playoff_round_two_games: int = 7
|
||||
playoff_round_three_games: int = 7
|
||||
modern_stats_start_season: int = 8
|
||||
offseason_flag: bool = False # When True, relaxes roster limits and disables weekly freeze/thaw
|
||||
|
||||
|
||||
@ -8,7 +8,8 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
||||
import discord
|
||||
from discord.ext import commands
|
||||
|
||||
from commands.dice.rolls import DiceRollCommands, DiceRoll
|
||||
from commands.dice.rolls import DiceRollCommands
|
||||
from utils.dice_utils import DiceRoll, parse_and_roll_multiple_dice, parse_and_roll_single_dice
|
||||
|
||||
|
||||
class TestDiceRollCommands:
|
||||
@ -32,6 +33,7 @@ class TestDiceRollCommands:
|
||||
|
||||
# Mock the user
|
||||
user = MagicMock(spec=discord.User)
|
||||
user.name = "TestUser"
|
||||
user.display_name = "TestUser"
|
||||
user.display_avatar.url = "https://example.com/avatar.png"
|
||||
interaction.user = user
|
||||
@ -62,7 +64,7 @@ class TestDiceRollCommands:
|
||||
def test_parse_valid_dice_notation(self, dice_cog):
|
||||
"""Test parsing valid dice notation."""
|
||||
# Test basic notation
|
||||
results = dice_cog._parse_and_roll_multiple_dice("2d6")
|
||||
results = parse_and_roll_multiple_dice("2d6")
|
||||
assert len(results) == 1
|
||||
result = results[0]
|
||||
assert result.num_dice == 2
|
||||
@ -72,7 +74,7 @@ class TestDiceRollCommands:
|
||||
assert result.total == sum(result.rolls)
|
||||
|
||||
# Test single die
|
||||
results = dice_cog._parse_and_roll_multiple_dice("1d20")
|
||||
results = parse_and_roll_multiple_dice("1d20")
|
||||
assert len(results) == 1
|
||||
result = results[0]
|
||||
assert result.num_dice == 1
|
||||
@ -83,22 +85,22 @@ class TestDiceRollCommands:
|
||||
def test_parse_invalid_dice_notation(self, dice_cog):
|
||||
"""Test parsing invalid dice notation."""
|
||||
# Invalid formats
|
||||
assert dice_cog._parse_and_roll_multiple_dice("invalid") == []
|
||||
assert dice_cog._parse_and_roll_multiple_dice("2d") == []
|
||||
assert dice_cog._parse_and_roll_multiple_dice("d6") == []
|
||||
assert dice_cog._parse_and_roll_multiple_dice("2d6+5") == [] # No modifiers in simple version
|
||||
assert dice_cog._parse_and_roll_multiple_dice("") == []
|
||||
assert parse_and_roll_multiple_dice("invalid") == []
|
||||
assert parse_and_roll_multiple_dice("2d") == []
|
||||
assert parse_and_roll_multiple_dice("d6") == []
|
||||
assert parse_and_roll_multiple_dice("2d6+5") == [] # No modifiers in simple version
|
||||
assert parse_and_roll_multiple_dice("") == []
|
||||
|
||||
# Out of bounds values
|
||||
assert dice_cog._parse_and_roll_multiple_dice("0d6") == [] # num_dice < 1
|
||||
assert dice_cog._parse_and_roll_multiple_dice("2d1") == [] # die_sides < 2
|
||||
assert dice_cog._parse_and_roll_multiple_dice("101d6") == [] # num_dice > 100
|
||||
assert dice_cog._parse_and_roll_multiple_dice("1d1001") == [] # die_sides > 1000
|
||||
assert parse_and_roll_multiple_dice("0d6") == [] # num_dice < 1
|
||||
assert parse_and_roll_multiple_dice("2d1") == [] # die_sides < 2
|
||||
assert parse_and_roll_multiple_dice("101d6") == [] # num_dice > 100
|
||||
assert parse_and_roll_multiple_dice("1d1001") == [] # die_sides > 1000
|
||||
|
||||
def test_parse_multiple_dice(self, dice_cog):
|
||||
"""Test parsing multiple dice rolls."""
|
||||
# Test multiple rolls
|
||||
results = dice_cog._parse_and_roll_multiple_dice("1d6;2d8;1d20")
|
||||
results = parse_and_roll_multiple_dice("1d6;2d8;1d20")
|
||||
assert len(results) == 3
|
||||
|
||||
assert results[0].dice_notation == '1d6'
|
||||
@ -115,8 +117,8 @@ class TestDiceRollCommands:
|
||||
|
||||
def test_parse_case_insensitive(self, dice_cog):
|
||||
"""Test that dice notation parsing is case insensitive."""
|
||||
result_lower = dice_cog._parse_and_roll_multiple_dice("2d6")
|
||||
result_upper = dice_cog._parse_and_roll_multiple_dice("2D6")
|
||||
result_lower = parse_and_roll_multiple_dice("2d6")
|
||||
result_upper = parse_and_roll_multiple_dice("2D6")
|
||||
|
||||
assert len(result_lower) == 1
|
||||
assert len(result_upper) == 1
|
||||
@ -125,12 +127,12 @@ class TestDiceRollCommands:
|
||||
|
||||
def test_parse_whitespace_handling(self, dice_cog):
|
||||
"""Test that whitespace is handled properly."""
|
||||
results = dice_cog._parse_and_roll_multiple_dice(" 2d6 ")
|
||||
results = parse_and_roll_multiple_dice(" 2d6 ")
|
||||
assert len(results) == 1
|
||||
assert results[0].num_dice == 2
|
||||
assert results[0].die_sides == 6
|
||||
|
||||
results = dice_cog._parse_and_roll_multiple_dice("2 d 6")
|
||||
results = parse_and_roll_multiple_dice("2 d 6")
|
||||
assert len(results) == 1
|
||||
assert results[0].num_dice == 2
|
||||
assert results[0].die_sides == 6
|
||||
@ -232,7 +234,7 @@ class TestDiceRollCommands:
|
||||
"""Test that dice rolls produce different results."""
|
||||
results = []
|
||||
for _ in range(20): # Roll 20 times
|
||||
result = dice_cog._parse_and_roll_multiple_dice("1d20")
|
||||
result = parse_and_roll_multiple_dice("1d20")
|
||||
results.append(result[0].rolls[0])
|
||||
|
||||
# Should have some variation in results (very unlikely all 20 rolls are the same)
|
||||
@ -242,20 +244,20 @@ class TestDiceRollCommands:
|
||||
def test_dice_boundaries(self, dice_cog):
|
||||
"""Test dice rolling at boundaries."""
|
||||
# Test maximum allowed dice
|
||||
results = dice_cog._parse_and_roll_multiple_dice("100d2")
|
||||
results = parse_and_roll_multiple_dice("100d2")
|
||||
assert len(results) == 1
|
||||
result = results[0]
|
||||
assert len(result.rolls) == 100
|
||||
assert all(roll in [1, 2] for roll in result.rolls)
|
||||
|
||||
# Test maximum die size
|
||||
results = dice_cog._parse_and_roll_multiple_dice("1d1000")
|
||||
results = parse_and_roll_multiple_dice("1d1000")
|
||||
assert len(results) == 1
|
||||
result = results[0]
|
||||
assert 1 <= result.rolls[0] <= 1000
|
||||
|
||||
# Test minimum valid values
|
||||
results = dice_cog._parse_and_roll_multiple_dice("1d2")
|
||||
results = parse_and_roll_multiple_dice("1d2")
|
||||
assert len(results) == 1
|
||||
result = results[0]
|
||||
assert result.rolls[0] in [1, 2]
|
||||
@ -328,6 +330,26 @@ class TestDiceRollCommands:
|
||||
assert command.name == "roll"
|
||||
assert command.aliases == ["r", "dice"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_d20_command_slash(self, dice_cog, mock_interaction):
|
||||
"""Test d20 slash command."""
|
||||
await dice_cog.d20_dice.callback(dice_cog, mock_interaction)
|
||||
|
||||
# Verify response was deferred
|
||||
mock_interaction.response.defer.assert_called_once()
|
||||
|
||||
# Verify followup was sent with embed
|
||||
mock_interaction.followup.send.assert_called_once()
|
||||
call_args = mock_interaction.followup.send.call_args
|
||||
assert 'embed' in call_args.kwargs
|
||||
|
||||
# Verify embed has the correct format
|
||||
embed = call_args.kwargs['embed']
|
||||
assert isinstance(embed, discord.Embed)
|
||||
assert embed.title == "d20 roll for TestUser"
|
||||
assert len(embed.fields) == 1
|
||||
assert "Details:[1d20" in embed.fields[0].value
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ab_command_slash(self, dice_cog, mock_interaction):
|
||||
"""Test ab slash command."""
|
||||
@ -379,7 +401,7 @@ class TestDiceRollCommands:
|
||||
def test_ab_command_dice_combination(self, dice_cog):
|
||||
"""Test that ab command uses the correct dice combination."""
|
||||
dice_notation = "1d6;2d6;1d20"
|
||||
results = dice_cog._parse_and_roll_multiple_dice(dice_notation)
|
||||
results = parse_and_roll_multiple_dice(dice_notation)
|
||||
|
||||
# Should have 3 dice groups
|
||||
assert len(results) == 3
|
||||
@ -538,7 +560,7 @@ class TestDiceRollCommands:
|
||||
def test_fielding_dice_combination(self, dice_cog):
|
||||
"""Test that fielding uses correct dice combination (1d20;3d6)."""
|
||||
dice_notation = "1d20;3d6"
|
||||
results = dice_cog._parse_and_roll_multiple_dice(dice_notation)
|
||||
results = parse_and_roll_multiple_dice(dice_notation)
|
||||
|
||||
# Should have 2 dice groups
|
||||
assert len(results) == 2
|
||||
@ -620,7 +642,7 @@ class TestDiceRollCommands:
|
||||
# Verify embed has the correct format
|
||||
embed = call_args.kwargs['embed']
|
||||
assert isinstance(embed, discord.Embed)
|
||||
assert embed.title == "Scouting roll for TestUser (Batter)"
|
||||
assert embed.title == "Scouting roll for TestUser"
|
||||
assert len(embed.fields) == 1
|
||||
assert "Details:[1d6;2d6;1d20" in embed.fields[0].value
|
||||
|
||||
@ -645,6 +667,6 @@ class TestDiceRollCommands:
|
||||
# Verify embed has the correct format
|
||||
embed = call_args.kwargs['embed']
|
||||
assert isinstance(embed, discord.Embed)
|
||||
assert embed.title == "Scouting roll for TestUser (Pitcher)"
|
||||
assert embed.title == "Scouting roll for TestUser"
|
||||
assert len(embed.fields) == 1
|
||||
assert "Details:[1d6;2d6;1d20" in embed.fields[0].value
|
||||
189
tests/test_utils_scorebug_helpers.py
Normal file
189
tests/test_utils_scorebug_helpers.py
Normal file
@ -0,0 +1,189 @@
|
||||
"""
|
||||
Tests for scorebug_helpers utility functions.
|
||||
|
||||
Tests the create_team_progress_bar function to ensure correct
|
||||
win probability visualization for home and away teams.
|
||||
"""
|
||||
import pytest
|
||||
from utils.scorebug_helpers import create_team_progress_bar
|
||||
|
||||
|
||||
class TestCreateTeamProgressBar:
|
||||
"""Tests for the create_team_progress_bar function."""
|
||||
|
||||
def test_home_team_winning_75_percent(self):
|
||||
"""Test progress bar when home team has 75% win probability."""
|
||||
result = create_team_progress_bar(
|
||||
win_percentage=75.0,
|
||||
away_abbrev="POR",
|
||||
home_abbrev="WV"
|
||||
)
|
||||
|
||||
# Home team winning: should show dark blocks (▓) on right side
|
||||
# Arrow should extend from right side (►)
|
||||
assert "►" in result
|
||||
assert "◄" not in result
|
||||
assert "75.0%" in result
|
||||
assert "POR" in result
|
||||
assert "WV" in result
|
||||
|
||||
# Should have more dark blocks (▓) than light blocks (░)
|
||||
dark_blocks = result.count("▓")
|
||||
light_blocks = result.count("░")
|
||||
assert dark_blocks > light_blocks, "Home team winning should have more dark blocks"
|
||||
|
||||
def test_away_team_winning_25_percent_home(self):
|
||||
"""Test progress bar when home team has only 25% win probability (away team winning)."""
|
||||
result = create_team_progress_bar(
|
||||
win_percentage=25.0,
|
||||
away_abbrev="POR",
|
||||
home_abbrev="WV"
|
||||
)
|
||||
|
||||
# Away team winning: should show dark blocks (▓) on left side
|
||||
# Arrow should extend from left side (◄)
|
||||
assert "◄" in result
|
||||
assert "►" not in result
|
||||
# Percentage should show away team's win % (75.0%) on left
|
||||
assert result.startswith("75.0%"), "Percentage should be on left when away team winning"
|
||||
assert "POR" in result
|
||||
assert "WV" in result
|
||||
|
||||
# Should have more dark blocks (▓) than light blocks (░)
|
||||
dark_blocks = result.count("▓")
|
||||
light_blocks = result.count("░")
|
||||
assert dark_blocks > light_blocks, "Away team winning should have more dark blocks"
|
||||
|
||||
def test_even_game_50_percent(self):
|
||||
"""Test progress bar when game is even at 50%."""
|
||||
result = create_team_progress_bar(
|
||||
win_percentage=50.0,
|
||||
away_abbrev="POR",
|
||||
home_abbrev="WV"
|
||||
)
|
||||
|
||||
# Even game: should have equals signs on both sides
|
||||
assert "=" in result
|
||||
assert "►" not in result
|
||||
assert "◄" not in result
|
||||
# Percentage should appear on both sides for even game
|
||||
assert result.startswith("50.0%"), "Percentage should be on left for even game"
|
||||
assert result.endswith("50.0%"), "Percentage should be on right for even game"
|
||||
assert "POR" in result
|
||||
assert "WV" in result
|
||||
|
||||
# All blocks should be dark (▓) for even game
|
||||
assert "░" not in result, "Even game should have no light blocks"
|
||||
|
||||
def test_home_team_slight_advantage_55_percent(self):
|
||||
"""Test progress bar when home team has slight advantage (55%)."""
|
||||
result = create_team_progress_bar(
|
||||
win_percentage=55.0,
|
||||
away_abbrev="NYK",
|
||||
home_abbrev="BOS"
|
||||
)
|
||||
|
||||
# Home team winning: arrow extends from right
|
||||
assert "►" in result
|
||||
assert "◄" not in result
|
||||
assert "55.0%" in result
|
||||
|
||||
def test_away_team_strong_advantage_30_percent_home(self):
|
||||
"""Test progress bar when away team has strong advantage (home only 30%)."""
|
||||
result = create_team_progress_bar(
|
||||
win_percentage=30.0,
|
||||
away_abbrev="LAD",
|
||||
home_abbrev="SF"
|
||||
)
|
||||
|
||||
# Away team winning: arrow extends from left
|
||||
assert "◄" in result
|
||||
assert "►" not in result
|
||||
# Percentage should show away team's win % (70.0%) on left
|
||||
assert result.startswith("70.0%"), "Percentage should be on left when away team winning"
|
||||
|
||||
def test_home_team_dominant_95_percent(self):
|
||||
"""Test progress bar when home team is dominant (95%)."""
|
||||
result = create_team_progress_bar(
|
||||
win_percentage=95.0,
|
||||
away_abbrev="POR",
|
||||
home_abbrev="WV"
|
||||
)
|
||||
|
||||
# Home team dominant: almost all dark blocks
|
||||
assert "►" in result
|
||||
assert "95.0%" in result
|
||||
|
||||
dark_blocks = result.count("▓")
|
||||
light_blocks = result.count("░")
|
||||
|
||||
# With 95% home win probability, should be 9.5/10 blocks dark (rounds to 9 or 10)
|
||||
assert dark_blocks >= 9, "95% should result in 9+ dark blocks"
|
||||
assert light_blocks <= 1, "95% should result in 0-1 light blocks"
|
||||
|
||||
def test_away_team_dominant_5_percent_home(self):
|
||||
"""Test progress bar when away team is dominant (home only 5%)."""
|
||||
result = create_team_progress_bar(
|
||||
win_percentage=5.0,
|
||||
away_abbrev="POR",
|
||||
home_abbrev="WV"
|
||||
)
|
||||
|
||||
# Away team dominant: almost all dark blocks
|
||||
assert "◄" in result
|
||||
# Percentage should show away team's win % (95.0%) on left
|
||||
assert result.startswith("95.0%"), "Percentage should be on left when away team winning"
|
||||
|
||||
dark_blocks = result.count("▓")
|
||||
light_blocks = result.count("░")
|
||||
|
||||
# With 5% home win probability, should be 9.5/10 blocks dark (rounds to 9 or 10)
|
||||
assert dark_blocks >= 9, "5% should result in 9+ dark blocks"
|
||||
assert light_blocks <= 1, "5% should result in 0-1 light blocks"
|
||||
|
||||
def test_custom_bar_length(self):
|
||||
"""Test progress bar with custom length."""
|
||||
result = create_team_progress_bar(
|
||||
win_percentage=75.0,
|
||||
away_abbrev="POR",
|
||||
home_abbrev="WV",
|
||||
length=20
|
||||
)
|
||||
|
||||
# Should have more blocks total
|
||||
total_blocks = result.count("▓") + result.count("░")
|
||||
assert total_blocks == 20, "Should have exactly 20 blocks with custom length"
|
||||
|
||||
def test_edge_case_0_percent(self):
|
||||
"""Test progress bar at edge case of 0% home win probability."""
|
||||
result = create_team_progress_bar(
|
||||
win_percentage=0.0,
|
||||
away_abbrev="POR",
|
||||
home_abbrev="WV"
|
||||
)
|
||||
|
||||
# Away team certain to win: arrow from left
|
||||
assert "◄" in result
|
||||
# Percentage should show away team's win % (100.0%) on left
|
||||
assert result.startswith("100.0%"), "Percentage should be on left when away team winning"
|
||||
|
||||
# Should be all dark blocks (away team dominant)
|
||||
assert result.count("▓") == 10, "0% home should be all dark blocks"
|
||||
assert "░" not in result, "0% home should have no light blocks"
|
||||
|
||||
def test_edge_case_100_percent(self):
|
||||
"""Test progress bar at edge case of 100% home win probability."""
|
||||
result = create_team_progress_bar(
|
||||
win_percentage=100.0,
|
||||
away_abbrev="POR",
|
||||
home_abbrev="WV"
|
||||
)
|
||||
|
||||
# Home team certain to win: arrow from right
|
||||
assert "►" in result
|
||||
# Percentage should be on right when home team winning
|
||||
assert result.endswith("100.0%"), "Percentage should be on right when home team winning"
|
||||
|
||||
# Should be all dark blocks (home team dominant)
|
||||
assert result.count("▓") == 10, "100% home should be all dark blocks"
|
||||
assert "░" not in result, "100% home should have no light blocks"
|
||||
501
tests/test_views_injury_modals.py
Normal file
501
tests/test_views_injury_modals.py
Normal file
@ -0,0 +1,501 @@
|
||||
"""
|
||||
Tests for Injury Modal Validation in Discord Bot v2.0
|
||||
|
||||
Tests week and game validation for BatterInjuryModal and PitcherRestModal,
|
||||
including regular season and playoff round validation.
|
||||
"""
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, MagicMock, Mock, patch, PropertyMock
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import discord
|
||||
|
||||
from views.modals import BatterInjuryModal, PitcherRestModal
|
||||
from views.embeds import EmbedTemplate
|
||||
from models.player import Player
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_config():
|
||||
"""Mock configuration with standard season structure."""
|
||||
config = MagicMock()
|
||||
config.weeks_per_season = 18
|
||||
config.playoff_weeks_per_season = 3
|
||||
config.games_per_week = 4
|
||||
config.playoff_round_one_games = 5
|
||||
config.playoff_round_two_games = 7
|
||||
config.playoff_round_three_games = 7
|
||||
return config
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_player():
|
||||
"""Create a sample player for testing."""
|
||||
return Player(
|
||||
id=1,
|
||||
name="Test Player",
|
||||
wara=2.5,
|
||||
season=12,
|
||||
team_id=1,
|
||||
image="https://example.com/player.jpg",
|
||||
pos_1="1B"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_interaction():
|
||||
"""Create a mock Discord interaction."""
|
||||
interaction = MagicMock(spec=discord.Interaction)
|
||||
interaction.response = MagicMock()
|
||||
interaction.response.send_message = AsyncMock()
|
||||
return interaction
|
||||
|
||||
|
||||
def create_mock_text_input(value: str):
|
||||
"""Create a mock TextInput with a specific value."""
|
||||
mock_input = MagicMock()
|
||||
type(mock_input).value = PropertyMock(return_value=value)
|
||||
return mock_input
|
||||
|
||||
|
||||
class TestBatterInjuryModalWeekValidation:
|
||||
"""Test week validation in BatterInjuryModal."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_regular_season_week_valid(self, sample_player, mock_interaction, mock_config):
|
||||
"""Test that regular season weeks (1-18) are accepted."""
|
||||
modal = BatterInjuryModal(
|
||||
player=sample_player,
|
||||
injury_games=4,
|
||||
season=12
|
||||
)
|
||||
|
||||
# Mock the TextInput values
|
||||
modal.current_week = create_mock_text_input("10")
|
||||
modal.current_game = create_mock_text_input("2")
|
||||
|
||||
with patch('config.get_config', return_value=mock_config), \
|
||||
patch('services.player_service.player_service') as mock_player_service, \
|
||||
patch('services.injury_service.injury_service') as mock_injury_service:
|
||||
|
||||
# Mock successful injury creation
|
||||
mock_injury_service.create_injury = AsyncMock(return_value=MagicMock(id=1))
|
||||
mock_player_service.update_player = AsyncMock()
|
||||
|
||||
await modal.on_submit(mock_interaction)
|
||||
|
||||
# Should not send error message
|
||||
assert not any(
|
||||
call[1].get('embed') and
|
||||
'Invalid Week' in str(call[1]['embed'].title)
|
||||
for call in mock_interaction.response.send_message.call_args_list
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_playoff_week_19_valid(self, sample_player, mock_interaction, mock_config):
|
||||
"""Test that playoff week 19 (round 1) is accepted."""
|
||||
modal = BatterInjuryModal(
|
||||
player=sample_player,
|
||||
injury_games=4,
|
||||
season=12
|
||||
)
|
||||
|
||||
modal.current_week = create_mock_text_input("19")
|
||||
modal.current_game = create_mock_text_input("3")
|
||||
|
||||
with patch('config.get_config', return_value=mock_config), \
|
||||
patch('services.player_service.player_service') as mock_player_service, \
|
||||
patch('services.injury_service.injury_service') as mock_injury_service:
|
||||
|
||||
mock_injury_service.create_injury = AsyncMock(return_value=MagicMock(id=1))
|
||||
mock_player_service.update_player = AsyncMock()
|
||||
|
||||
await modal.on_submit(mock_interaction)
|
||||
|
||||
# Should not send error message
|
||||
assert not any(
|
||||
call[1].get('embed') and
|
||||
'Invalid Week' in str(call[1]['embed'].title)
|
||||
for call in mock_interaction.response.send_message.call_args_list
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_playoff_week_21_valid(self, sample_player, mock_interaction, mock_config):
|
||||
"""Test that playoff week 21 (round 3) is accepted."""
|
||||
modal = BatterInjuryModal(
|
||||
player=sample_player,
|
||||
injury_games=4,
|
||||
season=12
|
||||
)
|
||||
|
||||
modal.current_week = create_mock_text_input("21")
|
||||
modal.current_game = create_mock_text_input("5")
|
||||
|
||||
with patch('config.get_config', return_value=mock_config), \
|
||||
patch('services.player_service.player_service') as mock_player_service, \
|
||||
patch('services.injury_service.injury_service') as mock_injury_service:
|
||||
|
||||
mock_injury_service.create_injury = AsyncMock(return_value=MagicMock(id=1))
|
||||
mock_player_service.update_player = AsyncMock()
|
||||
|
||||
await modal.on_submit(mock_interaction)
|
||||
|
||||
# Should not send error message
|
||||
assert not any(
|
||||
call[1].get('embed') and
|
||||
'Invalid Week' in str(call[1]['embed'].title)
|
||||
for call in mock_interaction.response.send_message.call_args_list
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_week_too_high_rejected(self, sample_player, mock_interaction, mock_config):
|
||||
"""Test that week > 21 is rejected."""
|
||||
modal = BatterInjuryModal(
|
||||
player=sample_player,
|
||||
injury_games=4,
|
||||
season=12
|
||||
)
|
||||
|
||||
modal.current_week = create_mock_text_input("22")
|
||||
modal.current_game = create_mock_text_input("2")
|
||||
|
||||
with patch('config.get_config', return_value=mock_config):
|
||||
await modal.on_submit(mock_interaction)
|
||||
|
||||
# Should send error message
|
||||
mock_interaction.response.send_message.assert_called_once()
|
||||
call_kwargs = mock_interaction.response.send_message.call_args[1]
|
||||
assert 'embed' in call_kwargs
|
||||
assert 'Invalid Week' in call_kwargs['embed'].title
|
||||
assert '21 (including playoffs)' in call_kwargs['embed'].description
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_week_zero_rejected(self, sample_player, mock_interaction, mock_config):
|
||||
"""Test that week 0 is rejected."""
|
||||
modal = BatterInjuryModal(
|
||||
player=sample_player,
|
||||
injury_games=4,
|
||||
season=12
|
||||
)
|
||||
|
||||
modal.current_week = create_mock_text_input("0")
|
||||
modal.current_game = create_mock_text_input("2")
|
||||
|
||||
with patch('config.get_config', return_value=mock_config):
|
||||
await modal.on_submit(mock_interaction)
|
||||
|
||||
# Should send error message
|
||||
mock_interaction.response.send_message.assert_called_once()
|
||||
call_kwargs = mock_interaction.response.send_message.call_args[1]
|
||||
assert 'embed' in call_kwargs
|
||||
assert 'Invalid Week' in call_kwargs['embed'].title
|
||||
|
||||
|
||||
class TestBatterInjuryModalGameValidation:
|
||||
"""Test game validation in BatterInjuryModal."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_regular_season_game_4_valid(self, sample_player, mock_interaction, mock_config):
|
||||
"""Test that game 4 is accepted in regular season."""
|
||||
modal = BatterInjuryModal(
|
||||
player=sample_player,
|
||||
injury_games=4,
|
||||
season=12
|
||||
)
|
||||
|
||||
modal.current_week = create_mock_text_input("10")
|
||||
modal.current_game = create_mock_text_input("4")
|
||||
|
||||
with patch('config.get_config', return_value=mock_config), \
|
||||
patch('services.player_service.player_service') as mock_player_service, \
|
||||
patch('services.injury_service.injury_service') as mock_injury_service:
|
||||
|
||||
mock_injury_service.create_injury = AsyncMock(return_value=MagicMock(id=1))
|
||||
mock_player_service.update_player = AsyncMock()
|
||||
|
||||
await modal.on_submit(mock_interaction)
|
||||
|
||||
# Should not send error about invalid game
|
||||
assert not any(
|
||||
call[1].get('embed') and
|
||||
'Invalid Game' in str(call[1]['embed'].title)
|
||||
for call in mock_interaction.response.send_message.call_args_list
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_regular_season_game_5_rejected(self, sample_player, mock_interaction, mock_config):
|
||||
"""Test that game 5 is rejected in regular season (only 4 games)."""
|
||||
modal = BatterInjuryModal(
|
||||
player=sample_player,
|
||||
injury_games=4,
|
||||
season=12
|
||||
)
|
||||
|
||||
modal.current_week = create_mock_text_input("10")
|
||||
modal.current_game = create_mock_text_input("5")
|
||||
|
||||
with patch('config.get_config', return_value=mock_config):
|
||||
await modal.on_submit(mock_interaction)
|
||||
|
||||
# Should send error message
|
||||
mock_interaction.response.send_message.assert_called_once()
|
||||
call_kwargs = mock_interaction.response.send_message.call_args[1]
|
||||
assert 'embed' in call_kwargs
|
||||
assert 'Invalid Game' in call_kwargs['embed'].title
|
||||
assert 'between 1 and 4' in call_kwargs['embed'].description
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_playoff_round_1_game_5_valid(self, sample_player, mock_interaction, mock_config):
|
||||
"""Test that game 5 is accepted in playoff round 1 (week 19)."""
|
||||
modal = BatterInjuryModal(
|
||||
player=sample_player,
|
||||
injury_games=4,
|
||||
season=12
|
||||
)
|
||||
|
||||
modal.current_week = create_mock_text_input("19")
|
||||
modal.current_game = create_mock_text_input("5")
|
||||
|
||||
with patch('config.get_config', return_value=mock_config), \
|
||||
patch('services.player_service.player_service') as mock_player_service, \
|
||||
patch('services.injury_service.injury_service') as mock_injury_service:
|
||||
|
||||
mock_injury_service.create_injury = AsyncMock(return_value=MagicMock(id=1))
|
||||
mock_player_service.update_player = AsyncMock()
|
||||
|
||||
await modal.on_submit(mock_interaction)
|
||||
|
||||
# Should not send error about invalid game
|
||||
assert not any(
|
||||
call[1].get('embed') and
|
||||
'Invalid Game' in str(call[1]['embed'].title)
|
||||
for call in mock_interaction.response.send_message.call_args_list
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_playoff_round_1_game_6_rejected(self, sample_player, mock_interaction, mock_config):
|
||||
"""Test that game 6 is rejected in playoff round 1 (only 5 games)."""
|
||||
modal = BatterInjuryModal(
|
||||
player=sample_player,
|
||||
injury_games=4,
|
||||
season=12
|
||||
)
|
||||
|
||||
modal.current_week = create_mock_text_input("19")
|
||||
modal.current_game = create_mock_text_input("6")
|
||||
|
||||
with patch('config.get_config', return_value=mock_config):
|
||||
await modal.on_submit(mock_interaction)
|
||||
|
||||
# Should send error message
|
||||
mock_interaction.response.send_message.assert_called_once()
|
||||
call_kwargs = mock_interaction.response.send_message.call_args[1]
|
||||
assert 'embed' in call_kwargs
|
||||
assert 'Invalid Game' in call_kwargs['embed'].title
|
||||
assert 'between 1 and 5' in call_kwargs['embed'].description
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_playoff_round_2_game_7_valid(self, sample_player, mock_interaction, mock_config):
|
||||
"""Test that game 7 is accepted in playoff round 2 (week 20)."""
|
||||
modal = BatterInjuryModal(
|
||||
player=sample_player,
|
||||
injury_games=4,
|
||||
season=12
|
||||
)
|
||||
|
||||
modal.current_week = create_mock_text_input("20")
|
||||
modal.current_game = create_mock_text_input("7")
|
||||
|
||||
with patch('config.get_config', return_value=mock_config), \
|
||||
patch('services.player_service.player_service') as mock_player_service, \
|
||||
patch('services.injury_service.injury_service') as mock_injury_service:
|
||||
|
||||
mock_injury_service.create_injury = AsyncMock(return_value=MagicMock(id=1))
|
||||
mock_player_service.update_player = AsyncMock()
|
||||
|
||||
await modal.on_submit(mock_interaction)
|
||||
|
||||
# Should not send error about invalid game
|
||||
assert not any(
|
||||
call[1].get('embed') and
|
||||
'Invalid Game' in str(call[1]['embed'].title)
|
||||
for call in mock_interaction.response.send_message.call_args_list
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_playoff_round_3_game_7_valid(self, sample_player, mock_interaction, mock_config):
|
||||
"""Test that game 7 is accepted in playoff round 3 (week 21)."""
|
||||
modal = BatterInjuryModal(
|
||||
player=sample_player,
|
||||
injury_games=4,
|
||||
season=12
|
||||
)
|
||||
|
||||
modal.current_week = create_mock_text_input("21")
|
||||
modal.current_game = create_mock_text_input("7")
|
||||
|
||||
with patch('config.get_config', return_value=mock_config), \
|
||||
patch('services.player_service.player_service') as mock_player_service, \
|
||||
patch('services.injury_service.injury_service') as mock_injury_service:
|
||||
|
||||
mock_injury_service.create_injury = AsyncMock(return_value=MagicMock(id=1))
|
||||
mock_player_service.update_player = AsyncMock()
|
||||
|
||||
await modal.on_submit(mock_interaction)
|
||||
|
||||
# Should not send error about invalid game
|
||||
assert not any(
|
||||
call[1].get('embed') and
|
||||
'Invalid Game' in str(call[1]['embed'].title)
|
||||
for call in mock_interaction.response.send_message.call_args_list
|
||||
)
|
||||
|
||||
|
||||
class TestPitcherRestModalValidation:
|
||||
"""Test week and game validation in PitcherRestModal (should match BatterInjuryModal)."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_playoff_week_19_valid(self, sample_player, mock_interaction, mock_config):
|
||||
"""Test that playoff week 19 is accepted for pitchers."""
|
||||
modal = PitcherRestModal(
|
||||
player=sample_player,
|
||||
injury_games=4,
|
||||
season=12
|
||||
)
|
||||
|
||||
modal.current_week = create_mock_text_input("19")
|
||||
modal.current_game = create_mock_text_input("3")
|
||||
modal.rest_games = create_mock_text_input("2")
|
||||
|
||||
with patch('config.get_config', return_value=mock_config), \
|
||||
patch('services.player_service.player_service') as mock_player_service, \
|
||||
patch('services.injury_service.injury_service') as mock_injury_service:
|
||||
|
||||
mock_injury_service.create_injury = AsyncMock(return_value=MagicMock(id=1))
|
||||
mock_player_service.update_player = AsyncMock()
|
||||
|
||||
await modal.on_submit(mock_interaction)
|
||||
|
||||
# Should not send error about invalid week
|
||||
assert not any(
|
||||
call[1].get('embed') and
|
||||
'Invalid Week' in str(call[1]['embed'].title)
|
||||
for call in mock_interaction.response.send_message.call_args_list
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_week_22_rejected(self, sample_player, mock_interaction, mock_config):
|
||||
"""Test that week 22 is rejected for pitchers."""
|
||||
modal = PitcherRestModal(
|
||||
player=sample_player,
|
||||
injury_games=4,
|
||||
season=12
|
||||
)
|
||||
|
||||
modal.current_week = create_mock_text_input("22")
|
||||
modal.current_game = create_mock_text_input("2")
|
||||
modal.rest_games = create_mock_text_input("2")
|
||||
|
||||
with patch('config.get_config', return_value=mock_config):
|
||||
await modal.on_submit(mock_interaction)
|
||||
|
||||
# Should send error message
|
||||
mock_interaction.response.send_message.assert_called_once()
|
||||
call_kwargs = mock_interaction.response.send_message.call_args[1]
|
||||
assert 'embed' in call_kwargs
|
||||
assert 'Invalid Week' in call_kwargs['embed'].title
|
||||
assert '21 (including playoffs)' in call_kwargs['embed'].description
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_playoff_round_2_game_7_valid(self, sample_player, mock_interaction, mock_config):
|
||||
"""Test that game 7 is accepted in playoff round 2 for pitchers."""
|
||||
modal = PitcherRestModal(
|
||||
player=sample_player,
|
||||
injury_games=4,
|
||||
season=12
|
||||
)
|
||||
|
||||
modal.current_week = create_mock_text_input("20")
|
||||
modal.current_game = create_mock_text_input("7")
|
||||
modal.rest_games = create_mock_text_input("3")
|
||||
|
||||
with patch('config.get_config', return_value=mock_config), \
|
||||
patch('services.player_service.player_service') as mock_player_service, \
|
||||
patch('services.injury_service.injury_service') as mock_injury_service:
|
||||
|
||||
mock_injury_service.create_injury = AsyncMock(return_value=MagicMock(id=1))
|
||||
mock_player_service.update_player = AsyncMock()
|
||||
|
||||
await modal.on_submit(mock_interaction)
|
||||
|
||||
# Should not send error about invalid game
|
||||
assert not any(
|
||||
call[1].get('embed') and
|
||||
'Invalid Game' in str(call[1]['embed'].title)
|
||||
for call in mock_interaction.response.send_message.call_args_list
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_playoff_round_1_game_6_rejected(self, sample_player, mock_interaction, mock_config):
|
||||
"""Test that game 6 is rejected in playoff round 1 for pitchers (only 5 games)."""
|
||||
modal = PitcherRestModal(
|
||||
player=sample_player,
|
||||
injury_games=4,
|
||||
season=12
|
||||
)
|
||||
|
||||
modal.current_week = create_mock_text_input("19")
|
||||
modal.current_game = create_mock_text_input("6")
|
||||
modal.rest_games = create_mock_text_input("2")
|
||||
|
||||
with patch('config.get_config', return_value=mock_config):
|
||||
await modal.on_submit(mock_interaction)
|
||||
|
||||
# Should send error message
|
||||
mock_interaction.response.send_message.assert_called_once()
|
||||
call_kwargs = mock_interaction.response.send_message.call_args[1]
|
||||
assert 'embed' in call_kwargs
|
||||
assert 'Invalid Game' in call_kwargs['embed'].title
|
||||
assert 'between 1 and 5' in call_kwargs['embed'].description
|
||||
|
||||
|
||||
class TestConfigDrivenValidation:
|
||||
"""Test that validation correctly uses config values."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_custom_config_values_respected(self, sample_player, mock_interaction):
|
||||
"""Test that custom config values change validation behavior."""
|
||||
# Create config with different values
|
||||
custom_config = MagicMock()
|
||||
custom_config.weeks_per_season = 20 # Different from default
|
||||
custom_config.playoff_weeks_per_season = 2 # Different from default
|
||||
custom_config.games_per_week = 4
|
||||
custom_config.playoff_round_one_games = 5
|
||||
custom_config.playoff_round_two_games = 7
|
||||
custom_config.playoff_round_three_games = 7
|
||||
|
||||
modal = BatterInjuryModal(
|
||||
player=sample_player,
|
||||
injury_games=4,
|
||||
season=12
|
||||
)
|
||||
|
||||
# Week 22 should be valid with this config (20 + 2 = 22)
|
||||
modal.current_week = create_mock_text_input("22")
|
||||
modal.current_game = create_mock_text_input("3")
|
||||
|
||||
with patch('config.get_config', return_value=custom_config), \
|
||||
patch('services.player_service.player_service') as mock_player_service, \
|
||||
patch('services.injury_service.injury_service') as mock_injury_service:
|
||||
|
||||
mock_injury_service.create_injury = AsyncMock(return_value=MagicMock(id=1))
|
||||
mock_player_service.update_player = AsyncMock()
|
||||
|
||||
await modal.on_submit(mock_interaction)
|
||||
|
||||
# Should not send error about invalid week
|
||||
assert not any(
|
||||
call[1].get('embed') and
|
||||
'Invalid Week' in str(call[1]['embed'].title)
|
||||
for call in mock_interaction.response.send_message.call_args_list
|
||||
)
|
||||
213
utils/CLAUDE.md
213
utils/CLAUDE.md
@ -8,7 +8,8 @@ This package contains utility functions, helpers, and shared components used thr
|
||||
1. [**Structured Logging**](#-structured-logging) - Contextual logging with Discord integration
|
||||
2. [**Redis Caching**](#-redis-caching) - Optional performance caching system
|
||||
3. [**Command Decorators**](#-command-decorators) - Boilerplate reduction decorators
|
||||
4. [**Future Utilities**](#-future-utilities) - Planned utility modules
|
||||
4. [**Dice Utilities**](#-dice-utilities) - Reusable dice rolling functions
|
||||
5. [**Future Utilities**](#-future-utilities) - Planned utility modules
|
||||
|
||||
---
|
||||
|
||||
@ -935,7 +936,215 @@ class RosterCommands(commands.Cog):
|
||||
|
||||
---
|
||||
|
||||
**Last Updated:** January 2025 - Added Autocomplete Functions and Fixed Team Filtering
|
||||
## 🎲 Dice Utilities
|
||||
|
||||
**Location:** `utils/dice_utils.py`
|
||||
**Purpose:** Provides reusable dice rolling functionality for commands that need dice mechanics.
|
||||
|
||||
### **Overview**
|
||||
|
||||
The dice utilities module provides a clean, reusable implementation of dice rolling functionality that can be imported by any command file. This was refactored from `commands/dice/rolls.py` to promote code reuse and maintainability.
|
||||
|
||||
### **Quick Start**
|
||||
|
||||
```python
|
||||
from utils.dice_utils import DiceRoll, parse_and_roll_multiple_dice, parse_and_roll_single_dice
|
||||
|
||||
# Roll multiple dice
|
||||
results = parse_and_roll_multiple_dice("1d6;2d6;1d20")
|
||||
for result in results:
|
||||
print(f"{result.dice_notation}: {result.total}")
|
||||
|
||||
# Roll a single die
|
||||
result = parse_and_roll_single_dice("2d6")
|
||||
print(f"Rolled {result.total} on {result.dice_notation}")
|
||||
print(f"Individual rolls: {result.rolls}")
|
||||
```
|
||||
|
||||
### **Data Structures**
|
||||
|
||||
#### **`DiceRoll` Dataclass**
|
||||
|
||||
Represents the result of a dice roll with complete information:
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class DiceRoll:
|
||||
dice_notation: str # Original notation (e.g., "2d6")
|
||||
num_dice: int # Number of dice rolled
|
||||
die_sides: int # Number of sides per die
|
||||
rolls: list[int] # Individual roll results
|
||||
total: int # Sum of all rolls
|
||||
```
|
||||
|
||||
**Example:**
|
||||
```python
|
||||
result = parse_and_roll_single_dice("2d6")
|
||||
# DiceRoll(
|
||||
# dice_notation='2d6',
|
||||
# num_dice=2,
|
||||
# die_sides=6,
|
||||
# rolls=[4, 5],
|
||||
# total=9
|
||||
# )
|
||||
```
|
||||
|
||||
### **Functions**
|
||||
|
||||
#### **`parse_and_roll_multiple_dice(dice_notation: str) -> list[DiceRoll]`**
|
||||
|
||||
Parse and roll multiple dice notations separated by semicolons.
|
||||
|
||||
**Parameters:**
|
||||
- `dice_notation` (str): Dice notation string, supports multiple rolls separated by semicolon (e.g., "2d6", "1d20;2d6;1d6")
|
||||
|
||||
**Returns:**
|
||||
- `list[DiceRoll]`: List of DiceRoll results, or empty list if any part is invalid
|
||||
|
||||
**Examples:**
|
||||
```python
|
||||
# Single roll
|
||||
results = parse_and_roll_multiple_dice("2d6")
|
||||
# Returns: [DiceRoll(dice_notation='2d6', ...)]
|
||||
|
||||
# Multiple rolls
|
||||
results = parse_and_roll_multiple_dice("1d6;2d6;1d20")
|
||||
# Returns: [
|
||||
# DiceRoll(dice_notation='1d6', ...),
|
||||
# DiceRoll(dice_notation='2d6', ...),
|
||||
# DiceRoll(dice_notation='1d20', ...)
|
||||
# ]
|
||||
|
||||
# Invalid input
|
||||
results = parse_and_roll_multiple_dice("invalid")
|
||||
# Returns: []
|
||||
```
|
||||
|
||||
**Error Handling:**
|
||||
- Returns empty list `[]` for invalid dice notation
|
||||
- Catches `ValueError` exceptions from individual rolls
|
||||
- Safe to use in user-facing commands
|
||||
|
||||
#### **`parse_and_roll_single_dice(dice_notation: str) -> DiceRoll`**
|
||||
|
||||
Parse and roll a single dice notation string.
|
||||
|
||||
**Parameters:**
|
||||
- `dice_notation` (str): Single dice notation string (e.g., "2d6", "1d20")
|
||||
|
||||
**Returns:**
|
||||
- `DiceRoll`: Roll result with complete information
|
||||
|
||||
**Raises:**
|
||||
- `ValueError`: If dice notation is invalid or values are out of reasonable limits
|
||||
|
||||
**Examples:**
|
||||
```python
|
||||
# Valid rolls
|
||||
result = parse_and_roll_single_dice("2d6")
|
||||
result = parse_and_roll_single_dice("1d20")
|
||||
result = parse_and_roll_single_dice("3d8")
|
||||
|
||||
# Invalid notation (raises ValueError)
|
||||
result = parse_and_roll_single_dice("invalid") # ValueError: Cannot parse dice string
|
||||
|
||||
# Out of bounds (raises ValueError)
|
||||
result = parse_and_roll_single_dice("101d6") # Too many dice
|
||||
result = parse_and_roll_single_dice("1d1001") # Die too large
|
||||
```
|
||||
|
||||
**Validation Rules:**
|
||||
- **Format**: Must match pattern `XdY` (e.g., "2d6", "1d20")
|
||||
- **Number of dice**: 1-100 (inclusive)
|
||||
- **Die sides**: 2-1000 (inclusive)
|
||||
- **Case insensitive**: "2d6" and "2D6" both work
|
||||
- **Whitespace tolerant**: " 2d6 " and "2 d 6" both work
|
||||
|
||||
### **Usage in Commands**
|
||||
|
||||
The dice utilities are used throughout the dice commands package:
|
||||
|
||||
```python
|
||||
from utils.dice_utils import parse_and_roll_multiple_dice
|
||||
|
||||
class DiceRollCommands(commands.Cog):
|
||||
@discord.app_commands.command(name="d20", description="Roll a single d20")
|
||||
@logged_command("/d20")
|
||||
async def d20_dice(self, interaction: discord.Interaction):
|
||||
await interaction.response.defer()
|
||||
|
||||
# Use the utility function
|
||||
dice_notation = "1d20"
|
||||
roll_results = parse_and_roll_multiple_dice(dice_notation)
|
||||
|
||||
# Create embed and send
|
||||
embed = self._create_multi_roll_embed(dice_notation, roll_results, interaction.user)
|
||||
await interaction.followup.send(embed=embed)
|
||||
```
|
||||
|
||||
### **Design Benefits**
|
||||
|
||||
1. **Reusability**: Can be imported by any command file that needs dice functionality
|
||||
2. **Maintainability**: Centralized dice logic in one place
|
||||
3. **Testability**: Functions can be tested independently of command cogs
|
||||
4. **Consistency**: All dice commands use the same underlying logic
|
||||
5. **Error Handling**: Graceful error handling with empty list returns
|
||||
|
||||
### **Implementation Details**
|
||||
|
||||
**Random Number Generation:**
|
||||
- Uses Python's `random.randint(1, die_sides)` for each die
|
||||
- Each roll is independent and equally likely
|
||||
|
||||
**Parsing:**
|
||||
- Regular expression pattern: `^(\d+)d(\d+)$`
|
||||
- Case-insensitive matching
|
||||
- Whitespace stripped before parsing
|
||||
|
||||
**Error Recovery:**
|
||||
- `parse_and_roll_multiple_dice` catches exceptions and returns empty list
|
||||
- Safe for user input validation in commands
|
||||
- Detailed error messages in exceptions for debugging
|
||||
|
||||
### **Testing**
|
||||
|
||||
Comprehensive test coverage in `tests/test_commands_dice.py`:
|
||||
|
||||
```python
|
||||
# Test valid notation
|
||||
results = parse_and_roll_multiple_dice("2d6")
|
||||
assert len(results) == 1
|
||||
assert results[0].num_dice == 2
|
||||
assert results[0].die_sides == 6
|
||||
|
||||
# Test invalid notation
|
||||
results = parse_and_roll_multiple_dice("invalid")
|
||||
assert results == []
|
||||
|
||||
# Test multiple rolls
|
||||
results = parse_and_roll_multiple_dice("1d6;2d8;1d20")
|
||||
assert len(results) == 3
|
||||
```
|
||||
|
||||
### **Commands Using Dice Utilities**
|
||||
|
||||
Current commands that use the dice utilities:
|
||||
- `/roll` - General purpose dice rolling
|
||||
- `/d20` - Quick d20 roll
|
||||
- `/ab` - Baseball at-bat dice (1d6;2d6;1d20)
|
||||
- `/fielding` - Super Advanced fielding rolls
|
||||
- `/scout` - Weighted scouting rolls
|
||||
- `/jump` - Baserunner jump rolls
|
||||
|
||||
### **Migration Notes**
|
||||
|
||||
**October 2025**: Dice rolling functions were refactored from `commands/dice/rolls.py` into `utils/dice_utils.py` to promote code reuse and allow other command files to easily import dice functionality.
|
||||
|
||||
**Breaking Changes:** None - all existing commands updated to use the new module.
|
||||
|
||||
---
|
||||
|
||||
**Last Updated:** October 2025 - Added Dice Utilities Documentation
|
||||
**Next Update:** When additional utility modules are added
|
||||
|
||||
For questions or improvements to the logging system, check the implementation in `utils/logging.py` or refer to the JSON log outputs in `logs/discord_bot_v2.json`.
|
||||
84
utils/dice_utils.py
Normal file
84
utils/dice_utils.py
Normal file
@ -0,0 +1,84 @@
|
||||
"""
|
||||
Dice Rolling Utilities
|
||||
|
||||
Provides reusable dice rolling functionality for commands that need dice mechanics.
|
||||
"""
|
||||
import random
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class DiceRoll:
|
||||
"""Represents the result of a dice roll."""
|
||||
dice_notation: str
|
||||
num_dice: int
|
||||
die_sides: int
|
||||
rolls: list[int]
|
||||
total: int
|
||||
|
||||
|
||||
def parse_and_roll_multiple_dice(dice_notation: str) -> list[DiceRoll]:
|
||||
"""Parse dice notation (supports multiple rolls) and return roll results.
|
||||
|
||||
Args:
|
||||
dice_notation: Dice notation string, supports multiple rolls separated by semicolon
|
||||
(e.g., "2d6", "1d20;2d6;1d6")
|
||||
|
||||
Returns:
|
||||
List of DiceRoll results, or empty list if any part is invalid
|
||||
"""
|
||||
# Split by semicolon for multiple rolls
|
||||
dice_parts = [part.strip() for part in dice_notation.split(';')]
|
||||
results = []
|
||||
|
||||
for dice_part in dice_parts:
|
||||
try:
|
||||
result = parse_and_roll_single_dice(dice_part)
|
||||
results.append(result)
|
||||
except ValueError:
|
||||
return [] # Return empty list if any part is invalid
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def parse_and_roll_single_dice(dice_notation: str) -> DiceRoll:
|
||||
"""Parse single dice notation and return roll results.
|
||||
|
||||
Args:
|
||||
dice_notation: Single dice notation string (e.g., "2d6", "1d20")
|
||||
|
||||
Returns:
|
||||
DiceRoll result
|
||||
|
||||
Raises:
|
||||
ValueError: If dice notation is invalid or values are out of reasonable limits
|
||||
"""
|
||||
# Clean the input
|
||||
dice_notation = dice_notation.strip().lower().replace(' ', '')
|
||||
|
||||
# Pattern: XdY
|
||||
pattern = r'^(\d+)d(\d+)$'
|
||||
match = re.match(pattern, dice_notation)
|
||||
|
||||
if not match:
|
||||
raise ValueError(f'Cannot parse dice string **{dice_notation}**')
|
||||
|
||||
num_dice = int(match.group(1))
|
||||
die_sides = int(match.group(2))
|
||||
|
||||
# Validate reasonable limits
|
||||
if num_dice > 100 or die_sides > 1000 or num_dice < 1 or die_sides < 2:
|
||||
raise ValueError('I don\'t know, bud, that just doesn\'t seem doable.')
|
||||
|
||||
# Roll the dice
|
||||
rolls = [random.randint(1, die_sides) for _ in range(num_dice)]
|
||||
total = sum(rolls)
|
||||
|
||||
return DiceRoll(
|
||||
dice_notation=dice_notation,
|
||||
num_dice=num_dice,
|
||||
die_sides=die_sides,
|
||||
rolls=rolls,
|
||||
total=total
|
||||
)
|
||||
@ -175,10 +175,11 @@ def create_team_progress_bar(
|
||||
Returns:
|
||||
Formatted bar with dark blocks (▓) weighted toward winning team.
|
||||
Arrow extends from the side with the advantage.
|
||||
Percentage displayed on winning team's side (or both sides if even).
|
||||
Examples:
|
||||
Home winning: "POR ░▓▓▓▓▓▓▓▓▓► WV 95.0%"
|
||||
Away winning: "POR ◄▓▓▓▓▓▓▓░░░ WV 30.0%"
|
||||
Even game: "POR =▓▓▓▓▓▓▓▓▓▓= WV 50.0%"
|
||||
Away winning: "70.0% POR ◄▓▓▓▓▓▓▓░░░ WV"
|
||||
Even game: "50.0% POR =▓▓▓▓▓▓▓▓▓▓= WV 50.0%"
|
||||
"""
|
||||
# Calculate blocks for each team (home team's percentage)
|
||||
home_blocks = int((win_percentage / 100) * length)
|
||||
@ -189,19 +190,20 @@ def create_team_progress_bar(
|
||||
away_char = '░' # Light blocks for losing team
|
||||
home_char = '▓' # Dark blocks for winning team
|
||||
bar = away_char * away_blocks + home_char * home_blocks
|
||||
# Arrow extends from right side
|
||||
# Arrow extends from right side, percentage on right
|
||||
return f'{away_abbrev} {bar}► {home_abbrev} {win_percentage:.1f}%'
|
||||
elif win_percentage < 50:
|
||||
# Away team (left side) is winning
|
||||
away_char = '▓' # Dark blocks for winning team
|
||||
home_char = '░' # Light blocks for losing team
|
||||
bar = away_char * away_blocks + home_char * home_blocks
|
||||
# Arrow extends from left side
|
||||
return f'{away_abbrev} ◄{bar} {home_abbrev} {win_percentage:.1f}%'
|
||||
# Arrow extends from left side, percentage on left (showing away team's win %)
|
||||
away_win_pct = 100 - win_percentage
|
||||
return f'{away_win_pct:.1f}% {away_abbrev} ◄{bar} {home_abbrev}'
|
||||
else:
|
||||
# Even game (50/50)
|
||||
away_char = '▓'
|
||||
home_char = '▓'
|
||||
bar = away_char * away_blocks + home_char * home_blocks
|
||||
# Arrows on both sides for balanced display
|
||||
return f'{away_abbrev} ={bar}= {home_abbrev} {win_percentage:.1f}%'
|
||||
# Arrows on both sides for balanced display, percentage on both sides
|
||||
return f'{win_percentage:.1f}% {away_abbrev} ={bar}= {home_abbrev} {win_percentage:.1f}%'
|
||||
|
||||
@ -539,48 +539,64 @@ class BatterInjuryModal(BaseModal):
|
||||
"""Handle batter injury input and log injury."""
|
||||
from services.player_service import player_service
|
||||
from services.injury_service import injury_service
|
||||
from config import get_config
|
||||
import math
|
||||
|
||||
config = get_config()
|
||||
max_week = config.weeks_per_season + config.playoff_weeks_per_season
|
||||
|
||||
# Validate current week
|
||||
try:
|
||||
week = int(self.current_week.value)
|
||||
if week < 1 or week > 18:
|
||||
raise ValueError("Week must be between 1 and 18")
|
||||
if week < 1 or week > max_week:
|
||||
raise ValueError(f"Week must be between 1 and {max_week}")
|
||||
except ValueError:
|
||||
embed = EmbedTemplate.error(
|
||||
title="Invalid Week",
|
||||
description="Current week must be a number between 1 and 18."
|
||||
description=f"Current week must be a number between 1 and {max_week} (including playoffs)."
|
||||
)
|
||||
await interaction.response.send_message(embed=embed, ephemeral=True)
|
||||
return
|
||||
|
||||
# Determine max games based on week (regular season vs playoff rounds)
|
||||
if week <= config.weeks_per_season:
|
||||
max_game = config.games_per_week
|
||||
elif week == config.weeks_per_season + 1:
|
||||
max_game = config.playoff_round_one_games
|
||||
elif week == config.weeks_per_season + 2:
|
||||
max_game = config.playoff_round_two_games
|
||||
elif week == config.weeks_per_season + 3:
|
||||
max_game = config.playoff_round_three_games
|
||||
else:
|
||||
max_game = config.games_per_week # Fallback
|
||||
|
||||
# Validate current game
|
||||
try:
|
||||
game = int(self.current_game.value)
|
||||
if game < 1 or game > 4:
|
||||
raise ValueError("Game must be between 1 and 4")
|
||||
if game < 1 or game > max_game:
|
||||
raise ValueError(f"Game must be between 1 and {max_game}")
|
||||
except ValueError:
|
||||
embed = EmbedTemplate.error(
|
||||
title="Invalid Game",
|
||||
description="Current game must be a number between 1 and 4."
|
||||
description=f"Current game must be a number between 1 and {max_game}."
|
||||
)
|
||||
await interaction.response.send_message(embed=embed, ephemeral=True)
|
||||
return
|
||||
|
||||
# Calculate injury dates
|
||||
out_weeks = math.floor(self.injury_games / 4)
|
||||
out_games = self.injury_games % 4
|
||||
out_weeks = math.floor(self.injury_games / config.games_per_week)
|
||||
out_games = self.injury_games % config.games_per_week
|
||||
|
||||
return_week = week + out_weeks
|
||||
return_game = game + 1 + out_games
|
||||
|
||||
if return_game > 4:
|
||||
if return_game > config.games_per_week:
|
||||
return_week += 1
|
||||
return_game -= 4
|
||||
return_game -= config.games_per_week
|
||||
|
||||
# Adjust start date if injury starts after game 4
|
||||
start_week = week if game != 4 else week + 1
|
||||
start_game = game + 1 if game != 4 else 1
|
||||
start_week = week if game != config.games_per_week else week + 1
|
||||
start_game = game + 1 if game != config.games_per_week else 1
|
||||
|
||||
return_date = f'w{return_week:02d}g{return_game}'
|
||||
|
||||
@ -707,30 +723,46 @@ class PitcherRestModal(BaseModal):
|
||||
from services.player_service import player_service
|
||||
from services.injury_service import injury_service
|
||||
from models.injury import Injury
|
||||
from config import get_config
|
||||
import math
|
||||
|
||||
config = get_config()
|
||||
max_week = config.weeks_per_season + config.playoff_weeks_per_season
|
||||
|
||||
# Validate current week
|
||||
try:
|
||||
week = int(self.current_week.value)
|
||||
if week < 1 or week > 18:
|
||||
raise ValueError("Week must be between 1 and 18")
|
||||
if week < 1 or week > max_week:
|
||||
raise ValueError(f"Week must be between 1 and {max_week}")
|
||||
except ValueError:
|
||||
embed = EmbedTemplate.error(
|
||||
title="Invalid Week",
|
||||
description="Current week must be a number between 1 and 18."
|
||||
description=f"Current week must be a number between 1 and {max_week} (including playoffs)."
|
||||
)
|
||||
await interaction.response.send_message(embed=embed, ephemeral=True)
|
||||
return
|
||||
|
||||
# Determine max games based on week (regular season vs playoff rounds)
|
||||
if week <= config.weeks_per_season:
|
||||
max_game = config.games_per_week
|
||||
elif week == config.weeks_per_season + 1:
|
||||
max_game = config.playoff_round_one_games
|
||||
elif week == config.weeks_per_season + 2:
|
||||
max_game = config.playoff_round_two_games
|
||||
elif week == config.weeks_per_season + 3:
|
||||
max_game = config.playoff_round_three_games
|
||||
else:
|
||||
max_game = config.games_per_week # Fallback
|
||||
|
||||
# Validate current game
|
||||
try:
|
||||
game = int(self.current_game.value)
|
||||
if game < 1 or game > 4:
|
||||
raise ValueError("Game must be between 1 and 4")
|
||||
if game < 1 or game > max_game:
|
||||
raise ValueError(f"Game must be between 1 and {max_game}")
|
||||
except ValueError:
|
||||
embed = EmbedTemplate.error(
|
||||
title="Invalid Game",
|
||||
description="Current game must be a number between 1 and 4."
|
||||
description=f"Current game must be a number between 1 and {max_game}."
|
||||
)
|
||||
await interaction.response.send_message(embed=embed, ephemeral=True)
|
||||
return
|
||||
|
||||
Loading…
Reference in New Issue
Block a user