From 35fc415d910725ba85c26cf685eeb6f64fa3980f Mon Sep 17 00:00:00 2001 From: anthorob Date: Sat, 26 Sep 2026 10:47:21 -0400 Subject: [PATCH] Improve argument parsing in `MessageCommandContext` Revised argument parsing to handle edge cases like text concatenation, invalid numeric input, and mention handling more robustly. Introduced utility methods for validating argument input and resolving mentions to improve readability and maintainability. Added tests (`UsageValidatorTest`) to verify proper execution of subcommand and argument validation. --- .../command/MessageCommandContext.java | 401 +++--- ...MessageCommandContextParseOptionsTest.java | 831 ++++++++++++ .../core/command/CommandProcessorTest.java | 1156 +++-------------- .../core/command/UsageValidatorTest.java | 329 +++++ 4 files changed, 1558 insertions(+), 1159 deletions(-) create mode 100644 src/test/java/me/bobert/netneutrality/context/command/MessageCommandContextParseOptionsTest.java create mode 100644 src/test/java/me/bobert/netneutrality/core/command/UsageValidatorTest.java 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 be0b6b2..bdae151 100644 --- a/src/main/java/me/bobert/netneutrality/context/command/MessageCommandContext.java +++ b/src/main/java/me/bobert/netneutrality/context/command/MessageCommandContext.java @@ -10,6 +10,7 @@ import me.bobert.netneutrality.core.command.options.MessageCommandOption; import me.bobert.netneutrality.core.context.CommandContext; import me.bobert.netneutrality.core.context.MessageContext; import me.bobert.netneutrality.utils.SettingsProvider; +import net.dv8tion.jda.api.entities.Guild; import net.dv8tion.jda.api.entities.Member; import net.dv8tion.jda.api.entities.Mentions; import net.dv8tion.jda.api.entities.Message; @@ -22,11 +23,20 @@ import net.dv8tion.jda.api.interactions.commands.OptionType; import net.dv8tion.jda.api.interactions.commands.build.OptionData; import java.util.ArrayList; +import java.util.EnumSet; import java.util.List; import java.util.Set; import java.util.StringJoiner; +import java.util.regex.Pattern; public class MessageCommandContext extends CommandContext implements MessageContext { + private static final Pattern INTEGER_PATTERN = Pattern.compile("[-+]?\\d+"); + private static final Pattern NUMBER_PATTERN = Pattern.compile("[-+]?\\d+(\\.\\d+)?"); + private static final Pattern MENTION_PATTERN = Pattern.compile("<(@[!&]?|#)\\d+>"); + private static final Set MENTION_TYPES = + EnumSet.of(OptionType.USER, OptionType.ROLE, OptionType.MENTIONABLE, OptionType.CHANNEL); + private static final OptionData LEFTOVER_ARGUMENTS = new OptionData(OptionType.STRING, "arguments", "arguments"); + private final MessageReceivedEvent event; private final Message message; @@ -61,7 +71,6 @@ 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; @@ -69,239 +78,251 @@ public class MessageCommandContext extends CommandContext implements MessageCont int channelMentionIndex = 0; int index = 0; - for (OptionData data : commandOptions) { + for (int i = 0; i < commandOptions.size(); i++) { + OptionData data = commandOptions.get(i); OptionType type = data.getType(); Set requiredChannelTypes = data.getChannelTypes(); List choices = data.getChoices(); + int position = index + 1; Object value = null; - try { - switch (type) { - case STRING -> { - 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 (index >= args.length) { + if (data.isRequired()) { + invalidateContext(data, "missing required argument at index " + position); + } + + continue; + } + + switch (type) { + case STRING -> { + String str; + if (i + 1 < commandOptions.size() && commandOptions.get(i + 1).getType() == OptionType.STRING) { + // The next option is also a string, so only the current argument belongs to this one + str = args[index++]; + } else { + StringJoiner joiner = new StringJoiner(" "); + while (index < args.length && !stopsTextCapture(args[index], commandOptions, i)) { + joiner.add(args[index++]); } - 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()) { - invalidateContext(data, "not a valid choice, valid choices are: " + validChoices(choices)); - } - } - - Integer min = data.getMinLength(); - Integer max = data.getMaxLength(); - - if (min != null && str.length() < min) { - invalidateContext(data, "minimum length for argument is " + min); - } - - if (max != null && str.length() > max) { - invalidateContext(data, "maximum length for argument is " + max); - } - - value = str; + str = joiner.toString(); } - case INTEGER -> { - long num = Long.parseLong(args[index]); - if (!choices.isEmpty()) { - List matched = choices.stream().filter(choice -> choice.getAsLong() == num).toList(); - if (matched.isEmpty()) { - invalidateContext(data, "not a valid choice, valid choices are: " + validChoices(choices)); - } - } - value = num; - Number max = data.getMaxValue(); - Number min = data.getMinValue(); - - if (min != null && num < min.longValue()) { - value = min.longValue(); - } else if (max != null && num > max.longValue()) { - value = max.longValue(); + if (str.isEmpty()) { + if (data.isRequired()) { + invalidateContext(data, "missing required argument at index " + position); } + + break; } - case BOOLEAN -> { - String str = args[index]; - if (str.equalsIgnoreCase("true") || str.equalsIgnoreCase("false")) { - value = Boolean.parseBoolean(str); - break; - } + if (!choices.isEmpty() && choices.stream().noneMatch(choice -> choice.getAsString().equalsIgnoreCase(str))) { + invalidateContext(data, "not a valid choice, valid choices are: " + validChoices(choices)); + break; + } + + Integer min = data.getMinLength(); + Integer max = data.getMaxLength(); + + if (min != null && str.length() < min) { + invalidateContext(data, "minimum length for argument is " + min); + break; + } + + if (max != null && str.length() > max) { + invalidateContext(data, "maximum length for argument is " + max); + break; + } + + value = str; + } + case INTEGER -> { + String str = args[index++]; + if (!INTEGER_PATTERN.matcher(str).matches()) { + invalidateContext(data, "the argument provided is not a number."); + break; + } + + long num = Long.parseLong(str); + if (!choices.isEmpty() && choices.stream().noneMatch(choice -> choice.getAsLong() == num)) { + invalidateContext(data, "not a valid choice, valid choices are: " + validChoices(choices)); + break; + } + + Number min = data.getMinValue(); + Number max = data.getMaxValue(); + + if (min != null && num < min.longValue()) { + invalidateContext(data, "minimum value for argument is " + min.longValue()); + break; + } + + if (max != null && num > max.longValue()) { + invalidateContext(data, "maximum value for argument is " + max.longValue()); + break; + } + + value = num; + } + case BOOLEAN -> { + String str = args[index++]; + if (!str.equalsIgnoreCase("true") && !str.equalsIgnoreCase("false")) { invalidateContext(data, "expected true or false"); + break; } - case NUMBER -> { - double num = Double.parseDouble(args[index]); - if (!choices.isEmpty()) { - List matched = choices.stream().filter(choice -> choice.getAsDouble() == num).toList(); - if (matched.isEmpty()) { - invalidateContext(data, "not a valid choice, valid choices are: " + validChoices(choices)); - break; - } - } - Number max = data.getMaxValue(); - Number min = data.getMinValue(); - value = num; - - if (min != null && num < min.doubleValue()) { - value = min.doubleValue(); - } else if (max != null && num > max.doubleValue()) { - value = max.doubleValue(); - } + value = Boolean.parseBoolean(str); + } + case NUMBER -> { + String str = args[index++]; + if (!NUMBER_PATTERN.matcher(str).matches()) { + invalidateContext(data, "the argument provided is not a number."); + break; } - case ROLE -> { - if (mentions.getRoles().size() > roleMentionIndex) { - Role role = mentions.getRoles().get(roleMentionIndex++); - if (role.getGuild().getId().equals(getGuild().getId())) { - value = role; - } - break; - } - - invalidateContext(data, "provided argument at position " + (index + 1) + " is not a valid role."); + double num = Double.parseDouble(str); + if (!choices.isEmpty() && choices.stream().noneMatch(choice -> choice.getAsDouble() == num)) { + invalidateContext(data, "not a valid choice, valid choices are: " + validChoices(choices)); + break; } - case USER -> { - if (mentions.getMembers().size() > memberMentionIndex) { - Member member = mentions.getMembers().get(memberMentionIndex++); - if (member.getGuild().getId().equals(getGuild().getId())) { - value = member; - break; - } - } - if (mentions.getUsers().size() > userMentionIndex) { - value = mentions.getUsers().get(userMentionIndex++); - break; - } + Number min = data.getMinValue(); + Number max = data.getMaxValue(); - invalidateContext(data, "provided argument at position " + (index + 1) + " is not a valid user."); + if (min != null && num < min.doubleValue()) { + invalidateContext(data, "minimum value for argument is " + min.doubleValue()); + break; } - case MENTIONABLE -> { - if (mentions.getRoles().size() > roleMentionIndex) { - Role role = mentions.getRoles().get(roleMentionIndex++); - if (role.getGuild().getId().equals(getGuild().getId())) { - value = role; - } - break; - } - - if (mentions.getMembers().size() > memberMentionIndex) { - Member member = mentions.getMembers().get(memberMentionIndex++); - if (member.getGuild().getId().equals(getGuild().getId())) { - value = member; - } - - break; - } - - if (mentions.getUsers().size() > userMentionIndex) { - value = mentions.getUsers().get(userMentionIndex++); - break; - } - - invalidateContext(data, "provided argument at position " + (index + 1) + " is not a valid user or role."); + if (max != null && num > max.doubleValue()) { + invalidateContext(data, "maximum value for argument is " + max.doubleValue()); + break; } - case CHANNEL -> { - if (mentions.getChannels().size() > channelMentionIndex) { - GuildChannel channel = mentions.getChannels().get(channelMentionIndex++); - if (!requiredChannelTypes.isEmpty() && !requiredChannelTypes.contains(channel.getType())) { - invalidateContext(data, "not a valid channel type at index " + (index + 1) + - " , valid types are: " + validTypes(requiredChannelTypes)); - break; - } + value = num; + } + case ROLE -> { + index++; + Role role = mentions.getRoles().size() > roleMentionIndex ? mentions.getRoles().get(roleMentionIndex++) : null; + if (role == null || !isFromCurrentGuild(role.getGuild())) { + invalidateContext(data, "provided argument at position " + position + " is not a valid role."); + break; + } - if (channel.getGuild().getId().equals(getGuild().getId())) { - value = channel; - break; - } - } + value = role; + } + case USER -> { + index++; + value = resolveUser(mentions, memberMentionIndex, userMentionIndex); + if (value instanceof Member) { + memberMentionIndex++; + } else if (value != null) { + userMentionIndex++; + } - invalidateContext(data, "provided argument at position " + (index + 1) + " is not a valid channel."); + if (value == null) { + invalidateContext(data, "provided argument at position " + position + " is not a valid user."); } } - } catch (IndexOutOfBoundsException e) { - if (options.size() < required) { - invalidateContext(data, "missing required argument at index " + (index + 1)); - } + case MENTIONABLE -> { + index++; + if (mentions.getRoles().size() > roleMentionIndex) { + Role role = mentions.getRoles().get(roleMentionIndex++); + if (!isFromCurrentGuild(role.getGuild())) { + invalidateContext(data, "provided argument at position " + position + " is not a valid role."); + break; + } - 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); + value = role; + break; + } + + value = resolveUser(mentions, memberMentionIndex, userMentionIndex); + if (value instanceof Member) { + memberMentionIndex++; + } else if (value != null) { + userMentionIndex++; + } + + if (value == null) { + invalidateContext(data, "provided argument at position " + position + " is not a valid user or role."); + } } - } catch (NumberFormatException ignored) { - invalidateContext(data, "the argument provided is not a number."); + case CHANNEL -> { + index++; + GuildChannel channel = mentions.getChannels().size() > channelMentionIndex ? mentions.getChannels().get(channelMentionIndex++) : null; + if (channel == null || !isFromCurrentGuild(channel.getGuild())) { + invalidateContext(data, "provided argument at position " + position + " is not a valid channel."); + break; + } + + if (!requiredChannelTypes.isEmpty() && !requiredChannelTypes.contains(channel.getType())) { + invalidateContext(data, "not a valid channel type at index " + position + + " , valid types are: " + validTypes(requiredChannelTypes)); + break; + } + + value = channel; + } + default -> index++; } if (value != null) { options.add(new MessageCommandOption(data, value)); } - - 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"); - } + OptionData lastOption = commandOptions.isEmpty() ? LEFTOVER_ARGUMENTS : commandOptions.getLast(); + invalidateContext(lastOption, "too many arguments"); } return options; } + /** + * A string option greedily consumes the remaining arguments, it has to stop as soon as an argument + * belongs to one of the following options. + */ + private boolean stopsTextCapture(String arg, List commandOptions, int currentOptionIndex) { + if (NUMBER_PATTERN.matcher(arg).matches()) { + return true; + } + + List remaining = commandOptions.subList(currentOptionIndex + 1, commandOptions.size()); + + if (arg.equalsIgnoreCase("true") || arg.equalsIgnoreCase("false")) { + return remaining.stream().anyMatch(option -> option.getType() == OptionType.BOOLEAN); + } + + if (MENTION_PATTERN.matcher(arg).matches()) { + return remaining.stream().anyMatch(option -> MENTION_TYPES.contains(option.getType())); + } + + return false; + } + + private Object resolveUser(Mentions mentions, int memberMentionIndex, int userMentionIndex) { + if (mentions.getMembers().size() > memberMentionIndex) { + Member member = mentions.getMembers().get(memberMentionIndex); + if (isFromCurrentGuild(member.getGuild())) { + return member; + } + } + + if (mentions.getUsers().size() > userMentionIndex) { + return mentions.getUsers().get(userMentionIndex); + } + + return null; + } + + private boolean isFromCurrentGuild(Guild guild) { + return getGuild() != null && guild != null && guild.getId().equals(getGuild().getId()); + } + private String validChoices(List choices) { StringJoiner joiner = new StringJoiner(", "); for (Command.Choice choice : choices) { diff --git a/src/test/java/me/bobert/netneutrality/context/command/MessageCommandContextParseOptionsTest.java b/src/test/java/me/bobert/netneutrality/context/command/MessageCommandContextParseOptionsTest.java new file mode 100644 index 0000000..bdfe8d3 --- /dev/null +++ b/src/test/java/me/bobert/netneutrality/context/command/MessageCommandContextParseOptionsTest.java @@ -0,0 +1,831 @@ +package me.bobert.netneutrality.context.command; + +/* + * Copyright (c) anthorob + * + * https://discord.gg/qUa6zNFZsd + */ + +import me.bobert.netneutrality.core.command.options.CommandOption; +import me.bobert.netneutrality.utils.SettingsProvider; +import net.dv8tion.jda.api.JDA; +import net.dv8tion.jda.api.entities.Guild; +import net.dv8tion.jda.api.entities.Member; +import net.dv8tion.jda.api.entities.Mentions; +import net.dv8tion.jda.api.entities.Message; +import net.dv8tion.jda.api.entities.Role; +import net.dv8tion.jda.api.entities.User; +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.build.OptionData; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +import java.util.Collections; +import java.util.List; +import java.util.Map; + +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.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.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Tests {@link MessageCommandContext#parseOptions(String[], List)}, which turns the raw words of a message + * command into typed options and reports why an argument was rejected. + *

+ * Everything in this class calls the parser directly, the dispatching of a command is not involved. Subcommand + * resolution and the required argument count are handled before the parser runs and are covered by + * {@code UsageValidatorTest}, the behaviour of the dispatcher itself by {@code CommandProcessorTest}. + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class MessageCommandContextParseOptionsTest { + private static final String GUILD_ID = "111"; + + @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 SettingsProvider settings; + + private MessageCommandContext createContext() { + return createContext(true); + } + + private MessageCommandContext createContext(boolean fromGuild) { + 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(fromGuild); + when(message.getMentions()).thenReturn(mentions); + when(mentions.getRoles()).thenReturn(Collections.emptyList()); + when(mentions.getUsers()).thenReturn(Collections.emptyList()); + when(mentions.getMembers()).thenReturn(Collections.emptyList()); + when(mentions.getChannels()).thenReturn(Collections.emptyList()); + when(guild.getId()).thenReturn(GUILD_ID); + + return new MessageCommandContext(event, settings); + } + + /** + * Parses the given command line, the arguments are split on spaces exactly like the command dispatcher does. + */ + private List parse(MessageCommandContext context, String args, OptionData... commandOptions) { + String[] split = args.isEmpty() ? new String[0] : args.split(" "); + + return context.parseOptions(split, List.of(commandOptions)); + } + + private CommandOption option(List options, String name) { + return options.stream().filter(option -> option.getName().equals(name)).findFirst().orElse(null); + } + + private String errorFor(MessageCommandContext context, String optionName) { + for (Map.Entry invalid : context.getInvalidOptions()) { + if (invalid.getKey().getName().equals(optionName)) { + return invalid.getValue(); + } + } + + return null; + } + + private void assertRejected(MessageCommandContext context, List options, String optionName, String expectedReason) { + assertFalse(context.isValid(), "The context must be invalidated"); + assertNull(option(options, optionName), "A rejected argument must not be handed to the command as a usable option"); + + String reason = errorFor(context, optionName); + assertNotNull(reason, "The option '" + optionName + "' must be reported as invalid"); + assertTrue(reason.contains(expectedReason), + "Expected the reason for '" + optionName + "' to contain '" + expectedReason + "' but got '" + reason + "'"); + } + + private void mockUserMention(String id) { + User mentioned = mock(User.class); + when(mentioned.getId()).thenReturn(id); + when(mentions.getUsers()).thenReturn(Collections.singletonList(mentioned)); + } + + private void mockRoleMention(String id) { + Role role = mock(Role.class); + when(role.getId()).thenReturn(id); + when(role.getGuild()).thenReturn(guild); + when(mentions.getRoles()).thenReturn(Collections.singletonList(role)); + } + + private void mockChannelMention(String id) { + GuildChannelUnion mentioned = mock(GuildChannelUnion.class); + when(mentioned.getId()).thenReturn(id); + when(mentioned.getGuild()).thenReturn(guild); + when(mentions.getChannels()).thenReturn(Collections.singletonList(mentioned)); + } + + @Nested + class RequiredAndOptionalArguments { + @Test + void shouldReportMissingRequiredArgument() { + MessageCommandContext context = createContext(); + + List options = parse(context, "", new OptionData(STRING, "arg1", "First", true)); + + assertRejected(context, options, "arg1", "missing required argument"); + } + + @Test + void shouldParseRequiredAndOptionalArguments() { + MessageCommandContext context = createContext(); + + List options = parse(context, "hello world", + new OptionData(STRING, "arg1", "First", true), + new OptionData(STRING, "arg2", "Second", false)); + + assertTrue(context.isValid()); + assertEquals("hello", option(options, "arg1").getAsString()); + assertEquals("world", option(options, "arg2").getAsString()); + } + + @Test + void shouldOmitOptionalArgumentsThatWereNotProvided() { + MessageCommandContext context = createContext(); + + List options = parse(context, "hello", + new OptionData(STRING, "arg1", "First", true), + new OptionData(STRING, "arg2", "Second", false)); + + assertTrue(context.isValid()); + assertEquals("hello", option(options, "arg1").getAsString()); + assertNull(option(options, "arg2")); + } + + @Test + void shouldParseAllOptionalArgumentsWhenProvided() { + MessageCommandContext context = createContext(); + + List options = parse(context, "hello 42 true", + new OptionData(STRING, "arg1", "Arg1", false), + new OptionData(INTEGER, "arg2", "Arg2", false), + new OptionData(BOOLEAN, "arg3", "Arg3", false)); + + assertTrue(context.isValid()); + assertEquals("hello", option(options, "arg1").getAsString()); + assertEquals(42, option(options, "arg2").getAsInt()); + assertEquals(true, option(options, "arg3").getAsBoolean()); + } + + @Test + void shouldAcceptAllOptionalArgumentsMissing() { + MessageCommandContext context = createContext(); + + List options = parse(context, "", + new OptionData(STRING, "arg1", "Arg1", false), + new OptionData(INTEGER, "arg2", "Arg2", false)); + + assertTrue(context.isValid()); + assertTrue(options.isEmpty()); + } + + @Test + void shouldParsePartiallyProvidedOptionalArguments() { + MessageCommandContext context = createContext(); + + List options = parse(context, "hello 42", + new OptionData(STRING, "required", "Required", true), + new OptionData(INTEGER, "optional1", "Optional 1", false), + new OptionData(BOOLEAN, "optional2", "Optional 2", false)); + + assertTrue(context.isValid()); + assertEquals("hello", option(options, "required").getAsString()); + assertEquals(42, option(options, "optional1").getAsInt()); + assertNull(option(options, "optional2")); + } + + @Test + void shouldParseRequiredArgumentsWhenTrailingOptionalOnesAreMissing() { + MessageCommandContext context = createContext(); + + List options = parse(context, "hello world", + 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)); + + assertTrue(context.isValid()); + assertEquals("hello", option(options, "required1").getAsString()); + assertEquals("world", option(options, "required2").getAsString()); + assertNull(option(options, "optional1")); + assertNull(option(options, "optional2")); + } + } + + @Nested + class GreedyStringCapture { + @Test + void shouldCaptureAllRemainingWordsWhenStringIsTheLastOption() { + MessageCommandContext context = createContext(); + + List options = parse(context, "str ing with spaces", + new OptionData(STRING, "reason", "Reason", true)); + + assertEquals("str ing with spaces", option(options, "reason").getAsString()); + } + + @Test + void shouldGiveOneWordToEachOfTwoConsecutiveStringOptions() { + MessageCommandContext context = createContext(); + + List options = parse(context, "hello world", + new OptionData(STRING, "arg1", "First", true), + new OptionData(STRING, "arg2", "Second", true)); + + assertEquals("hello", option(options, "arg1").getAsString()); + assertEquals("world", option(options, "arg2").getAsString()); + } + + @Test + void shouldGiveOneWordToEachOfThreeConsecutiveStringOptions() { + MessageCommandContext context = createContext(); + + List options = parse(context, "one two three", + new OptionData(STRING, "arg1", "First", true), + new OptionData(STRING, "arg2", "Second", true), + new OptionData(STRING, "arg3", "Third", true)); + + assertEquals("one", option(options, "arg1").getAsString()); + assertEquals("two", option(options, "arg2").getAsString()); + assertEquals("three", option(options, "arg3").getAsString()); + } + + @Test + void shouldStopCaptureAtRequiredInteger() { + MessageCommandContext context = createContext(); + + List options = parse(context, "spa ces 8", + new OptionData(STRING, "reason", "Reason", true), + new OptionData(INTEGER, "amount", "Amount", true)); + + assertEquals("spa ces", option(options, "reason").getAsString()); + assertEquals(8, option(options, "amount").getAsInt()); + } + + @Test + void shouldStopCaptureAtOptionalInteger() { + MessageCommandContext context = createContext(); + + List options = parse(context, "spa ces 8", + new OptionData(STRING, "reason", "Reason", true), + new OptionData(INTEGER, "amount", "Amount", false)); + + assertEquals("spa ces", option(options, "reason").getAsString()); + assertEquals(8, option(options, "amount").getAsInt()); + } + + @Test + void shouldStopCaptureAtBoolean() { + MessageCommandContext context = createContext(); + + List options = parse(context, "hello world true", + new OptionData(STRING, "text", "Text", true), + new OptionData(BOOLEAN, "flag", "Flag", true)); + + assertEquals("hello world", option(options, "text").getAsString()); + assertEquals(true, option(options, "flag").getAsBoolean()); + } + + @Test + void shouldStopCaptureAtDouble() { + MessageCommandContext context = createContext(); + + List options = parse(context, "multiple words here 2.5", + new OptionData(STRING, "reason", "Reason", true), + new OptionData(NUMBER, "value", "Value", true)); + + assertEquals("multiple words here", option(options, "reason").getAsString()); + assertEquals(2.5, option(options, "value").getAsDouble()); + } + + @Test + void shouldCaptureTextBetweenAnIntegerAndAFollowingString() { + MessageCommandContext context = createContext(); + + List options = parse(context, "ss ss 3 ss", + new OptionData(STRING, "reason", "Reason", true), + new OptionData(INTEGER, "amount", "Amount", true), + new OptionData(STRING, "reason2", "Reason 2", true)); + + assertEquals("ss ss", option(options, "reason").getAsString()); + assertEquals(3, option(options, "amount").getAsInt()); + assertEquals("ss", option(options, "reason2").getAsString()); + } + + @Test + void shouldCaptureAllRemainingWordsInATrailingOptionalString() { + MessageCommandContext context = createContext(); + + List options = parse(context, "ss 3 sss more words", + new OptionData(STRING, "reason", "Reason", true), + new OptionData(INTEGER, "amount", "Amount", true), + new OptionData(STRING, "reason2", "Reason 2", false)); + + assertEquals("ss", option(options, "reason").getAsString()); + assertEquals(3, option(options, "amount").getAsInt()); + assertEquals("sss more words", option(options, "reason2").getAsString()); + } + + @Test + void shouldAcceptLiteralTrueAsStringValue() { + MessageCommandContext context = createContext(); + + List options = parse(context, "true", new OptionData(STRING, "text", "Text", true)); + + assertEquals(1, options.size(), "A string option must accept the word 'true' as its value"); + assertEquals("true", options.getFirst().getAsString()); + } + + @Test + void shouldAcceptWordsJavaConsidersNumericAsStringValue() { + MessageCommandContext context = createContext(); + + List options = parse(context, "NaN", new OptionData(STRING, "text", "Text", true)); + + assertEquals(1, options.size(), "'NaN' is a normal word and must be accepted by a string option"); + assertEquals("NaN", options.getFirst().getAsString()); + } + + @Test + void shouldKeepMentionsInsideTheTextOfAStringOnlyCommand() { + MessageCommandContext context = createContext(); + + List options = parse(context, "hello <@123> world", new OptionData(STRING, "text", "Text", true)); + + assertEquals(1, options.size()); + assertEquals("hello <@123> world", options.getFirst().getAsString(), + "When no mention option is declared, the mention is part of the text"); + assertTrue(context.isValid(), "The whole text is consumed, there are no leftover arguments"); + } + } + + @Nested + class Strings { + @Test + void shouldRejectStringBelowMinimumLength() { + MessageCommandContext context = createContext(); + + List options = parse(context, "hi", new OptionData(STRING, "text", "Text", true).setMinLength(5)); + + assertRejected(context, options, "text", "minimum length"); + } + + @Test + void shouldRejectStringAboveMaximumLength() { + MessageCommandContext context = createContext(); + + List options = parse(context, "verylongtext", new OptionData(STRING, "text", "Text", true).setMaxLength(5)); + + assertRejected(context, options, "text", "maximum length"); + } + + @Test + void shouldNotProduceAnOptionWhenStringIsTooLong() { + MessageCommandContext context = createContext(); + + List options = parse(context, "waytoolong", new OptionData(STRING, "text", "Text", true).setMaxLength(3)); + + assertFalse(context.isValid(), "A too long string must invalidate the context"); + assertTrue(options.isEmpty(), "A rejected string must not be handed to the command as a usable option"); + } + + @Test + void shouldParseStringWithinValidLengthRange() { + MessageCommandContext context = createContext(); + + List options = parse(context, "valid", + new OptionData(STRING, "text", "Text", true).setMinLength(3).setMaxLength(10)); + + assertTrue(context.isValid()); + assertEquals("valid", option(options, "text").getAsString()); + } + + @Test + void shouldParseValidStringChoice() { + MessageCommandContext context = createContext(); + + List options = parse(context, "red", new OptionData(STRING, "color", "Color", true) + .addChoice("red", "red") + .addChoice("blue", "blue") + .addChoice("green", "green")); + + assertTrue(context.isValid()); + assertEquals("red", option(options, "color").getAsString()); + } + + @Test + void shouldRejectUnknownStringChoice() { + MessageCommandContext context = createContext(); + + List options = parse(context, "yellow", new OptionData(STRING, "color", "Color", true) + .addChoice("red", "red") + .addChoice("blue", "blue")); + + assertRejected(context, options, "color", "not a valid choice"); + } + + @Test + void shouldNotProduceAnOptionWhenStringIsNotAValidChoice() { + MessageCommandContext context = createContext(); + + List options = parse(context, "turbo", new OptionData(STRING, "mode", "Mode", true) + .addChoice("fast", "fast") + .addChoice("slow", "slow")); + + assertFalse(context.isValid(), "An unknown choice must invalidate the context"); + assertTrue(options.isEmpty(), "An invalid choice must not be handed to the command as a usable option"); + } + } + + @Nested + class Integers { + @Test + void shouldParseInteger() { + MessageCommandContext context = createContext(); + + List options = parse(context, "myname 42", + new OptionData(STRING, "name", "Name", true), + new OptionData(INTEGER, "amount", "Amount", true)); + + assertEquals("myname", option(options, "name").getAsString()); + assertEquals(42, option(options, "amount").getAsInt()); + } + + @Test + void shouldParseNegativeInteger() { + MessageCommandContext context = createContext(); + + List options = parse(context, "some text -42", + new OptionData(STRING, "text", "Text", true), + new OptionData(INTEGER, "num", "Number", true)); + + assertEquals("some text", option(options, "text").getAsString()); + assertEquals(-42, option(options, "num").getAsInt()); + } + + @Test + void shouldRejectArgumentThatIsNotAnInteger() { + MessageCommandContext context = createContext(); + + List options = parse(context, "42.5", new OptionData(INTEGER, "amount", "Amount", true)); + + assertRejected(context, options, "amount", "not a number"); + } + + @Test + void shouldRejectIntegerBelowMinimum() { + MessageCommandContext context = createContext(); + + List options = parse(context, "5", new OptionData(INTEGER, "amount", "Amount", true) + .setRequiredRange(10, 100)); + + assertRejected(context, options, "amount", "minimum value"); + } + + @Test + void shouldRejectIntegerAboveMaximumInsteadOfClampingIt() { + MessageCommandContext context = createContext(); + + List options = parse(context, "100", new OptionData(INTEGER, "amount", "Amount", true).setMaxValue(10)); + + assertFalse(context.isValid(), "A value above the maximum must be reported instead of silently clamped"); + assertTrue(options.isEmpty(), "An out of range value must not be silently replaced by the maximum"); + } + + @Test + void shouldParseValidIntegerChoice() { + MessageCommandContext context = createContext(); + + List options = parse(context, "5", new OptionData(INTEGER, "level", "Level", true) + .addChoice("low", 1) + .addChoice("medium", 5) + .addChoice("high", 10)); + + assertTrue(context.isValid()); + assertEquals(5, option(options, "level").getAsInt()); + } + + @Test + void shouldRejectUnknownIntegerChoice() { + MessageCommandContext context = createContext(); + + List options = parse(context, "5", new OptionData(INTEGER, "level", "Level", true) + .addChoice("low", 1) + .addChoice("high", 10)); + + assertRejected(context, options, "level", "not a valid choice"); + } + } + + @Nested + class Booleans { + @Test + void shouldParseTrue() { + MessageCommandContext context = createContext(); + + List options = parse(context, "true", new OptionData(BOOLEAN, "flag", "Flag", true)); + + assertEquals(true, option(options, "flag").getAsBoolean()); + } + + @Test + void shouldParseFalse() { + MessageCommandContext context = createContext(); + + List options = parse(context, "false", new OptionData(BOOLEAN, "flag", "Flag", true)); + + assertEquals(false, option(options, "flag").getAsBoolean()); + } + + @Test + void shouldRejectArgumentThatIsNotABoolean() { + MessageCommandContext context = createContext(); + + List options = parse(context, "maybe", new OptionData(BOOLEAN, "flag", "Flag", true)); + + assertRejected(context, options, "flag", "expected true or false"); + } + } + + @Nested + class Numbers { + @Test + void shouldParseDouble() { + MessageCommandContext context = createContext(); + + List options = parse(context, "3.14", new OptionData(NUMBER, "value", "Value", true)); + + assertEquals(3.14, option(options, "value").getAsDouble()); + } + + @Test + void shouldParseIntegerAsDouble() { + MessageCommandContext context = createContext(); + + List options = parse(context, "42", new OptionData(NUMBER, "value", "Value", true)); + + assertEquals(42.0, option(options, "value").getAsDouble()); + } + + @Test + void shouldParseNegativeDouble() { + MessageCommandContext context = createContext(); + + List options = parse(context, "test -3.14", + new OptionData(STRING, "text", "Text", true), + new OptionData(NUMBER, "value", "Value", true)); + + assertEquals("test", option(options, "text").getAsString()); + assertEquals(-3.14, option(options, "value").getAsDouble()); + } + + @Test + void shouldRejectArgumentThatIsNotANumber() { + MessageCommandContext context = createContext(); + + List options = parse(context, "notanumber", new OptionData(NUMBER, "value", "Value", true)); + + assertRejected(context, options, "value", "not a number"); + } + + @Test + void shouldRejectDoubleAboveMaximum() { + MessageCommandContext context = createContext(); + + List options = parse(context, "15.5", new OptionData(NUMBER, "value", "Value", true) + .setRequiredRange(1.0, 10.0)); + + assertRejected(context, options, "value", "maximum value"); + } + + @Test + void shouldRejectNumberBelowMinimumInsteadOfClampingIt() { + MessageCommandContext context = createContext(); + + List options = parse(context, "0.5", new OptionData(NUMBER, "ratio", "Ratio", true).setMinValue(1.5)); + + assertFalse(context.isValid(), "A value below the minimum must be reported instead of silently clamped"); + assertTrue(options.isEmpty(), "An out of range value must not be silently replaced by the minimum"); + } + + @Test + void shouldParseValidDoubleChoice() { + MessageCommandContext context = createContext(); + + List options = parse(context, "2.0", new OptionData(NUMBER, "multiplier", "Multiplier", true) + .addChoice("half", 0.5) + .addChoice("double", 2.0)); + + assertTrue(context.isValid()); + assertEquals(2.0, option(options, "multiplier").getAsDouble()); + } + + @Test + void shouldRejectUnknownDoubleChoice() { + MessageCommandContext context = createContext(); + + List options = parse(context, "1.5", new OptionData(NUMBER, "multiplier", "Multiplier", true) + .addChoice("half", 0.5) + .addChoice("double", 2.0)); + + assertRejected(context, options, "multiplier", "not a valid choice"); + } + } + + @Nested + class MentionOptions { + @Test + void shouldParseUserMention() { + MessageCommandContext context = createContext(); + mockUserMention("123"); + + List options = parse(context, "<@123>", new OptionData(USER, "user", "User", true)); + + assertTrue(context.isValid()); + assertEquals("123", option(options, "user").getAsUser().getId()); + } + + @Test + void shouldParseRoleMention() { + MessageCommandContext context = createContext(); + mockRoleMention("456"); + + List options = parse(context, "<@&456>", new OptionData(ROLE, "role", "Role", true)); + + assertTrue(context.isValid()); + assertEquals("456", option(options, "role").getAsRole().getId()); + } + + @Test + void shouldParseChannelMention() { + MessageCommandContext context = createContext(); + mockChannelMention("789"); + + List options = parse(context, "<#789>", new OptionData(CHANNEL, "channel", "Channel", true)); + + assertTrue(context.isValid()); + assertEquals("789", option(options, "channel").getAsChannel().getId()); + } + + @Test + void shouldParseEveryMentionTypeOfTheSameMessage() { + MessageCommandContext context = createContext(); + mockUserMention("123"); + mockRoleMention("456"); + mockChannelMention("789"); + + List options = parse(context, "<@123> <@&456> <#789>", + new OptionData(USER, "user", "User", false), + new OptionData(ROLE, "role", "Role", false), + new OptionData(CHANNEL, "channel", "Channel", false)); + + assertTrue(context.isValid()); + assertEquals("123", option(options, "user").getAsUser().getId()); + assertEquals("456", option(options, "role").getAsRole().getId()); + assertEquals("789", option(options, "channel").getAsChannel().getId()); + } + + @Test + void shouldReportMissingMention() { + MessageCommandContext context = createContext(); + + List options = parse(context, "notamention", new OptionData(USER, "user", "User", true)); + + assertRejected(context, options, "user", "is not a valid user"); + } + + @Test + void shouldReportRoleThatBelongsToAnotherGuild() { + MessageCommandContext context = createContext(); + + Guild otherGuild = mock(Guild.class); + when(otherGuild.getId()).thenReturn("222"); + + Role role = mock(Role.class); + when(role.getGuild()).thenReturn(otherGuild); + when(mentions.getRoles()).thenReturn(Collections.singletonList(role)); + + List options = parse(context, "<@&456>", new OptionData(ROLE, "role", "Role", true)); + + assertTrue(options.isEmpty()); + assertFalse(context.isValid(), "A role of another guild must be reported instead of silently dropped"); + } + + @Test + void shouldNotCrashWhenMentionOptionIsUsedInDirectMessage() { + MessageCommandContext context = createContext(false); + + Member mentioned = mock(Member.class); + Guild otherGuild = mock(Guild.class); + when(otherGuild.getId()).thenReturn("999"); + when(mentioned.getGuild()).thenReturn(otherGuild); + when(mentions.getMembers()).thenReturn(Collections.singletonList(mentioned)); + + assertDoesNotThrow(() -> parse(context, "<@123>", new OptionData(USER, "target", "Target", true)), + "A mention option used in a private message must not throw, the guild is null there"); + } + } + + @Nested + class MixedTypes { + @Test + void shouldParseStringIntegerBooleanAndNumberInOneCommand() { + MessageCommandContext context = createContext(); + + List options = parse(context, "hello world 42 true 3.14", + new OptionData(STRING, "text", "Text", true), + new OptionData(INTEGER, "count", "Count", true), + new OptionData(BOOLEAN, "flag", "Flag", true), + new OptionData(NUMBER, "ratio", "Ratio", true)); + + assertTrue(context.isValid()); + assertEquals("hello world", option(options, "text").getAsString()); + assertEquals(42, option(options, "count").getAsInt()); + assertEquals(true, option(options, "flag").getAsBoolean()); + assertEquals(3.14, option(options, "ratio").getAsDouble()); + } + + @Test + void shouldReportTheMissingIntegerWhenTheTextSwallowedEveryWord() { + MessageCommandContext context = createContext(); + + List options = parse(context, "value1 value2 value3", + new OptionData(STRING, "name", "Name", true), + new OptionData(INTEGER, "amount", "Amount", true)); + + assertEquals("value1 value2 value3", option(options, "name").getAsString()); + assertRejected(context, options, "amount", "missing required argument"); + } + } + + @Nested + class LeftoverArguments { + @Test + void shouldReportLeftoverArgumentsOnTheLastOption() { + MessageCommandContext context = createContext(); + + parse(context, "ss ss ss 8 ss ss ss 3 ss ss ss", + new OptionData(STRING, "reason", "Reason", true), + new OptionData(INTEGER, "amount", "Amount", true), + new OptionData(STRING, "reason2", "Reason 2", false)); + + assertFalse(context.isValid()); + assertEquals("too many arguments", errorFor(context, "reason2")); + } + + @Test + void shouldNotThrowOnExtraArgumentsWhenCommandDeclaresNoOptions() { + MessageCommandContext context = createContext(); + + assertDoesNotThrow(() -> parse(context, "extra"), + "A command without options must report extra arguments instead of throwing"); + assertFalse(context.isValid()); + } + } +} diff --git a/src/test/java/me/bobert/netneutrality/core/command/CommandProcessorTest.java b/src/test/java/me/bobert/netneutrality/core/command/CommandProcessorTest.java index 4d7108f..198ebb7 100644 --- a/src/test/java/me/bobert/netneutrality/core/command/CommandProcessorTest.java +++ b/src/test/java/me/bobert/netneutrality/core/command/CommandProcessorTest.java @@ -6,213 +6,77 @@ package me.bobert.netneutrality.core.command; * 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.command.precondition.CommandPrecondition; import me.bobert.netneutrality.core.context.CommandContext; -import me.bobert.netneutrality.utils.SettingsProvider; import me.bobert.netneutrality.core.tasks.TaskManager; -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 me.bobert.netneutrality.exceptions.GracefulContextError; +import net.dv8tion.jda.api.entities.Guild; +import net.dv8tion.jda.api.entities.Member; +import net.dv8tion.jda.api.entities.User; 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.BeforeEach; -import me.bobert.netneutrality.bootstrap.ApplicationContext; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; -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 java.util.Map; +import java.util.Set; -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.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyBoolean; -import static org.mockito.ArgumentMatchers.anyList; -import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.contains; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; /** - * This class tests the proper functionality of the abstraction for slash and message commands. + * Tests the dispatching done by {@link CommandProcessor}: resolving the command, running the preconditions, + * refusing to execute a command with invalid arguments and handing the execution result over. *

- * 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)} + * How the arguments of a message command are turned into options is not tested here, that is the job of + * {@code MessageCommandContextParseOptionsTest}, and how a usage is resolved the job of + * {@code UsageValidatorTest}. */ @ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) class CommandProcessorTest { - // ----- Test Command Implementation ----- - private static class TestCommand extends AbstractCommand { - static Consumer optionsConfigurator = _ -> {}; - static Consumer onExecute = _ -> {}; - - @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 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, commandManager, taskManager); - 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"); - } - } + private static final String COMMAND_NAME = "test"; @Mock - private JDA jda; + private CommandContext context; + + @Mock + private CommandRegistry commands; + + @Mock + private TaskManager taskManager; + + @Mock + private UsageValidator usageValidator; + + @Mock + private CommandResultHandler resultHandler; + + @Mock + private Command command; + + @Mock + private CommandContainer container; @Mock private Guild guild; - @Mock - private MessageChannelUnion channel; - @Mock private Member member; @@ -220,837 +84,191 @@ class CommandProcessorTest { private User user; @Mock - private Message message; + private MessageChannelUnion channel; - @Mock - private Mentions mentions; - - @Mock - private SettingsProvider settings; - private CommandManager commandManager; - private TaskManager taskManager; - - private void registerTestCommand(Consumer optionsConfig) { - TestCommand.optionsConfigurator = optionsConfig; - TestCommand.onExecute = _ -> {}; // Reset executor - Command command = new TestCommand(); - CommandContainer container = new CommandContainer(command); - commandManager.getCommands().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, settings)); - } + private CommandExecutionResult result; @BeforeEach void setup() { - when(settings.getPrefix()).thenReturn("$"); + result = CommandExecutionResult.success("Executed"); - commandManager = new CommandManager(mock(ApplicationContext.class)); - taskManager = mock(TaskManager.class); + when(context.parseCommandName()).thenReturn(COMMAND_NAME); + when(context.getId()).thenReturn("context-id"); + when(context.getGuild()).thenReturn(guild); + when(context.getGuildUser()).thenReturn(member); + when(context.getDiscordUser()).thenReturn(user); + when(context.getCommand()).thenReturn(command); + when(context.getChannel()).thenReturn(channel); + when(context.isValid()).thenReturn(true); + when(guild.getId()).thenReturn("111"); + when(member.getId()).thenReturn("222"); + when(user.getId()).thenReturn("222"); + when(channel.getId()).thenReturn("333"); + + when(commands.hasCommand(COMMAND_NAME)).thenReturn(true); + when(commands.getCommandContainer(COMMAND_NAME)).thenReturn(container); + when(container.getCommand()).thenReturn(command); + when(command.getCommandName()).thenReturn(COMMAND_NAME); + when(command.getType()).thenReturn(Command.Type.SLASH); + when(command.execute(context)).thenReturn(result); + when(usageValidator.validate(context, container)).thenReturn(true); } - // ===== Subcommand / Subcommand Group Tests ===== + private CommandProcessor processor(CommandPrecondition... preconditions) { + return new CommandProcessor(context, commands, taskManager, List.of(preconditions), usageValidator, resultHandler); + } + + private CommandPrecondition passing() { + return (_, _) -> true; + } + + private CommandPrecondition failing() { + return (_, _) -> false; + } + + // ===== Command resolution ===== @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) - ) - )); + void shouldIgnoreMessageThatIsNotAKnownCommand() { + when(commands.hasCommand(COMMAND_NAME)).thenReturn(false); - new CommandTester(createMessageContext("$test sub1 hello world")) - .execute() - .assertSubcommand("sub1") - .assertSubcommandGroup(null) - .assertOption("arg1", "hello") - .assertOption("arg2", "world"); + processor().dispatch(); + + verify(command, never()).execute(any()); + verify(context, never()).reply(anyString()); } @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) - ) - ) - )); + void shouldFailWhenTheContainerHasNoCommand() { + when(container.getCommand()).thenReturn(null); - new CommandTester(createMessageContext("$test group1 sub1 hello world")) - .execute() - .assertSubcommand("sub1") - .assertSubcommandGroup("group1") - .assertOption("arg1", "hello") - .assertOption("arg2", "world"); + assertThrows(IllegalArgumentException.class, () -> processor().dispatch()); } @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) - ) - ) - )); + void shouldPublishTheResolvedCommandOnTheContext() { + processor().dispatch(); - new CommandTester(createMessageContext("$test sub1 hello world")) - .execute() - .wrong_subcommand_group(); + verify(context).setCommand(command); + } + + // ===== Preconditions ===== + + @Test + void shouldExecuteWhenEveryPreconditionPasses() { + processor(passing(), passing()).dispatch(); + + verify(command).execute(context); } @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) - ) - )); + void shouldNotExecuteWhenAPreconditionFails() { + processor(failing()).dispatch(); - 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(); + verify(command, never()).execute(any()); + verify(resultHandler, never()).handle(any(), any(), any()); } @Test - void shouldParseExactRequiredArguments() { - registerTestCommand(builder -> builder.addOptions( - new OptionData(STRING, "arg1", "First argument", true) - )); + void shouldStopAtTheFirstFailingPrecondition() { + int[] checked = {0}; + CommandPrecondition counting = (_, _) -> { + checked[0]++; + return true; + }; - new CommandTester(createMessageContext("$test hello")) - .execute() - .assertOption("arg1", "hello"); + processor(failing(), counting).dispatch(); + + assertEquals(0, checked[0], "Preconditions after a failing one must not be checked"); + } + + // ===== Usage validation ===== + + @Test + void shouldNotExecuteWhenTheUsageIsInvalid() { + when(usageValidator.validate(context, container)).thenReturn(false); + + processor().dispatch(); + + verify(command, never()).execute(any()); } @Test - void shouldParseRequiredAndOptionalArguments() { - registerTestCommand(builder -> builder.addOptions( - new OptionData(STRING, "arg1", "First argument", true), - new OptionData(STRING, "arg2", "Second argument", false) - )); + void shouldSkipUsageValidationForNonSlashCommands() { + when(command.getType()).thenReturn(Command.Type.MESSAGE); - new CommandTester(createMessageContext("$test hello world")) - .execute() - .assertOption("arg1", "hello") - .assertOption("arg2", "world"); + processor().dispatch(); + + verify(usageValidator, never()).validate(any(), any()); + verify(command).execute(context); + } + + // ===== Invalid arguments ===== + + @Test + void shouldReportEveryInvalidArgumentInsteadOfExecuting() { + OptionData option = new OptionData(OptionType.INTEGER, "amount", "Amount"); + when(context.isValid()).thenReturn(false); + when(context.getInvalidOptions()).thenReturn(Set.copyOf(Map.of(option, "minimum value for argument is 10").entrySet())); + + processor().dispatch(); + + verify(command, never()).execute(any()); + verify(context).reply(contains("amount: minimum value for argument is 10")); + } + + // ===== Execution result ===== + + @Test + void shouldHandOverTheExecutionResult() { + processor().dispatch(); + + verify(resultHandler).handle(context, result, taskManager); } @Test - void shouldParseOnlyRequiredWhenOptionalMissing() { - registerTestCommand(builder -> builder.addOptions( - new OptionData(STRING, "arg1", "First argument", true), - new OptionData(STRING, "arg2", "Second argument", false) - )); + void shouldExecuteTheCommandExactlyOnce() { + processor().dispatch(); - new CommandTester(createMessageContext("$test hello")) - .execute() - .assertOption("arg1", "hello") - .assertOption("arg2", CommandOption::getAsString, null); + verify(command, times(1)).execute(context); } - // ===== String Concatenation Tests ===== - @Test - void shouldConcatenateStringsBeforeInteger() { - registerTestCommand(builder -> builder.addOptions( - new OptionData(STRING, "reason", "Reason", true), - new OptionData(INTEGER, "amount", "Amount", true) - )); + // ===== Error handling ===== - new CommandTester(createMessageContext("$test spa ces 8")) - .execute() - .assertOption("reason", "spa ces") - .assertOption("amount", 8); + @Test + void shouldReplyEphemerallyOnGracefulContextError() { + when(command.execute(context)).thenThrow(new GracefulContextError("nope")); + + processor().dispatch(); + + verify(context).replyEphemeral("nope"); } @Test - void shouldConcatenateStringsBeforeOptionalInteger() { - registerTestCommand(builder -> builder.addOptions( - new OptionData(STRING, "reason", "Reason", true), - new OptionData(INTEGER, "amount", "Amount", false) - )); + void shouldNotPropagateUnexpectedExceptions() { + when(command.execute(context)).thenThrow(new IllegalStateException("boom")); - new CommandTester(createMessageContext("$test spa ces 8")) - .execute() - .assertOption("reason", "spa ces") - .assertOption("amount", 8); + assertDoesNotThrow(() -> processor().dispatch()); + } + + // ===== Collaborators ===== + + @Test + void shouldRequireItsCollaborators() { + assertTrue(assertThrows(NullPointerException.class, + () -> new CommandProcessor(context, null, taskManager)).getMessage().contains("commands")); + assertTrue(assertThrows(NullPointerException.class, + () -> new CommandProcessor(context, commands, null)).getMessage().contains("taskManager")); + assertTrue(assertThrows(NullPointerException.class, + () -> new CommandProcessor(context, commands, taskManager, null, usageValidator, resultHandler)).getMessage().contains("preconditions")); + assertTrue(assertThrows(NullPointerException.class, + () -> new CommandProcessor(context, commands, taskManager, List.of(), null, resultHandler)).getMessage().contains("usageValidator")); + assertTrue(assertThrows(NullPointerException.class, + () -> new CommandProcessor(context, commands, taskManager, List.of(), usageValidator, null)).getMessage().contains("resultHandler")); } @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); + void shouldUseTheDefaultCollaboratorsWhenNoneAreGiven() { + assertDoesNotThrow(() -> new CommandProcessor(context, commands, taskManager)); } } diff --git a/src/test/java/me/bobert/netneutrality/core/command/UsageValidatorTest.java b/src/test/java/me/bobert/netneutrality/core/command/UsageValidatorTest.java new file mode 100644 index 0000000..6040065 --- /dev/null +++ b/src/test/java/me/bobert/netneutrality/core/command/UsageValidatorTest.java @@ -0,0 +1,329 @@ +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.SettingsProvider; +import net.dv8tion.jda.api.JDA; +import net.dv8tion.jda.api.entities.Guild; +import net.dv8tion.jda.api.entities.Member; +import net.dv8tion.jda.api.entities.Mentions; +import net.dv8tion.jda.api.entities.Message; +import net.dv8tion.jda.api.entities.User; +import net.dv8tion.jda.api.entities.channel.unions.MessageChannelUnion; +import net.dv8tion.jda.api.events.message.MessageReceivedEvent; +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.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +import java.util.Collections; +import java.util.function.Consumer; + +import static net.dv8tion.jda.api.interactions.commands.OptionType.STRING; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Tests the resolution of subcommands, subcommand groups and the argument count check performed by + * {@link UsageValidator}. + *

+ * Everything that happens afterwards on a single argument (type conversion, bounds, choices) is the job of + * {@link MessageCommandContext#parseOptions(String[], java.util.List)} and is covered by its own test class. + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class UsageValidatorTest { + + private static class TestCommand extends AbstractCommand { + private final Consumer optionsConfigurator; + + TestCommand(Consumer optionsConfigurator) { + this.optionsConfigurator = optionsConfigurator; + } + + @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) { + throw new UnsupportedOperationException(); + } + + @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; + } + } + + @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 SettingsProvider settings; + + private final UsageValidator validator = new UsageValidator(); + + private MessageCommandContext createContext(String messageContent) { + 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); + when(message.getMentions()).thenReturn(mentions); + when(mentions.getRoles()).thenReturn(Collections.emptyList()); + when(mentions.getUsers()).thenReturn(Collections.emptyList()); + when(mentions.getMembers()).thenReturn(Collections.emptyList()); + when(mentions.getChannels()).thenReturn(Collections.emptyList()); + when(settings.getPrefix()).thenReturn("$"); + + return spy(new MessageCommandContext(event, settings)); + } + + private CommandContainer register(MessageCommandContext context, Consumer optionsConfig) { + Command command = new TestCommand(optionsConfig); + context.setCommand(command); + + return new CommandContainer(command); + } + + private void assertReplyContains(MessageCommandContext context, String expected) { + ArgumentCaptor captor = ArgumentCaptor.forClass(MessageCreateData.class); + verify(context).reply(captor.capture(), anyBoolean(), anyList(), any()); + String content = captor.getValue().getContent(); + + assertNotNull(content); + assertTrue(content.contains(expected), "Expected reply to contain '" + expected + "' but got '" + content + "'"); + } + + // ===== Subcommands ===== + + @Test + void shouldResolveSubcommandAndPassItsArgumentsToTheContext() { + MessageCommandContext context = createContext("$test sub1 hello world"); + CommandContainer container = register(context, builder -> builder.addSubcommands( + new SubcommandData("sub1", "sub1").addOptions( + new OptionData(STRING, "arg1", "First argument", true), + new OptionData(STRING, "arg2", "Second argument", true) + ) + )); + + assertTrue(validator.validate(context, container)); + assertEquals("sub1", context.getSubcommand()); + assertNull(context.getSubcommandGroup()); + assertEquals("hello", context.getOption("arg1", CommandOption::getAsString)); + assertEquals("world", context.getOption("arg2", CommandOption::getAsString)); + } + + @Test + void shouldRejectUnknownSubcommand() { + MessageCommandContext context = createContext("$test hello world"); + CommandContainer container = register(context, builder -> builder.addSubcommands( + new SubcommandData("sub1", "sub1").addOptions( + new OptionData(STRING, "arg1", "First argument", true) + ) + )); + + assertFalse(validator.validate(context, container)); + assertReplyContains(context, "unknown subcommand"); + } + + @Test + void shouldRejectMissingSubcommand() { + MessageCommandContext context = createContext("$test"); + CommandContainer container = register(context, builder -> builder.addSubcommands( + new SubcommandData("sub1", "sub1") + )); + + assertFalse(validator.validate(context, container)); + assertReplyContains(context, "missing subcommand"); + } + + // ===== Subcommand groups ===== + + @Test + void shouldResolveSubcommandGroupAndPassItsArgumentsToTheContext() { + MessageCommandContext context = createContext("$test group1 sub1 hello world"); + CommandContainer container = register(context, 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) + ) + ) + )); + + assertTrue(validator.validate(context, container)); + assertEquals("group1", context.getSubcommandGroup()); + assertEquals("sub1", context.getSubcommand()); + assertEquals("hello", context.getOption("arg1", CommandOption::getAsString)); + assertEquals("world", context.getOption("arg2", CommandOption::getAsString)); + } + + @Test + void shouldRejectUnknownSubcommandGroup() { + MessageCommandContext context = createContext("$test sub1 hello world"); + CommandContainer container = register(context, builder -> builder.addSubcommandGroups( + new SubcommandGroupData("group1", "group1").addSubcommands( + new SubcommandData("sub1", "sub1").addOptions( + new OptionData(STRING, "arg1", "First argument", true) + ) + ) + )); + + assertFalse(validator.validate(context, container)); + assertReplyContains(context, "unknown subcommand group"); + } + + @Test + void shouldRejectMissingSubcommandGroup() { + MessageCommandContext context = createContext("$test"); + CommandContainer container = register(context, builder -> builder.addSubcommandGroups( + new SubcommandGroupData("group1", "group1").addSubcommands( + new SubcommandData("sub1", "sub1") + ) + )); + + assertFalse(validator.validate(context, container)); + assertReplyContains(context, "missing subcommand group"); + } + + @Test + void shouldRejectGroupWithoutSubcommand() { + MessageCommandContext context = createContext("$test group1"); + CommandContainer container = register(context, builder -> builder.addSubcommandGroups( + new SubcommandGroupData("group1", "group1").addSubcommands( + new SubcommandData("sub1", "sub1") + ) + )); + + assertFalse(validator.validate(context, container)); + assertReplyContains(context, "missing subcommand for command group"); + } + + @Test + void shouldRejectUnknownSubcommandInsideGroup() { + MessageCommandContext context = createContext("$test group1 nope"); + CommandContainer container = register(context, builder -> builder.addSubcommandGroups( + new SubcommandGroupData("group1", "group1").addSubcommands( + new SubcommandData("sub1", "sub1") + ) + )); + + assertFalse(validator.validate(context, container)); + assertReplyContains(context, "unknown subcommand in command group"); + } + + // ===== Argument count ===== + + @Test + void shouldRejectWhenFewerArgumentsThanRequiredOptions() { + MessageCommandContext context = createContext("$test"); + CommandContainer container = register(context, builder -> builder.addOptions( + new OptionData(STRING, "arg1", "First argument", true), + new OptionData(STRING, "arg2", "Second argument", true) + )); + + assertFalse(validator.validate(context, container)); + assertReplyContains(context, "missing one or more required arguments"); + } + + @Test + void shouldAcceptAndParseWhenEnoughArgumentsAreProvided() { + MessageCommandContext context = createContext("$test hello world"); + CommandContainer container = register(context, builder -> builder.addOptions( + new OptionData(STRING, "arg1", "First argument", true), + new OptionData(STRING, "arg2", "Second argument", true) + )); + + assertTrue(validator.validate(context, container)); + verify(context, never()).reply(any(MessageCreateData.class), anyBoolean(), anyList(), any()); + assertEquals("hello", context.getOption("arg1", CommandOption::getAsString)); + assertEquals("world", context.getOption("arg2", CommandOption::getAsString)); + } + + @Test + void shouldAcceptCommandWithoutAnyDeclaredUsage() { + MessageCommandContext context = createContext("$test"); + CommandContainer container = register(context, _ -> {}); + + assertTrue(validator.validate(context, container)); + assertNull(context.getSubcommand()); + assertNull(context.getSubcommandGroup()); + } +}