lastArgs = arguments.stream().skip(options.size() - 1).toList();
- arguments.removeAll(lastArgs);
- arguments.add(String.join(" ", lastArgs));
- }
- }
-
- if (arguments.size() > required + optional) {
- context.reply(invalidUsage("too many arguments"));
- return false;
- }
-
context.setCurrentCommandOptions(arguments.toArray(String[]::new), options);
return true;
}
diff --git a/src/main/java/me/bobert/netneutrality/core/command/options/MessageCommandOption.java b/src/main/java/me/bobert/netneutrality/core/command/options/MessageCommandOption.java
index e07b385..1d463e0 100644
--- a/src/main/java/me/bobert/netneutrality/core/command/options/MessageCommandOption.java
+++ b/src/main/java/me/bobert/netneutrality/core/command/options/MessageCommandOption.java
@@ -120,6 +120,10 @@ public class MessageCommandOption implements CommandOption {
@Override
public User getAsUser() {
+ if (value instanceof Member) {
+ return ((Member) value).getUser();
+ }
+
if (value instanceof User) {
return (User) value;
}
diff --git a/src/test/java/me/bobert/netneutrality/core/command/CommandProcessorTest.java b/src/test/java/me/bobert/netneutrality/core/command/CommandProcessorTest.java
new file mode 100644
index 0000000..1aa7b55
--- /dev/null
+++ b/src/test/java/me/bobert/netneutrality/core/command/CommandProcessorTest.java
@@ -0,0 +1,1067 @@
+package me.bobert.netneutrality.core.command;
+
+/*
+ * Copyright (c) anthorob
+ *
+ * https://discord.gg/qUa6zNFZsd
+ */
+
+import me.bobert.netneutrality.context.command.MessageCommandContext;
+import me.bobert.netneutrality.core.command.impl.AbstractCommand;
+import me.bobert.netneutrality.core.command.options.CommandOption;
+import me.bobert.netneutrality.core.context.CommandContext;
+import me.bobert.netneutrality.utils.Settings;
+import net.dv8tion.jda.api.JDA;
+import net.dv8tion.jda.api.entities.*;
+import net.dv8tion.jda.api.entities.channel.middleman.GuildChannel;
+import net.dv8tion.jda.api.entities.channel.unions.GuildChannelUnion;
+import net.dv8tion.jda.api.entities.channel.unions.MessageChannelUnion;
+import net.dv8tion.jda.api.events.message.MessageReceivedEvent;
+import net.dv8tion.jda.api.interactions.commands.OptionType;
+import net.dv8tion.jda.api.interactions.commands.build.OptionData;
+import net.dv8tion.jda.api.interactions.commands.build.SubcommandData;
+import net.dv8tion.jda.api.interactions.commands.build.SubcommandGroupData;
+import net.dv8tion.jda.api.utils.messages.MessageCreateData;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.MockedStatic;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.function.Consumer;
+import java.util.function.Function;
+import java.util.function.Supplier;
+
+import static net.dv8tion.jda.api.interactions.commands.OptionType.BOOLEAN;
+import static net.dv8tion.jda.api.interactions.commands.OptionType.CHANNEL;
+import static net.dv8tion.jda.api.interactions.commands.OptionType.INTEGER;
+import static net.dv8tion.jda.api.interactions.commands.OptionType.MENTIONABLE;
+import static net.dv8tion.jda.api.interactions.commands.OptionType.NUMBER;
+import static net.dv8tion.jda.api.interactions.commands.OptionType.ROLE;
+import static net.dv8tion.jda.api.interactions.commands.OptionType.STRING;
+import static net.dv8tion.jda.api.interactions.commands.OptionType.USER;
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyBoolean;
+import static org.mockito.ArgumentMatchers.anyList;
+import static org.mockito.Mockito.*;
+
+/**
+ * This class tests the proper functionality of the abstraction for slash and message commands.
+ *
+ * It provides a comprehensive number of test commands with different combinations of options
+ * that is used to validate the proper parsing in {@link MessageCommandContext#parseOptions(String[], List)}
+ */
+@ExtendWith(MockitoExtension.class)
+class CommandProcessorTest {
+ // ----- Test Command Implementation -----
+ private static class TestCommand extends AbstractCommand {
+ static Consumer optionsConfigurator = builder -> {};
+ static Consumer onExecute = ctx -> {};
+
+ @Override
+ public String getCommandName() {
+ return "test";
+ }
+
+ @Override
+ public String getDescription() {
+ return "Test command";
+ }
+
+ @Override
+ public void registerCommandOptions(CommandOptionsBuilder builder) {
+ optionsConfigurator.accept(builder);
+ }
+
+ @Override
+ public CommandExecutionResult execute(CommandContext context) {
+ onExecute.accept(context);
+ return CommandExecutionResult.success("Executed");
+ }
+
+ @Override
+ public boolean needAdmin() {
+ return false;
+ }
+
+ @Override
+ public boolean isEnabled() {
+ return true;
+ }
+
+ @Override
+ public boolean isGuildOnly() {
+ return false;
+ }
+
+ @Override
+ public CommandCategory getCategory() {
+ return CommandCategory.UNLISTED;
+ }
+ }
+
+ // ===== Test Helper Classes =====
+ private static class CommandTester {
+ private final MessageCommandContext ctx;
+ private CommandContext executedContext;
+ private final ArgumentCaptor replyCaptor;
+
+ CommandTester(MessageCommandContext ctx) {
+ this.ctx = ctx;
+ this.replyCaptor = ArgumentCaptor.forClass(MessageCreateData.class);
+ }
+
+ CommandTester execute() {
+ CommandProcessor processor = new CommandProcessor(ctx);
+ TestCommand.onExecute = context -> this.executedContext = context;
+ processor.dispatch();
+ return this;
+ }
+
+ CommandTester assertOption(String name, Supplier extends T> fallback, Function super CommandOption, ? extends T> resolver, T expected) {
+ if (executedContext == null) {
+ verify(ctx).reply(replyCaptor.capture(), anyBoolean(), anyList(), any());
+ String actualContent = replyCaptor.getValue().getContent();
+
+ throw new IllegalStateException("Context is invalid, error while parsing options\n" + actualContent);
+ }
+
+ T actual = executedContext.getOption(name, fallback, resolver);
+ assertEquals(expected, actual);
+ return this;
+ }
+
+ CommandTester assertOption(String name, Function super CommandOption, ? extends T> resolver, T expected) {
+ return assertOption(name, null, resolver, expected);
+ }
+
+ CommandTester assertOption(String name, String expected) {
+ return assertOption(name, CommandOption::getAsString, expected);
+ }
+
+ CommandTester assertOption(String name, Integer expected) {
+ return assertOption(name, CommandOption::getAsInt, expected);
+ }
+
+ CommandTester assertOption(String name, Boolean expected) {
+ return assertOption(name, CommandOption::getAsBoolean, expected);
+ }
+
+ CommandTester assertOption(String name, Double expected) {
+ return assertOption(name, CommandOption::getAsDouble, expected);
+ }
+
+ CommandTester assertReplyContains(String... expectedPhrases) {
+ verify(ctx).reply(replyCaptor.capture(), anyBoolean(), anyList(), any());
+ String actualContent = replyCaptor.getValue().getContent();
+ assertNotNull(actualContent);
+
+ boolean found = false;
+ for (String phrase : expectedPhrases) {
+ if (actualContent.contains(phrase)) {
+ found = true;
+ break;
+ }
+ }
+
+ assertTrue(found,
+ "Expected reply to contain at least one of " + Arrays.toString(expectedPhrases) +
+ " but got '" + actualContent + "'");
+ return this;
+ }
+
+ CommandTester assertSubcommand(String expected) {
+ assertEquals(expected, executedContext.getSubcommand());
+ return this;
+ }
+
+ CommandTester assertSubcommandGroup(String expected) {
+ assertEquals(expected, executedContext.getSubcommandGroup());
+ return this;
+ }
+
+ CommandTester wrong_subcommand() {
+ return assertReplyContains("unknown subcommand");
+ }
+
+ CommandTester wrong_subcommand_group() {
+ return assertReplyContains("unknown subcommand group");
+ }
+
+ CommandTester missing_required_args() {
+ return assertReplyContains("missing one or more required arguments", "missing required argument");
+ }
+
+ CommandTester too_many_arguments() {
+ return assertReplyContains("too many arguments");
+ }
+ }
+
+ @Mock
+ private JDA jda;
+
+ @Mock
+ private Guild guild;
+
+ @Mock
+ private MessageChannelUnion channel;
+
+ @Mock
+ private Member member;
+
+ @Mock
+ private User user;
+
+ @Mock
+ private Message message;
+
+ @Mock
+ private Mentions mentions;
+
+ @Mock
+ private Settings settings;
+
+ private MockedStatic settingsMockedStatic;
+
+ private void registerTestCommand(Consumer optionsConfig) {
+ TestCommand.optionsConfigurator = optionsConfig;
+ TestCommand.onExecute = ctx -> {}; // Reset executor
+ Command command = new TestCommand();
+ CommandContainer container = new CommandContainer(command);
+ CommandManager.commands.put(command.getCommandName(), container);
+ }
+
+ private MessageCommandContext createMessageContext(String messageContent) {
+ return createMessageContext(messageContent, null);
+ }
+
+ private MessageCommandContext createMessageContext(String messageContent, OptionType type) {
+ MessageReceivedEvent event = mock(MessageReceivedEvent.class);
+
+ when(event.getJDA()).thenReturn(jda);
+ when(event.getGuild()).thenReturn(guild);
+ when(event.getChannel()).thenReturn(channel);
+ when(event.getMember()).thenReturn(member);
+ when(event.getAuthor()).thenReturn(user);
+ when(event.getMessage()).thenReturn(message);
+ when(event.isFromGuild()).thenReturn(true);
+ when(message.getContentRaw()).thenReturn(messageContent);
+
+ if (type != null) {
+ User user = mock(User.class);
+ Role role = mock(Role.class);
+ GuildChannelUnion channel = mock(GuildChannelUnion.class);
+ when(message.getMentions()).thenReturn(mentions);
+
+ switch (type) {
+ case USER:
+ when(user.getId()).thenReturn("123");
+ when(mentions.getUsers()).thenReturn(Collections.singletonList(user));
+ break;
+ case ROLE:
+ when(role.getId()).thenReturn("456");
+ when(role.getGuild()).thenReturn(guild);
+ when(mentions.getRoles()).thenReturn(Collections.singletonList(role));
+ break;
+ case CHANNEL:
+ when(channel.getId()).thenReturn("789");
+ when(channel.getGuild()).thenReturn(guild);
+ when(mentions.getChannels()).thenReturn(Collections.singletonList(channel));
+ break;
+ case MENTIONABLE:
+ when(user.getId()).thenReturn("123");
+ when(role.getId()).thenReturn("456");
+ when(role.getGuild()).thenReturn(guild);
+ when(channel.getId()).thenReturn("789");
+ when(channel.getGuild()).thenReturn(guild);
+ when(mentions.getUsers()).thenReturn(Collections.singletonList(user));
+ when(mentions.getRoles()).thenReturn(Collections.singletonList(role));
+ when(mentions.getChannels()).thenReturn(Collections.singletonList(channel));
+ }
+ }
+
+ return spy(new MessageCommandContext(event));
+ }
+
+ @BeforeEach
+ void setup() {
+ // Mock Settings to return "$" as prefix
+ settingsMockedStatic = mockStatic(Settings.class);
+ settingsMockedStatic.when(Settings::getInstance).thenReturn(settings);
+ when(settings.getPrefix()).thenReturn("$");
+
+ CommandManager.commands.clear();
+ }
+
+ @AfterEach
+ void teardown() {
+ CommandManager.commands.clear();
+
+ if (settingsMockedStatic != null) {
+ settingsMockedStatic.close();
+ }
+ }
+
+ // ===== Subcommand / Subcommand Group Tests =====
+
+ @Test
+ void shouldReturnProperSubcommand() {
+ registerTestCommand(builder -> builder.addSubcommands(
+ new SubcommandData("sub1", "sub1").addOptions(
+ new OptionData(STRING, "arg1", "First argument", true),
+ new OptionData(STRING, "arg2", "Second argument", true)
+ )
+ ));
+
+ new CommandTester(createMessageContext("$test sub1 hello world"))
+ .execute()
+ .assertSubcommand("sub1")
+ .assertSubcommandGroup(null)
+ .assertOption("arg1", "hello")
+ .assertOption("arg2", "world");
+ }
+
+ @Test
+ void shouldReturnProperSubcommandGroup() {
+ registerTestCommand(builder -> builder.addSubcommandGroups(
+ new SubcommandGroupData("group1", "group1").addSubcommands(
+ new SubcommandData("sub1", "sub1").addOptions(
+ new OptionData(STRING, "arg1", "First argument", true),
+ new OptionData(STRING, "arg2", "Second argument", true)
+ )
+ )
+ ));
+
+ new CommandTester(createMessageContext("$test group1 sub1 hello world"))
+ .execute()
+ .assertSubcommand("sub1")
+ .assertSubcommandGroup("group1")
+ .assertOption("arg1", "hello")
+ .assertOption("arg2", "world");
+ }
+
+ @Test
+ void shouldFailWhenSubcommandGroupMissing() {
+ registerTestCommand(builder -> builder.addSubcommandGroups(
+ new SubcommandGroupData("group1", "group1").addSubcommands(
+ new SubcommandData("sub1", "sub1").addOptions(
+ new OptionData(STRING, "arg1", "First argument", true),
+ new OptionData(STRING, "arg2", "Second argument", true)
+ )
+ )
+ ));
+
+ new CommandTester(createMessageContext("$test sub1 hello world"))
+ .execute()
+ .wrong_subcommand_group();
+ }
+
+ @Test
+ void shouldFailWhenSubcommandMissing() {
+ registerTestCommand(builder -> builder.addSubcommands(
+ new SubcommandData("sub1", "sub1").addOptions(
+ new OptionData(STRING, "arg1", "First argument", true),
+ new OptionData(STRING, "arg2", "Second argument", true)
+ )
+ ));
+
+ new CommandTester(createMessageContext("$test hello world"))
+ .execute()
+ .wrong_subcommand();
+ }
+
+ // ===== Basic Argument Tests =====
+ @Test
+ void shouldFailWhenRequiredArgumentMissing() {
+ registerTestCommand(builder -> builder.addOptions(
+ new OptionData(STRING, "arg1", "First argument", true)
+ ));
+
+ new CommandTester(createMessageContext("$test"))
+ .execute()
+ .missing_required_args();
+ }
+
+ @Test
+ void shouldParseExactRequiredArguments() {
+ registerTestCommand(builder -> builder.addOptions(
+ new OptionData(STRING, "arg1", "First argument", true)
+ ));
+
+ new CommandTester(createMessageContext("$test hello"))
+ .execute()
+ .assertOption("arg1", "hello");
+ }
+
+ @Test
+ void shouldParseRequiredAndOptionalArguments() {
+ registerTestCommand(builder -> builder.addOptions(
+ new OptionData(STRING, "arg1", "First argument", true),
+ new OptionData(STRING, "arg2", "Second argument", false)
+ ));
+
+ new CommandTester(createMessageContext("$test hello world"))
+ .execute()
+ .assertOption("arg1", "hello")
+ .assertOption("arg2", "world");
+ }
+
+ @Test
+ void shouldParseOnlyRequiredWhenOptionalMissing() {
+ registerTestCommand(builder -> builder.addOptions(
+ new OptionData(STRING, "arg1", "First argument", true),
+ new OptionData(STRING, "arg2", "Second argument", false)
+ ));
+
+ new CommandTester(createMessageContext("$test hello"))
+ .execute()
+ .assertOption("arg1", "hello")
+ .assertOption("arg2", CommandOption::getAsString, null);
+ }
+
+ // ===== String Concatenation Tests =====
+ @Test
+ void shouldConcatenateStringsBeforeInteger() {
+ registerTestCommand(builder -> builder.addOptions(
+ new OptionData(STRING, "reason", "Reason", true),
+ new OptionData(INTEGER, "amount", "Amount", true)
+ ));
+
+ new CommandTester(createMessageContext("$test spa ces 8"))
+ .execute()
+ .assertOption("reason", "spa ces")
+ .assertOption("amount", 8);
+ }
+
+ @Test
+ void shouldConcatenateStringsBeforeOptionalInteger() {
+ registerTestCommand(builder -> builder.addOptions(
+ new OptionData(STRING, "reason", "Reason", true),
+ new OptionData(INTEGER, "amount", "Amount", false)
+ ));
+
+ new CommandTester(createMessageContext("$test spa ces 8"))
+ .execute()
+ .assertOption("reason", "spa ces")
+ .assertOption("amount", 8);
+ }
+
+ @Test
+ void shouldConcatenateAllRemainingStringsWhenLastOption() {
+ registerTestCommand(builder -> builder.addOptions(
+ new OptionData(STRING, "reason", "Reason for action", true)
+ ));
+
+ new CommandTester(createMessageContext("$test str ing with spaces"))
+ .execute()
+ .assertOption("reason", "str ing with spaces");
+ }
+
+ @Test
+ void shouldNotConcatenateConsecutiveStringOptions() {
+ registerTestCommand(builder -> builder.addOptions(
+ new OptionData(STRING, "arg1", "First", true),
+ new OptionData(STRING, "arg2", "Second", true)
+ ));
+
+ new CommandTester(createMessageContext("$test hello world"))
+ .execute()
+ .assertOption("arg1", "hello")
+ .assertOption("arg2", "world");
+ }
+
+ @Test
+ void shouldParseThreeConsecutiveStringOptions() {
+ registerTestCommand(builder -> builder.addOptions(
+ new OptionData(STRING, "arg1", "First", true),
+ new OptionData(STRING, "arg2", "Second", true),
+ new OptionData(STRING, "arg3", "Third", true)
+ ));
+
+ new CommandTester(createMessageContext("$test one two three"))
+ .execute()
+ .assertOption("arg1", "one")
+ .assertOption("arg2", "two")
+ .assertOption("arg3", "three");
+ }
+
+ @Test
+ void shouldConcatenateStringBetweenIntegerAndString() {
+ registerTestCommand(builder -> builder.addOptions(
+ new OptionData(STRING, "reason", "Reason", true),
+ new OptionData(INTEGER, "amount", "Amount", true),
+ new OptionData(STRING, "reason2", "Reason 2", true)
+ ));
+
+ new CommandTester(createMessageContext("$test ss ss 3 ss"))
+ .execute()
+ .assertOption("reason", "ss ss")
+ .assertOption("amount", 3)
+ .assertOption("reason2", "ss");
+ }
+
+ @Test
+ void shouldConcatenateOptionalStringAtEnd() {
+ registerTestCommand(builder -> builder.addOptions(
+ new OptionData(STRING, "reason", "Reason", true),
+ new OptionData(INTEGER, "amount", "Amount", true),
+ new OptionData(STRING, "reason2", "Reason 2", false)
+ ));
+
+ new CommandTester(createMessageContext("$test ss 3 sss more words"))
+ .execute()
+ .assertOption("reason", "ss")
+ .assertOption("amount", 3)
+ .assertOption("reason2", "sss more words");
+ }
+
+ // ===== Too Many Arguments Tests =====
+ @Test
+ void shouldFailWhenTooManyArgumentsWithInteger() {
+ registerTestCommand(builder -> builder.addOptions(
+ new OptionData(STRING, "name", "Name", true),
+ new OptionData(INTEGER, "amount", "Amount", true)
+ ));
+
+ new CommandTester(createMessageContext("$test value1 value2 value3"))
+ .execute()
+ .missing_required_args();
+ }
+
+ @Test
+ void shouldFailWhenTooManyArgumentsWithOptionalString() {
+ registerTestCommand(builder -> builder.addOptions(
+ new OptionData(STRING, "reason", "Reason", true),
+ new OptionData(INTEGER, "amount", "Amount", true),
+ new OptionData(STRING, "reason2", "Reason 2", false)
+ ));
+
+ new CommandTester(createMessageContext("$test ss ss ss 8 ss ss ss 3 ss ss ss"))
+ .execute()
+ .too_many_arguments();
+ }
+
+
+
+ // ===== Integer Tests =====
+ @Test
+ void shouldParseBasicIntegerArgument() {
+ registerTestCommand(builder -> builder.addOptions(
+ new OptionData(STRING, "name", "Name", true),
+ new OptionData(INTEGER, "amount", "Amount", true)
+ ));
+
+ new CommandTester(createMessageContext("$test myname 42"))
+ .execute()
+ .assertOption("name", "myname")
+ .assertOption("amount", 42);
+ }
+
+ @Test
+ void shouldFailOnInvalidIntegerFormat() {
+ registerTestCommand(builder -> builder.addOptions(
+ new OptionData(STRING, "name", "Name", true),
+ new OptionData(INTEGER, "amount", "Amount", true)
+ ));
+
+ new CommandTester(createMessageContext("$test myname notanumber"))
+ .execute()
+ .assertReplyContains("Invalid", "amount");
+ }
+
+ @Test
+ void shouldClampIntegerToMinValue() {
+ registerTestCommand(builder -> builder.addOptions(
+ new OptionData(INTEGER, "amount", "Amount", true)
+ .setRequiredRange(10, 100)
+ ));
+
+ new CommandTester(createMessageContext("$test 5"))
+ .execute()
+ .assertOption("amount", 10);
+ }
+
+ @Test
+ void shouldClampIntegerToMaxValue() {
+ registerTestCommand(builder -> builder.addOptions(
+ new OptionData(INTEGER, "amount", "Amount", true)
+ .setRequiredRange(10, 100)
+ ));
+
+ new CommandTester(createMessageContext("$test 150"))
+ .execute()
+ .assertOption("amount", 100);
+ }
+
+ // ===== Boolean Tests =====
+ @Test
+ void shouldParseTrueBoolean() {
+ registerTestCommand(builder -> builder.addOptions(
+ new OptionData(BOOLEAN, "flag", "Flag", true)
+ ));
+
+ new CommandTester(createMessageContext("$test true"))
+ .execute()
+ .assertOption("flag", true);
+ }
+
+ @Test
+ void shouldParseFalseBoolean() {
+ registerTestCommand(builder -> builder.addOptions(
+ new OptionData(BOOLEAN, "flag", "Flag", true)
+ ));
+
+ new CommandTester(createMessageContext("$test false"))
+ .execute()
+ .assertOption("flag", false);
+ }
+
+ @Test
+ void shouldFailOnInvalidBooleanValue() {
+ registerTestCommand(builder -> builder.addOptions(
+ new OptionData(BOOLEAN, "flag", "Flag", true)
+ ));
+
+ new CommandTester(createMessageContext("$test maybe"))
+ .execute()
+ .assertReplyContains("Invalid", "flag");
+ }
+
+ @Test
+ void shouldStopConcatenationAtBoolean() {
+ registerTestCommand(builder -> builder.addOptions(
+ new OptionData(STRING, "text", "Text", true),
+ new OptionData(BOOLEAN, "flag", "Flag", true)
+ ));
+
+ new CommandTester(createMessageContext("$test hello world true"))
+ .execute()
+ .assertOption("text", "hello world")
+ .assertOption("flag", true);
+ }
+
+ // ===== Number (Double) Tests =====
+ @Test
+ void shouldParseDoubleNumber() {
+ registerTestCommand(builder -> builder.addOptions(
+ new OptionData(NUMBER, "value", "Value", true)
+ ));
+
+ new CommandTester(createMessageContext("$test 3.14"))
+ .execute()
+ .assertOption("value", 3.14);
+ }
+
+ @Test
+ void shouldParseIntegerAsDouble() {
+ registerTestCommand(builder -> builder.addOptions(
+ new OptionData(NUMBER, "value", "Value", true)
+ ));
+
+ new CommandTester(createMessageContext("$test 42"))
+ .execute()
+ .assertOption("value", 42.0);
+ }
+
+ @Test
+ void shouldClampDoubleToMinValue() {
+ registerTestCommand(builder -> builder.addOptions(
+ new OptionData(NUMBER, "value", "Value", true)
+ .setRequiredRange(1.0, 10.0)
+ ));
+
+ new CommandTester(createMessageContext("$test 0.5"))
+ .execute()
+ .assertOption("value", 1.0);
+ }
+
+ @Test
+ void shouldClampDoubleToMaxValue() {
+ registerTestCommand(builder -> builder.addOptions(
+ new OptionData(NUMBER, "value", "Value", true)
+ .setRequiredRange(1.0, 10.0)
+ ));
+
+ new CommandTester(createMessageContext("$test 15.5"))
+ .execute()
+ .assertOption("value", 10.0);
+ }
+
+ @Test
+ void shouldStopConcatenationAtDouble() {
+ registerTestCommand(builder -> builder.addOptions(
+ new OptionData(STRING, "text", "Text", true),
+ new OptionData(NUMBER, "value", "Value", true)
+ ));
+
+ new CommandTester(createMessageContext("$test hello world 3.14"))
+ .execute()
+ .assertOption("text", "hello world")
+ .assertOption("value", 3.14);
+ }
+
+ // ===== User Mention Tests =====
+ @Test
+ void shouldParseUserMention() {
+ registerTestCommand(builder -> builder.addOptions(
+ new OptionData(USER, "user", "User", true)
+ ));
+
+ MessageCommandContext ctx = createMessageContext("$test <@123>", USER);
+ new CommandTester(ctx)
+ .execute();
+
+ User capturedUser = ctx.getOption("user", CommandOption::getAsUser);
+ assertNotNull(capturedUser);
+ assertEquals("123", capturedUser.getId());
+ }
+
+ // ===== Role Mention Tests =====
+ @Test
+ void shouldParseRoleMention() {
+ registerTestCommand(builder -> builder.addOptions(
+ new OptionData(ROLE, "role", "Role", true)
+ ));
+
+ when(guild.getId()).thenReturn("123");
+
+ MessageCommandContext ctx = createMessageContext("$test <@&456>", ROLE);
+ new CommandTester(ctx)
+ .execute();
+
+ Role capturedRole = ctx.getOption("role", CommandOption::getAsRole);
+ assertNotNull(capturedRole);
+ assertEquals("456", capturedRole.getId());
+ }
+
+ // ===== Channel Mention Tests =====
+ @Test
+ void shouldParseChannelMention() {
+ registerTestCommand(builder -> builder.addOptions(
+ new OptionData(CHANNEL, "channel", "Channel", true)
+ ));
+
+ when(guild.getId()).thenReturn("123");
+
+ MessageCommandContext ctx = createMessageContext("$test <#789>", CHANNEL);
+ new CommandTester(ctx)
+ .execute();
+
+ GuildChannel capturedChannel = ctx.getOption("channel", CommandOption::getAsChannel);
+ assertNotNull(capturedChannel);
+ assertEquals("789", capturedChannel.getId());
+ }
+
+ // ===== Mentionnable Mention Tests =====
+ @Test
+ void shouldParseMentionables() {
+ registerTestCommand(builder -> builder.addOptions(
+ new OptionData(USER, "user", "User", false),
+ new OptionData(ROLE, "role", "Role", false),
+ new OptionData(CHANNEL, "channel", "Channel", false)
+ ));
+
+ when(guild.getId()).thenReturn("123");
+
+ MessageCommandContext ctx = createMessageContext("$test <@123> <@&456> <#789>", MENTIONABLE);
+ new CommandTester(ctx)
+ .execute();
+
+ User capturedUser = ctx.getOption("user", CommandOption::getAsUser);
+ assertNotNull(capturedUser);
+ assertEquals("123", capturedUser.getId());
+
+ Role capturedRole = ctx.getOption("role", CommandOption::getAsRole);
+ assertNotNull(capturedRole);
+ assertEquals("456", capturedRole.getId());
+
+ GuildChannel capturedChannel = ctx.getOption("channel", CommandOption::getAsChannel);
+ assertNotNull(capturedChannel);
+ assertEquals("789", capturedChannel.getId());
+ }
+
+
+ // ===== String Length Validation Tests =====
+ @Test
+ void shouldFailWhenStringBelowMinLength() {
+ registerTestCommand(builder -> builder.addOptions(
+ new OptionData(STRING, "text", "Text", true)
+ .setMinLength(5)
+ ));
+
+ new CommandTester(createMessageContext("$test hi"))
+ .execute()
+ .assertReplyContains("Invalid", "minimum length");
+ }
+
+ @Test
+ void shouldFailWhenStringAboveMaxLength() {
+ registerTestCommand(builder -> builder.addOptions(
+ new OptionData(STRING, "text", "Text", true)
+ .setMaxLength(5)
+ ));
+
+ new CommandTester(createMessageContext("$test verylongtext"))
+ .execute()
+ .assertReplyContains("Invalid", "maximum length");
+ }
+
+ @Test
+ void shouldParseStringWithinValidLengthRange() {
+ registerTestCommand(builder -> builder.addOptions(
+ new OptionData(STRING, "text", "Text", true)
+ .setMinLength(3)
+ .setMaxLength(10)
+ ));
+
+ new CommandTester(createMessageContext("$test valid"))
+ .execute()
+ .assertOption("text", "valid");
+ }
+
+ // ===== Choice Validation Tests =====
+ @Test
+ void shouldParseValidStringChoice() {
+ registerTestCommand(builder -> builder.addOptions(
+ new OptionData(STRING, "color", "Color", true)
+ .addChoice("red", "red")
+ .addChoice("blue", "blue")
+ .addChoice("green", "green")
+ ));
+
+ new CommandTester(createMessageContext("$test red"))
+ .execute()
+ .assertOption("color", "red");
+ }
+
+ @Test
+ void shouldFailOnInvalidStringChoice() {
+ registerTestCommand(builder -> builder.addOptions(
+ new OptionData(STRING, "color", "Color", true)
+ .addChoice("red", "red")
+ .addChoice("blue", "blue")
+ ));
+
+ new CommandTester(createMessageContext("$test yellow"))
+ .execute()
+ .assertReplyContains("Invalid", "not a valid choice");
+ }
+
+ @Test
+ void shouldParseValidIntegerChoice() {
+ registerTestCommand(builder -> builder.addOptions(
+ new OptionData(INTEGER, "level", "Level", true)
+ .addChoice("low", 1)
+ .addChoice("medium", 5)
+ .addChoice("high", 10)
+ ));
+
+ new CommandTester(createMessageContext("$test 5"))
+ .execute()
+ .assertOption("level", 5);
+ }
+
+ @Test
+ void shouldFailOnInvalidIntegerChoice() {
+ registerTestCommand(builder -> builder.addOptions(
+ new OptionData(INTEGER, "level", "Level", true)
+ .addChoice("low", 1)
+ .addChoice("high", 10)
+ ));
+
+ new CommandTester(createMessageContext("$test 5"))
+ .execute()
+ .assertReplyContains("Invalid", "not a valid choice");
+ }
+
+ @Test
+ void shouldParseValidDoubleChoice() {
+ registerTestCommand(builder -> builder.addOptions(
+ new OptionData(NUMBER, "multiplier", "Multiplier", true)
+ .addChoice("half", 0.5)
+ .addChoice("double", 2.0)
+ ));
+
+ new CommandTester(createMessageContext("$test 2.0"))
+ .execute()
+ .assertOption("multiplier", 2.0);
+ }
+
+ @Test
+ void shouldFailOnInvalidDoubleChoice() {
+ registerTestCommand(builder -> builder.addOptions(
+ new OptionData(NUMBER, "multiplier", "Multiplier", true)
+ .addChoice("half", 0.5)
+ .addChoice("double", 2.0)
+ ));
+
+ new CommandTester(createMessageContext("$test 1.5"))
+ .execute()
+ .assertReplyContains("Invalid", "not a valid choice");
+ }
+
+ // ===== Mixed Type Scenarios =====
+ @Test
+ void shouldStopConcatenationAtBooleanValue() {
+ registerTestCommand(builder -> builder.addOptions(
+ new OptionData(STRING, "text", "Text", true),
+ new OptionData(BOOLEAN, "enabled", "Enabled", true)
+ ));
+
+ new CommandTester(createMessageContext("$test some words false"))
+ .execute()
+ .assertOption("text", "some words")
+ .assertOption("enabled", false);
+ }
+
+ @Test
+ void shouldParseComplexMixedTypes() {
+ registerTestCommand(builder -> builder.addOptions(
+ new OptionData(STRING, "text", "Text", true),
+ new OptionData(INTEGER, "count", "Count", true),
+ new OptionData(BOOLEAN, "flag", "Flag", true),
+ new OptionData(NUMBER, "ratio", "Ratio", true)
+ ));
+
+ new CommandTester(createMessageContext("$test hello world 42 true 3.14"))
+ .execute()
+ .assertOption("text", "hello world")
+ .assertOption("count", 42)
+ .assertOption("flag", true)
+ .assertOption("ratio", 3.14);
+ }
+
+ @Test
+ void shouldParseStringThenDoubleStoppingConcatenation() {
+ registerTestCommand(builder -> builder.addOptions(
+ new OptionData(STRING, "reason", "Reason", true),
+ new OptionData(NUMBER, "value", "Value", true)
+ ));
+
+ new CommandTester(createMessageContext("$test multiple words here 2.5"))
+ .execute()
+ .assertOption("reason", "multiple words here")
+ .assertOption("value", 2.5);
+ }
+
+ // ===== All Optional Arguments Tests =====
+ @Test
+ void shouldParseAllOptionalArgumentsProvided() {
+ registerTestCommand(builder -> builder.addOptions(
+ new OptionData(STRING, "arg1", "Arg1", false),
+ new OptionData(INTEGER, "arg2", "Arg2", false),
+ new OptionData(BOOLEAN, "arg3", "Arg3", false)
+ ));
+
+ new CommandTester(createMessageContext("$test hello 42 true"))
+ .execute()
+ .assertOption("arg1", "hello")
+ .assertOption("arg2", 42)
+ .assertOption("arg3", true);
+ }
+
+ @Test
+ void shouldHandleAllOptionalArgumentsMissing() {
+ registerTestCommand(builder -> builder.addOptions(
+ new OptionData(STRING, "arg1", "Arg1", false),
+ new OptionData(INTEGER, "arg2", "Arg2", false)
+ ));
+
+ new CommandTester(createMessageContext("$test"))
+ .execute()
+ .assertOption("arg1", CommandOption::getAsString, null)
+ .assertOption("arg2", CommandOption::getAsInt, null);
+ }
+
+ @Test
+ void shouldHandleMixedRequiredAndOptionalWithVaryingTypes() {
+ registerTestCommand(builder -> builder.addOptions(
+ new OptionData(STRING, "required1", "Required 1", true),
+ new OptionData(STRING, "required2", "Required 2", true),
+ new OptionData(INTEGER, "optional1", "Optional 1", false),
+ new OptionData(BOOLEAN, "optional2", "Optional 2", false)
+ ));
+
+ new CommandTester(createMessageContext("$test hello world"))
+ .execute()
+ .assertOption("required1", "hello")
+ .assertOption("optional1", CommandOption::getAsInt, null)
+ .assertOption("required2", "world")
+ .assertOption("optional2", CommandOption::getAsBoolean, null);
+ }
+
+ @Test
+ void shouldHandlePartialOptionalArguments() {
+ registerTestCommand(builder -> builder.addOptions(
+ new OptionData(STRING, "required", "Required", true),
+ new OptionData(INTEGER, "optional1", "Optional 1", false),
+ new OptionData(BOOLEAN, "optional2", "Optional 2", false)
+ ));
+
+ new CommandTester(createMessageContext("$test hello 42"))
+ .execute()
+ .assertOption("required", "hello")
+ .assertOption("optional1", 42)
+ .assertOption("optional2", CommandOption::getAsBoolean, null);
+ }
+
+ // ===== Edge Cases =====
+ @Test
+ void shouldStopConcatenationAtIntegerLookingLikeDecimal() {
+ registerTestCommand(builder -> builder.addOptions(
+ new OptionData(STRING, "text", "Text", true),
+ new OptionData(INTEGER, "num", "Number", true)
+ ));
+
+ new CommandTester(createMessageContext("$test hello world 123"))
+ .execute()
+ .assertOption("text", "hello world")
+ .assertOption("num", 123);
+ }
+
+ @Test
+ void shouldHandleNegativeIntegers() {
+ registerTestCommand(builder -> builder.addOptions(
+ new OptionData(STRING, "text", "Text", true),
+ new OptionData(INTEGER, "num", "Number", true)
+ ));
+
+ new CommandTester(createMessageContext("$test some text -42"))
+ .execute()
+ .assertOption("text", "some text")
+ .assertOption("num", -42);
+ }
+
+ @Test
+ void shouldHandleNegativeDoubles() {
+ registerTestCommand(builder -> builder.addOptions(
+ new OptionData(STRING, "text", "Text", true),
+ new OptionData(NUMBER, "value", "Value", true)
+ ));
+
+ new CommandTester(createMessageContext("$test test -3.14"))
+ .execute()
+ .assertOption("text", "test")
+ .assertOption("value", -3.14);
+ }
+
+ @Test
+ void shouldConcatenateUntilFirstNumberInMixedString() {
+ registerTestCommand(builder -> builder.addOptions(
+ new OptionData(STRING, "text", "Text", true),
+ new OptionData(INTEGER, "num", "Number", true)
+ ));
+
+ new CommandTester(createMessageContext("$test this is text 99"))
+ .execute()
+ .assertOption("text", "this is text")
+ .assertOption("num", 99);
+ }
+}