diff --git a/src/test/java/codechicken/multipart/handler/MultipartPacketHandlerBehaviorTest.java b/src/test/java/codechicken/multipart/handler/MultipartPacketHandlerBehaviorTest.java new file mode 100644 index 0000000..0d8b45c --- /dev/null +++ b/src/test/java/codechicken/multipart/handler/MultipartPacketHandlerBehaviorTest.java @@ -0,0 +1,212 @@ +package codechicken.multipart.handler; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +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.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.lang.reflect.Field; +import java.lang.reflect.Proxy; +import java.util.concurrent.atomic.AtomicReference; + +import net.minecraft.client.Minecraft; +import net.minecraft.client.multiplayer.WorldClient; +import net.minecraft.client.network.NetHandlerPlayClient; +import net.minecraft.entity.Entity; +import net.minecraft.network.play.INetHandlerPlayClient; +import net.minecraft.network.play.server.S40PacketDisconnect; +import net.minecraft.profiler.Profiler; +import net.minecraft.tileentity.TileEntity; +import net.minecraft.util.ChatComponentText; +import net.minecraft.util.ChatComponentTranslation; +import net.minecraft.world.EnumDifficulty; +import net.minecraft.world.WorldSettings; +import net.minecraft.world.chunk.IChunkProvider; + +import org.junit.jupiter.api.Test; + +import codechicken.lib.packet.PacketCustom; +import codechicken.lib.vec.BlockCoord; +import codechicken.multipart.ControlKeyModifer; +import codechicken.multipart.MultiPartRegistry; +import codechicken.multipart.TileCache; +import io.netty.buffer.ByteBuf; +import scala.MatchError; + +class MultipartPacketHandlerBehaviorTest { + + @Test + void reportsEveryMissingServerPartInOrder() { + AtomicReference disconnect = new AtomicReference<>(); + PacketCustom packet = new PacketCustom("test", 1).writeInt(2).writeString("server:missing-one") + .writeString("server:missing-two"); + try { + MultipartCPH.handlePartRegistration(incoming(packet), clientHandler(disconnect)); + } finally { + MultiPartRegistry.beforeServerStart(); + } + + S40PacketDisconnect disconnectPacket = disconnect.get(); + assertNotNull(disconnectPacket); + ChatComponentTranslation reason = (ChatComponentTranslation) disconnectPacket.func_149165_c(); + assertEquals("multipart.missing", reason.getKey()); + assertArrayEquals(new Object[] { "server:missing-one, server:missing-two" }, reason.getFormatArgs()); + } + + @Test + void turnsClientDesyncErrorsIntoAPlainDisconnectReason() throws Exception { + TileCache.clear(); + EmptyWorldClient world = allocateInstance(EmptyWorldClient.class); + world.isRemote = true; + Minecraft minecraft = allocateInstance(Minecraft.class); + minecraft.theWorld = world; + AtomicReference disconnect = new AtomicReference<>(); + PacketCustom packet = new PacketCustom("test", 3).writeInt(4).writeInt(5).writeInt(6).writeByte(0); + + MultipartCPH.handlePacket(incoming(packet), minecraft, clientHandler(disconnect)); + + S40PacketDisconnect disconnectPacket = disconnect.get(); + assertNotNull(disconnectPacket); + ChatComponentText reason = (ChatComponentText) disconnectPacket.func_149165_c(); + String text = reason.getUnformattedText(); + assertTrue(text.startsWith("Client multipart @")); + assertTrue(text.endsWith(" not found")); + assertFalse(text.startsWith("DC: ")); + } + + @Test + void keepsBothCompressedPacketTerminators() { + PacketCustom description = incoming(new PacketCustom("test", 2).writeInt(-3).writeInt(7).writeShort(0)); + PacketCustom updates = incoming( + new PacketCustom("test", 3).writeInt(16).writeInt(64).writeInt(32).writeByte(255) + .writeInt(Integer.MAX_VALUE)); + + assertDoesNotThrow(() -> MultipartCPH.handleCompressedTileDesc(description, null)); + assertDoesNotThrow(() -> MultipartCPH.handleCompressedTileData(updates, null)); + assertFullyRead(description); + assertFullyRead(updates); + } + + @Test + void rejectsUnknownPacketTypesLikeTheScalaMatches() { + assertThrows( + MatchError.class, + () -> MultipartCPH.handlePacket(incoming(new PacketCustom("test", 4)), null, clientHandler(null))); + assertThrows( + MatchError.class, + () -> MultipartSPH.handlePacket(incoming(new PacketCustom("test", 2)), null, null)); + } + + @Test + void serverPacketOneUpdatesTheControlKeyState() { + ControlKeyModifer.map().remove(null); + try { + MultipartSPH.handlePacket(incoming(new PacketCustom("test", 1).writeBoolean(true)), null, null); + assertEquals(Boolean.TRUE, ControlKeyModifer.map().get(null)); + + MultipartSPH$.MODULE$.handlePacket(incoming(new PacketCustom("test", 1).writeBoolean(false)), null, null); + assertEquals(Boolean.FALSE, ControlKeyModifer.map().get(null)); + } finally { + ControlKeyModifer.map().remove(null); + } + } + + @Test + @SuppressWarnings({ "rawtypes", "unchecked" }) + void tileStreamsAreCachedPerPositionAndStartWithTheCoordinate() throws Exception { + EmptyWorldClient world = allocateInstance(EmptyWorldClient.class); + world.isRemote = false; + BlockCoord position = new BlockCoord(0x01020304, -2, 0x0A0B0C0D); + scala.collection.mutable.Map updates = MultipartSPH$.MODULE$ + .codechicken$multipart$handler$MultipartSPH$$updateMap(); + updates.remove(world); + try { + MultipartSPH.MCByteStream stream = MultipartSPH.getTileStream(world, position); + assertSame(stream, MultipartSPH$.MODULE$.getTileStream(world, position)); + stream.writeByte(0x7F); + assertArrayEquals(new byte[] { 1, 2, 3, 4, -1, -1, -1, -2, 10, 11, 12, 13, 127 }, stream.getBytes()); + } finally { + updates.remove(world); + } + } + + @Test + @SuppressWarnings({ "rawtypes", "unchecked" }) + void tileStreamsRejectClientWorldsBeforeCreatingState() throws Exception { + EmptyWorldClient world = allocateInstance(EmptyWorldClient.class); + world.isRemote = true; + scala.collection.mutable.Map updates = MultipartSPH$.MODULE$ + .codechicken$multipart$handler$MultipartSPH$$updateMap(); + updates.remove(world); + + IllegalArgumentException error = assertThrows( + IllegalArgumentException.class, + () -> MultipartSPH.getTileStream(world, new BlockCoord(1, 2, 3))); + + assertEquals("Cannot use MultipartSPH on a client world", error.getMessage()); + assertFalse(updates.contains(world)); + } + + private static PacketCustom incoming(PacketCustom packet) { + return new PacketCustom(packet.getByteBuf().copy()); + } + + private static void assertFullyRead(PacketCustom packet) { + ByteBuf buffer = packet.getByteBuf(); + assertEquals(buffer.writerIndex(), buffer.readerIndex()); + } + + private static INetHandlerPlayClient clientHandler(AtomicReference disconnect) { + return (INetHandlerPlayClient) Proxy.newProxyInstance( + INetHandlerPlayClient.class.getClassLoader(), + new Class[] { INetHandlerPlayClient.class }, + (proxy, method, arguments) -> { + if ("handleDisconnect".equals(method.getName())) { + if (disconnect != null) { + disconnect.set((S40PacketDisconnect) arguments[0]); + } + return null; + } + throw new AssertionError("Unexpected client callback: " + method.getName()); + }); + } + + @SuppressWarnings("unchecked") + private static T allocateInstance(Class type) throws Exception { + Class unsafe = Class.forName("sun.misc.Unsafe"); + Field field = unsafe.getDeclaredField("theUnsafe"); + field.setAccessible(true); + return (T) unsafe.getMethod("allocateInstance", Class.class).invoke(field.get(null), type); + } + + private static final class EmptyWorldClient extends WorldClient { + + private EmptyWorldClient() { + super((NetHandlerPlayClient) null, (WorldSettings) null, 0, (EnumDifficulty) null, (Profiler) null); + } + + @Override + protected IChunkProvider createChunkProvider() { + return null; + } + + @Override + protected int func_152379_p() { + return 0; + } + + @Override + public Entity getEntityByID(int id) { + return null; + } + + @Override + public TileEntity getTileEntity(int x, int y, int z) { + return null; + } + } +} diff --git a/src/test/java/codechicken/multipart/handler/MultipartPacketHandlerCharacterizationTest.java b/src/test/java/codechicken/multipart/handler/MultipartPacketHandlerCharacterizationTest.java new file mode 100644 index 0000000..f6992a6 --- /dev/null +++ b/src/test/java/codechicken/multipart/handler/MultipartPacketHandlerCharacterizationTest.java @@ -0,0 +1,177 @@ +package codechicken.multipart.handler; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.ByteArrayOutputStream; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.util.Arrays; +import java.util.Set; +import java.util.TreeSet; + +import org.junit.jupiter.api.Test; +import org.objectweb.asm.Type; + +import codechicken.lib.data.MCDataOutputWrapper; +import codechicken.lib.packet.PacketCustom.IClientPacketHandler; +import codechicken.lib.packet.PacketCustom.IHandshakeHandler; +import codechicken.lib.packet.PacketCustom.IServerPacketHandler; +import scala.collection.mutable.HashMap; +import scala.collection.mutable.Map; + +class MultipartPacketHandlerCharacterizationTest { + + private static final Set CLIENT_METHODS = new TreeSet<>( + Arrays.asList( + "handleCompressedTileData(Lcodechicken/lib/packet/PacketCustom;Lnet/minecraft/world/World;)V", + "handleCompressedTileDesc(Lcodechicken/lib/packet/PacketCustom;Lnet/minecraft/world/World;)V", + "handlePartRegistration(Lcodechicken/lib/packet/PacketCustom;Lnet/minecraft/network/play/INetHandlerPlayClient;)V", + "handlePacket(Lcodechicken/lib/packet/PacketCustom;Lnet/minecraft/client/Minecraft;Lnet/minecraft/network/play/INetHandlerPlayClient;)V")); + private static final Set SERVER_METHODS = new TreeSet<>( + Arrays.asList( + "codechicken$multipart$handler$MultipartSPH$$chunkWatchers()Lscala/collection/mutable/HashMap;", + "codechicken$multipart$handler$MultipartSPH$$newWatchers()Lscala/collection/mutable/Map;", + "codechicken$multipart$handler$MultipartSPH$$updateMap()Lscala/collection/mutable/Map;", + "getDescPacket(Lnet/minecraft/world/chunk/Chunk;Ljava/util/Iterator;)Lcodechicken/lib/packet/PacketCustom;", + "getTileStream(Lnet/minecraft/world/World;Lcodechicken/lib/vec/BlockCoord;)Lcodechicken/multipart/handler/MultipartSPH$MCByteStream;", + "handlePacket(Lcodechicken/lib/packet/PacketCustom;Lnet/minecraft/entity/player/EntityPlayerMP;Lnet/minecraft/network/play/INetHandlerPlayServer;)V", + "handshakeRecieved(Lnet/minecraft/network/NetHandlerPlayServer;)V", + "onChunkUnWatch(Lnet/minecraft/entity/player/EntityPlayer;Lnet/minecraft/world/ChunkCoordIntPair;)V", + "onChunkWatch(Lnet/minecraft/entity/player/EntityPlayer;Lnet/minecraft/world/ChunkCoordIntPair;)V", + "onTickEnd(Lscala/collection/Seq;)V", + "onWorldUnload(Lnet/minecraft/world/World;)V")); + + @Test + void keepsTheSharedPacketHandlerBase() throws Exception { + assertTrue(Modifier.isPublic(MultipartPH.class.getModifiers())); + assertFalse(Modifier.isFinal(MultipartPH.class.getModifiers())); + assertArrayEquals(new Class[0], MultipartPH.class.getInterfaces()); + assertEquals( + new TreeSet<>( + Arrays.asList( + "channel()Lcodechicken/multipart/handler/MultipartMod$;", + "registryChannel()Ljava/lang/String;")), + publicMethodSignatures(MultipartPH.class)); + + assertPrivateFinalField(MultipartPH.class, "channel", MultipartMod$.class); + assertPrivateFinalField(MultipartPH.class, "registryChannel", String.class); + MultipartPH handler = new MultipartPH(); + assertSame(MultipartMod$.MODULE$, handler.channel()); + assertEquals("ForgeMultipart", handler.registryChannel()); + } + + @Test + void keepsTheClientFacadeAndCompanion() throws Exception { + assertFacade(MultipartCPH.class, withSharedChannels(CLIENT_METHODS)); + assertCompanion(MultipartCPH$.class, new Class[] { IClientPacketHandler.class }, CLIENT_METHODS); + assertSame(MultipartCPH$.class, MultipartCPH$.class.getField("MODULE$").getType()); + assertSame(MultipartCPH$.MODULE$, MultipartCPH$.class.getField("MODULE$").get(null)); + assertSame(MultipartMod$.MODULE$, MultipartCPH.channel()); + assertEquals("ForgeMultipart", MultipartCPH.registryChannel()); + } + + @Test + void keepsTheServerFacadeCompanionAndStateAccessors() throws Exception { + Set facadeMethods = withSharedChannels(SERVER_METHODS); + facadeMethods.remove( + "codechicken$multipart$handler$MultipartSPH$$chunkWatchers()Lscala/collection/mutable/HashMap;"); + facadeMethods.remove("codechicken$multipart$handler$MultipartSPH$$newWatchers()Lscala/collection/mutable/Map;"); + facadeMethods.remove("codechicken$multipart$handler$MultipartSPH$$updateMap()Lscala/collection/mutable/Map;"); + assertFacade(MultipartSPH.class, facadeMethods); + assertCompanion( + MultipartSPH$.class, + new Class[] { IServerPacketHandler.class, IHandshakeHandler.class }, + SERVER_METHODS); + assertSame(MultipartSPH$.class, MultipartSPH$.class.getField("MODULE$").getType()); + assertSame(MultipartSPH$.MODULE$, MultipartSPH$.class.getField("MODULE$").get(null)); + + assertPrivateFinalField( + MultipartSPH$.class, + "codechicken$multipart$handler$MultipartSPH$$updateMap", + Map.class); + assertPrivateFinalField( + MultipartSPH$.class, + "codechicken$multipart$handler$MultipartSPH$$chunkWatchers", + HashMap.class); + assertPrivateFinalField( + MultipartSPH$.class, + "codechicken$multipart$handler$MultipartSPH$$newWatchers", + Map.class); + assertSame(MultipartMod$.MODULE$, MultipartSPH.channel()); + assertEquals("ForgeMultipart", MultipartSPH.registryChannel()); + } + + @Test + void keepsTheNestedByteStreamShape() throws Exception { + Class type = MultipartSPH.MCByteStream.class; + assertSame(MultipartSPH.class, type.getDeclaringClass()); + assertTrue(Modifier.isPublic(type.getModifiers())); + assertTrue(Modifier.isStatic(type.getModifiers())); + assertFalse(Modifier.isFinal(type.getModifiers())); + assertSame(MCDataOutputWrapper.class, type.getSuperclass()); + assertArrayEquals(new Class[0], type.getInterfaces()); + assertEquals(new TreeSet<>(Arrays.asList("getBytes()[B")), publicMethodSignatures(type)); + assertPrivateFinalField(type, "bout", ByteArrayOutputStream.class); + assertSame(type, type.getConstructor(ByteArrayOutputStream.class).getDeclaringClass()); + } + + private static void assertFacade(Class type, Set methods) { + assertTrue(Modifier.isPublic(type.getModifiers())); + assertTrue(Modifier.isFinal(type.getModifiers())); + assertArrayEquals(new Class[0], type.getInterfaces()); + assertEquals(methods, publicMethodSignatures(type)); + for (Method method : type.getDeclaredMethods()) { + if (Modifier.isPublic(method.getModifiers())) { + assertTrue(Modifier.isStatic(method.getModifiers()), method.toString()); + } + } + } + + private static void assertCompanion(Class type, Class[] packetInterfaces, Set methods) + throws Exception { + assertTrue(Modifier.isPublic(type.getModifiers())); + assertTrue(Modifier.isFinal(type.getModifiers())); + assertSame(MultipartPH.class, type.getSuperclass()); + assertArrayEquals(packetInterfaces, type.getInterfaces()); + assertEquals(methods, publicMethodSignatures(type)); + + Field module = type.getField("MODULE$"); + assertTrue(Modifier.isPublic(module.getModifiers())); + assertTrue(Modifier.isStatic(module.getModifiers())); + assertTrue(Modifier.isFinal(module.getModifiers())); + for (Method method : type.getDeclaredMethods()) { + if (Modifier.isPublic(method.getModifiers())) { + assertFalse(Modifier.isStatic(method.getModifiers()), method.toString()); + } + } + } + + private static void assertPrivateFinalField(Class owner, String name, Class type) throws Exception { + Field field = owner.getDeclaredField(name); + assertSame(type, field.getType()); + assertTrue(Modifier.isPrivate(field.getModifiers())); + assertTrue(Modifier.isFinal(field.getModifiers())); + } + + private static Set withSharedChannels(Set methods) { + Set result = new TreeSet<>(methods); + result.add("channel()Lcodechicken/multipart/handler/MultipartMod$;"); + result.add("registryChannel()Ljava/lang/String;"); + return result; + } + + private static Set publicMethodSignatures(Class type) { + Set signatures = new TreeSet<>(); + for (Method method : type.getDeclaredMethods()) { + if (Modifier.isPublic(method.getModifiers())) { + signatures.add(method.getName() + Type.getMethodDescriptor(method)); + } + } + return signatures; + } +}