major-domo-v2/tests/test_commands_dice.py
Cal Corum 9991b5f4a0 CLAUDE: Refactor dice rolling into reusable utility module and add /d20 command
- Created utils/dice_utils.py with reusable dice rolling functions
  - DiceRoll dataclass for roll results
  - parse_and_roll_multiple_dice() for multiple dice notation
  - parse_and_roll_single_dice() for single dice notation
  - Graceful error handling with empty list returns

- Refactored commands/dice/rolls.py to use new utility module
  - Removed duplicate DiceRoll class and parsing methods
  - Updated all method calls to use standalone functions
  - Added new /d20 command for quick d20 rolls
  - Fixed fielding prefix command to include d100 roll

- Updated tests/test_commands_dice.py
  - Updated imports to use utils.dice_utils
  - Fixed all test calls to use standalone functions
  - Added comprehensive test for /d20 command
  - All 35 tests passing

- Updated utils/CLAUDE.md documentation
  - Added Dice Utilities section with full API reference
  - Documented functions, usage patterns, and design benefits
  - Listed all commands using dice utilities

Benefits:
- Reusability: Dice functions can be imported by any command file
- Maintainability: Centralized dice logic in one place
- Testability: Functions testable independent of command cogs
- Consistency: All dice commands use same underlying logic

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-29 01:15:11 -05:00

672 lines
25 KiB
Python

"""
Tests for dice rolling commands
Validates dice rolling functionality, parsing, and embed creation.
"""
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
import discord
from discord.ext import commands
from commands.dice.rolls import DiceRollCommands
from utils.dice_utils import DiceRoll, parse_and_roll_multiple_dice, parse_and_roll_single_dice
class TestDiceRollCommands:
"""Test dice rolling command functionality."""
@pytest.fixture
def bot(self):
"""Create a mock bot instance."""
bot = AsyncMock(spec=commands.Bot)
return bot
@pytest.fixture
def dice_cog(self, bot):
"""Create DiceRollCommands cog instance."""
return DiceRollCommands(bot)
@pytest.fixture
def mock_interaction(self):
"""Create a mock Discord interaction."""
interaction = AsyncMock(spec=discord.Interaction)
# 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
# Mock response methods
interaction.response.defer = AsyncMock()
interaction.followup.send = AsyncMock()
return interaction
@pytest.fixture
def mock_context(self):
"""Create a mock Discord context for prefix commands."""
ctx = AsyncMock(spec=commands.Context)
# Mock the author (user)
author = MagicMock(spec=discord.User)
author.display_name = "TestUser"
author.display_avatar.url = "https://example.com/avatar.png"
author.id = 12345 # Add user ID
ctx.author = author
# Mock send method
ctx.send = AsyncMock()
return ctx
def test_parse_valid_dice_notation(self, dice_cog):
"""Test parsing valid dice notation."""
# Test basic notation
results = parse_and_roll_multiple_dice("2d6")
assert len(results) == 1
result = results[0]
assert result.num_dice == 2
assert result.die_sides == 6
assert len(result.rolls) == 2
assert all(1 <= roll <= 6 for roll in result.rolls)
assert result.total == sum(result.rolls)
# Test single die
results = parse_and_roll_multiple_dice("1d20")
assert len(results) == 1
result = results[0]
assert result.num_dice == 1
assert result.die_sides == 20
assert len(result.rolls) == 1
assert 1 <= result.rolls[0] <= 20
def test_parse_invalid_dice_notation(self, dice_cog):
"""Test parsing invalid dice notation."""
# Invalid formats
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 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 = parse_and_roll_multiple_dice("1d6;2d8;1d20")
assert len(results) == 3
assert results[0].dice_notation == '1d6'
assert results[0].num_dice == 1
assert results[0].die_sides == 6
assert results[1].dice_notation == '2d8'
assert results[1].num_dice == 2
assert results[1].die_sides == 8
assert results[2].dice_notation == '1d20'
assert results[2].num_dice == 1
assert results[2].die_sides == 20
def test_parse_case_insensitive(self, dice_cog):
"""Test that dice notation parsing is case insensitive."""
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
assert result_lower[0].num_dice == result_upper[0].num_dice
assert result_lower[0].die_sides == result_upper[0].die_sides
def test_parse_whitespace_handling(self, dice_cog):
"""Test that whitespace is handled properly."""
results = parse_and_roll_multiple_dice(" 2d6 ")
assert len(results) == 1
assert results[0].num_dice == 2
assert results[0].die_sides == 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
@pytest.mark.asyncio
async def test_roll_dice_valid_input(self, dice_cog, mock_interaction):
"""Test roll_dice command with valid input."""
await dice_cog.roll_dice.callback(dice_cog, mock_interaction, "2d6")
# 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 is a Discord embed
embed = call_args.kwargs['embed']
assert isinstance(embed, discord.Embed)
assert embed.title == "🎲 Dice Roll"
@pytest.mark.asyncio
async def test_roll_dice_invalid_input(self, dice_cog, mock_interaction):
"""Test roll_dice command with invalid input."""
await dice_cog.roll_dice.callback(dice_cog, mock_interaction, "invalid")
# Verify response was deferred
mock_interaction.response.defer.assert_called_once()
# Verify error message was sent
mock_interaction.followup.send.assert_called_once()
call_args = mock_interaction.followup.send.call_args
assert "Invalid dice notation" in call_args.args[0]
assert call_args.kwargs['ephemeral'] is True
def test_create_multi_roll_embed_single_die(self, dice_cog, mock_interaction):
"""Test embed creation for single die roll."""
roll_results = [
DiceRoll(
dice_notation='1d20',
num_dice=1,
die_sides=20,
rolls=[15],
total=15
)
]
embed = dice_cog._create_multi_roll_embed("1d20", roll_results, mock_interaction.user)
assert embed.title == "🎲 Dice Roll"
assert embed.author.name == "TestUser"
assert embed.author.icon_url == "https://example.com/avatar.png"
# Check the formatted field content
assert len(embed.fields) == 1
assert embed.fields[0].name == 'Result'
expected_value = "```md\n# 15\nDetails:[1d20 (15)]```"
assert embed.fields[0].value == expected_value
def test_create_multi_roll_embed_multiple_dice(self, dice_cog, mock_interaction):
"""Test embed creation for multiple dice rolls."""
roll_results = [
DiceRoll(
dice_notation='1d6',
num_dice=1,
die_sides=6,
rolls=[5],
total=5
),
DiceRoll(
dice_notation='2d6',
num_dice=2,
die_sides=6,
rolls=[5, 6],
total=11
),
DiceRoll(
dice_notation='1d20',
num_dice=1,
die_sides=20,
rolls=[13],
total=13
)
]
embed = dice_cog._create_multi_roll_embed("1d6;2d6;1d20", roll_results, mock_interaction.user)
assert embed.title == "🎲 Dice Roll"
assert embed.author.name == "TestUser"
# Check the formatted field content matches the expected format
assert len(embed.fields) == 1
assert embed.fields[0].name == 'Result'
expected_value = "```md\n# 5,11,13\nDetails:[1d6;2d6;1d20 (5 - 5 6 - 13)]```"
assert embed.fields[0].value == expected_value
def test_dice_roll_randomness(self, dice_cog):
"""Test that dice rolls produce different results."""
results = []
for _ in range(20): # Roll 20 times
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)
unique_results = set(results)
assert len(unique_results) > 1, f"All rolls were the same: {results}"
def test_dice_boundaries(self, dice_cog):
"""Test dice rolling at boundaries."""
# Test maximum allowed dice
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 = parse_and_roll_multiple_dice("1d1000")
assert len(results) == 1
result = results[0]
assert 1 <= result.rolls[0] <= 1000
# Test minimum valid values
results = parse_and_roll_multiple_dice("1d2")
assert len(results) == 1
result = results[0]
assert result.rolls[0] in [1, 2]
@pytest.mark.asyncio
async def test_prefix_command_valid_input(self, dice_cog, mock_context):
"""Test prefix command with valid input."""
await dice_cog.roll_dice_prefix.callback(dice_cog, mock_context, dice="2d6")
# Verify send was called with embed
mock_context.send.assert_called_once()
call_args = mock_context.send.call_args
# Check if embed was passed as positional or keyword argument
if call_args.args:
embed = call_args.args[0]
else:
embed = call_args.kwargs.get('embed')
assert isinstance(embed, discord.Embed)
assert embed.title == "🎲 Dice Roll"
@pytest.mark.asyncio
async def test_prefix_command_invalid_input(self, dice_cog, mock_context):
"""Test prefix command with invalid input."""
await dice_cog.roll_dice_prefix.callback(dice_cog, mock_context, dice="invalid")
# Verify error message was sent
mock_context.send.assert_called_once()
call_args = mock_context.send.call_args
error_msg = call_args[0][0]
assert "Invalid dice notation" in error_msg
@pytest.mark.asyncio
async def test_prefix_command_no_input(self, dice_cog, mock_context):
"""Test prefix command with no input."""
await dice_cog.roll_dice_prefix.callback(dice_cog, mock_context, dice=None)
# Verify usage message was sent
mock_context.send.assert_called_once()
call_args = mock_context.send.call_args
usage_msg = call_args[0][0]
assert "Please provide dice notation" in usage_msg
@pytest.mark.asyncio
async def test_prefix_command_multiple_dice(self, dice_cog, mock_context):
"""Test prefix command with multiple dice rolls."""
await dice_cog.roll_dice_prefix.callback(dice_cog, mock_context, dice="1d6;2d8;1d20")
# Verify send was called with embed
mock_context.send.assert_called_once()
call_args = mock_context.send.call_args
# Check if embed was passed as positional or keyword argument
if call_args.args:
embed = call_args.args[0]
else:
embed = call_args.kwargs.get('embed')
assert isinstance(embed, discord.Embed)
assert embed.title == "🎲 Dice Roll"
# Should have summary format with 3 totals in field
assert len(embed.fields) == 1
assert embed.fields[0].name == 'Result'
assert embed.fields[0].value.startswith("```md\n#")
assert "Details:[1d6;2d8;1d20" in embed.fields[0].value
def test_prefix_command_attributes(self, dice_cog):
"""Test that prefix command has correct attributes."""
# Check command exists and has correct name
assert hasattr(dice_cog, 'roll_dice_prefix')
command = dice_cog.roll_dice_prefix
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."""
await dice_cog.ab_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 == "At bat roll for TestUser"
assert len(embed.fields) == 1
assert "Details:[1d6;2d6;1d20" in embed.fields[0].value
@pytest.mark.asyncio
async def test_ab_command_prefix(self, dice_cog, mock_context):
"""Test ab prefix command."""
await dice_cog.ab_dice_prefix.callback(dice_cog, mock_context)
# Verify send was called with embed
mock_context.send.assert_called_once()
call_args = mock_context.send.call_args
# Check if embed was passed as positional or keyword argument
if call_args.args:
embed = call_args.args[0]
else:
embed = call_args.kwargs.get('embed')
assert isinstance(embed, discord.Embed)
assert embed.title == "At bat roll for TestUser"
assert len(embed.fields) == 1
assert "Details:[1d6;2d6;1d20" in embed.fields[0].value
def test_ab_command_attributes(self, dice_cog):
"""Test that ab prefix command has correct attributes."""
# Check command exists and has correct name
assert hasattr(dice_cog, 'ab_dice_prefix')
command = dice_cog.ab_dice_prefix
assert command.name == "ab"
assert command.aliases == ["atbat"]
def test_ab_command_dice_combination(self, dice_cog):
"""Test that ab command uses the correct dice combination."""
dice_notation = "1d6;2d6;1d20"
results = parse_and_roll_multiple_dice(dice_notation)
# Should have 3 dice groups
assert len(results) == 3
# Check each dice type
assert results[0].dice_notation == '1d6'
assert results[0].num_dice == 1
assert results[0].die_sides == 6
assert results[1].dice_notation == '2d6'
assert results[1].num_dice == 2
assert results[1].die_sides == 6
assert results[2].dice_notation == '1d20'
assert results[2].num_dice == 1
assert results[2].die_sides == 20
# Fielding command tests
@pytest.mark.asyncio
async def test_fielding_command_slash(self, dice_cog, mock_interaction):
"""Test fielding slash command with valid position."""
# Mock a position choice
position_choice = MagicMock()
position_choice.value = '3B'
await dice_cog.fielding_roll.callback(dice_cog, mock_interaction, position_choice)
# 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 == "SA Fielding roll for TestUser"
assert len(embed.fields) >= 2 # Range and Error fields
@pytest.mark.asyncio
async def test_fielding_command_prefix_valid(self, dice_cog, mock_context):
"""Test fielding prefix command with valid position."""
await dice_cog.fielding_roll_prefix.callback(dice_cog, mock_context, "SS")
# Verify send was called with embed
mock_context.send.assert_called_once()
call_args = mock_context.send.call_args
# Check if embed was passed as positional or keyword argument
if call_args.args:
embed = call_args.args[0]
else:
embed = call_args.kwargs.get('embed')
assert isinstance(embed, discord.Embed)
assert embed.title == "SA Fielding roll for TestUser"
assert len(embed.fields) >= 2 # Range and Error fields
@pytest.mark.asyncio
async def test_fielding_command_prefix_no_position(self, dice_cog, mock_context):
"""Test fielding prefix command with no position."""
await dice_cog.fielding_roll_prefix.callback(dice_cog, mock_context, None)
# Verify error message was sent
mock_context.send.assert_called_once()
call_args = mock_context.send.call_args
error_msg = call_args[0][0]
assert "Please specify a position" in error_msg
@pytest.mark.asyncio
async def test_fielding_command_prefix_invalid_position(self, dice_cog, mock_context):
"""Test fielding prefix command with invalid position."""
await dice_cog.fielding_roll_prefix.callback(dice_cog, mock_context, "INVALID")
# Verify error message was sent
mock_context.send.assert_called_once()
call_args = mock_context.send.call_args
error_msg = call_args[0][0]
assert "Invalid position" in error_msg
def test_fielding_command_attributes(self, dice_cog):
"""Test that fielding prefix command has correct attributes."""
# Check command exists and has correct name
assert hasattr(dice_cog, 'fielding_roll_prefix')
command = dice_cog.fielding_roll_prefix
assert command.name == "f"
assert command.aliases == ["fielding", "saf"]
def test_fielding_range_charts(self, dice_cog):
"""Test that fielding range charts work for all positions."""
# Test infield range (applies to 1B, 2B, 3B, SS)
infield_result = dice_cog._get_infield_range(10)
assert isinstance(infield_result, str)
assert len(infield_result) > 0
# Test outfield range (applies to LF, CF, RF)
outfield_result = dice_cog._get_outfield_range(10)
assert isinstance(outfield_result, str)
assert len(outfield_result) > 0
# Test catcher range
catcher_result = dice_cog._get_catcher_range(10)
assert isinstance(catcher_result, str)
assert len(catcher_result) > 0
def test_fielding_error_charts(self, dice_cog):
"""Test that error charts work for all positions."""
# Test all position error methods
test_total = 10
# Test 1B error
error_1b = dice_cog._get_1b_error(test_total)
assert isinstance(error_1b, str)
# Test 2B error
error_2b = dice_cog._get_2b_error(test_total)
assert isinstance(error_2b, str)
# Test 3B error
error_3b = dice_cog._get_3b_error(test_total)
assert isinstance(error_3b, str)
# Test SS error
error_ss = dice_cog._get_ss_error(test_total)
assert isinstance(error_ss, str)
# Test corner OF error
error_corner = dice_cog._get_corner_of_error(test_total)
assert isinstance(error_corner, str)
# Test CF error
error_cf = dice_cog._get_cf_error(test_total)
assert isinstance(error_cf, str)
# Test catcher error
error_catcher = dice_cog._get_catcher_error(test_total)
assert isinstance(error_catcher, str)
def test_get_error_result_all_positions(self, dice_cog):
"""Test _get_error_result for all valid positions."""
test_total = 12
positions = ['1B', '2B', '3B', 'SS', 'LF', 'RF', 'CF', 'C']
for position in positions:
result = dice_cog._get_error_result(position, test_total)
assert isinstance(result, str)
assert len(result) > 0
def test_get_error_result_invalid_position(self, dice_cog):
"""Test _get_error_result with invalid position raises error."""
with pytest.raises(ValueError, match="Unknown position"):
dice_cog._get_error_result("INVALID", 10)
def test_fielding_dice_combination(self, dice_cog):
"""Test that fielding uses correct dice combination (1d20;3d6)."""
dice_notation = "1d20;3d6"
results = parse_and_roll_multiple_dice(dice_notation)
# Should have 2 dice groups
assert len(results) == 2
# Check 1d20
assert results[0].dice_notation == '1d20'
assert results[0].num_dice == 1
assert results[0].die_sides == 20
# Check 3d6
assert results[1].dice_notation == '3d6'
assert results[1].num_dice == 3
assert results[1].die_sides == 6
def test_weighted_scout_dice_batter(self, dice_cog):
"""Test that batter scout dice always rolls 1-3 for first d6."""
# Roll 20 times to ensure consistency
for _ in range(20):
results = dice_cog._roll_weighted_scout_dice("batter")
# Should have 3 dice groups (1d6, 2d6, 1d20)
assert len(results) == 3
# First d6 should ALWAYS be 1-3 for batter
first_d6 = results[0].rolls[0]
assert 1 <= first_d6 <= 3, f"Batter first d6 was {first_d6}, expected 1-3"
# Second roll (2d6) should be normal
assert results[1].num_dice == 2
assert results[1].die_sides == 6
assert all(1 <= roll <= 6 for roll in results[1].rolls)
# Third roll (1d20) should be normal
assert results[2].num_dice == 1
assert results[2].die_sides == 20
assert 1 <= results[2].rolls[0] <= 20
def test_weighted_scout_dice_pitcher(self, dice_cog):
"""Test that pitcher scout dice always rolls 4-6 for first d6."""
# Roll 20 times to ensure consistency
for _ in range(20):
results = dice_cog._roll_weighted_scout_dice("pitcher")
# Should have 3 dice groups (1d6, 2d6, 1d20)
assert len(results) == 3
# First d6 should ALWAYS be 4-6 for pitcher
first_d6 = results[0].rolls[0]
assert 4 <= first_d6 <= 6, f"Pitcher first d6 was {first_d6}, expected 4-6"
# Second roll (2d6) should be normal
assert results[1].num_dice == 2
assert results[1].die_sides == 6
assert all(1 <= roll <= 6 for roll in results[1].rolls)
# Third roll (1d20) should be normal
assert results[2].num_dice == 1
assert results[2].die_sides == 20
assert 1 <= results[2].rolls[0] <= 20
@pytest.mark.asyncio
async def test_scout_command_batter(self, dice_cog, mock_interaction):
"""Test scout slash command with batter card type."""
# Mock a card_type choice
card_type_choice = MagicMock()
card_type_choice.value = 'batter'
card_type_choice.name = 'Batter'
await dice_cog.scout_dice.callback(dice_cog, mock_interaction, card_type_choice)
# 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 == "Scouting roll for TestUser"
assert len(embed.fields) == 1
assert "Details:[1d6;2d6;1d20" in embed.fields[0].value
@pytest.mark.asyncio
async def test_scout_command_pitcher(self, dice_cog, mock_interaction):
"""Test scout slash command with pitcher card type."""
# Mock a card_type choice
card_type_choice = MagicMock()
card_type_choice.value = 'pitcher'
card_type_choice.name = 'Pitcher'
await dice_cog.scout_dice.callback(dice_cog, mock_interaction, card_type_choice)
# 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 == "Scouting roll for TestUser"
assert len(embed.fields) == 1
assert "Details:[1d6;2d6;1d20" in embed.fields[0].value