Files
Paper/paper-server/src/test/java/io/papermc/paper/adventure/AdventureCodecsTest.java
2024-12-27 22:04:17 +01:00

408 lines
19 KiB
Java

package io.papermc.paper.adventure;
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.JavaOps;
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;
import java.util.stream.Stream;
import net.kyori.adventure.key.Key;
import net.kyori.adventure.text.BlockNBTComponent;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.event.ClickEvent;
import net.kyori.adventure.text.event.HoverEvent;
import net.kyori.adventure.text.format.NamedTextColor;
import net.kyori.adventure.text.format.ShadowColor;
import net.kyori.adventure.text.format.Style;
import net.kyori.adventure.text.format.TextColor;
import net.kyori.adventure.text.format.TextDecoration;
import net.minecraft.core.component.DataComponents;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.nbt.ByteTag;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.nbt.IntTag;
import net.minecraft.nbt.ListTag;
import net.minecraft.nbt.NbtOps;
import net.minecraft.nbt.Tag;
import net.minecraft.network.chat.ComponentSerialization;
import net.minecraft.resources.RegistryOps;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
import org.apache.commons.lang3.RandomStringUtils;
import org.bukkit.support.RegistryHelper;
import org.bukkit.support.environment.VanillaFeature;
import org.junit.jupiter.api.Test;
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;
import static io.papermc.paper.adventure.AdventureCodecs.HOVER_EVENT_CODEC;
import static io.papermc.paper.adventure.AdventureCodecs.KEY_CODEC;
import static io.papermc.paper.adventure.AdventureCodecs.STYLE_MAP_CODEC;
import static io.papermc.paper.adventure.AdventureCodecs.TEXT_COLOR_CODEC;
import static java.util.Objects.requireNonNull;
import static net.kyori.adventure.key.Key.key;
import static net.kyori.adventure.text.Component.blockNBT;
import static net.kyori.adventure.text.Component.entityNBT;
import static net.kyori.adventure.text.Component.keybind;
import static net.kyori.adventure.text.Component.score;
import static net.kyori.adventure.text.Component.selector;
import static net.kyori.adventure.text.Component.storageNBT;
import static net.kyori.adventure.text.Component.text;
import static net.kyori.adventure.text.Component.translatable;
import static net.kyori.adventure.text.TranslationArgument.numeric;
import static net.kyori.adventure.text.event.ClickEvent.openUrl;
import static net.kyori.adventure.text.event.ClickEvent.suggestCommand;
import static net.kyori.adventure.text.event.HoverEvent.showEntity;
import static net.kyori.adventure.text.format.Style.style;
import static net.kyori.adventure.text.format.TextColor.color;
import static net.kyori.adventure.text.minimessage.MiniMessage.miniMessage;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
@VanillaFeature
class AdventureCodecsTest {
static final String PARAMETERIZED_NAME = "[{index}] {displayName}: {arguments}";
@Test
void testTextColor() {
final TextColor color = color(0x1d38df);
final Tag result = TEXT_COLOR_CODEC.encodeStart(NbtOps.INSTANCE, color).result().orElseThrow();
assertEquals(color.asHexString(), result.getAsString());
final net.minecraft.network.chat.TextColor nms = net.minecraft.network.chat.TextColor.CODEC.decode(NbtOps.INSTANCE, result).result().orElseThrow().getFirst();
assertEquals(color.value(), nms.getValue());
}
@Test
void testNamedTextColor() {
final NamedTextColor color = NamedTextColor.BLUE;
final Tag result = TEXT_COLOR_CODEC.encodeStart(NbtOps.INSTANCE, color).result().orElseThrow();
assertEquals(NamedTextColor.NAMES.keyOrThrow(color), result.getAsString());
final net.minecraft.network.chat.TextColor nms = net.minecraft.network.chat.TextColor.CODEC.decode(NbtOps.INSTANCE, result).result().orElseThrow().getFirst();
assertEquals(color.value(), nms.getValue());
}
@Test
void testKey() {
final Key key = key("hello", "there");
final Tag result = KEY_CODEC.encodeStart(NbtOps.INSTANCE, key).result().orElseThrow();
assertEquals(key.asString(), result.getAsString());
final ResourceLocation location = ResourceLocation.CODEC.decode(NbtOps.INSTANCE, result).result().orElseThrow().getFirst();
assertEquals(key.asString(), location.toString());
}
@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));
final Tag result = CLICK_EVENT_CODEC.encodeStart(NbtOps.INSTANCE, event).result().orElseThrow();
final net.minecraft.network.chat.ClickEvent nms = net.minecraft.network.chat.ClickEvent.CODEC.decode(NbtOps.INSTANCE, result).result().orElseThrow().getFirst();
assertEquals(event.action().toString(), nms.getAction().getSerializedName());
assertEquals(event.value(), nms.getValue());
}
@Test
void testShowTextHoverEvent() {
final HoverEvent<Component> hoverEvent = HoverEvent.hoverEvent(HoverEvent.Action.SHOW_TEXT, text("hello"));
final Tag result = HOVER_EVENT_CODEC.encodeStart(NbtOps.INSTANCE, hoverEvent).result().orElseThrow();
final net.minecraft.network.chat.HoverEvent nms = net.minecraft.network.chat.HoverEvent.CODEC.decode(NbtOps.INSTANCE, result).result().orElseThrow().getFirst();
assertEquals(hoverEvent.action().toString(), nms.getAction().getSerializedName());
assertNotNull(nms.getValue(net.minecraft.network.chat.HoverEvent.Action.SHOW_TEXT));
}
@Test
void testShowItemHoverEvent() throws IOException {
final ItemStack stack = new ItemStack(Items.PUMPKIN, 3);
stack.set(DataComponents.CUSTOM_NAME, net.minecraft.network.chat.Component.literal("NAME"));
final HoverEvent<HoverEvent.ShowItem> hoverEvent = HoverEvent.showItem(key("minecraft:pumpkin"), 3, PaperAdventure.asAdventure(stack.getComponentsPatch()));
final Tag result = HOVER_EVENT_CODEC.encodeStart(NbtOps.INSTANCE, hoverEvent).result().orElseThrow();
final DataResult<Pair<net.minecraft.network.chat.HoverEvent, Tag>> dataResult = net.minecraft.network.chat.HoverEvent.CODEC.decode(NbtOps.INSTANCE, result);
assertTrue(dataResult.result().isPresent(), () -> dataResult + " result is not present");
final net.minecraft.network.chat.HoverEvent nms = dataResult.result().orElseThrow().getFirst();
assertEquals(hoverEvent.action().toString(), nms.getAction().getSerializedName());
final net.minecraft.network.chat.HoverEvent.ItemStackInfo value = nms.getValue(net.minecraft.network.chat.HoverEvent.Action.SHOW_ITEM);
assertNotNull(value);
assertEquals(hoverEvent.value().count(), value.count);
assertEquals(hoverEvent.value().item().asString(), value.item.unwrapKey().orElseThrow().location().toString());
assertEquals(stack.getComponentsPatch(), value.components);
}
@Test
void testShowEntityHoverEvent() {
UUID uuid = UUID.randomUUID();
final HoverEvent<HoverEvent.ShowEntity> hoverEvent = showEntity(key("minecraft:wolf"), uuid, text("NAME"));
final Tag result = HOVER_EVENT_CODEC.encodeStart(NbtOps.INSTANCE, hoverEvent).result().orElseThrow();
final DataResult<Pair<net.minecraft.network.chat.HoverEvent, Tag>> dataResult = net.minecraft.network.chat.HoverEvent.CODEC.decode(NbtOps.INSTANCE, result);
assertTrue(dataResult.result().isPresent(), () -> dataResult + " result is not present");
final net.minecraft.network.chat.HoverEvent nms = dataResult.result().orElseThrow().getFirst();
assertEquals(hoverEvent.action().toString(), nms.getAction().getSerializedName());
final net.minecraft.network.chat.HoverEvent.EntityTooltipInfo value = nms.getValue(net.minecraft.network.chat.HoverEvent.Action.SHOW_ENTITY);
assertNotNull(value);
assertEquals(hoverEvent.value().type().asString(), BuiltInRegistries.ENTITY_TYPE.getKey(value.type).toString());
assertEquals(hoverEvent.value().id(), value.id);
assertEquals("NAME", value.name.orElseThrow().getString());
}
@Test
void testSimpleStyle() {
final Style style = style().decorate(TextDecoration.BOLD).color(NamedTextColor.RED).build();
final Tag result = STYLE_MAP_CODEC.codec().encodeStart(NbtOps.INSTANCE, style).result().orElseThrow();
final DataResult<Pair<net.minecraft.network.chat.Style, Tag>> dataResult = net.minecraft.network.chat.Style.Serializer.CODEC.decode(NbtOps.INSTANCE, result);
assertTrue(dataResult.result().isPresent(), () -> dataResult + " result is not present");
final net.minecraft.network.chat.Style nms = dataResult.result().get().getFirst();
assertTrue(nms.isBold());
assertEquals(requireNonNull(style.color()).value(), requireNonNull(nms.getColor()).getValue());
}
@CartesianTest(name = PARAMETERIZED_NAME)
void testDirectRoundTripStyle(
@MethodParameterSource("dynamicOps") final DynamicOps<?> dynamicOps,
@MethodParameterSource("testStyles") final Style style
) {
testDirectRoundTrip(dynamicOps, STYLE_MAP_CODEC.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);
}
@CartesianTest(name = PARAMETERIZED_NAME)
void testDirectRoundTripComponent(
@MethodParameterSource("dynamicOps") final DynamicOps<?> dynamicOps,
@TestComponents final Component component
) {
testDirectRoundTrip(dynamicOps, COMPONENT_CODEC, component);
}
@CartesianTest(name = PARAMETERIZED_NAME)
void testMinecraftRoundTripComponent(
@MethodParameterSource("dynamicOps") final DynamicOps<?> dynamicOps,
@TestComponents final Component component
) {
testMinecraftRoundTrip(dynamicOps, COMPONENT_CODEC, ComponentSerialization.CODEC, component);
}
static List<? extends DynamicOps<?>> dynamicOps() {
return Stream.of(
NbtOps.INSTANCE,
JavaOps.INSTANCE,
JsonOps.INSTANCE
)
.map(ops -> RegistryHelper.getRegistry().createSerializationContext(ops))
.toList();
}
@ParameterizedTest(name = PARAMETERIZED_NAME)
@MethodSource({"invalidData"})
void invalidThrows(final Tag input) {
assertThrows(RuntimeException.class, () -> {
require(
COMPONENT_CODEC.decode(NbtOps.INSTANCE, input),
msg -> "Failed to decode " + input + ": " + msg
);
});
}
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, O> roundTripResult = require(
codec.decode(ops, encoded),
msg -> "Failed to decode " + encoded + ": " + msg
);
assertEquals(adventure, roundTripResult.getFirst());
}
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(ops, encoded),
msg -> "Failed to decode to Minecraft: " + encoded + "; " + msg
).getFirst();
final O minecraftReEncoded = require(
minecraftCodec.encodeStart(ops, minecraftResult),
msg -> "Failed to re-encode Minecraft: " + minecraftResult + "; " + msg
);
final Pair<A, O> roundTripResult = require(
adventureCodec.decode(ops, minecraftReEncoded),
msg -> "Failed to decode " + minecraftReEncoded + ": " + msg
);
assertEquals(adventure, roundTripResult.getFirst());
}
static <R> R require(final DataResult<R> result, final Function<String, String> errorMessage) {
return result.getOrThrow(s -> new RuntimeException(errorMessage.apply(s)));
}
static List<Tag> invalidData() {
return List.of(
IntTag.valueOf(-1),
ByteTag.ZERO,
new CompoundTag(),
new ListTag()
);
}
static List<Style> testStyles() {
return List.of(
Style.empty(),
style(color(0x0a1ab9)),
style(NamedTextColor.LIGHT_PURPLE),
style(TextDecoration.BOLD),
style(TextDecoration.BOLD.withState(false)),
style(TextDecoration.BOLD.withState(TextDecoration.State.NOT_SET)),
style()
.font(key("kyori", "kittens"))
.shadowColor(ShadowColor.fromHexString("#FF00AAFF"))
.color(NamedTextColor.RED)
.decoration(TextDecoration.BOLD, true)
.clickEvent(openUrl("https://github.com"))
.build(),
style()
.hoverEvent(HoverEvent.showEntity(HoverEvent.ShowEntity.showEntity(
Key.key(Key.MINECRAFT_NAMESPACE, "pig"),
UUID.randomUUID(),
Component.text("Dolores", TextColor.color(0x0a1ab9))
)))
.build()
);
}
@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(),
text("Hello, world."),
text().content("c")
.color(NamedTextColor.GOLD)
.append(text("o", NamedTextColor.DARK_AQUA))
.append(text("l", NamedTextColor.LIGHT_PURPLE))
.append(text("o", NamedTextColor.DARK_PURPLE))
.append(text("u", NamedTextColor.BLUE))
.append(text("r", NamedTextColor.DARK_GREEN))
.append(text("s", NamedTextColor.RED))
.build(),
text().content("This is a test.")
.color(NamedTextColor.DARK_PURPLE)
.hoverEvent(HoverEvent.showText(text("A test.")))
.append(text(" "))
.append(text("A what?", NamedTextColor.DARK_AQUA))
.build(),
text().append(text("Hello")).build(),
miniMessage().deserialize("<rainbow>|||||||||||||||||||||||<bold>|||||||||||||</bold>|||||||||")
);
}
static List<Component> testTranslatables() {
final String key = "multiplayer.player.left";
final UUID id = UUID.fromString("eb121687-8b1a-4944-bd4d-e0a818d9dfe2");
final String name = "kashike";
final String command = String.format("/msg %s ", name);
return List.of(
translatable(key),
translatable()
.key("thisIsA")
.fallback("This is a test.")
.build(),
translatable(key, numeric(Integer.MAX_VALUE), text("HEY")), // boolean doesn't work in vanilla, can't test here
translatable(
key,
text().content(name)
.clickEvent(suggestCommand(command))
.hoverEvent(showEntity(HoverEvent.ShowEntity.showEntity(
key("minecraft", "player"),
id,
text(name)
)))
.build()
).color(NamedTextColor.YELLOW)
);
}
static List<Component> testKeybinds() {
return List.of(keybind("key.jump"));
}
static List<Component> testScores() {
final String name = "abc";
final String objective = "def";
return List.of(score(name, objective));
}
static List<Component> testSelectors() {
final String selector = "@p";
return List.of(
selector(selector),
selector(selector, text(','))
);
}
static List<Component> testBlockNbts() {
return List.of(
blockNBT().nbtPath("abc").localPos(1.23d, 2.0d, 3.89d).build(),
blockNBT().nbtPath("xyz").absoluteWorldPos(4, 5, 6).interpret(true).build(),
blockNBT().nbtPath("eeee").relativeWorldPos(7, 83, 900)
.separator(text(';'))
.build(),
blockNBT().nbtPath("qwert").worldPos(
BlockNBTComponent.WorldPos.Coordinate.absolute(12),
BlockNBTComponent.WorldPos.Coordinate.relative(3),
BlockNBTComponent.WorldPos.Coordinate.absolute(1200)
).build()
);
}
static List<Component> testEntityNbts() {
return List.of(
entityNBT().nbtPath("abc").selector("test").build(),
entityNBT().nbtPath("abc").selector("test").separator(text(',')).build(),
entityNBT().nbtPath("abc").selector("test").interpret(true).build()
);
}
static List<Component> testStorageNbts() {
return List.of(
storageNBT().nbtPath("abc").storage(key("doom:apple")).build(),
storageNBT().nbtPath("abc").storage(key("doom:apple")).separator(text(", ")).build(),
storageNBT().nbtPath("abc").storage(key("doom:apple")).interpret(true).build()
);
}
}