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()); + } +}