Add support for compound arguments for flags
Signed-off-by: Irmo van den Berge <irmo.vandenberge@ziggo.nl>
This commit is contained in:
parent
23c0ad77f9
commit
a978adc79f
3 changed files with 343 additions and 174 deletions
|
|
@ -55,6 +55,7 @@ import java.util.LinkedList;
|
|||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.Queue;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
|
@ -552,6 +553,11 @@ public final class CommandTree<C> {
|
|||
final @NonNull Queue<@NonNull String> commandQueue,
|
||||
final @NonNull Node<@Nullable CommandArgument<C, ?>> child
|
||||
) {
|
||||
/* If argument has no value associated, break out early */
|
||||
if (child.getValue() == null) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
/* When we get in here, we need to treat compound arguments a little differently */
|
||||
if (child.getValue() instanceof CompoundArgument) {
|
||||
@SuppressWarnings("unchecked") final CompoundArgument<?, C, ?> compoundArgument = (CompoundArgument<?, C, ?>) child
|
||||
|
|
@ -566,18 +572,27 @@ public final class CommandTree<C> {
|
|||
commandContext.store("__parsing_argument__", i + 2);
|
||||
}
|
||||
}
|
||||
} else if (child.getValue() instanceof FlagArgument) {
|
||||
/* Remove all but last */
|
||||
while (commandQueue.size() > 1) {
|
||||
commandContext.store(FlagArgument.FLAG_META, commandQueue.remove());
|
||||
} else if (child.getValue().getParser() instanceof FlagArgument.FlagArgumentParser) {
|
||||
|
||||
/*
|
||||
* Use the flag argument parser to deduce what flag is being suggested right now
|
||||
* If empty, then no flag value is being typed, and the different flag options should
|
||||
* be suggested instead.
|
||||
*
|
||||
* Note: the method parseCurrentFlag() will remove all but the last element from
|
||||
* the queue!
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
FlagArgument.FlagArgumentParser<C> parser = (FlagArgument.FlagArgumentParser<C>) child.getValue().getParser();
|
||||
Optional<String> lastFlag = parser.parseCurrentFlag(commandContext, commandQueue);
|
||||
if (lastFlag.isPresent()) {
|
||||
commandContext.store(FlagArgument.FLAG_META, lastFlag.get());
|
||||
}
|
||||
} else if (child.getValue() != null
|
||||
&& GenericTypeReflector.erase(child.getValue().getValueType().getType()).isArray()) {
|
||||
} else if (GenericTypeReflector.erase(child.getValue().getValueType().getType()).isArray()) {
|
||||
while (commandQueue.size() > 1) {
|
||||
commandQueue.remove();
|
||||
}
|
||||
} else if (child.getValue() != null
|
||||
&& commandQueue.size() <= child.getValue().getParser().getRequestedArgumentCount()) {
|
||||
} else if (commandQueue.size() <= child.getValue().getParser().getRequestedArgumentCount()) {
|
||||
for (int i = 0; i < child.getValue().getParser().getRequestedArgumentCount() - 1
|
||||
&& commandQueue.size() > 1; i++) {
|
||||
commandContext.store(
|
||||
|
|
@ -587,7 +602,6 @@ public final class CommandTree<C> {
|
|||
}
|
||||
}
|
||||
|
||||
if (child.getValue() != null) {
|
||||
if (commandQueue.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
} else if (child.isLeaf() && commandQueue.size() < 2) {
|
||||
|
|
@ -637,9 +651,6 @@ public final class CommandTree<C> {
|
|||
return child.getValue().getSuggestionsProvider().apply(commandContext, stringOrEmpty(commandQueue.peek()));
|
||||
}
|
||||
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
private @NonNull String stringOrEmpty(final @Nullable String string) {
|
||||
if (string == null) {
|
||||
return "";
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ import java.util.HashSet;
|
|||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Optional;
|
||||
import java.util.Queue;
|
||||
import java.util.Set;
|
||||
import java.util.function.BiFunction;
|
||||
|
|
@ -104,131 +105,66 @@ public final class FlagArgument<C> extends CommandArgument<C, Object> {
|
|||
}
|
||||
|
||||
@Override
|
||||
@SuppressWarnings({"unchecked", "rawtypes"})
|
||||
public @NonNull ArgumentParseResult<@NonNull Object> parse(
|
||||
final @NonNull CommandContext<@NonNull C> commandContext,
|
||||
final @NonNull Queue<@NonNull String> inputQueue
|
||||
) {
|
||||
/*
|
||||
This argument must necessarily be the last so we can just consume all remaining input. This argument type
|
||||
is similar to a greedy string in that sense. But, we need to keep all flag logic contained to the parser
|
||||
*/
|
||||
final Set<CommandFlag<?>> parsedFlags = new HashSet<>();
|
||||
CommandFlag<?> currentFlag = null;
|
||||
final FlagParser parser = new FlagParser();
|
||||
return parser.parse(commandContext, inputQueue);
|
||||
}
|
||||
|
||||
for (final @NonNull String string : inputQueue) {
|
||||
if (string.startsWith("-") && currentFlag == null) {
|
||||
if (string.startsWith("--")) {
|
||||
final String flagName = string.substring(2);
|
||||
for (final CommandFlag<?> flag : this.flags) {
|
||||
if (flagName.equalsIgnoreCase(flag.getName())) {
|
||||
currentFlag = flag;
|
||||
break;
|
||||
/**
|
||||
* Parse command input to figure out what flag is currently being
|
||||
* typed at the end of the input queue. If no flag value is being
|
||||
* inputed, returns {@link Optional#empty()}.<br>
|
||||
* <br>
|
||||
* Will consume all but the last element from the input queue.
|
||||
*
|
||||
* @param commandContext Command context
|
||||
* @param inputQueue The input queue of arguments
|
||||
* @return current flag being typed, or <i>empty()</i> if none is
|
||||
*/
|
||||
public @NonNull Optional<String> parseCurrentFlag(
|
||||
final @NonNull CommandContext<@NonNull C> commandContext,
|
||||
final @NonNull Queue<@NonNull String> inputQueue
|
||||
) {
|
||||
/* If empty, nothing to do */
|
||||
if (inputQueue.isEmpty()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
/* Before parsing, retrieve the last known input of the queue */
|
||||
String lastInputValue = "";
|
||||
for (String input : inputQueue) {
|
||||
lastInputValue = input;
|
||||
}
|
||||
|
||||
/* Parse, but ignore the result of parsing */
|
||||
final FlagParser parser = new FlagParser();
|
||||
parser.parse(commandContext, inputQueue);
|
||||
|
||||
/*
|
||||
* Remove all but the last element from the command input queue
|
||||
* If the parser parsed the entire queue, restore the last typed
|
||||
* input obtained earlier.
|
||||
*/
|
||||
if (inputQueue.isEmpty()) {
|
||||
inputQueue.add(lastInputValue);
|
||||
} else {
|
||||
final String flagName = string.substring(1);
|
||||
if (flagName.length() > 1) {
|
||||
boolean oneAdded = false;
|
||||
/* This is a multi-alias flag, find all flags that apply */
|
||||
for (final CommandFlag<?> flag : this.flags) {
|
||||
if (flag.getCommandArgument() != null) {
|
||||
continue;
|
||||
}
|
||||
for (final String alias : flag.getAliases()) {
|
||||
if (flagName.toLowerCase(Locale.ENGLISH).contains(alias.toLowerCase(Locale.ENGLISH))) {
|
||||
if (parsedFlags.contains(flag)) {
|
||||
return ArgumentParseResult.failure(new FlagParseException(
|
||||
string,
|
||||
FailureReason.DUPLICATE_FLAG,
|
||||
commandContext
|
||||
));
|
||||
}
|
||||
parsedFlags.add(flag);
|
||||
commandContext.flags().addPresenceFlag(flag);
|
||||
oneAdded = true;
|
||||
break;
|
||||
while (inputQueue.size() > 1) {
|
||||
inputQueue.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
/* We need to parse at least one flag */
|
||||
if (!oneAdded) {
|
||||
return ArgumentParseResult.failure(new FlagParseException(
|
||||
string,
|
||||
FailureReason.NO_FLAG_STARTED,
|
||||
commandContext
|
||||
));
|
||||
}
|
||||
continue;
|
||||
} else {
|
||||
for (final CommandFlag<?> flag : this.flags) {
|
||||
for (final String alias : flag.getAliases()) {
|
||||
if (alias.equalsIgnoreCase(flagName)) {
|
||||
currentFlag = flag;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (currentFlag == null) {
|
||||
return ArgumentParseResult.failure(new FlagParseException(
|
||||
string,
|
||||
FailureReason.UNKNOWN_FLAG,
|
||||
commandContext
|
||||
));
|
||||
} else if (parsedFlags.contains(currentFlag)) {
|
||||
return ArgumentParseResult.failure(new FlagParseException(
|
||||
string,
|
||||
FailureReason.DUPLICATE_FLAG,
|
||||
commandContext
|
||||
));
|
||||
}
|
||||
parsedFlags.add(currentFlag);
|
||||
if (currentFlag.getCommandArgument() == null) {
|
||||
/* It's a presence flag */
|
||||
commandContext.flags().addPresenceFlag(currentFlag);
|
||||
/* We don't want to parse a value for this flag */
|
||||
currentFlag = null;
|
||||
}
|
||||
} else {
|
||||
if (currentFlag == null) {
|
||||
return ArgumentParseResult.failure(new FlagParseException(
|
||||
string,
|
||||
FailureReason.NO_FLAG_STARTED,
|
||||
commandContext
|
||||
));
|
||||
} else {
|
||||
final ArgumentParseResult<?> result =
|
||||
((CommandArgument) currentFlag.getCommandArgument())
|
||||
.getParser()
|
||||
.parse(
|
||||
commandContext,
|
||||
new LinkedList<>(Collections.singletonList(string))
|
||||
);
|
||||
if (result.getFailure().isPresent()) {
|
||||
return ArgumentParseResult.failure(result.getFailure().get());
|
||||
} else if (result.getParsedValue().isPresent()) {
|
||||
final CommandFlag erasedFlag = currentFlag;
|
||||
final Object value = result.getParsedValue().get();
|
||||
commandContext.flags().addValueFlag(erasedFlag, value);
|
||||
currentFlag = null;
|
||||
} else {
|
||||
throw new IllegalStateException("Neither result or value were present. Panicking.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (currentFlag != null) {
|
||||
return ArgumentParseResult.failure(new FlagParseException(
|
||||
currentFlag.getName(),
|
||||
FailureReason.MISSING_ARGUMENT,
|
||||
commandContext
|
||||
));
|
||||
}
|
||||
/* We've consumed everything */
|
||||
inputQueue.clear();
|
||||
return ArgumentParseResult.success(FLAG_PARSE_RESULT_OBJECT);
|
||||
|
||||
/*
|
||||
* Map to name of the flag.
|
||||
*
|
||||
* Note: legacy API made it that FLAG_META stores not the flag name,
|
||||
* but the - or -- prefixed name or alias of the flag(s) instead.
|
||||
* This can be removed in the future.
|
||||
*/
|
||||
//return parser.currentFlagBeingParsed.map(CommandFlag::getName);
|
||||
return parser.currentFlagNameBeingParsed;
|
||||
}
|
||||
|
||||
@Override
|
||||
|
|
@ -342,6 +278,166 @@ public final class FlagArgument<C> extends CommandArgument<C, Object> {
|
|||
return suggestions(commandContext, input);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper class to parse the command input queue into flags
|
||||
* and flag values. On failure the intermediate results
|
||||
* can be obtained, which are used for providing suggestions.
|
||||
*/
|
||||
private class FlagParser {
|
||||
/** The current flag whose value is being parsed */
|
||||
@SuppressWarnings("unused")
|
||||
private Optional<CommandFlag<?>> currentFlagBeingParsed = Optional.empty();
|
||||
/**
|
||||
* The name of the current flag being parsed, can be obsoleted in the future.
|
||||
* This name includes the - or -- prefix.
|
||||
*/
|
||||
private Optional<String> currentFlagNameBeingParsed = Optional.empty();
|
||||
|
||||
@SuppressWarnings({"unchecked", "rawtypes"})
|
||||
public @NonNull ArgumentParseResult<@NonNull Object> parse(
|
||||
final @NonNull CommandContext<@NonNull C> commandContext,
|
||||
final @NonNull Queue<@NonNull String> inputQueue
|
||||
) {
|
||||
/*
|
||||
This argument must necessarily be the last so we can just consume all remaining input. This argument type
|
||||
is similar to a greedy string in that sense. But, we need to keep all flag logic contained to the parser
|
||||
*/
|
||||
final Set<CommandFlag<?>> parsedFlags = new HashSet<>();
|
||||
CommandFlag<?> currentFlag = null;
|
||||
String currentFlagName = null;
|
||||
|
||||
String string;
|
||||
while ((string = inputQueue.peek()) != null) {
|
||||
/* No longer typing the value of the current flag */
|
||||
this.currentFlagBeingParsed = Optional.empty();
|
||||
this.currentFlagNameBeingParsed = Optional.empty();
|
||||
|
||||
/* Parse next flag name to set */
|
||||
if (string.startsWith("-") && currentFlag == null) {
|
||||
/* Remove flag argument from input queue */
|
||||
inputQueue.poll();
|
||||
|
||||
if (string.startsWith("--")) {
|
||||
final String flagName = string.substring(2);
|
||||
for (final CommandFlag<?> flag : FlagArgumentParser.this.flags) {
|
||||
if (flagName.equalsIgnoreCase(flag.getName())) {
|
||||
currentFlag = flag;
|
||||
currentFlagName = string;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
final String flagName = string.substring(1);
|
||||
if (flagName.length() > 1) {
|
||||
boolean oneAdded = false;
|
||||
/* This is a multi-alias flag, find all flags that apply */
|
||||
for (final CommandFlag<?> flag : FlagArgumentParser.this.flags) {
|
||||
if (flag.getCommandArgument() != null) {
|
||||
continue;
|
||||
}
|
||||
for (final String alias : flag.getAliases()) {
|
||||
if (flagName.toLowerCase(Locale.ENGLISH).contains(alias.toLowerCase(Locale.ENGLISH))) {
|
||||
if (parsedFlags.contains(flag)) {
|
||||
return ArgumentParseResult.failure(new FlagParseException(
|
||||
string,
|
||||
FailureReason.DUPLICATE_FLAG,
|
||||
commandContext
|
||||
));
|
||||
}
|
||||
parsedFlags.add(flag);
|
||||
commandContext.flags().addPresenceFlag(flag);
|
||||
oneAdded = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
/* We need to parse at least one flag */
|
||||
if (!oneAdded) {
|
||||
return ArgumentParseResult.failure(new FlagParseException(
|
||||
string,
|
||||
FailureReason.NO_FLAG_STARTED,
|
||||
commandContext
|
||||
));
|
||||
}
|
||||
continue;
|
||||
} else {
|
||||
for (final CommandFlag<?> flag : FlagArgumentParser.this.flags) {
|
||||
for (final String alias : flag.getAliases()) {
|
||||
if (alias.equalsIgnoreCase(flagName)) {
|
||||
currentFlag = flag;
|
||||
currentFlagName = string;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (currentFlag == null) {
|
||||
return ArgumentParseResult.failure(new FlagParseException(
|
||||
string,
|
||||
FailureReason.UNKNOWN_FLAG,
|
||||
commandContext
|
||||
));
|
||||
} else if (parsedFlags.contains(currentFlag)) {
|
||||
return ArgumentParseResult.failure(new FlagParseException(
|
||||
string,
|
||||
FailureReason.DUPLICATE_FLAG,
|
||||
commandContext
|
||||
));
|
||||
}
|
||||
parsedFlags.add(currentFlag);
|
||||
if (currentFlag.getCommandArgument() == null) {
|
||||
/* It's a presence flag */
|
||||
commandContext.flags().addPresenceFlag(currentFlag);
|
||||
/* We don't want to parse a value for this flag */
|
||||
currentFlag = null;
|
||||
}
|
||||
} else {
|
||||
if (currentFlag == null) {
|
||||
return ArgumentParseResult.failure(new FlagParseException(
|
||||
string,
|
||||
FailureReason.NO_FLAG_STARTED,
|
||||
commandContext
|
||||
));
|
||||
} else {
|
||||
/* Mark this flag as the one currently being typed */
|
||||
this.currentFlagBeingParsed = Optional.of(currentFlag);
|
||||
this.currentFlagNameBeingParsed = Optional.of(currentFlagName);
|
||||
|
||||
final ArgumentParseResult<?> result =
|
||||
((CommandArgument) currentFlag.getCommandArgument())
|
||||
.getParser()
|
||||
.parse(
|
||||
commandContext,
|
||||
inputQueue
|
||||
);
|
||||
if (result.getFailure().isPresent()) {
|
||||
return ArgumentParseResult.failure(result.getFailure().get());
|
||||
} else if (result.getParsedValue().isPresent()) {
|
||||
final CommandFlag erasedFlag = currentFlag;
|
||||
final Object value = result.getParsedValue().get();
|
||||
commandContext.flags().addValueFlag(erasedFlag, value);
|
||||
currentFlag = null;
|
||||
} else {
|
||||
throw new IllegalStateException("Neither result or value were present. Panicking.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Queue ran out while a flag argument needs to be parsed still */
|
||||
if (currentFlag != null) {
|
||||
return ArgumentParseResult.failure(new FlagParseException(
|
||||
currentFlag.getName(),
|
||||
FailureReason.MISSING_ARGUMENT,
|
||||
commandContext
|
||||
));
|
||||
}
|
||||
|
||||
/* We've consumed everything */
|
||||
return ArgumentParseResult.success(FLAG_PARSE_RESULT_OBJECT);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -23,11 +23,14 @@
|
|||
//
|
||||
package cloud.commandframework;
|
||||
|
||||
import cloud.commandframework.arguments.compound.ArgumentTriplet;
|
||||
import cloud.commandframework.arguments.standard.BooleanArgument;
|
||||
import cloud.commandframework.arguments.standard.EnumArgument;
|
||||
import cloud.commandframework.arguments.standard.IntegerArgument;
|
||||
import cloud.commandframework.arguments.standard.StringArgument;
|
||||
import cloud.commandframework.types.tuples.Pair;
|
||||
import cloud.commandframework.types.tuples.Triplet;
|
||||
|
||||
import org.junit.jupiter.api.Assertions;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
|
@ -87,6 +90,16 @@ public class CommandSuggestionsTest {
|
|||
.flag(manager.flagBuilder("third").withAliases("t"))
|
||||
.build());
|
||||
|
||||
manager.command(manager.commandBuilder("flags3")
|
||||
.flag(manager.flagBuilder("compound")
|
||||
.withArgument(ArgumentTriplet.of(manager, "triplet",
|
||||
Triplet.of("x", "y", "z"),
|
||||
Triplet.of(int.class, int.class, int.class))
|
||||
.simple()))
|
||||
.flag(manager.flagBuilder("presence").withAliases("p"))
|
||||
.flag(manager.flagBuilder("single")
|
||||
.withArgument(IntegerArgument.of("value"))));
|
||||
|
||||
manager.command(manager.commandBuilder("numbers").argument(IntegerArgument.of("num")));
|
||||
manager.command(manager.commandBuilder("numberswithfollowingargument").argument(IntegerArgument.of("num"))
|
||||
.argument(BooleanArgument.of("another_argument")));
|
||||
|
|
@ -211,6 +224,55 @@ public class CommandSuggestionsTest {
|
|||
final String input6 = "flags2 -f -s";
|
||||
final List<String> suggestions6 = manager.suggest(new TestCommandSender(), input6);
|
||||
Assertions.assertEquals(Arrays.asList("-st", "-s"), suggestions6);
|
||||
|
||||
/* When an incorrect flag is specified, should resolve to listing flags */
|
||||
final String input7 = "flags2 --invalid ";
|
||||
final List<String> suggestions7 = manager.suggest(new TestCommandSender(), input7);
|
||||
Assertions.assertEquals(Arrays.asList("--first", "--second", "--third", "-f", "-s", "-t"), suggestions7);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCompoundFlags() {
|
||||
final String input = "flags3 ";
|
||||
final List<String> suggestions = manager.suggest(new TestCommandSender(), input);
|
||||
Assertions.assertEquals(Arrays.asList("--compound", "--presence", "--single", "-p"), suggestions);
|
||||
|
||||
final String input2 = "flags3 --c";
|
||||
final List<String> suggestions2 = manager.suggest(new TestCommandSender(), input2);
|
||||
Assertions.assertEquals(Arrays.asList("--compound"), suggestions2);
|
||||
|
||||
final String input3 = "flags3 --compound ";
|
||||
final List<String> suggestions3 = manager.suggest(new TestCommandSender(), input3);
|
||||
Assertions.assertEquals(Arrays.asList("0", "1", "2", "3", "4", "5", "6", "7", "8", "9"), suggestions3);
|
||||
|
||||
final String input4 = "flags3 --compound 1";
|
||||
final List<String> suggestions4 = manager.suggest(new TestCommandSender(), input4);
|
||||
Assertions.assertEquals(Arrays.asList("1", "10", "11", "12", "13", "14", "15", "16", "17", "18", "19"), suggestions4);
|
||||
|
||||
final String input5 = "flags3 --compound 22 ";
|
||||
final List<String> suggestions5 = manager.suggest(new TestCommandSender(), input5);
|
||||
Assertions.assertEquals(Arrays.asList("0", "1", "2", "3", "4", "5", "6", "7", "8", "9"), suggestions5);
|
||||
|
||||
final String input6 = "flags3 --compound 22 1";
|
||||
final List<String> suggestions6 = manager.suggest(new TestCommandSender(), input6);
|
||||
Assertions.assertEquals(Arrays.asList("1", "10", "11", "12", "13", "14", "15", "16", "17", "18", "19"), suggestions6);
|
||||
|
||||
/* We've typed compound already, so that flag should be omitted from the suggestions */
|
||||
final String input7 = "flags3 --compound 22 33 44 ";
|
||||
final List<String> suggestions7 = manager.suggest(new TestCommandSender(), input7);
|
||||
Assertions.assertEquals(Arrays.asList("--presence", "--single", "-p"), suggestions7);
|
||||
|
||||
final String input8 = "flags3 --compound 22 33 44 --pres";
|
||||
final List<String> suggestions8 = manager.suggest(new TestCommandSender(), input8);
|
||||
Assertions.assertEquals(Arrays.asList("--presence"), suggestions8);
|
||||
|
||||
final String input9 = "flags3 --compound 22 33 44 --presence ";
|
||||
final List<String> suggestions9 = manager.suggest(new TestCommandSender(), input9);
|
||||
Assertions.assertEquals(Arrays.asList("--single"), suggestions9);
|
||||
|
||||
final String input10 = "flags3 --compound 22 33 44 --single ";
|
||||
final List<String> suggestions10 = manager.suggest(new TestCommandSender(), input10);
|
||||
Assertions.assertEquals(Arrays.asList("0", "1", "2", "3", "4", "5", "6", "7", "8", "9"), suggestions10);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue