diff --git a/src/functionalTest/java/codechicken/multipart/test/PacketSchedulerFunctionalTest.java b/src/functionalTest/java/codechicken/multipart/test/PacketSchedulerFunctionalTest.java new file mode 100644 index 0000000..2e73f11 --- /dev/null +++ b/src/functionalTest/java/codechicken/multipart/test/PacketSchedulerFunctionalTest.java @@ -0,0 +1,140 @@ +package codechicken.multipart.test; + +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; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import net.minecraft.server.MinecraftServer; +import net.minecraft.world.World; + +import org.junit.jupiter.api.Test; + +import codechicken.lib.data.MCDataInput; +import codechicken.lib.data.MCDataOutput; +import codechicken.multipart.IScheduledPacketPart; +import codechicken.multipart.MultipartHelper; +import codechicken.multipart.PacketScheduler; +import codechicken.multipart.TMultiPart; +import codechicken.multipart.TileMultipart; + +/** + * schedulePacket needs a part bound to a tile in a non-remote world, and sendScheduled needs a real write stream, so + * none of this runs headless. Tests execute inside the server-started event, so no tick can interleave and flush the + * map between a schedule and a send. + */ +class PacketSchedulerFunctionalTest { + + @Test + void repeatedSchedulesAccumulateIntoOneMask() { + RecordingPart part = boundPart(1); + + PacketScheduler.schedulePacket(part, 0x1L); + PacketScheduler.schedulePacket(part, 0x2L); + PacketScheduler.schedulePacket(part, 0x2L); + sendScheduled(); + + assertEquals(Collections.singletonList(0x3L), part.written, "masks are OR-ed, and the part is written once"); + } + + @Test + void sendingClearsTheScheduleSoTheNextTickWritesNothing() { + RecordingPart part = boundPart(1); + PacketScheduler.schedulePacket(part, 0x5L); + + sendScheduled(); + sendScheduled(); + + assertEquals(Collections.singletonList(0x5L), part.written); + } + + @Test + void aPartThatLostItsTileIsSkippedButStillCleared() { + RecordingPart part = boundPart(1); + PacketScheduler.schedulePacket(part, 0x7L); + part.tile_$eq(null); + + sendScheduled(); + + assertTrue(part.written.isEmpty(), "a part with no tile must be skipped"); + } + + @Test + void everyDocumentedMaskWidthIsAccepted() { + for (int width : new int[] { 1, 2, 4, 8 }) { + RecordingPart part = boundPart(width); + PacketScheduler.schedulePacket(part, 0x1L); + + sendScheduled(); + + assertEquals(Collections.singletonList(0x1L), part.written, "maskWidth " + width + " must be accepted"); + } + } + + /** The reference matches on 1, 2, 4 and 8 with no fallback, so anything else fails rather than writing nothing. */ + @Test + void anUndocumentedMaskWidthIsRejected() { + RecordingPart part = boundPart(3); + PacketScheduler.schedulePacket(part, 0x1L); + + assertThrows(RuntimeException.class, PacketSchedulerFunctionalTest::sendScheduled); + + // Leave no scheduled entry behind for the tests that follow. + part.tile_$eq(null); + try { + sendScheduled(); + } catch (RuntimeException ignored) { + // already drained + } + } + + // The only place sendScheduled's owner is named. + private static void sendScheduled() { + codechicken.multipart.PacketScheduler$.MODULE$.sendScheduled(); + } + + private static RecordingPart boundPart(int maskWidth) { + RecordingPart part = new RecordingPart(maskWidth); + TileMultipart tile = MultipartHelper.createTileFromParts(Collections.singletonList(part)); + assertNotNull(tile); + World world = MinecraftServer.getServer().worldServers[0]; + assertNotNull(world); + tile.setWorldObj(world); + tile.xCoord = 1100; + tile.yCoord = 70; + tile.zCoord = 1100; + return part; + } + + private static final class RecordingPart extends TMultiPart implements IScheduledPacketPart { + + private final int maskWidth; + final List written = new ArrayList<>(); + + RecordingPart(int maskWidth) { + this.maskWidth = maskWidth; + } + + @Override + public String getType() { + return "test:scheduled"; + } + + @Override + public int maskWidth() { + return maskWidth; + } + + @Override + public void writeScheduled(long mask, MCDataOutput packet) { + written.add(mask); + } + + @Override + public void readScheduled(long mask, MCDataInput packet) {} + } +} diff --git a/src/test/java/codechicken/multipart/PacketSchedulerCharacterizationTest.java b/src/test/java/codechicken/multipart/PacketSchedulerCharacterizationTest.java new file mode 100644 index 0000000..b5577bf --- /dev/null +++ b/src/test/java/codechicken/multipart/PacketSchedulerCharacterizationTest.java @@ -0,0 +1,115 @@ +package codechicken.multipart; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +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 codechicken.lib.data.MCDataInput; +import codechicken.lib.data.MCDataOutput; + +/** + * schedulePacket refuses a client world and everything else here needs a bound tile, so the behavior lives in the Forge + * server suite. What is worth pinning headless is the shape of the two interfaces, and in particular that read cannot + * become a default method. + */ +class PacketSchedulerCharacterizationTest { + + @Test + void theCallbackInterfaceDeclaresThreeAbstractMembersAndExtendsNothing() { + assertTrue(IScheduledPacketPart.class.isInterface()); + assertEquals(0, IScheduledPacketPart.class.getInterfaces().length); + assertAllAbstract( + IScheduledPacketPart.class, + "writeScheduled(long,codechicken.lib.data.MCDataOutput)void", + "readScheduled(long,codechicken.lib.data.MCDataInput)void", + "maskWidth()int"); + } + + @Test + void thePartTraitExtendsTheCallbackInterfaceAndAddsRead() { + assertTrue(TScheduledPacketPart.class.isInterface()); + assertArrayEqualsAsSet(TScheduledPacketPart.class.getInterfaces(), IScheduledPacketPart.class); + assertAllAbstract( + TScheduledPacketPart.class, + "read(codechicken.lib.data.MCDataInput)void", + "writeScheduled(long,codechicken.lib.data.MCDataOutput)void", + "readScheduled(long,codechicken.lib.data.MCDataInput)void"); + } + + /** + * TMultiPart declares read, so a superclass method would beat any default the interface tried to supply. The trait + * kept its implementation in the $class helper, which only Scala implementors reach; a part that does not declare + * read resolves it to TMultiPart's, which reads a description rather than a mask. + */ + @Test + void readMustStayAbstractBecauseTMultiPartDeclaresIt() throws Exception { + Method onTheInterface = TScheduledPacketPart.class.getDeclaredMethod("read", MCDataInput.class); + assertTrue(Modifier.isAbstract(onTheInterface.getModifiers())); + + assertSame( + TMultiPart.class, + SilentPart.class.getMethod("read", MCDataInput.class).getDeclaringClass(), + "An implementor that does not declare read falls back to TMultiPart, not to the interface"); + } + + private static void assertAllAbstract(Class type, String... expectedMembers) { + Set actual = new TreeSet<>(); + for (Method method : type.getDeclaredMethods()) { + assertTrue(Modifier.isAbstract(method.getModifiers()), method + " must stay abstract"); + assertTrue(Modifier.isPublic(method.getModifiers()), method + " must stay public"); + actual.add(signature(method)); + } + assertEquals(new TreeSet<>(Arrays.asList(expectedMembers)), actual); + } + + private static void assertArrayEqualsAsSet(Class[] actual, Class... expected) { + assertEquals(new TreeSet<>(names(expected)), new TreeSet<>(names(actual))); + } + + private static Set names(Class[] types) { + Set names = new TreeSet<>(); + for (Class type : types) { + names.add(type.getName()); + } + return names; + } + + private static String signature(Method method) { + StringBuilder out = new StringBuilder(method.getName()).append('('); + Class[] parameters = method.getParameterTypes(); + for (int i = 0; i < parameters.length; i++) { + if (i > 0) { + out.append(','); + } + out.append(parameters[i].getName()); + } + return out.append(')').append(method.getReturnType().getName()).toString(); + } + + private static final class SilentPart extends TMultiPart implements TScheduledPacketPart { + + @Override + public String getType() { + return "test:silent"; + } + + @Override + public int maskWidth() { + return 1; + } + + @Override + public void writeScheduled(long mask, MCDataOutput packet) {} + + @Override + public void readScheduled(long mask, MCDataInput packet) {} + } +}