From e875c1d525b0dc8bfdb31fac5c610c69a97939ff Mon Sep 17 00:00:00 2001 From: Lexer747 Date: Sun, 15 Mar 2026 23:21:02 +0000 Subject: [PATCH 1/3] Add integration test add full state tracking Add eating food test --- build.gradle | 55 ++- .../AttackTimerMetronomePlugin.java | 52 ++- src/test/java/com/attacktimer/EnumTests.java | 27 +- .../com/attacktimer/IntegrationTests.java | 347 ++++++++++++++++++ src/test/java/com/attacktimer/Update.java | 73 ++++ .../com/attacktimer/testdata/basicTest.txt | 37 ++ .../attacktimer/testdata/eatingFoodTest.txt | 21 ++ 7 files changed, 590 insertions(+), 22 deletions(-) create mode 100644 src/test/java/com/attacktimer/IntegrationTests.java create mode 100644 src/test/java/com/attacktimer/Update.java create mode 100644 src/test/java/com/attacktimer/testdata/basicTest.txt create mode 100644 src/test/java/com/attacktimer/testdata/eatingFoodTest.txt diff --git a/build.gradle b/build.gradle index d6afd93..72f5e48 100644 --- a/build.gradle +++ b/build.gradle @@ -1,9 +1,17 @@ +import org.gradle.api.tasks.testing.logging.TestExceptionFormat +import org.gradle.api.tasks.testing.logging.TestLogEvent + plugins { id 'java' } repositories { - mavenLocal() + // mavenLocal() seems to always break on my PC (looking for linux binaries even though I'm on windows): + // Execution failed for task ':test'. + // > Could not resolve all files for configuration ':testRuntimeClasspath'. + // > Could not find lwjgl-opengl-3.3.2-natives-linux-arm64.jar (org.lwjgl:lwjgl-opengl:3.3.2). + // Searched in the following locations: + // file:/C:/Users/Lexer/.m2/repository/org/lwjgl/lwjgl-opengl/3.3.2/lwjgl-opengl-3.3.2-natives-linux-arm64.jar maven { url = 'https://repo.runelite.net' } @@ -21,11 +29,13 @@ dependencies { testImplementation 'junit:junit:4.12' testImplementation group: 'net.runelite', name:'client', version: runeLiteVersion testImplementation group: 'net.runelite', name:'jshell', version: runeLiteVersion + testImplementation 'org.mockito:mockito-core:3.1.0' + testImplementation 'com.google.inject.extensions:guice-testlib:4.1.0' } java { - targetCompatibility = "1.11" + targetCompatibility = "1.11" } group = 'com.attacktimer' @@ -35,3 +45,44 @@ tasks.withType(JavaCompile) { options.encoding = 'UTF-8' options.release.set(11) } + +// Code based on https://gist.github.com/lwasyl/6ae0b8a66903729033e18ffc7a503597#file-fancy_logging_original-gradle +tasks.withType(Test) { + // allow specific system properties to be forwarded + systemProperty "update", project.getProperty("update") + outputs.upToDateWhen { false } // always force tests to be re-run + testLogging { + // set options for log level LIFECYCLE + events TestLogEvent.FAILED, + TestLogEvent.PASSED, + TestLogEvent.SKIPPED, + TestLogEvent.STANDARD_OUT + exceptionFormat TestExceptionFormat.FULL + showExceptions true + showCauses true + showStackTraces true + + // set options for log level DEBUG and INFO + debug { + events TestLogEvent.STARTED, + TestLogEvent.FAILED, + TestLogEvent.PASSED, + TestLogEvent.SKIPPED, + TestLogEvent.STANDARD_ERROR, + TestLogEvent.STANDARD_OUT + exceptionFormat TestExceptionFormat.FULL + } + info.events = debug.events + info.exceptionFormat = debug.exceptionFormat + + afterSuite { desc, result -> + if (!desc.parent) + { // will match the outermost suite + def output = "Results: ${result.resultType} (${result.testCount} tests, ${result.successfulTestCount} successes, ${result.failedTestCount} failures, ${result.skippedTestCount} skipped)" + def startItem = '| ', endItem = ' |' + def repeatLength = startItem.length() + output.length() + endItem.length() + println('\n' + ('-' * repeatLength) + '\n' + startItem + output + endItem + '\n' + ('-' * repeatLength)) + } + } + } +} \ No newline at end of file diff --git a/src/main/java/com/attacktimer/AttackTimerMetronomePlugin.java b/src/main/java/com/attacktimer/AttackTimerMetronomePlugin.java index 396ec72..ca50e3f 100644 --- a/src/main/java/com/attacktimer/AttackTimerMetronomePlugin.java +++ b/src/main/java/com/attacktimer/AttackTimerMetronomePlugin.java @@ -31,14 +31,20 @@ import com.attacktimer.VariableSpeed.VariableSpeed; import com.google.common.collect.ImmutableMap; import com.google.common.collect.ImmutableSet; +import com.google.common.io.ByteArrayDataOutput; import com.google.inject.Provides; import java.awt.Color; import java.awt.Dimension; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.channels.SeekableByteChannel; +import java.nio.charset.StandardCharsets; import java.util.Arrays; import java.util.List; import java.util.Map; import java.util.Set; import java.util.regex.Pattern; +import java.util.stream.Stream; import javax.inject.Inject; import net.runelite.api.Actor; @@ -105,11 +111,7 @@ public enum AttackState { public int tickPeriod = 0; - final int ATTACK_DELAY_NONE = 0; - private int uiUnshowDebounceTickCount = 0; - private int uiUnshowDebounceTicksMax = 1; - public int attackDelayHoldoffTicks = ATTACK_DELAY_NONE; public AttackState attackState = AttackState.NOT_ATTACKING; @@ -120,7 +122,17 @@ public enum AttackState { public Color CurrentColor = Color.WHITE; - public int DEFAULT_SIZE_UNIT_PX = 25; + private Spellbook currentSpellBook = Spellbook.STANDARD; + private int lastEquippingMonotonicValue = -1; + private int soundEffectTick = -1; + private int soundEffectId = -1; + + public int pendingEatDelayTicks = 0; + + + private static final int uiUnshowDebounceTicksMax = 1; + private static final int ATTACK_DELAY_NONE = 0; + public static final int DEFAULT_SIZE_UNIT_PX = 25; public static final int SALAMANDER_SET_ANIM_ID = 952; // Used by all 4 types of salamander https://oldschool.runescape.wiki/w/Salamander @@ -157,13 +169,8 @@ public enum AttackState { private final int FAST_EAT_ATTACK_DELAY_TICKS = 2; public static final int EQUIPPING_MONOTONIC = 384; // From empirical testing this clientint seems to always increase whenever the player equips an item - private Spellbook currentSpellBook = Spellbook.STANDARD; - private int lastEquippingMonotonicValue = -1; - private int soundEffectTick = -1; - private int soundEffectId = -1; - public Dimension DEFAULT_SIZE = new Dimension(DEFAULT_SIZE_UNIT_PX, DEFAULT_SIZE_UNIT_PX); + public static final Dimension DEFAULT_SIZE = new Dimension(DEFAULT_SIZE_UNIT_PX, DEFAULT_SIZE_UNIT_PX); - private int pendingEatDelayTicks = 0; // region subscribers @@ -181,6 +188,7 @@ public void onVarbitChanged(VarbitChanged varbitChanged) @Subscribe public void onVarClientIntChanged(VarClientIntChanged varClientIntChanged) { + if (!config.enableMetronome()) return; final int currentMagicVarBit = client.getVarcIntValue(EQUIPPING_MONOTONIC); if (currentMagicVarBit <= lastEquippingMonotonicValue) { @@ -206,6 +214,7 @@ public void onVarClientIntChanged(VarClientIntChanged varClientIntChanged) @Subscribe public void onSoundEffectPlayed(SoundEffectPlayed event) { + if (!config.enableMetronome()) return; // event.getSource() will be null if the player cast a spell, it's only for area sounds. soundEffectTick = client.getTickCount(); soundEffectId = event.getSoundId(); @@ -401,6 +410,7 @@ public boolean isAttackCooldownPending() @Subscribe public void onChatMessage(ChatMessage event) { + if (!config.enableMetronome()) return; final String message = event.getMessage(); if (EAT_MESSAGE.matcher(message).find()) @@ -419,6 +429,7 @@ public void onChatMessage(ChatMessage event) @Subscribe public void onInteractingChanged(InteractingChanged interactingChanged) { + if (!config.enableMetronome()) return; Actor source = interactingChanged.getSource(); Actor target = interactingChanged.getTarget(); @@ -453,6 +464,7 @@ private void applyAndClearEats() { @Subscribe public void onGameTick(GameTick tick) { + if (!config.enableMetronome()) return; VariableSpeed.onGameTick(client, tick); boolean isAttacking = isPlayerAttacking(); switch (attackState) { @@ -510,4 +522,22 @@ protected void shutDown() throws Exception overlayManager.remove(barOverlay); attackDelayHoldoffTicks = 0; } + + public void writeState(ByteArrayDataOutput outChannel) + { + StringBuilder sb = new StringBuilder(); + sb.append("tickPeriod: "); sb.append(this.tickPeriod);sb.append(SEPARATOR); + sb.append("uiUnshowDebounceTickCount: "); sb.append(this.uiUnshowDebounceTickCount);sb.append(SEPARATOR); + sb.append("attackDelayHoldoffTicks: "); sb.append(this.attackDelayHoldoffTicks);sb.append(SEPARATOR); + sb.append("attackState: "); sb.append(this.attackState);sb.append(SEPARATOR); + sb.append("renderedState: "); sb.append(this.renderedState);sb.append(SEPARATOR); + sb.append("pendingEatDelayTicks: "); sb.append(this.pendingEatDelayTicks);sb.append(SEPARATOR); + sb.append("currentSpellBook: "); sb.append(this.currentSpellBook);sb.append(SEPARATOR); + sb.append("lastEquippingMonotonicValue: "); sb.append(this.lastEquippingMonotonicValue);sb.append(SEPARATOR); + sb.append("soundEffectTick: "); sb.append(this.soundEffectTick);sb.append(SEPARATOR); + sb.append("soundEffectId: "); sb.append(this.soundEffectId);sb.append("\n"); + byte[] bytes = sb.toString().getBytes(StandardCharsets.UTF_8); + outChannel.write(bytes); + } + private static final String SEPARATOR = ", "; } diff --git a/src/test/java/com/attacktimer/EnumTests.java b/src/test/java/com/attacktimer/EnumTests.java index 2d7d5d0..a8fbdcd 100644 --- a/src/test/java/com/attacktimer/EnumTests.java +++ b/src/test/java/com/attacktimer/EnumTests.java @@ -1,7 +1,7 @@ package com.attacktimer; /* - * Copyright (c) 2024, Lexer747 + * Copyright (c) 2024-2026, Lexer747 * All rights reserved. * * Redistribution and use in source and binary forms, with or without @@ -37,21 +37,25 @@ public class EnumTests public void runtimeCheck() { // Ensures every enum has no duplicate values + System.out.println("===== Animation Data ====="); for (AnimationData a : AnimationData.values()) { - System.out.println(a.toString()); + System.out.print(a.toString() + ", "); } + System.out.println("\n===== Powered Staves ====="); for (PoweredStaves a : PoweredStaves.values()) { - System.out.println(a.toString()); + System.out.print(a.toString() + ", "); } + System.out.println("\n===== Casting Sound Data ====="); for (CastingSoundData a : CastingSoundData.values()) { - System.out.println(a.toString()); + System.out.print(a.toString() + ", "); } + System.out.println("\n===== Weapon Type ====="); for (WeaponType a : WeaponType.values()) { - System.out.println(a.toString()); + System.out.print(a.toString() + ", "); } assertFalse(PoweredStaves.LOCAL_DEBUGGING); } @@ -64,22 +68,27 @@ public void missingIdReport() String fails = new String(); for (PoweredStaves staff : PoweredStaves.values()) { - for (int id : staff.getIds()) { + for (int id : staff.getIds()) + { boolean containsKey = PoweredStaves.poweredStaves.get(id).containsKey(PoweredStaves.UNKNOWN_SPELL); if (containsKey) { failed = true; - fails += MessageFormatter.format("Staff | {} IDs:{} doesn't have a spell associated\n", staff, staff.getIds()).getMessage(); + fails += MessageFormatter + .format("Staff | {} IDs:{} doesn't have a spell associated\n", staff, staff.getIds()) + .getMessage(); } break; } if (staff.getProjectiles() == null) { - fails += MessageFormatter.format("Staff | {} doesn't have a projectile associated\n", staff).getMessage(); + fails += MessageFormatter.format("Staff | {} doesn't have a projectile associated\n", staff) + .getMessage(); } } - if (failed) { + if (failed) + { fail(fails); } } diff --git a/src/test/java/com/attacktimer/IntegrationTests.java b/src/test/java/com/attacktimer/IntegrationTests.java new file mode 100644 index 0000000..4cbebb8 --- /dev/null +++ b/src/test/java/com/attacktimer/IntegrationTests.java @@ -0,0 +1,347 @@ +package com.attacktimer; + +/* + * Copyright (c) 2026, Lexer747 + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND + * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardOpenOption; +import java.util.Collections; +import java.util.EnumSet; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; + +import com.attacktimer.AttackTimerMetronomePlugin.AttackState; +import com.google.common.io.ByteArrayDataOutput; +import com.google.common.io.ByteStreams; +import com.google.inject.Guice; +import com.google.inject.Inject; +import com.google.inject.testing.fieldbinder.Bind; +import com.google.inject.testing.fieldbinder.BoundFieldModule; + +import net.runelite.api.ChatMessageType; +import net.runelite.api.Client; +import net.runelite.api.EnumComposition; +import net.runelite.api.EnumID; +import net.runelite.api.IndexedObjectSet; +import net.runelite.api.NPC; +import net.runelite.api.NPCComposition; +import net.runelite.api.Player; +import net.runelite.api.WorldType; +import net.runelite.api.WorldView; +import net.runelite.api.coords.LocalPoint; +import net.runelite.api.coords.WorldPoint; +import net.runelite.api.events.ChatMessage; +import net.runelite.api.events.GameTick; +import net.runelite.client.config.ConfigManager; +import net.runelite.client.game.ItemManager; +import net.runelite.client.game.NPCManager; +import net.runelite.client.ui.overlay.OverlayManager; + +@RunWith(MockitoJUnitRunner.class) +public class IntegrationTests +{ + @Mock + @Bind + private OverlayManager mockedOverlayManager; + + @Mock + @Bind + private ConfigManager mockedConfigManager; + + @Mock + @Bind + private AttackTimerMetronomeTileOverlay mockedOverlay; + + @Mock + @Bind + private AttackTimerBarOverlay mockedBarOverlay; + + @Mock + @Bind + private AttackTimerMetronomeConfig mockedConfig; + + @Mock + @Bind + private ItemManager mockedItemManager; + + @Mock + @Bind + private Client mockedClient; + + @Mock + @Bind + private NPCManager mockedNpcManager; + + @Inject + private AttackTimerMetronomePlugin underTest; + + @Before + public void setup() + { + Guice.createInjector(BoundFieldModule.of(this)).injectMembers(this); + } + + public Player pluginMockSetup() throws Exception + { + // enable the plugin + when(mockedConfig.enableMetronome()).thenReturn(true); + // Create player + Player mockedPlayer = mock(Player.class); + + // Create the enemy + NPC mockedTarget = mock(NPC.class); + NPCComposition mockedCompositions = mock(NPCComposition.class); + when(mockedTarget.getComposition()).thenReturn(mockedCompositions); + int mockedNpcId = 0xFFFF; + when(mockedTarget.getId()).thenReturn(mockedNpcId); + String[] actions = { + "Attack", "Examine" + }; + when(mockedCompositions.getActions()).thenReturn(actions); + when(mockedNpcManager.getHealth(mockedNpcId)).thenReturn(1); + + // set the player as "attacking" the NPC + when(mockedClient.getLocalPlayer()).thenReturn(mockedPlayer); + when(mockedPlayer.getInteracting()).thenReturn(mockedTarget); + + // need some extra mocks to stop the plugin running into an exception on the + // client APIs + // -- Mock World + WorldView mockedWorldView = mock(WorldView.class); + when(mockedClient.getTopLevelWorldView()).thenReturn(mockedWorldView); + when(mockedClient.getWorldView(0)).thenReturn(mockedWorldView); + int mockedPlane = 0; + when(mockedWorldView.getPlane()).thenReturn(mockedPlane); + WorldPoint worldPoint = new WorldPoint(0, 0, mockedPlane); + LocalPoint localPoint = new LocalPoint(0, 0, mockedPlane); + when(mockedPlayer.getWorldLocation()).thenReturn(worldPoint); + when(mockedPlayer.getLocalLocation()).thenReturn(localPoint); + var memsWorld = EnumSet.of(WorldType.MEMBERS); + when(mockedClient.getWorldType()).thenReturn(memsWorld); + // -- NPCs + IndexedObjectSet mockedNpcs = mock(IndexedObjectSet.class); + when(mockedNpcs.iterator()).thenReturn(Collections.emptyIterator()); + when(mockedWorldView.npcs()).thenReturn(mockedNpcs); + // -- Attack Styles + EnumComposition mockedWeaponEnum = mock(EnumComposition.class); + when(mockedClient.getEnum(EnumID.WEAPON_STYLES)).thenReturn(mockedWeaponEnum); + when(mockedWeaponEnum.getIntValue(0)).thenReturn(-1); // blue-moon-spear mock + + // Finally turn the plugin "on" + underTest.startUp(); + return mockedPlayer; + } + + @Test + public void basicTest() throws Exception + { + ByteArrayDataOutput channel = ByteStreams.newDataOutput(); + underTest.writeState(channel); + // Trivial Pre-conditions: + assertSame(AttackState.NOT_ATTACKING, underTest.attackState); + + // Basic test case: + // 1. Start by setting up the player + // 2. Mock an attack animation + // 3. Check that the plugin has registered the attack + // 4. Check that the plugin counts down correctly + // 5. Check that the plugin is back to a waiting state and it still counts down + + writeTestMessage("1. Start by setting up the player and plugin", channel); + int atkSpeed = 3; // no weapon equipped (4 ticks, plugin starts counting from 3) + int tick = 0; + Player mockedPlayer = pluginMockSetup(); + when(mockedClient.getTickCount()).thenReturn(tick); + + writeTestMessage("2. Mock an attack animation", channel); + + // set the animation to an attack + when(mockedPlayer.getAnimation()).thenReturn(AnimationData.MELEE_GENERIC_SLASH.animationId); + // tell the plugin that a tick occurred + onGameTick(channel); + + writeTestMessage("3. Check that the plugin has registered the attack", channel); + assertSame(AttackState.DELAYED_FIRST_TICK, underTest.attackState); + assertSame(atkSpeed, underTest.attackDelayHoldoffTicks); + + // clear the animation + when(mockedPlayer.getAnimation()).thenReturn(noAnimation); + + writeTestMessage("4. Check that the plugin counts down correctly", channel); + while (atkSpeed > 0) + { + tick++; + atkSpeed--; + when(mockedClient.getTickCount()).thenReturn(tick); + onGameTick(channel); + assertSame(AttackState.DELAYED, underTest.attackState); + assertSame(atkSpeed, underTest.attackDelayHoldoffTicks); + } + + writeTestMessage("5. Check that the plugin is back to a waiting state and it still counts down", channel); + tick++; + onGameTick(channel); + assertSame(AttackState.NOT_ATTACKING, underTest.attackState); + while (tick < 30) + { + tick++; + when(mockedClient.getTickCount()).thenReturn(tick); + onGameTick(channel); + assertSame(AttackState.NOT_ATTACKING, underTest.attackState); + assertTrue(underTest.attackDelayHoldoffTicks < 0); // hold off should go negative + } + + performStateVerificationOrUpdate(channel, Paths.get(testdata + "basicTest.txt")); + } + + @Test + public void eatingFoodTest() throws Exception + { + ByteArrayDataOutput channel = ByteStreams.newDataOutput(); + underTest.writeState(channel); + // Trivial Pre-conditions: + assertSame(AttackState.NOT_ATTACKING, underTest.attackState); + writeTestMessage("1. Start by setting up the player and plugin", channel); + int atkSpeed = 3; // no weapon equipped (4 ticks, plugin starts counting from 3) + int tick = 0; + Player mockedPlayer = pluginMockSetup(); + when(mockedClient.getTickCount()).thenReturn(tick); + + writeTestMessage("2. Mock an attack animation", channel); + + // set the animation to an attack + when(mockedPlayer.getAnimation()).thenReturn(AnimationData.MELEE_GENERIC_SLASH.animationId); + // tell the plugin that a tick occurred + onGameTick(channel); + + writeTestMessage("3. Check that the plugin has registered the attack", channel); + assertSame(AttackState.DELAYED_FIRST_TICK, underTest.attackState); + assertSame(atkSpeed, underTest.attackDelayHoldoffTicks); + + when(mockedPlayer.getAnimation()).thenReturn(noAnimation); + underTest.writeState(channel); + + writeTestMessage("Perform an eat", channel); + ChatMessage chatMessage = new ChatMessage(null, ChatMessageType.SPAM, "", "You eat the shark.", "", 0); + underTest.onChatMessage(chatMessage); + // the plugin wont apply the delay directly it waits until the next game tick + assertSame(3, underTest.pendingEatDelayTicks); + // Should still be same state + assertSame(AttackState.DELAYED_FIRST_TICK, underTest.attackState); + underTest.writeState(channel); + + // Subsequent values should be offset by the eat delay + writeTestMessage("Next game tick", channel); + atkSpeed += 2; + onGameTick(channel); + assertSame(AttackState.DELAYED, underTest.attackState); + assertSame(atkSpeed, underTest.attackDelayHoldoffTicks); + + // Note that this is essentially a "double" eat test. + writeTestMessage("Perform a fast eat", channel); + chatMessage = new ChatMessage(null, ChatMessageType.SPAM, "", "You eat the halibut.", "", 0); + underTest.onChatMessage(chatMessage); + // the plugin wont apply the delay directly it waits until the next game tick + assertSame(2, underTest.pendingEatDelayTicks); + // Should still be same state + assertSame(AttackState.DELAYED, underTest.attackState); + underTest.writeState(channel); + + // Subsequent values should be offset by the eat delay + writeTestMessage("Next game tick", channel); + atkSpeed += 1; + onGameTick(channel); + assertSame(AttackState.DELAYED, underTest.attackState); + assertSame(atkSpeed, underTest.attackDelayHoldoffTicks); + + writeTestMessage("4. Check that the plugin counts down correctly", channel); + while (atkSpeed > 0) + { + tick++; + atkSpeed--; + when(mockedClient.getTickCount()).thenReturn(tick); + onGameTick(channel); + assertSame(AttackState.DELAYED, underTest.attackState); + assertSame(atkSpeed, underTest.attackDelayHoldoffTicks); + } + + performStateVerificationOrUpdate(channel, Paths.get(testdata + "eatingFoodTest.txt")); + } + + private void performStateVerificationOrUpdate(ByteArrayDataOutput channel, Path path) throws IOException + { + var actualBytes = channel.toByteArray(); + if (Update.system() == Update.NONE) + { + // We verify that the file on disk is the same as the one generated + var expectedBytes = Files.readAllBytes(path); + assertArrayEquals(expectedBytes, actualBytes); + } + else + { + // Write the generated one to disk + try (var file = Files.newByteChannel(path, EnumSet.of(StandardOpenOption.CREATE, StandardOpenOption.WRITE))) + { + file.write(ByteBuffer.wrap(actualBytes)); + file.close(); + } + } + } + + private void onGameTick(ByteArrayDataOutput file) + { + underTest.onGameTick(new GameTick()); + underTest.writeState(file); + } + + private void writeTestMessage(String message, ByteArrayDataOutput file) + { + file.write(testMessagePrefix); + file.write(message.getBytes(StandardCharsets.UTF_8)); + file.write(testMessageSuffix); + } + + private static final int noAnimation = -1; + private static final String testdata = "src/test/java/com/attacktimer/testdata/"; + + private static final byte[] testMessagePrefix = "[TEST MESSAGE] ".getBytes(StandardCharsets.UTF_8); + private static final byte[] testMessageSuffix = "\n".getBytes(StandardCharsets.UTF_8); +} \ No newline at end of file diff --git a/src/test/java/com/attacktimer/Update.java b/src/test/java/com/attacktimer/Update.java new file mode 100644 index 0000000..0fe8b44 --- /dev/null +++ b/src/test/java/com/attacktimer/Update.java @@ -0,0 +1,73 @@ +package com.attacktimer; + +/* + * Copyright (c) 2026, Lexer747 + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND + * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +import javax.annotation.Nonnull; + +import com.google.common.collect.ImmutableMap; + +public enum Update +{ + ALL("all"), + NONE("none"); + + @Nonnull + private final String value; + + Update(@Nonnull String value) + { + this.value = value; + } + + private static final ImmutableMap updates; + + static + { + ImmutableMap.Builder builder = new ImmutableMap.Builder<>(); + + for (Update data : values()) + { + builder.put(data.value, data); + } + + updates = builder.build(); + } + + public static Update of(String value) + { + if (value == null) + return NONE; + Update update = updates.get(value.toLowerCase()); + if (update == null) + return NONE; + return update; + } + + public static Update system() + { + return Update.of(System.getProperty("update")); + } +} diff --git a/src/test/java/com/attacktimer/testdata/basicTest.txt b/src/test/java/com/attacktimer/testdata/basicTest.txt new file mode 100644 index 0000000..eae54c4 --- /dev/null +++ b/src/test/java/com/attacktimer/testdata/basicTest.txt @@ -0,0 +1,37 @@ +tickPeriod: 0, uiUnshowDebounceTickCount: 0, attackDelayHoldoffTicks: 0, attackState: NOT_ATTACKING, renderedState: NOT_ATTACKING, pendingEatDelayTicks: 0, currentSpellBook: STANDARD, lastEquippingMonotonicValue: -1, soundEffectTick: -1, soundEffectId: -1 +[TEST MESSAGE] 1. Start by setting up the player and plugin +[TEST MESSAGE] 2. Mock an attack animation +tickPeriod: 4, uiUnshowDebounceTickCount: 1, attackDelayHoldoffTicks: 3, attackState: DELAYED_FIRST_TICK, renderedState: NOT_ATTACKING, pendingEatDelayTicks: 0, currentSpellBook: STANDARD, lastEquippingMonotonicValue: -1, soundEffectTick: -1, soundEffectId: -1 +[TEST MESSAGE] 3. Check that the plugin has registered the attack +[TEST MESSAGE] 4. Check that the plugin counts down correctly +tickPeriod: 4, uiUnshowDebounceTickCount: 1, attackDelayHoldoffTicks: 2, attackState: DELAYED, renderedState: NOT_ATTACKING, pendingEatDelayTicks: 0, currentSpellBook: STANDARD, lastEquippingMonotonicValue: -1, soundEffectTick: -1, soundEffectId: -1 +tickPeriod: 4, uiUnshowDebounceTickCount: 1, attackDelayHoldoffTicks: 1, attackState: DELAYED, renderedState: NOT_ATTACKING, pendingEatDelayTicks: 0, currentSpellBook: STANDARD, lastEquippingMonotonicValue: -1, soundEffectTick: -1, soundEffectId: -1 +tickPeriod: 4, uiUnshowDebounceTickCount: 1, attackDelayHoldoffTicks: 0, attackState: DELAYED, renderedState: NOT_ATTACKING, pendingEatDelayTicks: 0, currentSpellBook: STANDARD, lastEquippingMonotonicValue: -1, soundEffectTick: -1, soundEffectId: -1 +[TEST MESSAGE] 5. Check that the plugin is back to a waiting state and it still counts down +tickPeriod: 4, uiUnshowDebounceTickCount: 1, attackDelayHoldoffTicks: -1, attackState: NOT_ATTACKING, renderedState: NOT_ATTACKING, pendingEatDelayTicks: 0, currentSpellBook: STANDARD, lastEquippingMonotonicValue: -1, soundEffectTick: -1, soundEffectId: -1 +tickPeriod: 4, uiUnshowDebounceTickCount: 0, attackDelayHoldoffTicks: -2, attackState: NOT_ATTACKING, renderedState: NOT_ATTACKING, pendingEatDelayTicks: 0, currentSpellBook: STANDARD, lastEquippingMonotonicValue: -1, soundEffectTick: -1, soundEffectId: -1 +tickPeriod: 4, uiUnshowDebounceTickCount: -1, attackDelayHoldoffTicks: -3, attackState: NOT_ATTACKING, renderedState: NOT_ATTACKING, pendingEatDelayTicks: 0, currentSpellBook: STANDARD, lastEquippingMonotonicValue: -1, soundEffectTick: -1, soundEffectId: -1 +tickPeriod: 4, uiUnshowDebounceTickCount: -2, attackDelayHoldoffTicks: -4, attackState: NOT_ATTACKING, renderedState: NOT_ATTACKING, pendingEatDelayTicks: 0, currentSpellBook: STANDARD, lastEquippingMonotonicValue: -1, soundEffectTick: -1, soundEffectId: -1 +tickPeriod: 4, uiUnshowDebounceTickCount: -3, attackDelayHoldoffTicks: -5, attackState: NOT_ATTACKING, renderedState: NOT_ATTACKING, pendingEatDelayTicks: 0, currentSpellBook: STANDARD, lastEquippingMonotonicValue: -1, soundEffectTick: -1, soundEffectId: -1 +tickPeriod: 4, uiUnshowDebounceTickCount: -4, attackDelayHoldoffTicks: -6, attackState: NOT_ATTACKING, renderedState: NOT_ATTACKING, pendingEatDelayTicks: 0, currentSpellBook: STANDARD, lastEquippingMonotonicValue: -1, soundEffectTick: -1, soundEffectId: -1 +tickPeriod: 4, uiUnshowDebounceTickCount: -5, attackDelayHoldoffTicks: -7, attackState: NOT_ATTACKING, renderedState: NOT_ATTACKING, pendingEatDelayTicks: 0, currentSpellBook: STANDARD, lastEquippingMonotonicValue: -1, soundEffectTick: -1, soundEffectId: -1 +tickPeriod: 4, uiUnshowDebounceTickCount: -6, attackDelayHoldoffTicks: -8, attackState: NOT_ATTACKING, renderedState: NOT_ATTACKING, pendingEatDelayTicks: 0, currentSpellBook: STANDARD, lastEquippingMonotonicValue: -1, soundEffectTick: -1, soundEffectId: -1 +tickPeriod: 4, uiUnshowDebounceTickCount: -7, attackDelayHoldoffTicks: -9, attackState: NOT_ATTACKING, renderedState: NOT_ATTACKING, pendingEatDelayTicks: 0, currentSpellBook: STANDARD, lastEquippingMonotonicValue: -1, soundEffectTick: -1, soundEffectId: -1 +tickPeriod: 4, uiUnshowDebounceTickCount: -8, attackDelayHoldoffTicks: -10, attackState: NOT_ATTACKING, renderedState: NOT_ATTACKING, pendingEatDelayTicks: 0, currentSpellBook: STANDARD, lastEquippingMonotonicValue: -1, soundEffectTick: -1, soundEffectId: -1 +tickPeriod: 4, uiUnshowDebounceTickCount: -9, attackDelayHoldoffTicks: -11, attackState: NOT_ATTACKING, renderedState: NOT_ATTACKING, pendingEatDelayTicks: 0, currentSpellBook: STANDARD, lastEquippingMonotonicValue: -1, soundEffectTick: -1, soundEffectId: -1 +tickPeriod: 4, uiUnshowDebounceTickCount: -10, attackDelayHoldoffTicks: -12, attackState: NOT_ATTACKING, renderedState: NOT_ATTACKING, pendingEatDelayTicks: 0, currentSpellBook: STANDARD, lastEquippingMonotonicValue: -1, soundEffectTick: -1, soundEffectId: -1 +tickPeriod: 4, uiUnshowDebounceTickCount: -11, attackDelayHoldoffTicks: -13, attackState: NOT_ATTACKING, renderedState: NOT_ATTACKING, pendingEatDelayTicks: 0, currentSpellBook: STANDARD, lastEquippingMonotonicValue: -1, soundEffectTick: -1, soundEffectId: -1 +tickPeriod: 4, uiUnshowDebounceTickCount: -12, attackDelayHoldoffTicks: -14, attackState: NOT_ATTACKING, renderedState: NOT_ATTACKING, pendingEatDelayTicks: 0, currentSpellBook: STANDARD, lastEquippingMonotonicValue: -1, soundEffectTick: -1, soundEffectId: -1 +tickPeriod: 4, uiUnshowDebounceTickCount: -13, attackDelayHoldoffTicks: -15, attackState: NOT_ATTACKING, renderedState: NOT_ATTACKING, pendingEatDelayTicks: 0, currentSpellBook: STANDARD, lastEquippingMonotonicValue: -1, soundEffectTick: -1, soundEffectId: -1 +tickPeriod: 4, uiUnshowDebounceTickCount: -14, attackDelayHoldoffTicks: -16, attackState: NOT_ATTACKING, renderedState: NOT_ATTACKING, pendingEatDelayTicks: 0, currentSpellBook: STANDARD, lastEquippingMonotonicValue: -1, soundEffectTick: -1, soundEffectId: -1 +tickPeriod: 4, uiUnshowDebounceTickCount: -15, attackDelayHoldoffTicks: -17, attackState: NOT_ATTACKING, renderedState: NOT_ATTACKING, pendingEatDelayTicks: 0, currentSpellBook: STANDARD, lastEquippingMonotonicValue: -1, soundEffectTick: -1, soundEffectId: -1 +tickPeriod: 4, uiUnshowDebounceTickCount: -16, attackDelayHoldoffTicks: -18, attackState: NOT_ATTACKING, renderedState: NOT_ATTACKING, pendingEatDelayTicks: 0, currentSpellBook: STANDARD, lastEquippingMonotonicValue: -1, soundEffectTick: -1, soundEffectId: -1 +tickPeriod: 4, uiUnshowDebounceTickCount: -17, attackDelayHoldoffTicks: -19, attackState: NOT_ATTACKING, renderedState: NOT_ATTACKING, pendingEatDelayTicks: 0, currentSpellBook: STANDARD, lastEquippingMonotonicValue: -1, soundEffectTick: -1, soundEffectId: -1 +tickPeriod: 4, uiUnshowDebounceTickCount: -18, attackDelayHoldoffTicks: -20, attackState: NOT_ATTACKING, renderedState: NOT_ATTACKING, pendingEatDelayTicks: 0, currentSpellBook: STANDARD, lastEquippingMonotonicValue: -1, soundEffectTick: -1, soundEffectId: -1 +tickPeriod: 4, uiUnshowDebounceTickCount: -19, attackDelayHoldoffTicks: -20, attackState: NOT_ATTACKING, renderedState: NOT_ATTACKING, pendingEatDelayTicks: 0, currentSpellBook: STANDARD, lastEquippingMonotonicValue: -1, soundEffectTick: -1, soundEffectId: -1 +tickPeriod: 4, uiUnshowDebounceTickCount: -20, attackDelayHoldoffTicks: -20, attackState: NOT_ATTACKING, renderedState: NOT_ATTACKING, pendingEatDelayTicks: 0, currentSpellBook: STANDARD, lastEquippingMonotonicValue: -1, soundEffectTick: -1, soundEffectId: -1 +tickPeriod: 4, uiUnshowDebounceTickCount: -21, attackDelayHoldoffTicks: -20, attackState: NOT_ATTACKING, renderedState: NOT_ATTACKING, pendingEatDelayTicks: 0, currentSpellBook: STANDARD, lastEquippingMonotonicValue: -1, soundEffectTick: -1, soundEffectId: -1 +tickPeriod: 4, uiUnshowDebounceTickCount: -22, attackDelayHoldoffTicks: -20, attackState: NOT_ATTACKING, renderedState: NOT_ATTACKING, pendingEatDelayTicks: 0, currentSpellBook: STANDARD, lastEquippingMonotonicValue: -1, soundEffectTick: -1, soundEffectId: -1 +tickPeriod: 4, uiUnshowDebounceTickCount: -23, attackDelayHoldoffTicks: -20, attackState: NOT_ATTACKING, renderedState: NOT_ATTACKING, pendingEatDelayTicks: 0, currentSpellBook: STANDARD, lastEquippingMonotonicValue: -1, soundEffectTick: -1, soundEffectId: -1 +tickPeriod: 4, uiUnshowDebounceTickCount: -24, attackDelayHoldoffTicks: -20, attackState: NOT_ATTACKING, renderedState: NOT_ATTACKING, pendingEatDelayTicks: 0, currentSpellBook: STANDARD, lastEquippingMonotonicValue: -1, soundEffectTick: -1, soundEffectId: -1 +tickPeriod: 4, uiUnshowDebounceTickCount: -25, attackDelayHoldoffTicks: -20, attackState: NOT_ATTACKING, renderedState: NOT_ATTACKING, pendingEatDelayTicks: 0, currentSpellBook: STANDARD, lastEquippingMonotonicValue: -1, soundEffectTick: -1, soundEffectId: -1 diff --git a/src/test/java/com/attacktimer/testdata/eatingFoodTest.txt b/src/test/java/com/attacktimer/testdata/eatingFoodTest.txt new file mode 100644 index 0000000..2a633f4 --- /dev/null +++ b/src/test/java/com/attacktimer/testdata/eatingFoodTest.txt @@ -0,0 +1,21 @@ +tickPeriod: 0, uiUnshowDebounceTickCount: 0, attackDelayHoldoffTicks: 0, attackState: NOT_ATTACKING, renderedState: NOT_ATTACKING, pendingEatDelayTicks: 0, currentSpellBook: STANDARD, lastEquippingMonotonicValue: -1, soundEffectTick: -1, soundEffectId: -1 +[TEST MESSAGE] 1. Start by setting up the player and plugin +[TEST MESSAGE] 2. Mock an attack animation +tickPeriod: 4, uiUnshowDebounceTickCount: 1, attackDelayHoldoffTicks: 3, attackState: DELAYED_FIRST_TICK, renderedState: NOT_ATTACKING, pendingEatDelayTicks: 0, currentSpellBook: STANDARD, lastEquippingMonotonicValue: -1, soundEffectTick: -1, soundEffectId: -1 +[TEST MESSAGE] 3. Check that the plugin has registered the attack +tickPeriod: 4, uiUnshowDebounceTickCount: 1, attackDelayHoldoffTicks: 3, attackState: DELAYED_FIRST_TICK, renderedState: NOT_ATTACKING, pendingEatDelayTicks: 0, currentSpellBook: STANDARD, lastEquippingMonotonicValue: -1, soundEffectTick: -1, soundEffectId: -1 +[TEST MESSAGE] Perform an eat +tickPeriod: 4, uiUnshowDebounceTickCount: 1, attackDelayHoldoffTicks: 3, attackState: DELAYED_FIRST_TICK, renderedState: NOT_ATTACKING, pendingEatDelayTicks: 3, currentSpellBook: STANDARD, lastEquippingMonotonicValue: -1, soundEffectTick: -1, soundEffectId: -1 +[TEST MESSAGE] Next game tick +tickPeriod: 4, uiUnshowDebounceTickCount: 1, attackDelayHoldoffTicks: 5, attackState: DELAYED, renderedState: NOT_ATTACKING, pendingEatDelayTicks: 0, currentSpellBook: STANDARD, lastEquippingMonotonicValue: -1, soundEffectTick: -1, soundEffectId: -1 +[TEST MESSAGE] Perform a fast eat +tickPeriod: 4, uiUnshowDebounceTickCount: 1, attackDelayHoldoffTicks: 5, attackState: DELAYED, renderedState: NOT_ATTACKING, pendingEatDelayTicks: 2, currentSpellBook: STANDARD, lastEquippingMonotonicValue: -1, soundEffectTick: -1, soundEffectId: -1 +[TEST MESSAGE] Next game tick +tickPeriod: 4, uiUnshowDebounceTickCount: 1, attackDelayHoldoffTicks: 6, attackState: DELAYED, renderedState: NOT_ATTACKING, pendingEatDelayTicks: 0, currentSpellBook: STANDARD, lastEquippingMonotonicValue: -1, soundEffectTick: -1, soundEffectId: -1 +[TEST MESSAGE] 4. Check that the plugin counts down correctly +tickPeriod: 4, uiUnshowDebounceTickCount: 1, attackDelayHoldoffTicks: 5, attackState: DELAYED, renderedState: NOT_ATTACKING, pendingEatDelayTicks: 0, currentSpellBook: STANDARD, lastEquippingMonotonicValue: -1, soundEffectTick: -1, soundEffectId: -1 +tickPeriod: 4, uiUnshowDebounceTickCount: 1, attackDelayHoldoffTicks: 4, attackState: DELAYED, renderedState: NOT_ATTACKING, pendingEatDelayTicks: 0, currentSpellBook: STANDARD, lastEquippingMonotonicValue: -1, soundEffectTick: -1, soundEffectId: -1 +tickPeriod: 4, uiUnshowDebounceTickCount: 1, attackDelayHoldoffTicks: 3, attackState: DELAYED, renderedState: NOT_ATTACKING, pendingEatDelayTicks: 0, currentSpellBook: STANDARD, lastEquippingMonotonicValue: -1, soundEffectTick: -1, soundEffectId: -1 +tickPeriod: 4, uiUnshowDebounceTickCount: 1, attackDelayHoldoffTicks: 2, attackState: DELAYED, renderedState: NOT_ATTACKING, pendingEatDelayTicks: 0, currentSpellBook: STANDARD, lastEquippingMonotonicValue: -1, soundEffectTick: -1, soundEffectId: -1 +tickPeriod: 4, uiUnshowDebounceTickCount: 1, attackDelayHoldoffTicks: 1, attackState: DELAYED, renderedState: NOT_ATTACKING, pendingEatDelayTicks: 0, currentSpellBook: STANDARD, lastEquippingMonotonicValue: -1, soundEffectTick: -1, soundEffectId: -1 +tickPeriod: 4, uiUnshowDebounceTickCount: 1, attackDelayHoldoffTicks: 0, attackState: DELAYED, renderedState: NOT_ATTACKING, pendingEatDelayTicks: 0, currentSpellBook: STANDARD, lastEquippingMonotonicValue: -1, soundEffectTick: -1, soundEffectId: -1 From de2269d89e3ed135c6a3e73547463736da7a28b4 Mon Sep 17 00:00:00 2001 From: Lexer747 Date: Tue, 17 Mar 2026 22:47:27 +0000 Subject: [PATCH 2/3] fix gradle invocation --- build.gradle | 4 +++- src/main/java/com/attacktimer/AttackTimerMetronomePlugin.java | 2 ++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 72f5e48..d8a2744 100644 --- a/build.gradle +++ b/build.gradle @@ -48,8 +48,10 @@ tasks.withType(JavaCompile) { // Code based on https://gist.github.com/lwasyl/6ae0b8a66903729033e18ffc7a503597#file-fancy_logging_original-gradle tasks.withType(Test) { + // if no `-Pupdate=all` is passed to gradle then we assume none + def update=project.properties['update'] ?: "none" // allow specific system properties to be forwarded - systemProperty "update", project.getProperty("update") + systemProperty "update", update outputs.upToDateWhen { false } // always force tests to be re-run testLogging { // set options for log level LIFECYCLE diff --git a/src/main/java/com/attacktimer/AttackTimerMetronomePlugin.java b/src/main/java/com/attacktimer/AttackTimerMetronomePlugin.java index ca50e3f..a22e98a 100644 --- a/src/main/java/com/attacktimer/AttackTimerMetronomePlugin.java +++ b/src/main/java/com/attacktimer/AttackTimerMetronomePlugin.java @@ -526,6 +526,7 @@ protected void shutDown() throws Exception public void writeState(ByteArrayDataOutput outChannel) { StringBuilder sb = new StringBuilder(); + // @formatter:off sb.append("tickPeriod: "); sb.append(this.tickPeriod);sb.append(SEPARATOR); sb.append("uiUnshowDebounceTickCount: "); sb.append(this.uiUnshowDebounceTickCount);sb.append(SEPARATOR); sb.append("attackDelayHoldoffTicks: "); sb.append(this.attackDelayHoldoffTicks);sb.append(SEPARATOR); @@ -536,6 +537,7 @@ public void writeState(ByteArrayDataOutput outChannel) sb.append("lastEquippingMonotonicValue: "); sb.append(this.lastEquippingMonotonicValue);sb.append(SEPARATOR); sb.append("soundEffectTick: "); sb.append(this.soundEffectTick);sb.append(SEPARATOR); sb.append("soundEffectId: "); sb.append(this.soundEffectId);sb.append("\n"); + // @formatter:on byte[] bytes = sb.toString().getBytes(StandardCharsets.UTF_8); outChannel.write(bytes); } From 583dd77882da14feab200d024902347ab277489f Mon Sep 17 00:00:00 2001 From: Lexer747 Date: Tue, 17 Mar 2026 23:05:54 +0000 Subject: [PATCH 3/3] docs and use switch --- .../com/attacktimer/IntegrationTests.java | 14 +++++--- src/test/java/com/attacktimer/README.md | 35 +++++++++++++++++++ 2 files changed, 44 insertions(+), 5 deletions(-) create mode 100644 src/test/java/com/attacktimer/README.md diff --git a/src/test/java/com/attacktimer/IntegrationTests.java b/src/test/java/com/attacktimer/IntegrationTests.java index 4cbebb8..7fb4d4d 100644 --- a/src/test/java/com/attacktimer/IntegrationTests.java +++ b/src/test/java/com/attacktimer/IntegrationTests.java @@ -41,6 +41,7 @@ import static org.junit.Assert.assertArrayEquals; import static org.junit.Assert.assertSame; import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; import org.junit.Before; import org.junit.Test; @@ -309,20 +310,23 @@ public void eatingFoodTest() throws Exception private void performStateVerificationOrUpdate(ByteArrayDataOutput channel, Path path) throws IOException { var actualBytes = channel.toByteArray(); - if (Update.system() == Update.NONE) + switch (Update.system()) { - // We verify that the file on disk is the same as the one generated + case NONE: + // We verify that the file on disk/repo are the same as the one generated var expectedBytes = Files.readAllBytes(path); assertArrayEquals(expectedBytes, actualBytes); - } - else - { + return; + case ALL: // Write the generated one to disk try (var file = Files.newByteChannel(path, EnumSet.of(StandardOpenOption.CREATE, StandardOpenOption.WRITE))) { file.write(ByteBuffer.wrap(actualBytes)); file.close(); } + return; + default: + fail("Unexpected Update enum"); } } diff --git a/src/test/java/com/attacktimer/README.md b/src/test/java/com/attacktimer/README.md new file mode 100644 index 0000000..b68bdf5 --- /dev/null +++ b/src/test/java/com/attacktimer/README.md @@ -0,0 +1,35 @@ +# Testing Approach + +Instead of all testing being done in game in the client and capturing the results as screen shots or videos, +we can instead programmatically mock out scenarios and concretely assert that the plugin state is as expected. + +This can be done via the `performStateVerificationOrUpdate` function which will assert that the plugin state +and test messages are expected. These expectations can be created programmatically too, essentially marking +them as "golden" and verified to be working when they were added. This has better guarantees that more +unexpected state is asserted on, which will help reduce breakages. However it will also produce more +false-negatives as too much state is captured. This trade-off is worth it in my eyes. + +Finally because the test expectations are human plain text, they're easy to digest and diff between revisions. + +## How do I run the tests? + +``` +gradlew test +``` + +You should see something like: +``` +-------------------------------------------------------------------- +| Results: SUCCESS (4 tests, 3 successes, 0 failures, 1 skipped) | +-------------------------------------------------------------------- +``` + +If all the tests are passing. + +## How do I update test expectations? + +``` +gradlew test -Pupdate=all +``` + +You should now see a dirty working dir as the new expectations will be written to your disk. \ No newline at end of file