From 14253bdf2c785613e9d74cac67edd0041d49c364 Mon Sep 17 00:00:00 2001 From: Jason Penilla <11360596+jpenilla@users.noreply.github.com> Date: Sat, 17 Feb 2024 14:58:56 -0700 Subject: [PATCH] Run round-trip adventure codec tests with JSON, NBT, and Java ops. Use JavaOps for conversions. (#10031) --- patches/server/Adventure.patch | 117 +++++++++------ patches/server/Test-changes.patch | 240 ++++++++++++++++++++++++++++++ 2 files changed, 315 insertions(+), 42 deletions(-) diff --git a/patches/server/Adventure.patch b/patches/server/Adventure.patch index 2b54ea84f4..d56513e1c3 100644 --- a/patches/server/Adventure.patch +++ b/patches/server/Adventure.patch @@ -1684,9 +1684,8 @@ index 0000000000000000000000000000000000000000..00000000000000000000000000000000 +import java.util.function.Function; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.serializer.ComponentSerializer; -+import net.minecraft.nbt.NbtOps; -+import net.minecraft.nbt.Tag; +import net.minecraft.network.chat.ComponentSerialization; ++import net.minecraft.util.JavaOps; + +final class WrapperAwareSerializer implements ComponentSerializer<Component, Component, net.minecraft.network.chat.Component> { + @Override @@ -1694,26 +1693,26 @@ index 0000000000000000000000000000000000000000..00000000000000000000000000000000 + if (input instanceof AdventureComponent) { + return ((AdventureComponent) input).adventure; + } -+ final Tag tag = ComponentSerialization.CODEC.encodeStart(NbtOps.INSTANCE, input) ++ final Object obj = ComponentSerialization.CODEC.encodeStart(JavaOps.INSTANCE, input) + .get().map(Function.identity(), partial -> { + throw new RuntimeException("Failed to encode Minecraft Component: " + input + "; " + partial.message()); + }); -+ final Pair<Component, Tag> converted = AdventureCodecs.COMPONENT_CODEC.decode(NbtOps.INSTANCE, tag) ++ final Pair<Component, Object> converted = AdventureCodecs.COMPONENT_CODEC.decode(JavaOps.INSTANCE, obj) + .get().map(Function.identity(), partial -> { -+ throw new RuntimeException("Failed to decode to adventure Component: " + tag + "; " + partial.message()); ++ throw new RuntimeException("Failed to decode to adventure Component: " + obj + "; " + partial.message()); + }); + return converted.getFirst(); + } + + @Override + public net.minecraft.network.chat.Component serialize(final Component component) { -+ final Tag tag = AdventureCodecs.COMPONENT_CODEC.encodeStart(NbtOps.INSTANCE, component) ++ final Object obj = AdventureCodecs.COMPONENT_CODEC.encodeStart(JavaOps.INSTANCE, component) + .get().map(Function.identity(), partial -> { + throw new RuntimeException("Failed to encode adventure Component: " + component + "; " + partial.message()); + }); -+ final Pair<net.minecraft.network.chat.Component, Tag> converted = ComponentSerialization.CODEC.decode(NbtOps.INSTANCE, tag) ++ final Pair<net.minecraft.network.chat.Component, Object> converted = ComponentSerialization.CODEC.decode(JavaOps.INSTANCE, obj) + .get().map(Function.identity(), partial -> { -+ throw new RuntimeException("Failed to decode to Minecraft Component: " + tag + "; " + partial.message()); ++ throw new RuntimeException("Failed to decode to Minecraft Component: " + obj + "; " + partial.message()); + }); + return converted.getFirst(); + } @@ -5640,7 +5639,14 @@ index 0000000000000000000000000000000000000000..00000000000000000000000000000000 +import com.mojang.datafixers.util.Pair; +import com.mojang.serialization.Codec; +import com.mojang.serialization.DataResult; ++import com.mojang.serialization.DynamicOps; ++import com.mojang.serialization.JsonOps; ++import io.papermc.paper.util.MethodParameterSource; +import java.io.IOException; ++import java.lang.annotation.ElementType; ++import java.lang.annotation.Retention; ++import java.lang.annotation.RetentionPolicy; ++import java.lang.annotation.Target; +import java.util.List; +import java.util.UUID; +import java.util.function.Function; @@ -5663,6 +5669,7 @@ index 0000000000000000000000000000000000000000..00000000000000000000000000000000 +import net.minecraft.nbt.Tag; +import net.minecraft.network.chat.ComponentSerialization; +import net.minecraft.resources.ResourceLocation; ++import net.minecraft.util.JavaOps; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import org.apache.commons.lang3.RandomStringUtils; @@ -5671,6 +5678,7 @@ index 0000000000000000000000000000000000000000..00000000000000000000000000000000 +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.MethodSource; ++import org.junitpioneer.jupiter.cartesian.CartesianTest; + +import static io.papermc.paper.adventure.AdventureCodecs.CLICK_EVENT_CODEC; +import static io.papermc.paper.adventure.AdventureCodecs.COMPONENT_CODEC; @@ -5704,6 +5712,8 @@ index 0000000000000000000000000000000000000000..00000000000000000000000000000000 + +class AdventureCodecsTest extends AbstractTestingBase { + ++ static final String PARAMETERIZED_NAME = "[{index}] {displayName}: {arguments}"; ++ + @Test + void testTextColor() { + final TextColor color = color(0x1d38df); @@ -5731,7 +5741,7 @@ index 0000000000000000000000000000000000000000..00000000000000000000000000000000 + assertEquals(key.asString(), location.toString()); + } + -+ @ParameterizedTest ++ @ParameterizedTest(name = PARAMETERIZED_NAME) + @EnumSource(value = ClickEvent.Action.class, mode = EnumSource.Mode.EXCLUDE, names = {"OPEN_FILE"}) + void testClickEvent(final ClickEvent.Action action) { + final ClickEvent event = ClickEvent.clickEvent(action, RandomStringUtils.randomAlphanumeric(20)); @@ -5794,33 +5804,47 @@ index 0000000000000000000000000000000000000000..00000000000000000000000000000000 + assertEquals(requireNonNull(style.color()).value(), requireNonNull(nms.getColor()).getValue()); + } + -+ @ParameterizedTest -+ @MethodSource({"testStyles"}) -+ void testDirectRoundTripStyle(final Style style) { -+ testDirectRoundTrip(STYLE_MAP_CODEC.codec(), style); ++ @CartesianTest(name = PARAMETERIZED_NAME) ++ void testDirectRoundTripStyle( ++ @MethodParameterSource("dynamicOps") final DynamicOps<?> dynamicOps, ++ @MethodParameterSource("testStyles") final Style style ++ ) { ++ testDirectRoundTrip(dynamicOps, STYLE_MAP_CODEC.codec(), style); + } + -+ @ParameterizedTest -+ @MethodSource({"testStyles"}) -+ void testMinecraftRoundTripStyle(final Style style) { -+ testMinecraftRoundTrip(STYLE_MAP_CODEC.codec(), net.minecraft.network.chat.Style.Serializer.CODEC, style); ++ @CartesianTest(name = PARAMETERIZED_NAME) ++ void testMinecraftRoundTripStyle( ++ @MethodParameterSource("dynamicOps") final DynamicOps<?> dynamicOps, ++ @MethodParameterSource("testStyles") final Style style ++ ) { ++ testMinecraftRoundTrip(dynamicOps, STYLE_MAP_CODEC.codec(), net.minecraft.network.chat.Style.Serializer.CODEC, style); + } + -+ @ParameterizedTest -+ @MethodSource({"testTexts", "testTranslatables", "testKeybinds", "testScores", -+ "testSelectors", "testBlockNbts", "testEntityNbts", "testStorageNbts"}) -+ void testDirectRoundTripComponent(final Component component) { -+ testDirectRoundTrip(COMPONENT_CODEC, component); ++ @CartesianTest(name = PARAMETERIZED_NAME) ++ void testDirectRoundTripComponent( ++ @MethodParameterSource("dynamicOps") final DynamicOps<?> dynamicOps, ++ @TestComponents final Component component ++ ) { ++ testDirectRoundTrip(dynamicOps, COMPONENT_CODEC, component); + } + -+ @ParameterizedTest -+ @MethodSource({"testTexts", "testTranslatables", "testKeybinds", "testScores", -+ "testSelectors", "testBlockNbts", "testEntityNbts", "testStorageNbts"}) -+ void testMinecraftRoundTripComponent(final Component component) { -+ testMinecraftRoundTrip(COMPONENT_CODEC, ComponentSerialization.CODEC, component); ++ @CartesianTest(name = PARAMETERIZED_NAME) ++ void testMinecraftRoundTripComponent( ++ @MethodParameterSource("dynamicOps") final DynamicOps<?> dynamicOps, ++ @TestComponents final Component component ++ ) { ++ testMinecraftRoundTrip(dynamicOps, COMPONENT_CODEC, ComponentSerialization.CODEC, component); + } + -+ @ParameterizedTest ++ static List<DynamicOps<?>> dynamicOps() { ++ return List.of( ++ NbtOps.INSTANCE, ++ JavaOps.INSTANCE, ++ JsonOps.INSTANCE ++ ); ++ } ++ ++ @ParameterizedTest(name = PARAMETERIZED_NAME) + @MethodSource({"invalidData"}) + void invalidThrows(final Tag input) { + assertThrows(RuntimeException.class, () -> { @@ -5831,33 +5855,33 @@ index 0000000000000000000000000000000000000000..00000000000000000000000000000000 + }); + } + -+ static <A> void testDirectRoundTrip(final Codec<A> codec, final A adventure) { -+ final Tag encoded = require( -+ codec.encodeStart(NbtOps.INSTANCE, adventure), ++ static <A, O> void testDirectRoundTrip(final DynamicOps<O> ops, final Codec<A> codec, final A adventure) { ++ final O encoded = require( ++ codec.encodeStart(ops, adventure), + msg -> "Failed to encode " + adventure + ": " + msg + ); -+ final Pair<A, Tag> roundTripResult = require( -+ codec.decode(NbtOps.INSTANCE, encoded), ++ final Pair<A, O> roundTripResult = require( ++ codec.decode(ops, encoded), + msg -> "Failed to decode " + encoded + ": " + msg + ); + assertEquals(adventure, roundTripResult.getFirst()); + } + -+ static <A, M> void testMinecraftRoundTrip(final Codec<A> adventureCodec, final Codec<M> minecraftCodec, final A adventure) { -+ final Tag encoded = require( -+ adventureCodec.encodeStart(NbtOps.INSTANCE, adventure), ++ static <A, M, O> void testMinecraftRoundTrip(final DynamicOps<O> ops, final Codec<A> adventureCodec, final Codec<M> minecraftCodec, final A adventure) { ++ final O encoded = require( ++ adventureCodec.encodeStart(ops, adventure), + msg -> "Failed to encode " + adventure + ": " + msg + ); + final M minecraftResult = require( -+ minecraftCodec.decode(NbtOps.INSTANCE, encoded), ++ minecraftCodec.decode(ops, encoded), + msg -> "Failed to decode to Minecraft: " + encoded + "; " + msg + ).getFirst(); -+ final Tag minecraftReEncoded = require( -+ minecraftCodec.encodeStart(NbtOps.INSTANCE, minecraftResult), ++ final O minecraftReEncoded = require( ++ minecraftCodec.encodeStart(ops, minecraftResult), + msg -> "Failed to re-encode Minecraft: " + minecraftResult + "; " + msg + ); -+ final Pair<A, Tag> roundTripResult = require( -+ adventureCodec.decode(NbtOps.INSTANCE, minecraftReEncoded), ++ final Pair<A, O> roundTripResult = require( ++ adventureCodec.decode(ops, minecraftReEncoded), + msg -> "Failed to decode " + minecraftReEncoded + ": " + msg + ); + assertEquals(adventure, roundTripResult.getFirst()); @@ -5902,6 +5926,15 @@ index 0000000000000000000000000000000000000000..00000000000000000000000000000000 + ); + } + ++ @Retention(RetentionPolicy.RUNTIME) ++ @Target({ElementType.PARAMETER, ElementType.ANNOTATION_TYPE}) ++ @MethodParameterSource({ ++ "testTexts", "testTranslatables", "testKeybinds", "testScores", ++ "testSelectors", "testBlockNbts", "testEntityNbts", "testStorageNbts" ++ }) ++ @interface TestComponents { ++ } ++ + static List<Component> testTexts() { + return List.of( + Component.empty(), @@ -5938,7 +5971,7 @@ index 0000000000000000000000000000000000000000..00000000000000000000000000000000 + .key("thisIsA") + .fallback("This is a test.") + .build(), -+ translatable(key, numeric(5), text("HEY")), // boolean doesn't work in vanilla, can't test here ++ translatable(key, numeric(Integer.MAX_VALUE), text("HEY")), // boolean doesn't work in vanilla, can't test here + translatable( + key, + text().content(name) diff --git a/patches/server/Test-changes.patch b/patches/server/Test-changes.patch index 09f135a330..305b3e8ec0 100644 --- a/patches/server/Test-changes.patch +++ b/patches/server/Test-changes.patch @@ -8,6 +8,14 @@ diff --git a/build.gradle.kts b/build.gradle.kts index 0000000000000000000000000000000000000000..0000000000000000000000000000000000000000 100644 --- a/build.gradle.kts +++ b/build.gradle.kts +@@ -0,0 +0,0 @@ dependencies { + testImplementation("org.junit.jupiter:junit-jupiter:5.10.0") + testImplementation("org.hamcrest:hamcrest:2.2") + testImplementation("org.mockito:mockito-core:5.5.0") ++ testImplementation("org.junit-pioneer:junit-pioneer:2.2.0") // Paper - CartesianTest + } + + val craftbukkitPackageVersion = "1_20_R3" // Paper @@ -0,0 +0,0 @@ tasks.compileJava { options.setIncremental(false) } @@ -97,6 +105,238 @@ index 0000000000000000000000000000000000000000..00000000000000000000000000000000 + return Collections.emptySet(); + } +} +diff --git a/src/test/java/io/papermc/paper/util/MethodParameterProvider.java b/src/test/java/io/papermc/paper/util/MethodParameterProvider.java +new file mode 100644 +index 0000000000000000000000000000000000000000..0000000000000000000000000000000000000000 +--- /dev/null ++++ b/src/test/java/io/papermc/paper/util/MethodParameterProvider.java +@@ -0,0 +0,0 @@ ++/* ++ * Copyright 2015-2023 the original author or authors of https://github.com/junit-team/junit5/blob/6593317c15fb556febbde11914fa7afe00abf8cd/junit-jupiter-params/src/main/java/org/junit/jupiter/params/provider/MethodArgumentsProvider.java ++ * ++ * All rights reserved. This program and the accompanying materials are ++ * made available under the terms of the Eclipse Public License v2.0 which ++ * accompanies this distribution and is available at ++ * ++ * https://www.eclipse.org/legal/epl-v20.html ++ */ ++ ++package io.papermc.paper.util; ++ ++import java.lang.reflect.Method; ++import java.lang.reflect.Parameter; ++import java.util.List; ++import java.util.function.Predicate; ++import java.util.stream.Stream; ++import org.junit.jupiter.api.Test; ++import org.junit.jupiter.api.TestFactory; ++import org.junit.jupiter.api.TestTemplate; ++import org.junit.jupiter.api.extension.ExtensionContext; ++import org.junit.jupiter.params.support.AnnotationConsumer; ++import org.junit.platform.commons.JUnitException; ++import org.junit.platform.commons.PreconditionViolationException; ++import org.junit.platform.commons.util.ClassLoaderUtils; ++import org.junit.platform.commons.util.CollectionUtils; ++import org.junit.platform.commons.util.Preconditions; ++import org.junit.platform.commons.util.ReflectionUtils; ++import org.junit.platform.commons.util.StringUtils; ++import org.junitpioneer.jupiter.cartesian.CartesianParameterArgumentsProvider; ++ ++import static java.lang.String.format; ++import static java.util.Arrays.stream; ++import static java.util.stream.Collectors.toList; ++import static org.junit.platform.commons.util.AnnotationUtils.isAnnotated; ++import static org.junit.platform.commons.util.CollectionUtils.isConvertibleToStream; ++ ++public class MethodParameterProvider implements CartesianParameterArgumentsProvider<Object>, AnnotationConsumer<MethodParameterSource> { ++ private MethodParameterSource source; ++ ++ MethodParameterProvider() { ++ } ++ ++ @Override ++ public void accept(final MethodParameterSource source) { ++ this.source = source; ++ } ++ ++ @Override ++ public Stream<Object> provideArguments(ExtensionContext context, Parameter parameter) { ++ return this.provideArguments(context, this.source); ++ } ++ ++ // Below is mostly copied from MethodArgumentsProvider ++ ++ private static final Predicate<Method> isFactoryMethod = // ++ method -> isConvertibleToStream(method.getReturnType()) && !isTestMethod(method); ++ ++ protected Stream<Object> provideArguments(ExtensionContext context, MethodParameterSource methodSource) { ++ Class<?> testClass = context.getRequiredTestClass(); ++ Method testMethod = context.getRequiredTestMethod(); ++ Object testInstance = context.getTestInstance().orElse(null); ++ String[] methodNames = methodSource.value(); ++ // @formatter:off ++ return stream(methodNames) ++ .map(factoryMethodName -> findFactoryMethod(testClass, testMethod, factoryMethodName)) ++ .map(factoryMethod -> validateFactoryMethod(factoryMethod, testInstance)) ++ .map(factoryMethod -> context.getExecutableInvoker().invoke(factoryMethod, testInstance)) ++ .flatMap(CollectionUtils::toStream); ++ // @formatter:on ++ } ++ ++ private static Method findFactoryMethod(Class<?> testClass, Method testMethod, String factoryMethodName) { ++ String originalFactoryMethodName = factoryMethodName; ++ ++ // If the user did not provide a factory method name, find a "default" local ++ // factory method with the same name as the parameterized test method. ++ if (StringUtils.isBlank(factoryMethodName)) { ++ factoryMethodName = testMethod.getName(); ++ return findFactoryMethodBySimpleName(testClass, testMethod, factoryMethodName); ++ } ++ ++ // Convert local factory method name to fully-qualified method name. ++ if (!looksLikeAFullyQualifiedMethodName(factoryMethodName)) { ++ factoryMethodName = testClass.getName() + "#" + factoryMethodName; ++ } ++ ++ // Find factory method using fully-qualified name. ++ Method factoryMethod = findFactoryMethodByFullyQualifiedName(testClass, testMethod, factoryMethodName); ++ ++ // Ensure factory method has a valid return type and is not a test method. ++ Preconditions.condition(isFactoryMethod.test(factoryMethod), () -> format( ++ "Could not find valid factory method [%s] for test class [%s] but found the following invalid candidate: %s", ++ originalFactoryMethodName, testClass.getName(), factoryMethod)); ++ ++ return factoryMethod; ++ } ++ ++ private static boolean looksLikeAFullyQualifiedMethodName(String factoryMethodName) { ++ if (factoryMethodName.contains("#")) { ++ return true; ++ } ++ int indexOfFirstDot = factoryMethodName.indexOf('.'); ++ if (indexOfFirstDot == -1) { ++ return false; ++ } ++ int indexOfLastOpeningParenthesis = factoryMethodName.lastIndexOf('('); ++ if (indexOfLastOpeningParenthesis > 0) { ++ // Exclude simple/local method names with parameters ++ return indexOfFirstDot < indexOfLastOpeningParenthesis; ++ } ++ // If we get this far, we conclude the supplied factory method name "looks" ++ // like it was intended to be a fully-qualified method name, even if the ++ // syntax is invalid. We do this in order to provide better diagnostics for ++ // the user when a fully-qualified method name is in fact invalid. ++ return true; ++ } ++ ++ // package-private for testing ++ static Method findFactoryMethodByFullyQualifiedName( ++ Class<?> testClass, Method testMethod, ++ String fullyQualifiedMethodName ++ ) { ++ String[] methodParts = ReflectionUtils.parseFullyQualifiedMethodName(fullyQualifiedMethodName); ++ String className = methodParts[0]; ++ String methodName = methodParts[1]; ++ String methodParameters = methodParts[2]; ++ ClassLoader classLoader = ClassLoaderUtils.getClassLoader(testClass); ++ Class<?> clazz = loadRequiredClass(className, classLoader); ++ ++ // Attempt to find an exact match first. ++ Method factoryMethod = ReflectionUtils.findMethod(clazz, methodName, methodParameters).orElse(null); ++ if (factoryMethod != null) { ++ return factoryMethod; ++ } ++ ++ boolean explicitParameterListSpecified = // ++ StringUtils.isNotBlank(methodParameters) || fullyQualifiedMethodName.endsWith("()"); ++ ++ // If we didn't find an exact match but an explicit parameter list was specified, ++ // that's a user configuration error. ++ Preconditions.condition(!explicitParameterListSpecified, ++ () -> format("Could not find factory method [%s(%s)] in class [%s]", methodName, methodParameters, ++ className)); ++ ++ // Otherwise, fall back to the same lenient search semantics that are used ++ // to locate a "default" local factory method. ++ return findFactoryMethodBySimpleName(clazz, testMethod, methodName); ++ } ++ ++ /** ++ * Find the factory method by searching for all methods in the given {@code clazz} ++ * with the desired {@code factoryMethodName} which have return types that can be ++ * converted to a {@link Stream}, ignoring the {@code testMethod} itself as well ++ * as any {@code @Test}, {@code @TestTemplate}, or {@code @TestFactory} methods ++ * with the same name. ++ * ++ * @return the single factory method matching the search criteria ++ * @throws PreconditionViolationException if the factory method was not found or ++ * multiple competing factory methods with the same name were found ++ */ ++ private static Method findFactoryMethodBySimpleName(Class<?> clazz, Method testMethod, String factoryMethodName) { ++ Predicate<Method> isCandidate = candidate -> factoryMethodName.equals(candidate.getName()) ++ && !testMethod.equals(candidate); ++ List<Method> candidates = ReflectionUtils.findMethods(clazz, isCandidate); ++ ++ List<Method> factoryMethods = candidates.stream().filter(isFactoryMethod).collect(toList()); ++ ++ Preconditions.condition(factoryMethods.size() > 0, () -> { ++ // If we didn't find the factory method using the isFactoryMethod Predicate, perhaps ++ // the specified factory method has an invalid return type or is a test method. ++ // In that case, we report the invalid candidates that were found. ++ if (candidates.size() > 0) { ++ return format( ++ "Could not find valid factory method [%s] in class [%s] but found the following invalid candidates: %s", ++ factoryMethodName, clazz.getName(), candidates); ++ } ++ // Otherwise, report that we didn't find anything. ++ return format("Could not find factory method [%s] in class [%s]", factoryMethodName, clazz.getName()); ++ }); ++ Preconditions.condition(factoryMethods.size() == 1, ++ () -> format("%d factory methods named [%s] were found in class [%s]: %s", factoryMethods.size(), ++ factoryMethodName, clazz.getName(), factoryMethods)); ++ return factoryMethods.get(0); ++ } ++ ++ private static boolean isTestMethod(Method candidate) { ++ return isAnnotated(candidate, Test.class) || isAnnotated(candidate, TestTemplate.class) ++ || isAnnotated(candidate, TestFactory.class); ++ } ++ ++ private static Class<?> loadRequiredClass(String className, ClassLoader classLoader) { ++ return ReflectionUtils.tryToLoadClass(className, classLoader).getOrThrow( ++ cause -> new JUnitException(format("Could not load class [%s]", className), cause)); ++ } ++ ++ private static Method validateFactoryMethod(Method factoryMethod, Object testInstance) { ++ Preconditions.condition( ++ factoryMethod.getDeclaringClass().isInstance(testInstance) || ReflectionUtils.isStatic(factoryMethod), ++ () -> format("Method '%s' must be static: local factory methods must be static " ++ + "unless the PER_CLASS @TestInstance lifecycle mode is used; " ++ + "external factory methods must always be static.", ++ factoryMethod.toGenericString())); ++ return factoryMethod; ++ } ++} +diff --git a/src/test/java/io/papermc/paper/util/MethodParameterSource.java b/src/test/java/io/papermc/paper/util/MethodParameterSource.java +new file mode 100644 +index 0000000000000000000000000000000000000000..0000000000000000000000000000000000000000 +--- /dev/null ++++ b/src/test/java/io/papermc/paper/util/MethodParameterSource.java +@@ -0,0 +0,0 @@ ++package io.papermc.paper.util; ++ ++import java.lang.annotation.ElementType; ++import java.lang.annotation.Retention; ++import java.lang.annotation.RetentionPolicy; ++import java.lang.annotation.Target; ++import org.junitpioneer.jupiter.cartesian.CartesianArgumentsSource; ++ ++@Retention(RetentionPolicy.RUNTIME) ++@Target({ElementType.PARAMETER, ElementType.ANNOTATION_TYPE}) ++@CartesianArgumentsSource(MethodParameterProvider.class) ++public @interface MethodParameterSource { ++ String[] value() default {}; ++} diff --git a/src/test/java/org/bukkit/support/DummyServer.java b/src/test/java/org/bukkit/support/DummyServer.java index 0000000000000000000000000000000000000000..0000000000000000000000000000000000000000 100644 --- a/src/test/java/org/bukkit/support/DummyServer.java