major-domo-v2/tests/test_commands_dice.py
Cal Corum 1dd930e4b3 CLAUDE: Complete dice command system with fielding mechanics
Implements comprehensive dice rolling system for gameplay:

## New Features
- `/roll` and `!roll` commands for XdY dice notation with multiple roll support
- `/ab` and `!atbat` commands for baseball at-bat dice shortcuts (1d6;2d6;1d20)
- `/fielding` and `!f` commands for Super Advanced fielding with full position charts

## Technical Implementation
- Complete dice command package in commands/dice/
- Full range and error charts for all 8 defensive positions (1B,2B,3B,SS,LF,RF,CF,C)
- Pre-populated position choices for user-friendly slash command interface
- Backwards compatibility with prefix commands (!roll, !r, !dice, !ab, !atbat, !f, !fielding, !saf)
- Type-safe implementation following "Raise or Return" pattern

## Testing & Quality
- 30 comprehensive tests with 100% pass rate
- Complete test coverage for all dice functionality, parsing, validation, and error handling
- Integration with bot.py command loading system
- Maintainable data structures replacing verbose original implementation

## User Experience
- Consistent embed formatting across all commands
- Detailed fielding results with range and error analysis
- Support for complex dice combinations and multiple roll formats
- Clear error messages for invalid inputs

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-24 22:30:31 -05:00

554 lines
21 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
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.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 = dice_cog._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 = dice_cog._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 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("") == []
# 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
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")
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 = dice_cog._parse_and_roll_multiple_dice("2d6")
result_upper = dice_cog._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 = dice_cog._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")
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 = [
{
'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 = [
{
'dice_notation': '1d6',
'num_dice': 1,
'die_sides': 6,
'rolls': [5],
'total': 5
},
{
'dice_notation': '2d6',
'num_dice': 2,
'die_sides': 6,
'rolls': [5, 6],
'total': 11
},
{
'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 = dice_cog._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 = dice_cog._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")
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")
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_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 = dice_cog._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 = dice_cog._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