Improve argument parsing in MessageCommandContext
Revised argument parsing to handle edge cases like text concatenation, invalid numeric input, and mention handling more robustly. Introduced utility methods for validating argument input and resolving mentions to improve readability and maintainability. Added tests (`UsageValidatorTest`) to verify proper execution of subcommand and argument validation.
This commit is contained in:
1 parent
ffc15e8044
commit
35fc415d91
4 files changed
+1558
-1159
No files matched your search
+211
-190
@@ -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<OptionType> 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<CommandOption> parseOptions(String[] args, List<OptionData> commandOptions) {
|
||||
List<CommandOption> 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<ChannelType> requiredChannelTypes = data.getChannelTypes();
|
||||
List<Command.Choice> 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<Command.Choice> 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<Command.Choice> 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<Command.Choice> 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<OptionData> commandOptions, int currentOptionIndex) {
|
||||
if (NUMBER_PATTERN.matcher(arg).matches()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
List<OptionData> 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<Command.Choice> choices) {
|
||||
StringJoiner joiner = new StringJoiner(", ");
|
||||
for (Command.Choice choice : choices) {
|
||||
|
||||
+831
@@ -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.
|
||||
* <p>
|
||||
* 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<CommandOption> 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<CommandOption> 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<OptionData, String> invalid : context.getInvalidOptions()) {
|
||||
if (invalid.getKey().getName().equals(optionName)) {
|
||||
return invalid.getValue();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private void assertRejected(MessageCommandContext context, List<CommandOption> 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<CommandOption> options = parse(context, "", new OptionData(STRING, "arg1", "First", true));
|
||||
|
||||
assertRejected(context, options, "arg1", "missing required argument");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldParseRequiredAndOptionalArguments() {
|
||||
MessageCommandContext context = createContext();
|
||||
|
||||
List<CommandOption> 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<CommandOption> 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<CommandOption> 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<CommandOption> 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<CommandOption> 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<CommandOption> 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<CommandOption> 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<CommandOption> 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<CommandOption> 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<CommandOption> 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<CommandOption> 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<CommandOption> 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<CommandOption> 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<CommandOption> 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<CommandOption> 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<CommandOption> 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<CommandOption> 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<CommandOption> 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<CommandOption> 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<CommandOption> 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<CommandOption> 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<CommandOption> 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<CommandOption> 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<CommandOption> 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<CommandOption> 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<CommandOption> 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<CommandOption> 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<CommandOption> 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<CommandOption> 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<CommandOption> 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<CommandOption> 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<CommandOption> 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<CommandOption> options = parse(context, "true", new OptionData(BOOLEAN, "flag", "Flag", true));
|
||||
|
||||
assertEquals(true, option(options, "flag").getAsBoolean());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldParseFalse() {
|
||||
MessageCommandContext context = createContext();
|
||||
|
||||
List<CommandOption> options = parse(context, "false", new OptionData(BOOLEAN, "flag", "Flag", true));
|
||||
|
||||
assertEquals(false, option(options, "flag").getAsBoolean());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectArgumentThatIsNotABoolean() {
|
||||
MessageCommandContext context = createContext();
|
||||
|
||||
List<CommandOption> 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<CommandOption> 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<CommandOption> options = parse(context, "42", new OptionData(NUMBER, "value", "Value", true));
|
||||
|
||||
assertEquals(42.0, option(options, "value").getAsDouble());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldParseNegativeDouble() {
|
||||
MessageCommandContext context = createContext();
|
||||
|
||||
List<CommandOption> 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<CommandOption> options = parse(context, "notanumber", new OptionData(NUMBER, "value", "Value", true));
|
||||
|
||||
assertRejected(context, options, "value", "not a number");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectDoubleAboveMaximum() {
|
||||
MessageCommandContext context = createContext();
|
||||
|
||||
List<CommandOption> 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<CommandOption> 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<CommandOption> 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<CommandOption> 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<CommandOption> 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<CommandOption> 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<CommandOption> 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<CommandOption> 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<CommandOption> 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<CommandOption> 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<CommandOption> 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<CommandOption> 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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}.
|
||||
* <p>
|
||||
* 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<CommandOptionsBuilder> optionsConfigurator;
|
||||
|
||||
TestCommand(Consumer<CommandOptionsBuilder> 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<CommandOptionsBuilder> optionsConfig) {
|
||||
Command command = new TestCommand(optionsConfig);
|
||||
context.setCommand(command);
|
||||
|
||||
return new CommandContainer(command);
|
||||
}
|
||||
|
||||
private void assertReplyContains(MessageCommandContext context, String expected) {
|
||||
ArgumentCaptor<MessageCreateData> 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());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user