diff --git a/build.gradle b/build.gradle
index 57e98633..00d0511f 100755
--- a/build.gradle
+++ b/build.gradle
@@ -188,6 +188,13 @@ sourceSets {
}
refMap = "mixins.replaymod.refmap.json"
}
+ integrationTest {
+ compileClasspath += main.runtimeClasspath + main.output
+ java {
+ srcDir file('src/integration-test/java')
+ }
+ resources.srcDir file('src/integration-test/resources')
+ }
}
task copySrg(type: Copy, dependsOn: 'genSrgs') {
@@ -199,6 +206,30 @@ setupDecompWorkspace.dependsOn copySrg
setupDevWorkspace.dependsOn copySrg
project.tasks.idea.dependsOn copySrg
+task runIntegrationTest(type: JavaExec, dependsOn: ["makeStart", "jar"]) {
+ main = 'GradleStart'
+ standardOutput = System.out
+ errorOutput = System.err
+ workingDir file(minecraft.runDir)
+
+ def testDir = new File(minecraft.runDir, "integration-test")
+ doFirst {
+ testDir.deleteDir()
+ testDir.mkdirs()
+ }
+
+ doLast {
+ testDir.deleteDir()
+ }
+
+ afterEvaluate {
+ def runClient = tasks.getByName("runClient")
+ runIntegrationTest.jvmArgs = runClient.jvmArgs + "-Dfml.noGrab=true"
+ runIntegrationTest.args = runClient.args + "--gameDir" + testDir.canonicalPath
+ runIntegrationTest.classpath runClient.classpath + sourceSets.integrationTest.output
+ }
+}
+
def generateVersionsJson() {
// List all tags
def stdout = new ByteArrayOutputStream()
diff --git a/jGui b/jGui
index a77d2f3f..72e7a73b 160000
--- a/jGui
+++ b/jGui
@@ -1 +1 @@
-Subproject commit a77d2f3f50ba61ce68d2de66b798ec1e0a62723e
+Subproject commit 72e7a73b65ab3751efb17b3f542484fd3947594c
diff --git a/src/integration-test/java/IntegrationTest.java b/src/integration-test/java/IntegrationTest.java
new file mode 100644
index 00000000..6f39680e
--- /dev/null
+++ b/src/integration-test/java/IntegrationTest.java
@@ -0,0 +1,30 @@
+import org.apache.commons.io.FileUtils;
+
+import java.io.File;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+/**
+ * Entry point for running integration tests from within the IDE.
+ * This is not called when running integration tests directly from gradle.
+ */
+public class IntegrationTest {
+ public static void main(String[] args) throws Throwable {
+ // Prevent MC from grabbing our mouse during the test (it's not needed)
+ System.setProperty("fml.noGrab", "true");
+
+ // Make sure the test folder exists and is fresh
+ File gameDir = new File(System.getProperty("user.dir"), "integration-test");
+ if (gameDir.exists()) {
+ FileUtils.forceDelete(gameDir);
+ }
+ FileUtils.forceMkdir(gameDir);
+
+ // Set game dir to test folder and call regular entry point
+ List argsList = new ArrayList<>(Arrays.asList(args));
+ argsList.add("--gameDir");
+ argsList.add(gameDir.getCanonicalPath());
+ GradleStart.main(argsList.toArray(new String[argsList.size()]));
+ }
+}
diff --git a/src/integration-test/java/com/replaymod/core/AbstractTask.java b/src/integration-test/java/com/replaymod/core/AbstractTask.java
new file mode 100644
index 00000000..9dd30b4d
--- /dev/null
+++ b/src/integration-test/java/com/replaymod/core/AbstractTask.java
@@ -0,0 +1,342 @@
+package com.replaymod.core;
+
+import com.google.common.collect.Iterables;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.common.util.concurrent.SettableFuture;
+import de.johni0702.minecraft.gui.container.AbstractGuiOverlay;
+import de.johni0702.minecraft.gui.container.AbstractGuiScreen;
+import de.johni0702.minecraft.gui.container.GuiContainer;
+import de.johni0702.minecraft.gui.container.GuiOverlay;
+import de.johni0702.minecraft.gui.container.GuiScreen;
+import de.johni0702.minecraft.gui.element.GuiButton;
+import de.johni0702.minecraft.gui.element.GuiElement;
+import de.johni0702.minecraft.gui.element.GuiTexturedButton;
+import de.johni0702.minecraft.gui.popup.AbstractGuiPopup;
+import de.johni0702.minecraft.gui.utils.Consumer;
+import net.minecraft.client.Minecraft;
+import net.minecraft.client.settings.KeyBinding;
+import net.minecraftforge.common.MinecraftForge;
+import net.minecraftforge.fml.common.FMLCommonHandler;
+import net.minecraftforge.fml.common.eventhandler.SubscribeEvent;
+import net.minecraftforge.fml.common.gameevent.TickEvent;
+import org.lwjgl.input.Keyboard;
+
+import java.lang.reflect.Field;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import java.util.Arrays;
+import java.util.List;
+import java.util.NoSuchElementException;
+import java.util.concurrent.TimeoutException;
+
+import static com.replaymod.core.ReplayModIntegrationTest.LOGGER;
+import static com.replaymod.core.Utils.addCallback;
+
+public abstract class AbstractTask implements Task {
+ public static Task create(Consumer init) {
+ return new AbstractTask() {
+ @Override
+ protected void init() {
+ init.consume(this);
+ }
+ };
+ }
+
+ public static final Minecraft mc = Minecraft.getMinecraft();
+ public final ReplayMod core = ReplayMod.instance;
+ public SettableFuture future;
+
+ @Override
+ public ListenableFuture execute() {
+ future = SettableFuture.create();
+
+ FMLCommonHandler.instance().bus().register(this);
+ MinecraftForge.EVENT_BUS.register(this);
+ addCallback(future, success -> {
+ FMLCommonHandler.instance().bus().unregister(this);
+ MinecraftForge.EVENT_BUS.unregister(this);
+ }, error -> {});
+
+ init();
+
+ return future;
+ }
+
+ protected void init() {}
+
+ protected void runLater(Runnable runnable) {
+ core.runLater(() -> {
+ try {
+ runnable.run();
+ } catch (Throwable t) {
+ future.setException(t);
+ }
+ });
+ }
+
+ public void expectGuiClosed(Runnable onClosed) {
+ expectGuiClosed0(10, onClosed);
+ }
+
+ public void expectGuiClosed(int timeout, Runnable onClosed) {
+ expectGuiClosed0(timeout, onClosed);
+ }
+
+ private void expectGuiClosed0(int timeout, Runnable onClosed) {
+ StackTraceElement[] stackTrace = Thread.currentThread().getStackTrace();
+ class EventHandler {
+ final net.minecraft.client.gui.GuiScreen currentScreen = mc.currentScreen;
+ int framesPassed;
+
+ @SubscribeEvent
+ public void onGuiOpen(TickEvent.RenderTickEvent event) {
+ if (event.phase != TickEvent.Phase.START) return;
+ if (currentScreen != mc.currentScreen) {
+ FMLCommonHandler.instance().bus().unregister(this);
+ onClosed.run();
+ } else {
+ if (framesPassed < timeout) {
+ framesPassed++;
+ } else {
+ Object gui = (gui = GuiScreen.from(currentScreen)) == null ? currentScreen : gui;
+ Exception e = new TimeoutException("Timeout while waiting for " + gui + " to be closed.");
+ e.setStackTrace(Arrays.copyOfRange(stackTrace, 3, stackTrace.length));
+ future.setException(e);
+ }
+ }
+ }
+ }
+ FMLCommonHandler.instance().bus().register(new EventHandler());
+ }
+
+ public void expectPopupClosed(Runnable onClosed) {
+ expectPopupClosed0(10, onClosed);
+ }
+
+ public void expectPopupClosed(int timeout, Runnable onClosed) {
+ expectPopupClosed0(timeout, onClosed);
+ }
+
+ private void expectPopupClosed0(int timeout, Runnable onClosed) {
+ StackTraceElement[] stackTrace = Thread.currentThread().getStackTrace();
+ AbstractGuiPopup popup = getPopup(mc.currentScreen);
+ if (popup == null) {
+ throw new IllegalStateException("No popup found.");
+ }
+ class EventHandler {
+ int framesPassed;
+
+ @SubscribeEvent
+ public void onGuiOpen(TickEvent.RenderTickEvent event) {
+ if (event.phase != TickEvent.Phase.START) return;
+ if (getPopup(mc.currentScreen) != popup) {
+ FMLCommonHandler.instance().bus().unregister(this);
+ onClosed.run();
+ } else {
+ if (framesPassed < timeout) {
+ framesPassed++;
+ } else {
+ Exception e = new TimeoutException("Timeout while waiting for " + popup + " to be closed.");
+ e.setStackTrace(Arrays.copyOfRange(stackTrace, 3, stackTrace.length));
+ future.setException(e);
+ }
+ }
+ }
+ }
+ FMLCommonHandler.instance().bus().register(new EventHandler());
+ }
+
+ private AbstractGuiPopup getPopup(net.minecraft.client.gui.GuiScreen minecraft) {
+ GuiContainer> container = GuiOverlay.from(minecraft);
+ if (container == null) {
+ container = GuiScreen.from(minecraft);
+ }
+ if (container != null) {
+ while (container.getContainer() != null) {
+ container = container.getContainer();
+ }
+ GuiElement popup = Iterables.getLast(container.getChildren());
+ if (popup instanceof AbstractGuiPopup) {
+ return (AbstractGuiPopup) popup;
+ }
+ }
+ return null;
+ }
+
+ public void expectGui(Class guiClass, Consumer onOpen) {
+ StackTraceElement[] stackTrace = Thread.currentThread().getStackTrace();
+ class EventHandler {
+ net.minecraft.client.gui.GuiScreen currentScreen;
+ int framesPassed;
+
+ @SubscribeEvent
+ public void onGuiOpen(TickEvent.RenderTickEvent event) {
+ if (event.phase != TickEvent.Phase.START) return;
+ if (currentScreen != mc.currentScreen) {
+ currentScreen = mc.currentScreen;
+ framesPassed = 0;
+ }
+ if (framesPassed < 10) {
+ framesPassed++;
+ return;
+ }
+
+ FMLCommonHandler.instance().bus().unregister(this);
+
+ Object foundGui = null;
+ if (AbstractGuiScreen.class.isAssignableFrom(guiClass)) {
+ AbstractGuiScreen guiScreen = GuiScreen.from(currentScreen);
+ if (guiClass.isInstance(guiScreen)) {
+ onOpen.consume(guiClass.cast(guiScreen));
+ return;
+ }
+ foundGui = guiScreen;
+ } else if (AbstractGuiOverlay.class.isAssignableFrom(guiClass)) {
+ AbstractGuiOverlay guiScreen = GuiOverlay.from(currentScreen);
+ if (guiClass.isInstance(guiScreen)) {
+ onOpen.consume(guiClass.cast(guiScreen));
+ return;
+ }
+ foundGui = guiScreen;
+ } else if (AbstractGuiPopup.class.isAssignableFrom(guiClass)) {
+ AbstractGuiPopup popup = getPopup(currentScreen);
+ if (guiClass.isInstance(popup)) {
+ onOpen.consume(guiClass.cast(popup));
+ return;
+ }
+ } else {
+ if (guiClass.isInstance(currentScreen)) {
+ onOpen.consume(guiClass.cast(currentScreen));
+ return;
+ }
+ }
+ class UnexpectedGuiException extends Exception {
+ UnexpectedGuiException(Object foundGui) {
+ super("Expected instance of " + guiClass + " but found " + foundGui);
+ setStackTrace(Arrays.copyOfRange(stackTrace, 2, stackTrace.length));
+ }
+ }
+ future.setException(new UnexpectedGuiException(foundGui == null ? currentScreen : foundGui));
+ }
+ }
+ FMLCommonHandler.instance().bus().register(new EventHandler());
+ }
+
+ private void clickNow(int x, int y) {
+ try {
+ Method method = net.minecraft.client.gui.GuiScreen.class
+ .getDeclaredMethod("mouseClicked", int.class, int.class, int.class);
+ method.setAccessible(true);
+ method.invoke(mc.currentScreen, x, y, 0);
+ } catch (NoSuchMethodException | InvocationTargetException | IllegalAccessException e) {
+ future.setException(e);
+ }
+ }
+
+ public void click(int x, int y) {
+ runLater(() -> {
+ LOGGER.info("Clicking at {}/{}", x, y);
+ clickNow(x, y);
+ });
+ }
+
+ private void dragNow(int x, int y) {
+ try {
+ Method method = net.minecraft.client.gui.GuiScreen.class
+ .getDeclaredMethod("mouseClickMove", int.class, int.class, int.class, long.class);
+ method.setAccessible(true);
+ method.invoke(mc.currentScreen, x, y, 0, 0);
+ } catch (NoSuchMethodException | InvocationTargetException | IllegalAccessException e) {
+ future.setException(e);
+ }
+ }
+
+ public void drag(int x, int y) {
+ runLater(() -> {
+ LOGGER.info("Dragging to {}/{}", x, y);
+ dragNow(x, y);
+ });
+ }
+
+ public void click(GuiButton button) {
+ runLater(() -> {
+ if (!button.isEnabled()) {
+ future.setException(new IllegalStateException("Button is disabled: " + button.getLabel()));
+ return;
+ }
+ LOGGER.info("Clicking button {}", button.getLabel());
+ button.onClick();
+ });
+ }
+
+ public void click(GuiTexturedButton button) {
+ runLater(() -> {
+ if (!button.isEnabled()) {
+ future.setException(new IllegalStateException("Button is disabled: " + button.getTexture()));
+ return;
+ }
+ LOGGER.info("Clicking textured button {}", button.getTexture());
+ button.onClick();
+ });
+ }
+
+ public void click(String buttonText) {
+ runLater(() -> {
+ LOGGER.info("Clicking button {}", buttonText);
+ try {
+ Field field = net.minecraft.client.gui.GuiScreen.class.getDeclaredField("buttonList");
+ field.setAccessible(true);
+ @SuppressWarnings("unchecked")
+ List buttonList = (List)
+ field.get(mc.currentScreen);
+
+ net.minecraft.client.gui.GuiButton button = null;
+ for (net.minecraft.client.gui.GuiButton guiButton : buttonList) {
+ if (guiButton.displayString.equals(buttonText)) {
+ button = guiButton;
+ }
+ }
+ if (button == null) {
+ future.setException(new NoSuchElementException("No button with label: " + buttonText));
+ return;
+ }
+
+ clickNow(button.xPosition + 5, button.yPosition + 5);
+ } catch (IllegalAccessException | NoSuchFieldException e) {
+ future.setException(e);
+ }
+ });
+ }
+
+ public void type(String string) {
+ for (char c : string.toCharArray()) {
+ press(c, Keyboard.getKeyIndex(String.valueOf(c).toUpperCase()));
+ }
+ }
+
+ public void press(int keyCode) {
+ String keyName = Keyboard.getKeyName(keyCode);
+ char character = keyName.length() == 1 ? keyName.charAt(0) : '\0';
+ press(character, keyCode);
+ }
+
+ public void press(char character, int keyCode) {
+ runLater(() -> {
+ LOGGER.info("Pressing key {}", Keyboard.getKeyName(keyCode));
+ if (mc.currentScreen == null || mc.currentScreen.allowUserInput) {
+ KeyBinding.onTick(keyCode);
+ FMLCommonHandler.instance().fireKeyInput();
+ }
+ if (mc.currentScreen != null) {
+ try {
+ Method method = net.minecraft.client.gui.GuiScreen.class
+ .getDeclaredMethod("keyTyped", char.class, int.class);
+ method.setAccessible(true);
+ method.invoke(mc.currentScreen, character, keyCode);
+ } catch (NoSuchMethodException | InvocationTargetException | IllegalAccessException e) {
+ future.setException(e);
+ }
+ }
+ });
+ }
+}
diff --git a/src/integration-test/java/com/replaymod/core/CompositeTask.java b/src/integration-test/java/com/replaymod/core/CompositeTask.java
new file mode 100644
index 00000000..3dca8f2f
--- /dev/null
+++ b/src/integration-test/java/com/replaymod/core/CompositeTask.java
@@ -0,0 +1,40 @@
+package com.replaymod.core;
+
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.common.util.concurrent.SettableFuture;
+
+import static com.replaymod.core.ReplayModIntegrationTest.LOGGER;
+
+public class CompositeTask implements Task {
+ private SettableFuture future;
+ protected final Task[] children;
+
+ public CompositeTask(Task[] children) {
+ this.children = children;
+ }
+
+ @Override
+ public ListenableFuture execute() {
+ future = SettableFuture.create();
+ executeChild(0);
+ return future;
+ }
+
+ private void executeChild(int childIndex) {
+ if (future.isDone()) return;
+ if (childIndex < children.length) {
+ ReplayMod.instance.runLater(() -> {
+ try {
+ Task task = children[childIndex];
+ LOGGER.info("Running task {}", task);
+ ListenableFuture childFuture = task.execute();
+ Utils.addCallback(childFuture, done -> executeChild(childIndex + 1), err -> future.setException(err));
+ } catch (Throwable t) {
+ future.setException(t);
+ }
+ });
+ } else {
+ future.set(null);
+ }
+ }
+}
diff --git a/src/integration-test/java/com/replaymod/core/ReplayModIntegrationTest.java b/src/integration-test/java/com/replaymod/core/ReplayModIntegrationTest.java
new file mode 100644
index 00000000..bcf615ce
--- /dev/null
+++ b/src/integration-test/java/com/replaymod/core/ReplayModIntegrationTest.java
@@ -0,0 +1,69 @@
+package com.replaymod.core;
+
+import com.replaymod.core.regression.RegressionTest60;
+import com.replaymod.extra.DownloadOpenEye;
+import com.replaymod.online.SkipLogin;
+import com.replaymod.recording.CreateSPWorld;
+import com.replaymod.recording.ExitSPWorld;
+import com.replaymod.replay.ExitReplay;
+import com.replaymod.replay.LoadReplay;
+import com.replaymod.replay.OpenReplayViewer;
+import net.minecraft.client.Minecraft;
+import net.minecraftforge.fml.common.FMLCommonHandler;
+import net.minecraftforge.fml.common.Mod;
+import net.minecraftforge.fml.common.event.FMLPreInitializationEvent;
+import org.apache.logging.log4j.Logger;
+
+import static com.replaymod.core.AbstractTask.mc;
+import static com.replaymod.core.ReplayModIntegrationTest.MOD_ID;
+import static com.replaymod.core.Utils.addCallback;
+
+/**
+ * Helper mod that initiates the integration tests.
+ */
+@Mod(modid = MOD_ID)
+public class ReplayModIntegrationTest {
+ public static final String MOD_ID = "replaymod-integration-test";
+
+ public static Logger LOGGER;
+
+ @Mod.EventHandler
+ public void init(FMLPreInitializationEvent event) {
+ LOGGER = event.getModLog();
+
+ // Make sure the game window doesn't have to remain in focus during the test
+ mc.gameSettings.pauseOnLostFocus = false;
+
+ runTasks(
+ new SkipLogin(),
+ new DownloadOpenEye(),
+ new CreateSPWorld(),
+ new Wait(5000),
+ new ExitSPWorld(),
+ new OpenReplayViewer(),
+ new LoadReplay(),
+
+ new RegressionTest60(),
+
+ // new AbstractTask() {}, // Uncomment to not exit on success (useful for writing more tests)
+ new ExitReplay()
+ );
+ }
+
+ private void runTasks(Task... tests) {
+ addCallback(new CompositeTask(tests).execute(), success -> {
+ if (!Minecraft.getMinecraft().hasCrashed) {
+ LOGGER.info("===================================================");
+ LOGGER.info("= ALL TESTS PASSED =");
+ LOGGER.info("===================================================");
+ FMLCommonHandler.instance().exitJava(0, false);
+ }
+ }, error -> {
+ LOGGER.error("Failed task:", error);
+ LOGGER.error("===================================================");
+ LOGGER.error("= TEST FAILED =");
+ LOGGER.error("===================================================");
+ FMLCommonHandler.instance().exitJava(1, false);
+ });
+ }
+}
diff --git a/src/integration-test/java/com/replaymod/core/Task.java b/src/integration-test/java/com/replaymod/core/Task.java
new file mode 100644
index 00000000..4db75a0e
--- /dev/null
+++ b/src/integration-test/java/com/replaymod/core/Task.java
@@ -0,0 +1,35 @@
+package com.replaymod.core;
+
+import com.google.common.util.concurrent.ListenableFuture;
+
+public interface Task {
+ ListenableFuture execute();
+
+ static Task click(int x, int y) {
+ return AbstractTask.create(task -> {
+ task.click(x, y);
+ task.runLater(() -> task.future.set(null));
+ });
+ }
+
+ static Task drag(int x, int y) {
+ return AbstractTask.create(task -> {
+ task.drag(x, y);
+ task.runLater(() -> task.future.set(null));
+ });
+ }
+
+ static Task pressKey(int keyCode) {
+ return AbstractTask.create(task -> {
+ task.press(keyCode);
+ task.runLater(() -> task.future.set(null));
+ });
+ }
+
+ static Task pressKey(char character, int keyCode) {
+ return AbstractTask.create(task -> {
+ task.press(character, keyCode);
+ task.runLater(() -> task.future.set(null));
+ });
+ }
+}
diff --git a/src/integration-test/java/com/replaymod/core/Utils.java b/src/integration-test/java/com/replaymod/core/Utils.java
new file mode 100644
index 00000000..b732f25b
--- /dev/null
+++ b/src/integration-test/java/com/replaymod/core/Utils.java
@@ -0,0 +1,31 @@
+package com.replaymod.core;
+
+import com.google.common.util.concurrent.FutureCallback;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+import java.util.function.Consumer;
+
+public class Utils {
+ public static void addCallback(ListenableFuture future, Consumer onSuccess, Consumer onFailure) {
+ Futures.addCallback(future, new FutureCallback() {
+ @Override
+ public void onSuccess(@Nullable T result) {
+ onSuccess.accept(result);
+ }
+
+ @Override
+ public void onFailure(@Nonnull Throwable t) {
+ onFailure.accept(t);
+ }
+ });
+ }
+
+ public static void times(int x, Runnable runnable) {
+ for (int i = 0; i < x; i++) {
+ runnable.run();
+ }
+ }
+}
diff --git a/src/integration-test/java/com/replaymod/core/Wait.java b/src/integration-test/java/com/replaymod/core/Wait.java
new file mode 100644
index 00000000..b528889d
--- /dev/null
+++ b/src/integration-test/java/com/replaymod/core/Wait.java
@@ -0,0 +1,21 @@
+package com.replaymod.core;
+
+public class Wait extends AbstractTask {
+ private final int duration;
+
+ public Wait(int duration) {
+ this.duration = duration;
+ }
+
+ @Override
+ protected void init() {
+ new Thread(() -> {
+ try {
+ Thread.sleep(duration);
+ runLater(() -> future.set(null));
+ } catch (InterruptedException e) {
+ runLater(() -> future.setException(e));
+ }
+ }).start();
+ }
+}
diff --git a/src/integration-test/java/com/replaymod/core/regression/RegressionTest60.java b/src/integration-test/java/com/replaymod/core/regression/RegressionTest60.java
new file mode 100644
index 00000000..72603462
--- /dev/null
+++ b/src/integration-test/java/com/replaymod/core/regression/RegressionTest60.java
@@ -0,0 +1,59 @@
+package com.replaymod.core.regression;
+
+import com.replaymod.core.AbstractTask;
+import com.replaymod.core.CompositeTask;
+import com.replaymod.core.Task;
+import com.replaymod.replay.SpectatePlayer;
+import com.replaymod.replay.overlay.OverlayGui;
+import com.replaymod.simplepathing.GuiPathingTasks;
+import com.replaymod.simplepathing.gui.GuiEditKeyframe;
+import org.lwjgl.input.Keyboard;
+
+import static com.replaymod.core.AbstractTask.mc;
+import static com.replaymod.core.Utils.times;
+
+/**
+ * Regression test: #60 Crash in path preview when two spectator keyframes are closer than 50ms
+ */
+public class RegressionTest60 extends CompositeTask {
+ public RegressionTest60() {
+ super(new Task[]{
+ new SpectatePlayer(),
+ OverlayGui.whileOpened(
+ // Place first spectator keyframe
+ new GuiPathingTasks.ClickPositionKeyframeButton(),
+ // Place second spectator keyframe
+ Task.click(130, 50),
+ new GuiPathingTasks.ClickPositionKeyframeButton(),
+ // Move second keyframe to 20ms on the keyframe timeline
+ AbstractTask.create(task -> {
+ // Double click keyframe
+ task.click(130, 50);
+ task.click(130, 50);
+ task.expectGui(GuiEditKeyframe.Spectator.class, gui -> {
+ // Set ms field to 20
+ task.click(mc.currentScreen.width / 2 + 80, mc.currentScreen.height / 2);
+ times(4, () -> task.press(Keyboard.KEY_BACK));
+ task.type("20");
+ // Set other fields to 0
+ times(2, () -> {
+ task.press(Keyboard.KEY_TAB);
+ times(4, () -> task.press(Keyboard.KEY_BACK));
+ task.press(Keyboard.KEY_0);
+ });
+ task.click(gui.saveButton);
+ task.expectPopupClosed(() -> task.future.set(null));
+ });
+ }),
+ // Place second spectator keyframe
+ new GuiPathingTasks.ClickPositionKeyframeButton()
+ ),
+ // Stop spectating player
+ new SpectatePlayer.End(),
+ // Enable path preview
+ Task.pressKey(Keyboard.KEY_H),
+ new GuiPathingTasks.ClearKeyframeTimeline(),
+ Task.pressKey(Keyboard.KEY_H),
+ });
+ }
+}
diff --git a/src/integration-test/java/com/replaymod/extra/DownloadOpenEye.java b/src/integration-test/java/com/replaymod/extra/DownloadOpenEye.java
new file mode 100644
index 00000000..753090c0
--- /dev/null
+++ b/src/integration-test/java/com/replaymod/extra/DownloadOpenEye.java
@@ -0,0 +1,25 @@
+package com.replaymod.extra;
+
+import com.replaymod.core.AbstractTask;
+import com.replaymod.extras.OpenEyeExtra;
+import net.minecraftforge.fml.common.Loader;
+
+import java.io.File;
+import java.nio.file.NoSuchFileException;
+
+public class DownloadOpenEye extends AbstractTask {
+ @Override
+ protected void init() {
+ expectGui(OpenEyeExtra.OfferGui.class, offerGui -> {
+ click(offerGui.yesButton);
+ expectGuiClosed(20 * 1000, () -> {
+ File targetFile = new File(mc.mcDataDir, "mods/" + Loader.MC_VERSION + "/OpenEye.jar");
+ if (!targetFile.exists()) {
+ future.setException(new NoSuchFileException(targetFile.getAbsolutePath()));
+ } else {
+ future.set(null);
+ }
+ });
+ });
+ }
+}
diff --git a/src/integration-test/java/com/replaymod/online/SkipLogin.java b/src/integration-test/java/com/replaymod/online/SkipLogin.java
new file mode 100644
index 00000000..733b6543
--- /dev/null
+++ b/src/integration-test/java/com/replaymod/online/SkipLogin.java
@@ -0,0 +1,14 @@
+package com.replaymod.online;
+
+import com.replaymod.core.AbstractTask;
+import com.replaymod.online.gui.GuiLoginPrompt;
+
+public class SkipLogin extends AbstractTask {
+ @Override
+ protected void init() {
+ expectGui(GuiLoginPrompt.class, gui -> {
+ click(gui.cancelButton);
+ expectGuiClosed(() -> future.set(null));
+ });
+ }
+}
diff --git a/src/integration-test/java/com/replaymod/recording/CreateSPWorld.java b/src/integration-test/java/com/replaymod/recording/CreateSPWorld.java
new file mode 100644
index 00000000..95f4cb16
--- /dev/null
+++ b/src/integration-test/java/com/replaymod/recording/CreateSPWorld.java
@@ -0,0 +1,32 @@
+package com.replaymod.recording;
+
+import com.replaymod.core.AbstractTask;
+import net.minecraft.client.gui.GuiCreateWorld;
+import net.minecraft.client.gui.GuiMainMenu;
+import net.minecraft.client.gui.GuiSelectWorld;
+import net.minecraftforge.client.event.RenderGameOverlayEvent;
+import net.minecraftforge.common.MinecraftForge;
+import net.minecraftforge.fml.common.eventhandler.SubscribeEvent;
+
+public class CreateSPWorld extends AbstractTask {
+ @Override
+ protected void init() {
+ expectGui(GuiMainMenu.class, mainMenu -> {
+ click("Singleplayer");
+ expectGui(GuiSelectWorld.class, selectWorld -> {
+ click("Create New World");
+ expectGui(GuiCreateWorld.class, createWorld -> {
+ click("Create New World");
+ class EventHandler {
+ @SubscribeEvent
+ public void onRenderIngame(RenderGameOverlayEvent.Pre event) {
+ MinecraftForge.EVENT_BUS.unregister(this);
+ runLater(() -> future.set(null));
+ }
+ }
+ MinecraftForge.EVENT_BUS.register(new EventHandler());
+ });
+ });
+ });
+ }
+}
diff --git a/src/integration-test/java/com/replaymod/recording/ExitSPWorld.java b/src/integration-test/java/com/replaymod/recording/ExitSPWorld.java
new file mode 100644
index 00000000..b531a275
--- /dev/null
+++ b/src/integration-test/java/com/replaymod/recording/ExitSPWorld.java
@@ -0,0 +1,36 @@
+package com.replaymod.recording;
+
+import com.replaymod.core.AbstractTask;
+import net.minecraft.client.gui.GuiIngameMenu;
+import net.minecraft.client.gui.GuiMainMenu;
+import org.apache.commons.io.filefilter.DirectoryFileFilter;
+
+import java.io.IOException;
+
+public class ExitSPWorld extends AbstractTask {
+ @Override
+ protected void init() {
+ mc.displayInGameMenu();
+ expectGui(GuiIngameMenu.class, ingameMenu -> {
+ click("Save and Quit to Title");
+ expectGui(GuiMainMenu.class, mainMenu -> new Thread(() -> {
+ try {
+ while (true) {
+ String[] dirs = core.getReplayFolder().list(DirectoryFileFilter.DIRECTORY);
+ if (dirs == null) {
+ future.setException(new NullPointerException("dirs is null"));
+ return;
+ }
+ if (dirs.length == 0) {
+ runLater(() -> future.set(null));
+ return;
+ }
+ Thread.sleep(10);
+ }
+ } catch (IOException | InterruptedException e) {
+ future.setException(e);
+ }
+ }).start());
+ });
+ }
+}
diff --git a/src/integration-test/java/com/replaymod/replay/ExitReplay.java b/src/integration-test/java/com/replaymod/replay/ExitReplay.java
new file mode 100644
index 00000000..5989e0ef
--- /dev/null
+++ b/src/integration-test/java/com/replaymod/replay/ExitReplay.java
@@ -0,0 +1,16 @@
+package com.replaymod.replay;
+
+import com.replaymod.core.AbstractTask;
+import net.minecraft.client.gui.GuiIngameMenu;
+import net.minecraft.client.gui.GuiMainMenu;
+
+public class ExitReplay extends AbstractTask {
+ @Override
+ protected void init() {
+ mc.displayInGameMenu();
+ expectGui(GuiIngameMenu.class, ingameMenu -> {
+ click("Exit Replay");
+ expectGui(GuiMainMenu.class, mainMenu -> future.set(null));
+ });
+ }
+}
diff --git a/src/integration-test/java/com/replaymod/replay/LoadReplay.java b/src/integration-test/java/com/replaymod/replay/LoadReplay.java
new file mode 100644
index 00000000..2db6383b
--- /dev/null
+++ b/src/integration-test/java/com/replaymod/replay/LoadReplay.java
@@ -0,0 +1,32 @@
+package com.replaymod.replay;
+
+import com.replaymod.core.AbstractTask;
+import com.replaymod.replay.gui.screen.GuiReplayViewer;
+import de.johni0702.minecraft.gui.function.Clickable;
+import net.minecraftforge.client.event.RenderGameOverlayEvent;
+import net.minecraftforge.common.MinecraftForge;
+import net.minecraftforge.fml.common.eventhandler.SubscribeEvent;
+import org.lwjgl.util.Point;
+import org.lwjgl.util.ReadableDimension;
+
+public class LoadReplay extends AbstractTask {
+ @Override
+ protected void init() {
+ expectGui(GuiReplayViewer.class, replayViewer -> runLater(() -> {
+ ReadableDimension size = replayViewer.getMaxSize();
+ // Select first entry
+ replayViewer.forEach(Clickable.class).mouseClick(new Point(size.getWidth() / 2, 40), 0);
+ // Load first replay
+ click(replayViewer.loadButton);
+
+ class EventHandler {
+ @SubscribeEvent
+ public void onRenderIngame(RenderGameOverlayEvent.Pre event) {
+ MinecraftForge.EVENT_BUS.unregister(this);
+ runLater(() -> future.set(null));
+ }
+ }
+ MinecraftForge.EVENT_BUS.register(new EventHandler());
+ }));
+ }
+}
diff --git a/src/integration-test/java/com/replaymod/replay/OpenReplayViewer.java b/src/integration-test/java/com/replaymod/replay/OpenReplayViewer.java
new file mode 100644
index 00000000..fb0e5b50
--- /dev/null
+++ b/src/integration-test/java/com/replaymod/replay/OpenReplayViewer.java
@@ -0,0 +1,15 @@
+package com.replaymod.replay;
+
+import com.replaymod.core.AbstractTask;
+import com.replaymod.replay.gui.screen.GuiReplayViewer;
+import net.minecraft.client.gui.GuiMainMenu;
+
+public class OpenReplayViewer extends AbstractTask {
+ @Override
+ protected void init() {
+ expectGui(GuiMainMenu.class, mainMenu -> {
+ click("Replay Viewer");
+ expectGui(GuiReplayViewer.class, replayViewer -> future.set(null));
+ });
+ }
+}
diff --git a/src/integration-test/java/com/replaymod/replay/SpectatePlayer.java b/src/integration-test/java/com/replaymod/replay/SpectatePlayer.java
new file mode 100644
index 00000000..e024f347
--- /dev/null
+++ b/src/integration-test/java/com/replaymod/replay/SpectatePlayer.java
@@ -0,0 +1,42 @@
+package com.replaymod.replay;
+
+import com.replaymod.core.AbstractTask;
+import com.replaymod.extras.playeroverview.PlayerOverviewGui;
+import net.minecraftforge.fml.common.eventhandler.SubscribeEvent;
+import net.minecraftforge.fml.common.gameevent.TickEvent;
+import org.lwjgl.input.Keyboard;
+
+import java.util.concurrent.TimeoutException;
+
+public class SpectatePlayer extends AbstractTask {
+ @Override
+ protected void init() {
+ press(Keyboard.KEY_B);
+ expectGui(PlayerOverviewGui.class, overview -> {
+ click(overview.getMaxSize().getWidth() / 2 - 60, 60);
+ press(Keyboard.KEY_ESCAPE);
+ expectGuiClosed(() -> future.set(null));
+ });
+ }
+
+ public static class End extends AbstractTask {
+ private int timeout;
+
+ @Override
+ protected void init() {
+ runLater(() -> press(Keyboard.KEY_LSHIFT));
+ }
+
+ @SubscribeEvent
+ public void onTick(TickEvent.RenderTickEvent event) {
+ if (event.phase != TickEvent.Phase.START) return;
+ if (timeout++ > 20) {
+ future.setException(new TimeoutException("Camera hasn't stopped spectating."));
+ return;
+ }
+ if (mc.getRenderViewEntity() == mc.thePlayer) {
+ future.set(null);
+ }
+ }
+ }
+}
diff --git a/src/integration-test/java/com/replaymod/replay/overlay/OverlayGui.java b/src/integration-test/java/com/replaymod/replay/overlay/OverlayGui.java
new file mode 100644
index 00000000..9e261082
--- /dev/null
+++ b/src/integration-test/java/com/replaymod/replay/overlay/OverlayGui.java
@@ -0,0 +1,33 @@
+package com.replaymod.replay.overlay;
+
+import com.replaymod.core.AbstractTask;
+import com.replaymod.core.CompositeTask;
+import com.replaymod.core.Task;
+import com.replaymod.replay.gui.overlay.GuiReplayOverlay;
+import org.lwjgl.input.Keyboard;
+
+public class OverlayGui {
+ public static Task whileOpened(Task...tasks) {
+ Task[] nTasks = new Task[tasks.length + 2];
+ System.arraycopy(tasks, 0, nTasks, 1, tasks.length);
+ nTasks[0] = new Open();
+ nTasks[tasks.length + 1] = new Close();
+ return new CompositeTask(nTasks);
+ }
+
+ public static class Open extends AbstractTask {
+ @Override
+ protected void init() {
+ press(Keyboard.KEY_T);
+ expectGui(GuiReplayOverlay.class, done -> future.set(null));
+ }
+ }
+
+ public static class Close extends AbstractTask {
+ @Override
+ protected void init() {
+ press(Keyboard.KEY_ESCAPE);
+ expectGuiClosed(() -> future.set(null));
+ }
+ }
+}
diff --git a/src/integration-test/java/com/replaymod/simplepathing/GuiPathingTasks.java b/src/integration-test/java/com/replaymod/simplepathing/GuiPathingTasks.java
new file mode 100644
index 00000000..2e88ac6b
--- /dev/null
+++ b/src/integration-test/java/com/replaymod/simplepathing/GuiPathingTasks.java
@@ -0,0 +1,35 @@
+package com.replaymod.simplepathing;
+
+import com.replaymod.core.AbstractTask;
+import com.replaymod.replay.gui.overlay.GuiReplayOverlay;
+import com.replaymod.simplepathing.gui.GuiPathing;
+import de.johni0702.minecraft.gui.popup.GuiYesNoPopup;
+import org.lwjgl.input.Keyboard;
+
+public abstract class GuiPathingTasks extends AbstractTask {
+ @Override
+ protected void init() {
+ expectGui(GuiReplayOverlay.class, ign -> init0(ReplayModSimplePathing.instance.getGuiPathing()));
+ }
+
+ protected abstract void init0(GuiPathing guiPathing);
+
+ public static class ClickPositionKeyframeButton extends GuiPathingTasks {
+ @Override
+ protected void init0(GuiPathing guiPathing) {
+ click(guiPathing.positionKeyframeButton);
+ runLater(() -> future.set(null));
+ }
+ }
+
+ public static class ClearKeyframeTimeline extends AbstractTask {
+ @Override
+ protected void init() {
+ press(Keyboard.KEY_C);
+ expectGui(GuiYesNoPopup.class, popup -> {
+ click(popup.getYesButton());
+ expectGuiClosed(() -> future.set(null));
+ });
+ }
+ }
+}
diff --git a/src/main/java/com/replaymod/extras/OpenEyeExtra.java b/src/main/java/com/replaymod/extras/OpenEyeExtra.java
index 72bdce4d..ba0575fb 100644
--- a/src/main/java/com/replaymod/extras/OpenEyeExtra.java
+++ b/src/main/java/com/replaymod/extras/OpenEyeExtra.java
@@ -43,7 +43,7 @@ public class OpenEyeExtra implements Extra {
}
}
- private class OfferGui extends AbstractGuiScreen {
+ public class OfferGui extends AbstractGuiScreen {
public final GuiScreen parent;
public final GuiPanel textPanel = new GuiPanel().setLayout(new VerticalLayout().setSpacing(3))
.addElements(new VerticalLayout.Data(0.5),
@@ -107,7 +107,7 @@ public class OpenEyeExtra implements Extra {
}
}
- private static final class GuiPopup extends AbstractGuiPopup {
+ public static final class GuiPopup extends AbstractGuiPopup {
GuiPopup(GuiContainer container) {
super(container);
popup.addElements(null, new GuiIndicator().setColor(Colors.BLACK));
diff --git a/src/main/java/com/replaymod/online/gui/GuiLoginPrompt.java b/src/main/java/com/replaymod/online/gui/GuiLoginPrompt.java
index aeccce8f..f85080d1 100755
--- a/src/main/java/com/replaymod/online/gui/GuiLoginPrompt.java
+++ b/src/main/java/com/replaymod/online/gui/GuiLoginPrompt.java
@@ -21,7 +21,7 @@ public class GuiLoginPrompt extends AbstractGuiScreen {
private GuiLabel noAccountLabel = new GuiLabel(this).setI18nText("replaymod.gui.login.noacc");
private GuiLabel statusLabel = new GuiLabel(this);
private GuiButton loginButton = new GuiButton(this).setI18nLabel("replaymod.gui.login").setSize(150, 20).setEnabled(false);
- private GuiButton cancelButton = new GuiButton(this).setI18nLabel("replaymod.gui.cancel").setSize(150, 20);
+ public GuiButton cancelButton = new GuiButton(this).setI18nLabel("replaymod.gui.cancel").setSize(150, 20);
private GuiButton registerButton = new GuiButton(this).setI18nLabel("replaymod.gui.register").setSize(150, 20);
private GuiTextField username = new GuiTextField(this).setSize(145, 20).setMaxLength(16).setFocused(true);
private GuiPasswordField password = new GuiPasswordField(this).setSize(145, 20)
diff --git a/src/main/java/com/replaymod/simplepathing/ReplayModSimplePathing.java b/src/main/java/com/replaymod/simplepathing/ReplayModSimplePathing.java
index 85dbd5d9..fec48686 100644
--- a/src/main/java/com/replaymod/simplepathing/ReplayModSimplePathing.java
+++ b/src/main/java/com/replaymod/simplepathing/ReplayModSimplePathing.java
@@ -22,6 +22,9 @@ import org.apache.logging.log4j.Logger;
public class ReplayModSimplePathing {
public static final String MOD_ID = "replaymod-simplepathing";
+ @Mod.Instance(MOD_ID)
+ public static ReplayModSimplePathing instance;
+
private ReplayMod core;
public static Logger LOGGER;