From 6fc3dc9577e4cc45bd75df3c101607e9da66dfa2 Mon Sep 17 00:00:00 2001 From: anthorob Date: Mon, 27 Oct 2025 07:08:56 +0000 Subject: [PATCH] Command processor testing --- pom.xml | 7 +- .../bobert/netneutrality/NetNeutrality.java | 2 +- .../command/MessageCommandContext.java | 72 +- .../core/command/CommandProcessor.java | 17 - .../command/options/MessageCommandOption.java | 4 + .../core/command/CommandProcessorTest.java | 1067 +++++++++++++++++ 6 files changed, 1142 insertions(+), 27 deletions(-) create mode 100644 src/test/java/me/bobert/netneutrality/core/command/CommandProcessorTest.java diff --git a/pom.xml b/pom.xml index d4afede..15acc32 100644 --- a/pom.xml +++ b/pom.xml @@ -277,7 +277,12 @@ ${mockito.version} test - + + org.mockito + mockito-junit-jupiter + ${mockito.version} + test + \ No newline at end of file diff --git a/src/main/java/me/bobert/netneutrality/NetNeutrality.java b/src/main/java/me/bobert/netneutrality/NetNeutrality.java index 6d786a4..3316804 100644 --- a/src/main/java/me/bobert/netneutrality/NetNeutrality.java +++ b/src/main/java/me/bobert/netneutrality/NetNeutrality.java @@ -70,8 +70,8 @@ public class NetNeutrality { throw new RuntimeException("No bot token provided, exiting."); } - DatabaseMigration.migrate(); SessionManager.getSessionFactory(); + DatabaseMigration.migrate(); CommandManager.init(); MusicManager.initPlayerManager(); TaskManager.getInstance().init(); diff --git a/src/main/java/me/bobert/netneutrality/context/command/MessageCommandContext.java b/src/main/java/me/bobert/netneutrality/context/command/MessageCommandContext.java index b47efde..5c247aa 100644 --- a/src/main/java/me/bobert/netneutrality/context/command/MessageCommandContext.java +++ b/src/main/java/me/bobert/netneutrality/context/command/MessageCommandContext.java @@ -58,6 +58,7 @@ public class MessageCommandContext extends CommandContext implements MessageCont @Override public List parseOptions(String[] args, List commandOptions) { List options = new ArrayList<>(); + int required = commandOptions.stream().filter(OptionData::isRequired).toList().size(); Mentions mentions = message.getMentions(); int roleMentionIndex = 0; int userMentionIndex = 0; @@ -74,13 +75,59 @@ public class MessageCommandContext extends CommandContext implements MessageCont try { switch (type) { case STRING -> { - String str = args[index]; + int currentOptionIndex = commandOptions.indexOf(data); + boolean nextOptionIsString = false; + if (currentOptionIndex + 1 < commandOptions.size()) { + OptionData nextOption = commandOptions.get(currentOptionIndex + 1); + nextOptionIsString = nextOption.getType() == OptionType.STRING; + } - if ((str.contains("<@") || str.contains("<#")) && str.contains(">")) { - invalidateContext(data, "expected a string at position " + (index + 1) + " but a role/user/channel was provided"); + StringBuilder strBuilder = new StringBuilder(); + if (!nextOptionIsString) { + while (index < args.length) { + String nextArg = args[index]; + + if ((nextArg.contains("<@") || nextArg.contains("<#") || nextArg.contains("<@&")) && nextArg.contains(">")) { + break; + } + + if (nextArg.equalsIgnoreCase("true") || nextArg.equalsIgnoreCase("false")) { + break; + } + + try { + Long.parseLong(nextArg); + break; + } catch (NumberFormatException ignored) {} + + try { + Double.parseDouble(nextArg); + break; + } catch (NumberFormatException ignored) {} + + if (!strBuilder.isEmpty()) { + strBuilder.append(" "); + } + + strBuilder.append(nextArg); + index++; + } + + // Update index to skip the concatenated strings + index = index - 1; + } else { + // Next option is also a string, so just take the current argument + if (index < args.length) { + strBuilder.append(args[index]); + } + } + + if (strBuilder.isEmpty()) { break; } + String str = strBuilder.toString(); + if (!choices.isEmpty()) { List matched = choices.stream().filter(choice -> choice.getAsString().equalsIgnoreCase(str)).toList(); if (matched.isEmpty()) { @@ -119,8 +166,6 @@ public class MessageCommandContext extends CommandContext implements MessageCont } else if (max != null && num > max.longValue()) { value = max.longValue(); } - - value = num; } case BOOLEAN -> { String str = args[index]; @@ -132,7 +177,7 @@ public class MessageCommandContext extends CommandContext implements MessageCont invalidateContext(data, "expected true or false"); } case NUMBER -> { - double num = Long.parseLong(args[index]); + double num = Double.parseDouble(args[index]); if (!choices.isEmpty()) { List matched = choices.stream().filter(choice -> choice.getAsDouble() == num).toList(); if (matched.isEmpty()) { @@ -209,7 +254,7 @@ public class MessageCommandContext extends CommandContext implements MessageCont if (mentions.getChannels().size() > channelMentionIndex) { GuildChannel channel = mentions.getChannels().get(channelMentionIndex++); - if (!requiredChannelTypes.contains(channel.getType())) { + if (!requiredChannelTypes.isEmpty() && !requiredChannelTypes.contains(channel.getType())) { invalidateContext(data, "not a valid channel type at index " + (index + 1) + " , valid types are: " + validTypes(requiredChannelTypes)); break; @@ -225,7 +270,11 @@ public class MessageCommandContext extends CommandContext implements MessageCont } } } catch (IndexOutOfBoundsException e) { - if (data.isRequired()) { + if (options.size() < required) { + invalidateContext(data, "missing required argument at index " + (index + 1)); + } + + if (data.isRequired() && index < required) { // This should never happen because required options are validated before calling parseOptions() throw new IllegalArgumentException("Required option " + data.getName() + " was not found at index " + index); } @@ -240,6 +289,13 @@ public class MessageCommandContext extends CommandContext implements MessageCont index++; } + // Check if there are leftover arguments that weren't consumed + if (index < args.length) { + if (options.size() >= required) { + invalidateContext(commandOptions.getLast(), "too many arguments"); + } + } + return options; } diff --git a/src/main/java/me/bobert/netneutrality/core/command/CommandProcessor.java b/src/main/java/me/bobert/netneutrality/core/command/CommandProcessor.java index 8260eed..a44d05b 100644 --- a/src/main/java/me/bobert/netneutrality/core/command/CommandProcessor.java +++ b/src/main/java/me/bobert/netneutrality/core/command/CommandProcessor.java @@ -14,7 +14,6 @@ import me.bobert.netneutrality.core.tasks.TaskManager; import me.bobert.netneutrality.exceptions.CommandExecutionException; import me.bobert.netneutrality.exceptions.GracefulContextError; import me.bobert.netneutrality.utils.UserFunction; -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; @@ -204,22 +203,6 @@ public class CommandProcessor { return false; } - // check if last option is a string, if it is then combine all remaining strings into one - // ["@user", "you", "are", "banned"] becomes ["@user", "you are banned"] - if (!options.isEmpty() && arguments.size() > 1) { - OptionData last = options.get(options.size() - 1); - if (last.getType() == OptionType.STRING) { - List 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 fallback, Function 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 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); + } +}