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:
anthorob committed 2026-09-26 10:47:21 -04:00
1 parent ffc15e8044
commit 35fc415d91
4 files changed
+1558 -1159

No files matched your search

@@ -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.CommandContext;
import me.bobert.netneutrality.core.context.MessageContext; import me.bobert.netneutrality.core.context.MessageContext;
import me.bobert.netneutrality.utils.SettingsProvider; 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.Member;
import net.dv8tion.jda.api.entities.Mentions; import net.dv8tion.jda.api.entities.Mentions;
import net.dv8tion.jda.api.entities.Message; 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 net.dv8tion.jda.api.interactions.commands.build.OptionData;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.EnumSet;
import java.util.List; import java.util.List;
import java.util.Set; import java.util.Set;
import java.util.StringJoiner; import java.util.StringJoiner;
import java.util.regex.Pattern;
public class MessageCommandContext extends CommandContext implements MessageContext { 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 MessageReceivedEvent event;
private final Message message; private final Message message;
@@ -61,7 +71,6 @@ public class MessageCommandContext extends CommandContext implements MessageCont
@Override @Override
public List<CommandOption> parseOptions(String[] args, List<OptionData> commandOptions) { public List<CommandOption> parseOptions(String[] args, List<OptionData> commandOptions) {
List<CommandOption> options = new ArrayList<>(); List<CommandOption> options = new ArrayList<>();
int required = commandOptions.stream().filter(OptionData::isRequired).toList().size();
Mentions mentions = message.getMentions(); Mentions mentions = message.getMentions();
int roleMentionIndex = 0; int roleMentionIndex = 0;
int userMentionIndex = 0; int userMentionIndex = 0;
@@ -69,239 +78,251 @@ public class MessageCommandContext extends CommandContext implements MessageCont
int channelMentionIndex = 0; int channelMentionIndex = 0;
int index = 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(); OptionType type = data.getType();
Set<ChannelType> requiredChannelTypes = data.getChannelTypes(); Set<ChannelType> requiredChannelTypes = data.getChannelTypes();
List<Command.Choice> choices = data.getChoices(); List<Command.Choice> choices = data.getChoices();
int position = index + 1;
Object value = null; Object value = null;
try { if (index >= args.length) {
switch (type) { if (data.isRequired()) {
case STRING -> { invalidateContext(data, "missing required argument at index " + position);
int currentOptionIndex = commandOptions.indexOf(data); }
boolean nextOptionIsString = false;
if (currentOptionIndex + 1 < commandOptions.size()) { continue;
OptionData nextOption = commandOptions.get(currentOptionIndex + 1); }
nextOptionIsString = nextOption.getType() == OptionType.STRING;
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(); str = joiner.toString();
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;
} }
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(); if (str.isEmpty()) {
Number min = data.getMinValue(); if (data.isRequired()) {
invalidateContext(data, "missing required argument at index " + position);
if (min != null && num < min.longValue()) {
value = min.longValue();
} else if (max != null && num > max.longValue()) {
value = max.longValue();
} }
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"); 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(); value = Boolean.parseBoolean(str);
Number min = data.getMinValue(); }
value = num; case NUMBER -> {
String str = args[index++];
if (min != null && num < min.doubleValue()) { if (!NUMBER_PATTERN.matcher(str).matches()) {
value = min.doubleValue(); invalidateContext(data, "the argument provided is not a number.");
} else if (max != null && num > max.doubleValue()) { break;
value = max.doubleValue();
}
} }
case ROLE -> {
if (mentions.getRoles().size() > roleMentionIndex) {
Role role = mentions.getRoles().get(roleMentionIndex++);
if (role.getGuild().getId().equals(getGuild().getId())) {
value = role;
}
break; 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));
invalidateContext(data, "provided argument at position " + (index + 1) + " is not a valid role."); 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) { Number min = data.getMinValue();
value = mentions.getUsers().get(userMentionIndex++); Number max = data.getMaxValue();
break;
}
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 (max != null && num > max.doubleValue()) {
} invalidateContext(data, "maximum value for argument is " + max.doubleValue());
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.");
} }
case CHANNEL -> {
if (mentions.getChannels().size() > channelMentionIndex) {
GuildChannel channel = mentions.getChannels().get(channelMentionIndex++);
if (!requiredChannelTypes.isEmpty() && !requiredChannelTypes.contains(channel.getType())) { value = num;
invalidateContext(data, "not a valid channel type at index " + (index + 1) + }
" , valid types are: " + validTypes(requiredChannelTypes)); case ROLE -> {
break; 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 = role;
value = channel; }
break; 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) { case MENTIONABLE -> {
if (options.size() < required) { index++;
invalidateContext(data, "missing required argument at index " + (index + 1)); 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) { value = role;
// This should never happen because required options are validated before calling parseOptions() break;
throw new IllegalArgumentException("Required option " + data.getName() + " was not found at index " + index); }
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) { case CHANNEL -> {
invalidateContext(data, "the argument provided is not a number."); 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) { if (value != null) {
options.add(new MessageCommandOption(data, value)); options.add(new MessageCommandOption(data, value));
} }
index++;
} }
// Check if there are leftover arguments that weren't consumed // Check if there are leftover arguments that weren't consumed
if (index < args.length) { if (index < args.length) {
if (options.size() >= required) { OptionData lastOption = commandOptions.isEmpty() ? LEFTOVER_ARGUMENTS : commandOptions.getLast();
invalidateContext(commandOptions.getLast(), "too many arguments"); invalidateContext(lastOption, "too many arguments");
}
} }
return options; 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) { private String validChoices(List<Command.Choice> choices) {
StringJoiner joiner = new StringJoiner(", "); StringJoiner joiner = new StringJoiner(", ");
for (Command.Choice choice : choices) { for (Command.Choice choice : choices) {
@@ -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());
}
}