From 2a7a44a7097eba2685f75c34ba013e521907ee34 Mon Sep 17 00:00:00 2001 From: Kevin Turner <83819+keturn@users.noreply.github.com> Date: Tue, 2 Nov 2021 13:22:10 -0700 Subject: [PATCH 1/5] refactor: provide configuration values in constructor remove JUnit annotations now that this is no longer a superclass --- .../MTEExtension.java | 18 +--- .../ModuleTestingEnvironment.java | 93 ++++--------------- .../ModuleTestingHelper.java | 7 +- 3 files changed, 26 insertions(+), 92 deletions(-) diff --git a/src/main/java/org/terasology/moduletestingenvironment/MTEExtension.java b/src/main/java/org/terasology/moduletestingenvironment/MTEExtension.java index cd0516b..d33e550 100644 --- a/src/main/java/org/terasology/moduletestingenvironment/MTEExtension.java +++ b/src/main/java/org/terasology/moduletestingenvironment/MTEExtension.java @@ -185,25 +185,9 @@ void setupLogging() { static class HelperCleaner implements ExtensionContext.Store.CloseableResource { protected ModuleTestingHelper helper; - HelperCleaner(ModuleTestingHelper helper) { - this.helper = helper; - } - HelperCleaner(Set dependencyNames, String worldGeneratorUri) { - this(setupHelper(new ModuleTestingHelper(), dependencyNames, worldGeneratorUri)); - } - - protected static ModuleTestingHelper setupHelper(ModuleTestingHelper helper, Set dependencyNames, - String worldGeneratorUri) { - // This is a shim to fit the existing ModuleTestingEnvironment interface, but - // I expect we can make things cleaner after we drop the old interface that is - // also pretending to be a TestCase class itself. - helper.setDependencies(dependencyNames); - if (worldGeneratorUri != null) { - helper.setWorldGeneratorUri(worldGeneratorUri); - } + helper = new ModuleTestingHelper(dependencyNames, worldGeneratorUri); helper.setup(); - return helper; } @Override diff --git a/src/main/java/org/terasology/moduletestingenvironment/ModuleTestingEnvironment.java b/src/main/java/org/terasology/moduletestingenvironment/ModuleTestingEnvironment.java index 51bf158..35ee2df 100644 --- a/src/main/java/org/terasology/moduletestingenvironment/ModuleTestingEnvironment.java +++ b/src/main/java/org/terasology/moduletestingenvironment/ModuleTestingEnvironment.java @@ -2,7 +2,6 @@ // SPDX-License-Identifier: Apache-2.0 package org.terasology.moduletestingenvironment; -import com.google.common.base.Preconditions; import com.google.common.collect.Lists; import com.google.common.collect.Sets; import com.google.common.util.concurrent.ListenableFuture; @@ -14,8 +13,6 @@ import org.joml.Vector3fc; import org.joml.Vector3i; import org.joml.Vector3ic; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.terasology.engine.config.Config; @@ -103,25 +100,11 @@ * or conversely run until a condition is true via {@link #runUntil(Supplier)}
{@code runUntil(()-> false);} * *

Specifying Dependencies

- * By default the environment will load only the engine itself. In order to load your own module code, you must override - * {@link #getDependencies()} in your test subclass. - *
- * {@literal
- * public Set getDependencies() {
- *     return Sets.newHashSet("engine", "ModuleTestingEnvironment");
- * }
- * }
- * 
+ * By default the environment will load only the engine itself. FIXME + * *

Specifying World Generator

* By default the environment will use a dummy world generator which creates nothing but air. To specify a more useful - * world generator you must override {@link #getWorldGeneratorUri()} in your test subclass. - *
- * {@literal
- * public String getWorldGeneratorUri() {
- *     return "moduletestingenvironment:dummy";
- * }
- * }
- * 
+ * world generator you must FIXME * *

Reuse the MTE for Multiple Tests

* To use the same engine for multiple tests the testing environment can be set up explicitly and shared between tests. @@ -162,26 +145,36 @@ public class ModuleTestingEnvironment { public static final long DEFAULT_SAFETY_TIMEOUT = 60000; public static final long DEFAULT_GAME_TIME_TIMEOUT = 30000; + public static final String DEFAULT_WORLD_GENERATOR = "moduletestingenvironment:dummy"; + private static final Logger logger = LoggerFactory.getLogger(ModuleTestingEnvironment.class); + protected final Set dependencies = Sets.newHashSet("engine"); + protected String worldGeneratorUri = DEFAULT_WORLD_GENERATOR; + PathManager pathManager; PathManagerProvider.Cleaner pathManagerCleaner; - private final Set dependencies = Sets.newHashSet("engine"); - private String worldGeneratorUri = "moduletestingenvironment:dummy"; private boolean doneLoading; private TerasologyEngine host; private Context hostContext; private final List engines = Lists.newArrayList(); private long safetyTimeoutMs = DEFAULT_SAFETY_TIMEOUT; + protected ModuleTestingEnvironment(Set dependencies, String worldGeneratorUri) { + this.dependencies.addAll(dependencies); + + if (worldGeneratorUri != null) { + this.worldGeneratorUri = worldGeneratorUri; + } + } + /** * Set up and start the engine as configured via this environment. *

* Every instance should be shut down properly by calling {@link #tearDown()}. */ - @BeforeEach - public void setup() { + protected void setup() { mockPathManager(); try { host = createHost(); @@ -198,8 +191,7 @@ public void setup() { *

* Used to properly shut down and clean up a testing environment set up and started with {@link #setup()}. */ - @AfterEach - public void tearDown() { + protected void tearDown() { engines.forEach(TerasologyEngine::shutdown); engines.forEach(TerasologyEngine::cleanup); engines.clear(); @@ -214,53 +206,6 @@ public void tearDown() { hostContext = null; } - /** - * Override this to change which modules must be loaded for the environment - * - * @return The set of module names to load - */ - public Set getDependencies() { - return dependencies; - } - - /** - * Setting dependencies for using by {@link ModuleTestingEnvironment}. - * - * @param dependencies the set of module names to load - * @throws IllegalStateException if you tried setWorldGeneratorUrl after {@link - * ModuleTestingEnvironment#setup()} - */ - void setDependencies(Set dependencies) { - Preconditions.checkState(host == null, "You cannot set Dependencies after setup"); - synchronized (this.dependencies) { - this.dependencies.clear(); - this.dependencies.addAll(dependencies); - } - } - - /** - * Override this to change which world generator to use. Defaults to a dummy generator that leaves all blocks as - * air - * - * @return the uri of the desired world generator - */ - public String getWorldGeneratorUri() { - return worldGeneratorUri; - } - - /** - * Setting world generator for using by {@link ModuleTestingEnvironment}. - * - * @param worldGeneratorUri the uri of desired world generator - * @throws IllegalStateException if you try'd setWorldGeneratorUrl after {@link - * ModuleTestingEnvironment#setup()} - */ - void setWorldGeneratorUri(String worldGeneratorUri) { - Preconditions.checkState(host == null, "You cannot set Dependencies after setup"); - this.worldGeneratorUri = worldGeneratorUri; - } - - /** * Creates a dummy entity with RelevanceRegion component to force a chunk's generation and availability. Blocks * while waiting for the chunk to become loaded @@ -556,7 +501,7 @@ private TerasologyEngine createHost() throws IOException { TerasologyEngine terasologyEngine = createHeadlessEngine(); terasologyEngine.getFromEngineContext(SystemConfig.class).writeSaveGamesEnabled.set(false); terasologyEngine.subscribeToStateChange(new HeadlessStateChangeListener(terasologyEngine)); - terasologyEngine.changeState(new TestingStateHeadlessSetup(getDependencies(), getWorldGeneratorUri())); + terasologyEngine.changeState(new TestingStateHeadlessSetup(dependencies, worldGeneratorUri)); doneLoading = false; terasologyEngine.subscribeToStateChange(() -> { diff --git a/src/main/java/org/terasology/moduletestingenvironment/ModuleTestingHelper.java b/src/main/java/org/terasology/moduletestingenvironment/ModuleTestingHelper.java index 2f562dc..012cc5f 100644 --- a/src/main/java/org/terasology/moduletestingenvironment/ModuleTestingHelper.java +++ b/src/main/java/org/terasology/moduletestingenvironment/ModuleTestingHelper.java @@ -1,8 +1,10 @@ -// Copyright 2020 The Terasology Foundation +// Copyright 2021 The Terasology Foundation // SPDX-License-Identifier: Apache-2.0 package org.terasology.moduletestingenvironment; +import java.util.Set; + /** * Helpers for use in module tests. *

@@ -12,4 +14,7 @@ * In the next major release the base class will be removed and all methods will live in this class directly. */ public class ModuleTestingHelper extends ModuleTestingEnvironment { + public ModuleTestingHelper(Set dependencies, String worldGeneratorUri) { + super(dependencies, worldGeneratorUri); + } } From d9e12ec14b613d6c9afd65d61fcb8811ef5be9e5 Mon Sep 17 00:00:00 2001 From: Kevin Turner <83819+keturn@users.noreply.github.com> Date: Tue, 2 Nov 2021 15:14:00 -0700 Subject: [PATCH 2/5] refactor: split ModuleTestingEnvironment into Engines and MainLoop classes MainLoop has the methods tests tend to interact with, Engines manages the setup behind the scenes. --- .../moduletestingenvironment/Engines.java | 295 +++++++++++ .../MTEExtension.java | 3 +- .../moduletestingenvironment/MainLoop.java | 211 ++++++++ .../ModuleTestingEnvironment.java | 500 ++---------------- .../ModuleTestingHelper.java | 173 +++++- 5 files changed, 707 insertions(+), 475 deletions(-) create mode 100644 src/main/java/org/terasology/moduletestingenvironment/Engines.java create mode 100644 src/main/java/org/terasology/moduletestingenvironment/MainLoop.java diff --git a/src/main/java/org/terasology/moduletestingenvironment/Engines.java b/src/main/java/org/terasology/moduletestingenvironment/Engines.java new file mode 100644 index 0000000..3f0e91a --- /dev/null +++ b/src/main/java/org/terasology/moduletestingenvironment/Engines.java @@ -0,0 +1,295 @@ +// Copyright 2021 The Terasology Foundation +// SPDX-License-Identifier: Apache-2.0 + +package org.terasology.moduletestingenvironment; + +import com.google.common.collect.Lists; +import com.google.common.collect.Sets; +import org.mockito.Mockito; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.terasology.engine.config.Config; +import org.terasology.engine.config.SystemConfig; +import org.terasology.engine.context.Context; +import org.terasology.engine.core.GameEngine; +import org.terasology.engine.core.PathManager; +import org.terasology.engine.core.PathManagerProvider; +import org.terasology.engine.core.TerasologyConstants; +import org.terasology.engine.core.TerasologyEngine; +import org.terasology.engine.core.TerasologyEngineBuilder; +import org.terasology.engine.core.modes.GameState; +import org.terasology.engine.core.modes.StateIngame; +import org.terasology.engine.core.modes.StateLoading; +import org.terasology.engine.core.modes.StateMainMenu; +import org.terasology.engine.core.module.ModuleManager; +import org.terasology.engine.core.subsystem.EngineSubsystem; +import org.terasology.engine.core.subsystem.headless.HeadlessAudio; +import org.terasology.engine.core.subsystem.headless.HeadlessGraphics; +import org.terasology.engine.core.subsystem.headless.HeadlessInput; +import org.terasology.engine.core.subsystem.headless.HeadlessTimer; +import org.terasology.engine.core.subsystem.headless.mode.HeadlessStateChangeListener; +import org.terasology.engine.core.subsystem.lwjgl.LwjglAudio; +import org.terasology.engine.core.subsystem.lwjgl.LwjglGraphics; +import org.terasology.engine.core.subsystem.lwjgl.LwjglInput; +import org.terasology.engine.core.subsystem.lwjgl.LwjglTimer; +import org.terasology.engine.core.subsystem.openvr.OpenVRInput; +import org.terasology.engine.network.JoinStatus; +import org.terasology.engine.network.NetworkSystem; +import org.terasology.engine.registry.CoreRegistry; +import org.terasology.engine.rendering.opengl.ScreenGrabber; +import org.terasology.engine.rendering.world.viewDistance.ViewDistance; +import org.terasology.engine.testUtil.WithUnittestModule; +import org.terasology.gestalt.module.Module; +import org.terasology.gestalt.module.ModuleMetadataJsonAdapter; +import org.terasology.gestalt.module.ModuleRegistry; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Collections; +import java.util.List; +import java.util.Set; + +/** + * Manages game engines for tests. + *

+ * There is always one engine that serves as the host. There may also be additional engines + * simulating remote clients. + *

+ * Most tests run with a single host and do not need to make direct references to this class. + */ +public class Engines { + private static final Logger logger = LoggerFactory.getLogger(Engines.class); + + protected final Set dependencies = Sets.newHashSet("engine"); + protected String worldGeneratorUri = ModuleTestingEnvironment.DEFAULT_WORLD_GENERATOR; + protected boolean doneLoading; + protected Context hostContext; + protected final List engines = Lists.newArrayList(); + + PathManager pathManager; + PathManagerProvider.Cleaner pathManagerCleaner; + TerasologyEngine host; + + public Engines(Set dependencies, String worldGeneratorUri) { + this.dependencies.addAll(dependencies); + + if (worldGeneratorUri != null) { + this.worldGeneratorUri = worldGeneratorUri; + } + } + + /** + * Set up and start the engine as configured via this environment. + *

+ * Every instance should be shut down properly by calling {@link #tearDown()}. + */ + protected void setup() { + mockPathManager(); + try { + host = createHost(); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + ScreenGrabber grabber = Mockito.mock(ScreenGrabber.class); + hostContext.put(ScreenGrabber.class, grabber); + CoreRegistry.put(GameEngine.class, host); + } + + /** + * Shut down a previously started testing environment. + *

+ * Used to properly shut down and clean up a testing environment set up and started with {@link #setup()}. + */ + protected void tearDown() { + engines.forEach(TerasologyEngine::shutdown); + engines.forEach(TerasologyEngine::cleanup); + engines.clear(); + try { + pathManagerCleaner.close(); + } catch (RuntimeException e) { + throw e; + } catch (Throwable e) { + throw new RuntimeException(e); + } + host = null; + hostContext = null; + } + + /** + * Creates a new client and connects it to the host. + * + * @return the created client's context object + */ + public Context createClient(MainLoop mainLoop) throws IOException { + TerasologyEngine terasologyEngine = createHeadlessEngine(); + terasologyEngine.getFromEngineContext(Config.class).getRendering().setViewDistance(ViewDistance.LEGALLY_BLIND); + + terasologyEngine.changeState(new StateMainMenu()); + connectToHost(terasologyEngine, mainLoop); + Context context = terasologyEngine.getState().getContext(); + context.put(ScreenGrabber.class, hostContext.get(ScreenGrabber.class)); + return terasologyEngine.getState().getContext(); + } + + /** + * The engines active in this instance of the module testing environment. + *

+ * Engines are created for the host and connecting clients. + * + * @return list of active engines + */ + public List getEngines() { + return Lists.newArrayList(engines); + } + + /** + * Get the host context for this module testing environment. + *

+ * The host context will be null if the testing environment has not been set up via {@link ModuleTestingHelper#setup()} + * beforehand. + * + * @return the engine's host context, or null if not set up yet + */ + public Context getHostContext() { + return hostContext; + } + + TerasologyEngine createHeadlessEngine() throws IOException { + TerasologyEngineBuilder terasologyEngineBuilder = new TerasologyEngineBuilder(); + terasologyEngineBuilder + .add(new WithUnittestModule()) + .add(new HeadlessGraphics()) + .add(new HeadlessTimer()) + .add(new HeadlessAudio()) + .add(new HeadlessInput()); + + return createEngine(terasologyEngineBuilder); + } + + @SuppressWarnings("unused") + TerasologyEngine createHeadedEngine() throws IOException { + EngineSubsystem audio = new LwjglAudio(); + TerasologyEngineBuilder terasologyEngineBuilder = new TerasologyEngineBuilder() + .add(new WithUnittestModule()) + .add(audio) + .add(new LwjglGraphics()) + .add(new LwjglTimer()) + .add(new LwjglInput()) + .add(new OpenVRInput()); + + return createEngine(terasologyEngineBuilder); + } + + TerasologyEngine createEngine(TerasologyEngineBuilder terasologyEngineBuilder) throws IOException { + System.setProperty(ModuleManager.LOAD_CLASSPATH_MODULES_PROPERTY, "true"); + + // create temporary home paths so the MTE engines don't overwrite config/save files in your real home path + // FIXME: Collisions when attempting to do multiple simultaneous createEngines. + // (PathManager will need to be set in Context, not a process-wide global.) + Path path = Files.createTempDirectory("terasology-mte-engine"); + PathManager.getInstance().useOverrideHomePath(path); + logger.info("Created temporary engine home path: {}", path); + + // JVM will delete these on normal termination but not exceptions. + path.toFile().deleteOnExit(); + + TerasologyEngine terasologyEngine = terasologyEngineBuilder.build(); + terasologyEngine.initialize(); + registerCurrentDirectoryIfModule(terasologyEngine); + + engines.add(terasologyEngine); + return terasologyEngine; + } + + /** + * In standalone module environments (i.e. Jenkins CI builds) the CWD is the module under test. When it uses MTE it very likely needs to + * load itself as a module, but it won't be loadable from the typical path such as ./modules. This means that modules using MTE would + * always fail CI tests due to failing to load themselves. + *

+ * For these cases we try to load the CWD (via the installPath) as a module and put it in the global module registry. + *

+ * This process is based on how ModuleManagerImpl uses ModulePathScanner to scan for available modules. + */ + protected void registerCurrentDirectoryIfModule(TerasologyEngine terasologyEngine) { + Path installPath = PathManager.getInstance().getInstallPath(); + ModuleManager moduleManager = terasologyEngine.getFromEngineContext(ModuleManager.class); + ModuleRegistry registry = moduleManager.getRegistry(); + ModuleMetadataJsonAdapter metadataReader = moduleManager.getModuleMetadataReader(); + moduleManager.getModuleFactory().getModuleMetadataLoaderMap() + .put(TerasologyConstants.MODULE_INFO_FILENAME.toString(), metadataReader); + + + try { + Module module = moduleManager.getModuleFactory().createModule(installPath.toFile()); + if (module != null) { + registry.add(module); + logger.info("Added install path as module: {}", installPath); + } else { + logger.info("Install path does not appear to be a module: {}", installPath); + } + } catch (IOException e) { + logger.warn("Could not read install path as module at " + installPath); + } + } + + protected void mockPathManager() { + PathManager originalPathManager = PathManager.getInstance(); + pathManager = Mockito.spy(originalPathManager); + Mockito.when(pathManager.getModulePaths()).thenReturn(Collections.emptyList()); + pathManagerCleaner = new PathManagerProvider.Cleaner(originalPathManager, pathManager); + PathManagerProvider.setPathManager(pathManager); + } + + TerasologyEngine createHost() throws IOException { + TerasologyEngine terasologyEngine = createHeadlessEngine(); + terasologyEngine.getFromEngineContext(SystemConfig.class).writeSaveGamesEnabled.set(false); + terasologyEngine.subscribeToStateChange(new HeadlessStateChangeListener(terasologyEngine)); + terasologyEngine.changeState(new TestingStateHeadlessSetup(dependencies, worldGeneratorUri)); + + doneLoading = false; + terasologyEngine.subscribeToStateChange(() -> { + GameState newState = terasologyEngine.getState(); + logger.debug("New engine state is {}", terasologyEngine.getState()); + if (newState instanceof StateIngame) { + hostContext = newState.getContext(); + if (hostContext == null) { + logger.warn("hostContext is NULL in engine state {}", newState); + } + doneLoading = true; + } else if (newState instanceof StateLoading) { + CoreRegistry.put(GameEngine.class, terasologyEngine); + } + }); + + boolean keepTicking; + while (!doneLoading) { + keepTicking = terasologyEngine.tick(); + if (!keepTicking) { + throw new RuntimeException(String.format( + "Engine stopped ticking before we got in game. Current state: %s", + terasologyEngine.getState() + )); + } + } + return terasologyEngine; + } + + void connectToHost(TerasologyEngine client, MainLoop mainLoop) { + CoreRegistry.put(Config.class, client.getFromEngineContext(Config.class)); + JoinStatus joinStatus = null; + try { + joinStatus = client.getFromEngineContext(NetworkSystem.class).join("localhost", 25777); + } catch (InterruptedException e) { + logger.warn("Interrupted while joining: ", e); + } + + client.changeState(new StateLoading(joinStatus)); + CoreRegistry.put(GameEngine.class, client); + + // TODO: subscribe to state change and return an asynchronous result + // so that we don't need to pass mainLoop to here. + mainLoop.runUntil(() -> client.getState() instanceof StateIngame); + } +} diff --git a/src/main/java/org/terasology/moduletestingenvironment/MTEExtension.java b/src/main/java/org/terasology/moduletestingenvironment/MTEExtension.java index d33e550..61f3385 100644 --- a/src/main/java/org/terasology/moduletestingenvironment/MTEExtension.java +++ b/src/main/java/org/terasology/moduletestingenvironment/MTEExtension.java @@ -65,7 +65,6 @@ public void beforeAll(ExtensionContext context) { public boolean supportsParameter(ParameterContext parameterContext, ExtensionContext extensionContext) throws ParameterResolutionException { ModuleTestingHelper helper = getHelper(extensionContext); return helper.getHostContext().get(parameterContext.getParameter().getType()) != null - || parameterContext.getParameter().getType().equals(ModuleTestingEnvironment.class) || parameterContext.getParameter().getType().equals(ModuleTestingHelper.class); } @@ -78,7 +77,7 @@ public Object resolveParameter(ParameterContext parameterContext, ExtensionConte } private Object getDIInstance(ModuleTestingHelper helper, Class type) { - if (type.equals(ModuleTestingHelper.class) || type.equals(ModuleTestingEnvironment.class)) { + if (type.equals(ModuleTestingHelper.class)) { return helper; } return helper.getHostContext().get(type); diff --git a/src/main/java/org/terasology/moduletestingenvironment/MainLoop.java b/src/main/java/org/terasology/moduletestingenvironment/MainLoop.java new file mode 100644 index 0000000..e1a4c8f --- /dev/null +++ b/src/main/java/org/terasology/moduletestingenvironment/MainLoop.java @@ -0,0 +1,211 @@ +// Copyright 2021 The Terasology Foundation +// SPDX-License-Identifier: Apache-2.0 + +package org.terasology.moduletestingenvironment; + +import com.google.common.base.Preconditions; +import com.google.common.base.Verify; +import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.UncheckedExecutionException; +import com.google.common.util.concurrent.UncheckedTimeoutException; +import org.joml.Matrix4f; +import org.joml.RoundingMode; +import org.joml.Vector3f; +import org.joml.Vector3fc; +import org.joml.Vector3i; +import org.joml.Vector3ic; +import org.terasology.engine.core.TerasologyEngine; +import org.terasology.engine.core.Time; +import org.terasology.engine.entitySystem.entity.EntityManager; +import org.terasology.engine.world.WorldProvider; +import org.terasology.engine.world.block.BlockRegion; +import org.terasology.engine.world.block.BlockRegionc; +import org.terasology.engine.world.chunks.Chunks; +import org.terasology.engine.world.chunks.localChunkProvider.RelevanceSystem; + +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.function.Supplier; + +/** + * Methods to run the main loop of the game. + *

+ * {@link MTEExtension} provides tests with a game engine, configured with a module environment + * and a world. The engine is ready by the time a test method is executed, but does not run + * until you use one of these methods. + *

+ * If there are multiple engines (a host and one or more clients), they will tick in a round-robin fashion. + */ +public class MainLoop { + long safetyTimeoutMs = ModuleTestingEnvironment.DEFAULT_SAFETY_TIMEOUT; + + private final Engines engines; + + public MainLoop(Engines engines) { + this.engines = engines; + } + + /** + * Creates a dummy entity with RelevanceRegion component to force a chunk's generation and availability. Blocks while waiting for the + * chunk to become loaded + * + * @param blockPos the block position of the dummy entity. Only the chunk containing this position will be available + */ + public void forceAndWaitForGeneration(Vector3ic blockPos) { + WorldProvider worldProvider = engines.getHostContext().get(WorldProvider.class); + if (worldProvider.isBlockRelevant(blockPos)) { + return; + } + + ListenableFuture chunkRegion = makeBlocksRelevant(new BlockRegion(blockPos)); + runUntil(chunkRegion); + } + + /** + * @param blocks blocks to mark as relevant + * @return relevant chunks + */ + public ListenableFuture makeBlocksRelevant(BlockRegionc blocks) { + BlockRegion desiredChunkRegion = Chunks.toChunkRegion(new BlockRegion(blocks)); + return makeChunksRelevant(desiredChunkRegion, blocks.center(new Vector3f())); + } + + @SuppressWarnings("unused") + public ListenableFuture makeChunksRelevant(BlockRegion chunks) { + // Pick a central point (in block coordinates). + Vector3f centerPoint = chunkRegionToNewBlockRegion(chunks).center(new Vector3f()); + + return makeChunksRelevant(chunks, centerPoint); + } + + public ListenableFuture makeChunksRelevant(BlockRegion chunks, Vector3fc centerBlock) { + Preconditions.checkArgument(chunks.contains(Chunks.toChunkPos(new Vector3i(centerBlock, RoundingMode.FLOOR))), + "centerBlock should %s be within the region %s", + centerBlock, chunkRegionToNewBlockRegion(chunks)); + Vector3i desiredSize = chunks.getSize(new Vector3i()); + + EntityManager entityManager = Verify.verifyNotNull(engines.getHostContext().get(EntityManager.class)); + RelevanceSystem relevanceSystem = Verify.verifyNotNull(engines.getHostContext().get(RelevanceSystem.class)); + ChunkRegionFuture listener = ChunkRegionFuture.create(entityManager, relevanceSystem, centerBlock, desiredSize); + return listener.getFuture(); + } + + BlockRegionc chunkRegionToNewBlockRegion(BlockRegionc chunks) { + BlockRegion blocks = new BlockRegion(chunks); + return blocks.transform(new Matrix4f().scaling(new Vector3f(Chunks.CHUNK_SIZE))); + } + + public T runUntil(ListenableFuture future) { + boolean timedOut = runUntil(future::isDone); + if (timedOut) { + // TODO: if runUntil returns timedOut but does not throw an exception, it + // means it hit DEFAULT_GAME_TIME_TIMEOUT but not SAFETY_TIMEOUT, and + // that's a weird interface due for a revision. + future.cancel(true); // let it know we no longer expect results + throw new UncheckedTimeoutException("No result within default timeout."); + } + try { + return future.get(0, TimeUnit.SECONDS); + } catch (ExecutionException e) { + throw new UncheckedExecutionException(e); + } catch (InterruptedException e) { + throw new RuntimeException("Interrupted while waiting for " + future, e); + } catch (TimeoutException e) { + throw new UncheckedTimeoutException( + "Checked isDone before calling get, so this shouldn't happen.", e); + } + } + + /** + * Runs tick() on the engine until f evaluates to true or DEFAULT_GAME_TIME_TIMEOUT milliseconds have passed in game time + * + * @return true if execution timed out + */ + public boolean runUntil(Supplier f) { + return runWhile(() -> !f.get()); + } + + /** + * Runs tick() on the engine until f evaluates to true or gameTimeTimeoutMs has passed in game time + * + * @return true if execution timed out + */ + public boolean runUntil(long gameTimeTimeoutMs, Supplier f) { + return runWhile(gameTimeTimeoutMs, () -> !f.get()); + } + + /** + * Runs tick() on the engine while f evaluates to true or until DEFAULT_GAME_TIME_TIMEOUT milliseconds have passed + * + * @return true if execution timed out + */ + public boolean runWhile(Supplier f) { + return runWhile(ModuleTestingEnvironment.DEFAULT_GAME_TIME_TIMEOUT, f); + } + + /** + * Runs tick() on the engine while f evaluates to true or until gameTimeTimeoutMs has passed in game time. + * + * @return true if execution timed out + */ + public boolean runWhile(long gameTimeTimeoutMs, Supplier f) { + boolean timedOut = false; + Time hostTime = engines.getHostContext().get(Time.class); + long startRealTime = System.currentTimeMillis(); + long startGameTime = hostTime.getGameTimeInMs(); + + while (f.get() && !timedOut) { + Thread.yield(); + if (Thread.currentThread().isInterrupted()) { + throw new RuntimeException(String.format("Thread %s interrupted while waiting for %s.", + Thread.currentThread(), f)); + } + for (TerasologyEngine terasologyEngine : engines.getEngines()) { + boolean keepRunning = terasologyEngine.tick(); + if (!keepRunning && terasologyEngine == engines.host) { + throw new RuntimeException("Host has shut down: " + engines.host.getStatus()); + } + } + + // handle safety timeout + if (System.currentTimeMillis() - startRealTime > safetyTimeoutMs) { + timedOut = true; + // If we've passed the _safety_ timeout, throw an exception. + throw new UncheckedTimeoutException("MTE Safety timeout exceeded. See setSafetyTimeoutMs()"); + } + + // handle game time timeout + if (hostTime.getGameTimeInMs() - startGameTime > gameTimeTimeoutMs) { + // If we've passed the user-specified timeout but are still under the + // safety threshold, set timed-out status without throwing. + timedOut = true; + } + } + + return timedOut; + } + + /** + * @return the current safety timeout + */ + public long getSafetyTimeoutMs() { + return safetyTimeoutMs; + } + + /** + * Sets the safety timeout (default 30s). + * + * @param safetyTimeoutMs The safety timeout applies to {@link #runWhile runWhile} and related helpers, and stops execution when + * the specified number of real time milliseconds has passed. Note that this is different from the timeout parameter of those + * methods, which is specified in game time. + *

+ * When a single {@code run*} helper invocation exceeds the safety timeout, MTE asserts false to explicitly fail the test. + *

+ * The safety timeout exists to prevent indefinite execution in Jenkins or long IDE test runs, and should be adjusted as needed + * so that tests pass reliably in all environments. + */ + public void setSafetyTimeoutMs(long safetyTimeoutMs) { + this.safetyTimeoutMs = safetyTimeoutMs; + } +} diff --git a/src/main/java/org/terasology/moduletestingenvironment/ModuleTestingEnvironment.java b/src/main/java/org/terasology/moduletestingenvironment/ModuleTestingEnvironment.java index 35ee2df..d7e941a 100644 --- a/src/main/java/org/terasology/moduletestingenvironment/ModuleTestingEnvironment.java +++ b/src/main/java/org/terasology/moduletestingenvironment/ModuleTestingEnvironment.java @@ -1,366 +1,82 @@ // Copyright 2021 The Terasology Foundation // SPDX-License-Identifier: Apache-2.0 + package org.terasology.moduletestingenvironment; -import com.google.common.collect.Lists; -import com.google.common.collect.Sets; import com.google.common.util.concurrent.ListenableFuture; -import com.google.common.util.concurrent.UncheckedExecutionException; -import com.google.common.util.concurrent.UncheckedTimeoutException; -import org.joml.Matrix4f; -import org.joml.RoundingMode; -import org.joml.Vector3f; import org.joml.Vector3fc; -import org.joml.Vector3i; import org.joml.Vector3ic; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.terasology.engine.config.Config; -import org.terasology.engine.config.SystemConfig; import org.terasology.engine.context.Context; -import org.terasology.engine.core.GameEngine; -import org.terasology.engine.core.PathManager; -import org.terasology.engine.core.PathManagerProvider; -import org.terasology.engine.core.TerasologyConstants; import org.terasology.engine.core.TerasologyEngine; -import org.terasology.engine.core.TerasologyEngineBuilder; -import org.terasology.engine.core.Time; -import org.terasology.engine.core.modes.GameState; -import org.terasology.engine.core.modes.StateIngame; -import org.terasology.engine.core.modes.StateLoading; -import org.terasology.engine.core.modes.StateMainMenu; -import org.terasology.engine.core.module.ModuleManager; -import org.terasology.engine.core.subsystem.EngineSubsystem; -import org.terasology.engine.core.subsystem.headless.HeadlessAudio; -import org.terasology.engine.core.subsystem.headless.HeadlessGraphics; -import org.terasology.engine.core.subsystem.headless.HeadlessInput; -import org.terasology.engine.core.subsystem.headless.HeadlessTimer; -import org.terasology.engine.core.subsystem.headless.mode.HeadlessStateChangeListener; -import org.terasology.engine.core.subsystem.lwjgl.LwjglAudio; -import org.terasology.engine.core.subsystem.lwjgl.LwjglGraphics; -import org.terasology.engine.core.subsystem.lwjgl.LwjglInput; -import org.terasology.engine.core.subsystem.lwjgl.LwjglTimer; -import org.terasology.engine.core.subsystem.openvr.OpenVRInput; -import org.terasology.engine.entitySystem.entity.EntityManager; -import org.terasology.engine.network.JoinStatus; -import org.terasology.engine.network.NetworkSystem; -import org.terasology.engine.registry.CoreRegistry; -import org.terasology.engine.rendering.opengl.ScreenGrabber; -import org.terasology.engine.rendering.world.viewDistance.ViewDistance; -import org.terasology.engine.testUtil.WithUnittestModule; -import org.terasology.engine.world.WorldProvider; import org.terasology.engine.world.block.BlockRegion; import org.terasology.engine.world.block.BlockRegionc; -import org.terasology.engine.world.chunks.Chunks; -import org.terasology.engine.world.chunks.localChunkProvider.RelevanceSystem; -import org.terasology.gestalt.module.Module; -import org.terasology.gestalt.module.ModuleMetadataJsonAdapter; -import org.terasology.gestalt.module.ModuleRegistry; import java.io.IOException; -import java.io.UncheckedIOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.Collections; import java.util.List; -import java.util.Set; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.TimeoutException; import java.util.function.Supplier; -import static com.google.common.base.Preconditions.checkArgument; -import static com.google.common.base.Verify.verifyNotNull; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.spy; -import static org.mockito.Mockito.when; - /** - * Base class for tests involving full {@link TerasologyEngine} instances. View the tests included in this module for - * simple usage examples. - * - *

Introduction

- * If test classes extend this class will create a new host engine for each {@code @Test} method. If the testing - * environment is used by composition {@link #setup()} and {@link #tearDown()} need to be called explicitly. This can be - * done once for the test class or for each test. - *

- * The in-game {@link Context} for this engine can be accessed via {@link #getHostContext()}. The result of this getter - * is equivalent to the CoreRegistry available to module code at runtime. However, it is very important that you do not - * use CoreRegistry in your test code, as this is manipulated by the test environment to allow multiple instances of the - * engine to peacefully coexist. You should always use the returned context reference to manipulate or inspect the - * CoreRegistry of a given engine instance. - * - *

Client Engine Instances

- * Client instances can be easily created via {@link #createClient()} which returns the in-game context of the created - * engine instance. When this method returns, the client will be in the {@link StateIngame} state and connected to the - * host. Currently all engine instances are headless, though it is possible to use headed engines in the future. - *

- * Engines can be run while a condition is true via {@link #runWhile(Supplier)}
{@code runWhile(()-> true);} - *

- * or conversely run until a condition is true via {@link #runUntil(Supplier)}
{@code runUntil(()-> false);} - * - *

Specifying Dependencies

- * By default the environment will load only the engine itself. FIXME - * - *

Specifying World Generator

- * By default the environment will use a dummy world generator which creates nothing but air. To specify a more useful - * world generator you must FIXME - * - *

Reuse the MTE for Multiple Tests

- * To use the same engine for multiple tests the testing environment can be set up explicitly and shared between tests. - * To configure module dependencies or the world generator an anonymous class may be used. - *
- * private static ModuleTestingEnvironment context;
- *
- * @BeforeAll
- * public static void setup() throws Exception {
- *     context = new ModuleTestingEnvironment() {
- *     @Override
- *     public Set<String> getDependencies() {
- *         return Sets.newHashSet("ModuleTestingEnvironment");
- *     }
- *     };
- *     context.setup();
- * }
- *
- * @AfterAll
- * public static void tearDown() throws Exception {
- *     context.tearDown();
- * }
- *
- * @Test
- * public void someTest() {
- *     Context hostContext = context.getHostContext();
- *     EntityManager entityManager = hostContext.get(EntityManager.class);
- *     // ...
- * }
- * 
- * - * @deprecated Use the {@link MTEExtension} or {@link IsolatedMTEExtension} instead with JUnit5. + * The public methods that were available via ModuleTestingHelper v0.3.2. */ -@Deprecated -public class ModuleTestingEnvironment { - @Deprecated - public static final long DEFAULT_TIMEOUT = 30000; - - public static final long DEFAULT_SAFETY_TIMEOUT = 60000; - public static final long DEFAULT_GAME_TIME_TIMEOUT = 30000; - public static final String DEFAULT_WORLD_GENERATOR = "moduletestingenvironment:dummy"; - - private static final Logger logger = LoggerFactory.getLogger(ModuleTestingEnvironment.class); - - protected final Set dependencies = Sets.newHashSet("engine"); - protected String worldGeneratorUri = DEFAULT_WORLD_GENERATOR; - - PathManager pathManager; - PathManagerProvider.Cleaner pathManagerCleaner; - - private boolean doneLoading; - private TerasologyEngine host; - private Context hostContext; - private final List engines = Lists.newArrayList(); - private long safetyTimeoutMs = DEFAULT_SAFETY_TIMEOUT; - - protected ModuleTestingEnvironment(Set dependencies, String worldGeneratorUri) { - this.dependencies.addAll(dependencies); - - if (worldGeneratorUri != null) { - this.worldGeneratorUri = worldGeneratorUri; - } - } - - /** - * Set up and start the engine as configured via this environment. - *

- * Every instance should be shut down properly by calling {@link #tearDown()}. - */ - protected void setup() { - mockPathManager(); - try { - host = createHost(); - } catch (IOException e) { - throw new UncheckedIOException(e); - } - ScreenGrabber grabber = mock(ScreenGrabber.class); - hostContext.put(ScreenGrabber.class, grabber); - CoreRegistry.put(GameEngine.class, host); - } - - /** - * Shut down a previously started testing environment. - *

- * Used to properly shut down and clean up a testing environment set up and started with {@link #setup()}. - */ - protected void tearDown() { - engines.forEach(TerasologyEngine::shutdown); - engines.forEach(TerasologyEngine::cleanup); - engines.clear(); - try { - pathManagerCleaner.close(); - } catch (RuntimeException e) { - throw e; - } catch (Throwable e) { - throw new RuntimeException(e); - } - host = null; - hostContext = null; - } +public interface ModuleTestingEnvironment { + long DEFAULT_SAFETY_TIMEOUT = 60000; + long DEFAULT_GAME_TIME_TIMEOUT = 30000; + String DEFAULT_WORLD_GENERATOR = "moduletestingenvironment:dummy"; /** - * Creates a dummy entity with RelevanceRegion component to force a chunk's generation and availability. Blocks - * while waiting for the chunk to become loaded + * Creates a dummy entity with RelevanceRegion component to force a chunk's generation and availability. Blocks while waiting for the + * chunk to become loaded * - * @param blockPos the block position of the dummy entity. Only the chunk containing this position will be - * available + * @param blockPos the block position of the dummy entity. Only the chunk containing this position will be available */ - public void forceAndWaitForGeneration(Vector3ic blockPos) { - WorldProvider worldProvider = hostContext.get(WorldProvider.class); - if (worldProvider.isBlockRelevant(blockPos)) { - return; - } - - ListenableFuture chunkRegion = makeBlocksRelevant(new BlockRegion(blockPos)); - runUntil(chunkRegion); - } + void forceAndWaitForGeneration(Vector3ic blockPos); /** - * * @param blocks blocks to mark as relevant * @return relevant chunks */ - public ListenableFuture makeBlocksRelevant(BlockRegionc blocks) { - BlockRegion desiredChunkRegion = Chunks.toChunkRegion(new BlockRegion(blocks)); - return makeChunksRelevant(desiredChunkRegion, blocks.center(new Vector3f())); - } - - @SuppressWarnings("unused") - public ListenableFuture makeChunksRelevant(BlockRegion chunks) { - // Pick a central point (in block coordinates). - Vector3f centerPoint = chunkRegionToNewBlockRegion(chunks).center(new Vector3f()); - - return makeChunksRelevant(chunks, centerPoint); - } + ListenableFuture makeBlocksRelevant(BlockRegionc blocks); - public ListenableFuture makeChunksRelevant(BlockRegion chunks, Vector3fc centerBlock) { - checkArgument(chunks.contains(Chunks.toChunkPos(new Vector3i(centerBlock, RoundingMode.FLOOR))), - "centerBlock should %s be within the region %s", - centerBlock, chunkRegionToNewBlockRegion(chunks)); - Vector3i desiredSize = chunks.getSize(new Vector3i()); + ListenableFuture makeChunksRelevant(BlockRegion chunks); - EntityManager entityManager = verifyNotNull(hostContext.get(EntityManager.class)); - RelevanceSystem relevanceSystem = verifyNotNull(hostContext.get(RelevanceSystem.class)); - ChunkRegionFuture listener = ChunkRegionFuture.create(entityManager, relevanceSystem, centerBlock, desiredSize); - return listener.getFuture(); - } + ListenableFuture makeChunksRelevant(BlockRegion chunks, Vector3fc centerBlock); - private BlockRegionc chunkRegionToNewBlockRegion(BlockRegionc chunks) { - BlockRegion blocks = new BlockRegion(chunks); - return blocks.transform(new Matrix4f().scaling(new Vector3f(Chunks.CHUNK_SIZE))); - } - - public T runUntil(ListenableFuture future) { - boolean timedOut = runUntil(future::isDone); - if (timedOut) { - // TODO: if runUntil returns timedOut but does not throw an exception, it - // means it hit DEFAULT_GAME_TIME_TIMEOUT but not SAFETY_TIMEOUT, and - // that's a weird interface due for a revision. - future.cancel(true); // let it know we no longer expect results - throw new UncheckedTimeoutException("No result within default timeout."); - } - try { - return future.get(0, TimeUnit.SECONDS); - } catch (ExecutionException e) { - throw new UncheckedExecutionException(e); - } catch (InterruptedException e) { - throw new RuntimeException("Interrupted while waiting for " + future, e); - } catch (TimeoutException e) { - throw new UncheckedTimeoutException( - "Checked isDone before calling get, so this shouldn't happen.", e); - } - } + T runUntil(ListenableFuture future); /** * Runs tick() on the engine until f evaluates to true or DEFAULT_GAME_TIME_TIMEOUT milliseconds have passed in game time + * * @return true if execution timed out */ - public boolean runUntil(Supplier f) { - return runWhile(() -> !f.get()); - } + boolean runUntil(Supplier f); /** * Runs tick() on the engine until f evaluates to true or gameTimeTimeoutMs has passed in game time * * @return true if execution timed out */ - public boolean runUntil(long gameTimeTimeoutMs, Supplier f) { - return runWhile(gameTimeTimeoutMs, () -> !f.get()); - } + boolean runUntil(long gameTimeTimeoutMs, Supplier f); /** * Runs tick() on the engine while f evaluates to true or until DEFAULT_GAME_TIME_TIMEOUT milliseconds have passed + * * @return true if execution timed out */ - public boolean runWhile(Supplier f) { - return runWhile(DEFAULT_GAME_TIME_TIMEOUT, f); - } + boolean runWhile(Supplier f); /** * Runs tick() on the engine while f evaluates to true or until gameTimeTimeoutMs has passed in game time. * * @return true if execution timed out */ - public boolean runWhile(long gameTimeTimeoutMs, Supplier f) { - boolean timedOut = false; - Time hostTime = getHostContext().get(Time.class); - long startRealTime = System.currentTimeMillis(); - long startGameTime = hostTime.getGameTimeInMs(); - - while (f.get() && !timedOut) { - Thread.yield(); - if (Thread.currentThread().isInterrupted()) { - throw new RuntimeException(String.format("Thread %s interrupted while waiting for %s.", - Thread.currentThread(), f)); - } - for (TerasologyEngine terasologyEngine : engines) { - boolean keepRunning = terasologyEngine.tick(); - if (!keepRunning && terasologyEngine == host) { - throw new RuntimeException("Host has shut down: " + host.getStatus()); - } - } - - // handle safety timeout - if (System.currentTimeMillis() - startRealTime > safetyTimeoutMs) { - timedOut = true; - // If we've passed the _safety_ timeout, throw an exception. - throw new UncheckedTimeoutException("MTE Safety timeout exceeded. See setSafetyTimeoutMs()"); - } - - // handle game time timeout - if (hostTime.getGameTimeInMs() - startGameTime > gameTimeTimeoutMs) { - // If we've passed the user-specified timeout but are still under the - // safety threshold, set timed-out status without throwing. - timedOut = true; - } - } - - return timedOut; - } + boolean runWhile(long gameTimeTimeoutMs, Supplier f); /** * Creates a new client and connects it to the host * * @return the created client's context object */ - public Context createClient() throws IOException { - TerasologyEngine terasologyEngine = createHeadlessEngine(); - terasologyEngine.getFromEngineContext(Config.class).getRendering().setViewDistance(ViewDistance.LEGALLY_BLIND); - - terasologyEngine.changeState(new StateMainMenu()); - connectToHost(terasologyEngine); - Context context = terasologyEngine.getState().getContext(); - context.put(ScreenGrabber.class, hostContext.get(ScreenGrabber.class)); - return terasologyEngine.getState().getContext(); - } + Context createClient() throws IOException; /** * The engines active in this instance of the module testing environment. @@ -369,180 +85,34 @@ public Context createClient() throws IOException { * * @return list of active engines */ - public List getEngines() { - return Lists.newArrayList(engines); - } + List getEngines(); /** * Get the host context for this module testing environment. *

- * The host context will be null if the testing environment has not been set up via {@link - * ModuleTestingEnvironment#setup()} beforehand. + * The host context will be null if the testing environment has not been set up via {@link ModuleTestingHelper#setup()} + * beforehand. * * @return the engine's host context, or null if not set up yet */ - public Context getHostContext() { - return hostContext; - } - + Context getHostContext(); /** * @return the current safety timeout */ - public long getSafetyTimeoutMs() { - return safetyTimeoutMs; - } + long getSafetyTimeoutMs(); /** * Sets the safety timeout (default 30s). * - * @param safetyTimeoutMs The safety timeout applies to {@link #runWhile runWhile} and related helpers, and - * stops execution when the specified number of real time milliseconds has passed. Note that this is different from - * the timeout parameter of those methods, which is specified in game time. - *

- * When a single {@code run*} helper invocation exceeds the safety timeout, MTE asserts false to explicitly fail the test. - *

- * The safety timeout exists to prevent indefinite execution in Jenkins or long IDE test runs, and should be - * adjusted as needed so that tests pass reliably in all environments. - */ - public void setSafetyTimeoutMs(long safetyTimeoutMs) { - this.safetyTimeoutMs = safetyTimeoutMs; - } - - private TerasologyEngine createHeadlessEngine() throws IOException { - TerasologyEngineBuilder terasologyEngineBuilder = new TerasologyEngineBuilder(); - terasologyEngineBuilder - .add(new WithUnittestModule()) - .add(new HeadlessGraphics()) - .add(new HeadlessTimer()) - .add(new HeadlessAudio()) - .add(new HeadlessInput()); - - return createEngine(terasologyEngineBuilder); - } - - private TerasologyEngine createHeadedEngine() throws IOException { - EngineSubsystem audio = new LwjglAudio(); - TerasologyEngineBuilder terasologyEngineBuilder = new TerasologyEngineBuilder() - .add(new WithUnittestModule()) - .add(audio) - .add(new LwjglGraphics()) - .add(new LwjglTimer()) - .add(new LwjglInput()) - .add(new OpenVRInput()); - - return createEngine(terasologyEngineBuilder); - } - - private TerasologyEngine createEngine(TerasologyEngineBuilder terasologyEngineBuilder) throws IOException { - System.setProperty(ModuleManager.LOAD_CLASSPATH_MODULES_PROPERTY, "true"); - - // create temporary home paths so the MTE engines don't overwrite config/save files in your real home path - // FIXME: Collisions when attempting to do multiple simultaneous createEngines. - // (PathManager will need to be set in Context, not a process-wide global.) - Path path = Files.createTempDirectory("terasology-mte-engine"); - PathManager.getInstance().useOverrideHomePath(path); - logger.info("Created temporary engine home path: {}", path); - - // JVM will delete these on normal termination but not exceptions. - path.toFile().deleteOnExit(); - - TerasologyEngine terasologyEngine = terasologyEngineBuilder.build(); - terasologyEngine.initialize(); - registerCurrentDirectoryIfModule(terasologyEngine); - - engines.add(terasologyEngine); - return terasologyEngine; - } - - /** - * In standalone module environments (i.e. Jenkins CI builds) the CWD is the module under test. When it uses MTE - * it very likely needs to load itself as a module, but it won't be loadable from the typical path such as - * ./modules. This means that modules using MTE would always fail CI tests due to failing to load themselves. - *

- * For these cases we try to load the CWD (via the installPath) as a module and put it in the global module - * registry. - *

- * This process is based on how ModuleManagerImpl uses ModulePathScanner to scan for available modules. - * - * @param terasologyEngine + * @param safetyTimeoutMs The safety timeout applies to {@link #runWhile runWhile} and related helpers, and stops execution when + * the specified number of real time milliseconds has passed. Note that this is different from the timeout parameter of those + * methods, which is specified in game time. + *

+ * When a single {@code run*} helper invocation exceeds the safety timeout, MTE asserts false to explicitly fail the test. + *

+ * The safety timeout exists to prevent indefinite execution in Jenkins or long IDE test runs, and should be adjusted as needed + * so that tests pass reliably in all environments. */ - private void registerCurrentDirectoryIfModule(TerasologyEngine terasologyEngine) { - Path installPath = PathManager.getInstance().getInstallPath(); - ModuleManager moduleManager = terasologyEngine.getFromEngineContext(ModuleManager.class); - ModuleRegistry registry = moduleManager.getRegistry(); - ModuleMetadataJsonAdapter metadataReader = moduleManager.getModuleMetadataReader(); - moduleManager.getModuleFactory().getModuleMetadataLoaderMap() - .put(TerasologyConstants.MODULE_INFO_FILENAME.toString(), metadataReader); - - - try { - Module module = moduleManager.getModuleFactory().createModule(installPath.toFile()); - if (module != null) { - registry.add(module); - logger.info("Added install path as module: {}", installPath); - } else { - logger.info("Install path does not appear to be a module: {}", installPath); - } - } catch (IOException e) { - logger.warn("Could not read install path as module at " + installPath); - } - } - - protected void mockPathManager() { - PathManager originalPathManager = PathManager.getInstance(); - pathManager = spy(originalPathManager); - when(pathManager.getModulePaths()).thenReturn(Collections.emptyList()); - pathManagerCleaner = new PathManagerProvider.Cleaner(originalPathManager, pathManager); - PathManagerProvider.setPathManager(pathManager); - }; - - private TerasologyEngine createHost() throws IOException { - TerasologyEngine terasologyEngine = createHeadlessEngine(); - terasologyEngine.getFromEngineContext(SystemConfig.class).writeSaveGamesEnabled.set(false); - terasologyEngine.subscribeToStateChange(new HeadlessStateChangeListener(terasologyEngine)); - terasologyEngine.changeState(new TestingStateHeadlessSetup(dependencies, worldGeneratorUri)); - - doneLoading = false; - terasologyEngine.subscribeToStateChange(() -> { - GameState newState = terasologyEngine.getState(); - logger.debug("New engine state is {}", terasologyEngine.getState()); - if (newState instanceof org.terasology.engine.core.modes.StateIngame) { - hostContext = newState.getContext(); - if (hostContext == null) { - logger.warn("hostContext is NULL in engine state {}", newState); - } - doneLoading = true; - } else if (newState instanceof org.terasology.engine.core.modes.StateLoading) { - org.terasology.engine.registry.CoreRegistry.put(GameEngine.class, terasologyEngine); - } - }); - - boolean keepTicking; - while (!doneLoading) { - keepTicking = terasologyEngine.tick(); - if (!keepTicking) { - throw new RuntimeException(String.format( - "Engine stopped ticking before we got in game. Current state: %s", - terasologyEngine.getState() - )); - } - } - return terasologyEngine; - } - - private void connectToHost(TerasologyEngine client) { - CoreRegistry.put(Config.class, client.getFromEngineContext(Config.class)); - JoinStatus joinStatus = null; - try { - joinStatus = client.getFromEngineContext(NetworkSystem.class).join("localhost", 25777); - } catch (InterruptedException e) { - logger.warn("Interrupted while joining: ", e); - } - - client.changeState(new StateLoading(joinStatus)); - CoreRegistry.put(GameEngine.class, client); - - runUntil(() -> client.getState() instanceof StateIngame); - } + void setSafetyTimeoutMs(long safetyTimeoutMs); } diff --git a/src/main/java/org/terasology/moduletestingenvironment/ModuleTestingHelper.java b/src/main/java/org/terasology/moduletestingenvironment/ModuleTestingHelper.java index 012cc5f..205cfd7 100644 --- a/src/main/java/org/terasology/moduletestingenvironment/ModuleTestingHelper.java +++ b/src/main/java/org/terasology/moduletestingenvironment/ModuleTestingHelper.java @@ -1,20 +1,177 @@ // Copyright 2021 The Terasology Foundation // SPDX-License-Identifier: Apache-2.0 - package org.terasology.moduletestingenvironment; +import com.google.common.util.concurrent.ListenableFuture; +import org.joml.Vector3fc; +import org.joml.Vector3ic; +import org.terasology.engine.context.Context; +import org.terasology.engine.core.TerasologyEngine; +import org.terasology.engine.core.modes.StateIngame; +import org.terasology.engine.world.block.BlockRegion; +import org.terasology.engine.world.block.BlockRegionc; + +import java.io.IOException; +import java.util.List; import java.util.Set; +import java.util.function.Supplier; /** - * Helpers for use in module tests. + * Base class for tests involving full {@link TerasologyEngine} instances. View the tests included in this module for + * simple usage examples. + * + *

Introduction

+ * If test classes extend this class will create a new host engine for each {@code @Test} method. If the testing + * environment is used by composition {@link #setup()} and {@link #tearDown()} need to be called explicitly. This can be + * done once for the test class or for each test. + *

+ * The in-game {@link Context} for this engine can be accessed via {@link #getHostContext()}. The result of this getter + * is equivalent to the CoreRegistry available to module code at runtime. However, it is very important that you do not + * use CoreRegistry in your test code, as this is manipulated by the test environment to allow multiple instances of the + * engine to peacefully coexist. You should always use the returned context reference to manipulate or inspect the + * CoreRegistry of a given engine instance. + * + *

Client Engine Instances

+ * Client instances can be easily created via {@link #createClient()} which returns the in-game context of the created + * engine instance. When this method returns, the client will be in the {@link StateIngame} state and connected to the + * host. Currently all engine instances are headless, though it is possible to use headed engines in the future. *

- * See {@link ModuleTestingEnvironment} for available methods + * Engines can be run while a condition is true via {@link #runWhile(Supplier)}
{@code runWhile(()-> true);} *

- * This dummy subclass exists to provide a non-deprecated interface to the helper functions in the base class. - * In the next major release the base class will be removed and all methods will live in this class directly. + * or conversely run until a condition is true via {@link #runUntil(Supplier)}
{@code runUntil(()-> false);} + * + *

Specifying Dependencies

+ * By default the environment will load only the engine itself. FIXME + * + *

Specifying World Generator

+ * By default the environment will use a dummy world generator which creates nothing but air. To specify a more useful + * world generator you must FIXME + * + *

Reuse the MTE for Multiple Tests

+ * To use the same engine for multiple tests the testing environment can be set up explicitly and shared between tests. + * To configure module dependencies or the world generator an anonymous class may be used. + *
+ * private static ModuleTestingHelper context;
+ *
+ * @BeforeAll
+ * public static void setup() throws Exception {
+ *     context = new ModuleTestingHelper() {
+ *     @Override
+ *     public Set<String> getDependencies() {
+ *         return Sets.newHashSet("ModuleTestingHelper");
+ *     }
+ *     };
+ *     context.setup();
+ * }
+ *
+ * @AfterAll
+ * public static void tearDown() throws Exception {
+ *     context.tearDown();
+ * }
+ *
+ * @Test
+ * public void someTest() {
+ *     Context hostContext = context.getHostContext();
+ *     EntityManager entityManager = hostContext.get(EntityManager.class);
+ *     // ...
+ * }
+ * 
*/ -public class ModuleTestingHelper extends ModuleTestingEnvironment { - public ModuleTestingHelper(Set dependencies, String worldGeneratorUri) { - super(dependencies, worldGeneratorUri); +public class ModuleTestingHelper implements ModuleTestingEnvironment { + + protected final Engines engines; + protected final MainLoop mainLoop; + + protected ModuleTestingHelper(Set dependencies, String worldGeneratorUri) { + engines = new Engines(dependencies, worldGeneratorUri); + mainLoop = new MainLoop(engines); + } + + /** + * Set up and start the engine as configured via this environment. + *

+ * Every instance should be shut down properly by calling {@link #tearDown()}. + */ + protected void setup() { + engines.setup(); + } + + /** + * Shut down a previously started testing environment. + *

+ * Used to properly shut down and clean up a testing environment set up and started with {@link #setup()}. + */ + protected void tearDown() { + engines.tearDown(); + } + + @Override + public void forceAndWaitForGeneration(Vector3ic blockPos) { + mainLoop.forceAndWaitForGeneration(blockPos); + } + + @Override + public ListenableFuture makeBlocksRelevant(BlockRegionc blocks) { + return mainLoop.makeBlocksRelevant(blocks); + } + + @Override + public ListenableFuture makeChunksRelevant(BlockRegion chunks) { + return mainLoop.makeChunksRelevant(chunks); + } + + @Override + public ListenableFuture makeChunksRelevant(BlockRegion chunks, Vector3fc centerBlock) { + return mainLoop.makeChunksRelevant(chunks, centerBlock); + } + + @Override + public T runUntil(ListenableFuture future) { + return mainLoop.runUntil(future); + } + + @Override + public boolean runUntil(Supplier f) { + return mainLoop.runUntil(f); + } + + @Override + public boolean runUntil(long gameTimeTimeoutMs, Supplier f) { + return mainLoop.runUntil(gameTimeTimeoutMs, f); + } + + @Override + public boolean runWhile(Supplier f) { + return mainLoop.runWhile(f); + } + + @Override + public boolean runWhile(long gameTimeTimeoutMs, Supplier f) { + return mainLoop.runWhile(gameTimeTimeoutMs, f); + } + + @Override + public Context createClient() throws IOException { + return engines.createClient(mainLoop); + } + + @Override + public List getEngines() { + return engines.getEngines(); + } + + @Override + public Context getHostContext() { + return engines.getHostContext(); + } + + @Override + public long getSafetyTimeoutMs() { + return mainLoop.getSafetyTimeoutMs(); + } + + @Override + public void setSafetyTimeoutMs(long safetyTimeoutMs) { + mainLoop.setSafetyTimeoutMs(safetyTimeoutMs); } } From b891e14477e7617eede99598c78656f4fcf9872c Mon Sep 17 00:00:00 2001 From: Kevin Turner <83819+keturn@users.noreply.github.com> Date: Tue, 2 Nov 2021 15:49:37 -0700 Subject: [PATCH 3/5] feat: provide Engines and MainLoop classes for injection to tests --- .../moduletestingenvironment/MTEExtension.java | 16 ++++++++++++---- .../ModuleTestingHelper.java | 4 ++-- .../ChunkRegionFutureTest.java | 4 ++-- .../ModuleTestingEnvironmentTest.java | 8 ++++---- .../moduletestingenvironment/NestedTest.java | 10 +++++----- .../WorldProviderTest.java | 6 +++--- 6 files changed, 28 insertions(+), 20 deletions(-) diff --git a/src/main/java/org/terasology/moduletestingenvironment/MTEExtension.java b/src/main/java/org/terasology/moduletestingenvironment/MTEExtension.java index 61f3385..f0e90c8 100644 --- a/src/main/java/org/terasology/moduletestingenvironment/MTEExtension.java +++ b/src/main/java/org/terasology/moduletestingenvironment/MTEExtension.java @@ -63,9 +63,12 @@ public void beforeAll(ExtensionContext context) { @Override public boolean supportsParameter(ParameterContext parameterContext, ExtensionContext extensionContext) throws ParameterResolutionException { + Class type = parameterContext.getParameter().getType(); ModuleTestingHelper helper = getHelper(extensionContext); - return helper.getHostContext().get(parameterContext.getParameter().getType()) != null - || parameterContext.getParameter().getType().equals(ModuleTestingHelper.class); + return helper.getHostContext().get(type) != null + || type.isAssignableFrom(Engines.class) + || type.isAssignableFrom(MainLoop.class) + || type.isAssignableFrom(ModuleTestingHelper.class); } @Override @@ -77,10 +80,15 @@ public Object resolveParameter(ParameterContext parameterContext, ExtensionConte } private Object getDIInstance(ModuleTestingHelper helper, Class type) { - if (type.equals(ModuleTestingHelper.class)) { + if (type.isAssignableFrom(Engines.class)) { + return helper.engines; + } else if (type.isAssignableFrom(MainLoop.class)) { + return helper.mainLoop; + } else if (type.isAssignableFrom(ModuleTestingHelper.class)) { return helper; + } else { + return helper.getHostContext().get(type); } - return helper.getHostContext().get(type); } @Override diff --git a/src/main/java/org/terasology/moduletestingenvironment/ModuleTestingHelper.java b/src/main/java/org/terasology/moduletestingenvironment/ModuleTestingHelper.java index 205cfd7..a9067f4 100644 --- a/src/main/java/org/terasology/moduletestingenvironment/ModuleTestingHelper.java +++ b/src/main/java/org/terasology/moduletestingenvironment/ModuleTestingHelper.java @@ -79,8 +79,8 @@ */ public class ModuleTestingHelper implements ModuleTestingEnvironment { - protected final Engines engines; - protected final MainLoop mainLoop; + final Engines engines; + final MainLoop mainLoop; protected ModuleTestingHelper(Set dependencies, String worldGeneratorUri) { engines = new Engines(dependencies, worldGeneratorUri); diff --git a/src/test/java/org/terasology/moduletestingenvironment/ChunkRegionFutureTest.java b/src/test/java/org/terasology/moduletestingenvironment/ChunkRegionFutureTest.java index 92e363d..c98be83 100644 --- a/src/test/java/org/terasology/moduletestingenvironment/ChunkRegionFutureTest.java +++ b/src/test/java/org/terasology/moduletestingenvironment/ChunkRegionFutureTest.java @@ -33,10 +33,10 @@ class ChunkRegionFutureTest { WorldProvider world; @Test - void createChunkRegionFuture(EntityManager entityManager, RelevanceSystem relevanceSystem, ModuleTestingHelper mte) { + void createChunkRegionFuture(EntityManager entityManager, RelevanceSystem relevanceSystem, MainLoop mainLoop) { ChunkRegionFuture chunkRegionFuture = ChunkRegionFuture.create(entityManager, relevanceSystem, center, sizeInChunks); - mte.runUntil(chunkRegionFuture.getFuture()); + mainLoop.runUntil(chunkRegionFuture.getFuture()); Vector3fc someplaceInside = center.add( sizeInChunks.x() * Chunks.SIZE_X / 3f, 0, 0, diff --git a/src/test/java/org/terasology/moduletestingenvironment/ModuleTestingEnvironmentTest.java b/src/test/java/org/terasology/moduletestingenvironment/ModuleTestingEnvironmentTest.java index 7f4341d..ebc4c3b 100644 --- a/src/test/java/org/terasology/moduletestingenvironment/ModuleTestingEnvironmentTest.java +++ b/src/test/java/org/terasology/moduletestingenvironment/ModuleTestingEnvironmentTest.java @@ -21,19 +21,19 @@ public class ModuleTestingEnvironmentTest { public static final int THE_ANSWER = 42; @Test - public void runUntilWithUnsatisfiedFutureExplainsTimeout(ModuleTestingHelper mte) { + public void runUntilWithUnsatisfiedFutureExplainsTimeout(MainLoop mainLoop) { SettableFuture unsatisfiedFuture = SettableFuture.create(); UncheckedTimeoutException exception = assertThrows(UncheckedTimeoutException.class, // TODO: change the timeout for this test so it doesn't always take // a minimum of 30 seconds. - () -> mte.runUntil(unsatisfiedFuture)); + () -> mainLoop.runUntil(unsatisfiedFuture)); assertThat(exception).hasMessageThat().contains("default timeout"); } @Test - public void runUntilWithImmediateFutureReturnsValue(ModuleTestingHelper mte) { + public void runUntilWithImmediateFutureReturnsValue(MainLoop mainLoop) { ListenableFuture valueFuture = Futures.immediateFuture(THE_ANSWER); - assertThat(mte.runUntil(valueFuture)).isEqualTo(THE_ANSWER); + assertThat(mainLoop.runUntil(valueFuture)).isEqualTo(THE_ANSWER); } } diff --git a/src/test/java/org/terasology/moduletestingenvironment/NestedTest.java b/src/test/java/org/terasology/moduletestingenvironment/NestedTest.java index 65d8066..c994841 100644 --- a/src/test/java/org/terasology/moduletestingenvironment/NestedTest.java +++ b/src/test/java/org/terasology/moduletestingenvironment/NestedTest.java @@ -9,29 +9,29 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.terasology.engine.entitySystem.entity.EntityManager; -import org.terasology.moduletestingenvironment.extension.Dependencies; import org.terasology.engine.registry.In; +import org.terasology.moduletestingenvironment.extension.Dependencies; @Tag("MteTest") @ExtendWith(MTEExtension.class) @Dependencies({"engine", "ModuleTestingEnvironment"}) public class NestedTest { @In - public static ModuleTestingHelper outerHelper; + public static Engines outerEngines; @In public static EntityManager outerManager; @Test public void outerTest() { - Assertions.assertNotNull(outerHelper); + Assertions.assertNotNull(outerEngines); Assertions.assertNotNull(outerManager); } @Nested class NestedTestClass { @In - ModuleTestingHelper innerHelper; + Engines innerEngines; @In EntityManager innerManager; @@ -39,7 +39,7 @@ class NestedTestClass { @Test public void innerTest() { Assertions.assertSame(innerManager, outerManager); - Assertions.assertSame(innerHelper, outerHelper); + Assertions.assertSame(innerEngines, outerEngines); } } } diff --git a/src/test/java/org/terasology/moduletestingenvironment/WorldProviderTest.java b/src/test/java/org/terasology/moduletestingenvironment/WorldProviderTest.java index bc12879..70fa367 100644 --- a/src/test/java/org/terasology/moduletestingenvironment/WorldProviderTest.java +++ b/src/test/java/org/terasology/moduletestingenvironment/WorldProviderTest.java @@ -7,10 +7,10 @@ import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; -import org.terasology.moduletestingenvironment.extension.Dependencies; import org.terasology.engine.registry.In; import org.terasology.engine.world.WorldProvider; import org.terasology.engine.world.block.BlockManager; +import org.terasology.moduletestingenvironment.extension.Dependencies; @Tag("MteTest") @ExtendWith(MTEExtension.class) @@ -22,11 +22,11 @@ public class WorldProviderTest { @In BlockManager blockManager; @In - ModuleTestingHelper helper; + MainLoop mainLoop; @Test public void defaultWorldSetBlockTest() { - helper.forceAndWaitForGeneration(new Vector3i()); + mainLoop.forceAndWaitForGeneration(new Vector3i()); // this will change if the worldgenerator changes or the seed is altered, the main point is that this is a real // block type and not engine:unloaded From 3e896629e13f5100df8fa2fcbf2d6356efbc1893 Mon Sep 17 00:00:00 2001 From: Kevin Turner <83819+keturn@users.noreply.github.com> Date: Tue, 2 Nov 2021 16:05:08 -0700 Subject: [PATCH 4/5] refactor(MTEExtension): use Engines instead of broad Helper class --- .../moduletestingenvironment/Engines.java | 2 +- .../MTEExtension.java | 54 +++++++++---------- .../ModuleTestingEnvironment.java | 2 +- .../ModuleTestingHelper.java | 25 ++------- 4 files changed, 32 insertions(+), 51 deletions(-) diff --git a/src/main/java/org/terasology/moduletestingenvironment/Engines.java b/src/main/java/org/terasology/moduletestingenvironment/Engines.java index 3f0e91a..d1dc2b8 100644 --- a/src/main/java/org/terasology/moduletestingenvironment/Engines.java +++ b/src/main/java/org/terasology/moduletestingenvironment/Engines.java @@ -147,7 +147,7 @@ public List getEngines() { /** * Get the host context for this module testing environment. *

- * The host context will be null if the testing environment has not been set up via {@link ModuleTestingHelper#setup()} + * The host context will be null if the testing environment has not been set up via {@link #setup()} * beforehand. * * @return the engine's host context, or null if not set up yet diff --git a/src/main/java/org/terasology/moduletestingenvironment/MTEExtension.java b/src/main/java/org/terasology/moduletestingenvironment/MTEExtension.java index f0e90c8..73806e7 100644 --- a/src/main/java/org/terasology/moduletestingenvironment/MTEExtension.java +++ b/src/main/java/org/terasology/moduletestingenvironment/MTEExtension.java @@ -64,8 +64,8 @@ public void beforeAll(ExtensionContext context) { @Override public boolean supportsParameter(ParameterContext parameterContext, ExtensionContext extensionContext) throws ParameterResolutionException { Class type = parameterContext.getParameter().getType(); - ModuleTestingHelper helper = getHelper(extensionContext); - return helper.getHostContext().get(type) != null + Engines engines = getEngines(extensionContext); + return engines.getHostContext().get(type) != null || type.isAssignableFrom(Engines.class) || type.isAssignableFrom(MainLoop.class) || type.isAssignableFrom(ModuleTestingHelper.class); @@ -73,27 +73,27 @@ public boolean supportsParameter(ParameterContext parameterContext, ExtensionCon @Override public Object resolveParameter(ParameterContext parameterContext, ExtensionContext extensionContext) throws ParameterResolutionException { - ModuleTestingHelper helper = getHelper(extensionContext); + Engines engines = getEngines(extensionContext); Class type = parameterContext.getParameter().getType(); - return getDIInstance(helper, type); + return getDIInstance(engines, type); } - private Object getDIInstance(ModuleTestingHelper helper, Class type) { + private Object getDIInstance(Engines engines, Class type) { if (type.isAssignableFrom(Engines.class)) { - return helper.engines; + return engines; } else if (type.isAssignableFrom(MainLoop.class)) { - return helper.mainLoop; + return new MainLoop(engines); } else if (type.isAssignableFrom(ModuleTestingHelper.class)) { - return helper; + return new ModuleTestingHelper(engines); } else { - return helper.getHostContext().get(type); + return engines.getHostContext().get(type); } } @Override public void postProcessTestInstance(Object testInstance, ExtensionContext extensionContext) { - ModuleTestingHelper helper = getHelper(extensionContext); + Engines engines = getEngines(extensionContext); List exceptionList = new LinkedList<>(); Class type = testInstance.getClass(); while (type != null) { @@ -101,7 +101,7 @@ public void postProcessTestInstance(Object testInstance, ExtensionContext extens .filter((field) -> field.getAnnotation(In.class) != null) .peek((field) -> field.setAccessible(true)) .forEach((field) -> { - Object candidateObject = getDIInstance(helper, field.getType()); + Object candidateObject = getDIInstance(engines, field.getType()); try { field.set(testInstance, candidateObject); } catch (IllegalAccessException e) { @@ -128,9 +128,9 @@ public Set getDependencyNames(ExtensionContext context) { } /** - * Get the ModuleTestingHelper for this test. + * Get the Engines for this test. *

- * The new ModuleTestingHelper instance is configured using the {@link Dependencies} and {@link UseWorldGenerator} + * The new Engines instance is configured using the {@link Dependencies} and {@link UseWorldGenerator} * annotations for the test class. *

* This will create a new instance when necessary. It will be stored in the @@ -140,12 +140,12 @@ public Set getDependencyNames(ExtensionContext context) { * @param context for the current test * @return configured for this test */ - protected ModuleTestingHelper getHelper(ExtensionContext context) { + protected Engines getEngines(ExtensionContext context) { ExtensionContext.Store store = context.getStore(helperLifecycle.apply(context)); - HelperCleaner autoCleaner = store.getOrComputeIfAbsent( - HelperCleaner.class, k -> new HelperCleaner(getDependencyNames(context), getWorldGeneratorUri(context)), - HelperCleaner.class); - return autoCleaner.helper; + EnginesCleaner autoCleaner = store.getOrComputeIfAbsent( + EnginesCleaner.class, k -> new EnginesCleaner(getDependencyNames(context), getWorldGeneratorUri(context)), + EnginesCleaner.class); + return autoCleaner.engines; } /** @@ -184,23 +184,23 @@ void setupLogging() { } /** - * Manages a ModuleTestingHelper for storage in an ExtensionContext. + * Manages Engines for storage in an ExtensionContext. *

* Implements {@link ExtensionContext.Store.CloseableResource CloseableResource} to dispose of - * the {@link ModuleTestingHelper} when the context is closed. + * the {@link Engines} when the context is closed. */ - static class HelperCleaner implements ExtensionContext.Store.CloseableResource { - protected ModuleTestingHelper helper; + static class EnginesCleaner implements ExtensionContext.Store.CloseableResource { + protected Engines engines; - HelperCleaner(Set dependencyNames, String worldGeneratorUri) { - helper = new ModuleTestingHelper(dependencyNames, worldGeneratorUri); - helper.setup(); + EnginesCleaner(Set dependencyNames, String worldGeneratorUri) { + engines = new Engines(dependencyNames, worldGeneratorUri); + engines.setup(); } @Override public void close() { - helper.tearDown(); - helper = null; + engines.tearDown(); + engines = null; } } } diff --git a/src/main/java/org/terasology/moduletestingenvironment/ModuleTestingEnvironment.java b/src/main/java/org/terasology/moduletestingenvironment/ModuleTestingEnvironment.java index d7e941a..909f1c5 100644 --- a/src/main/java/org/terasology/moduletestingenvironment/ModuleTestingEnvironment.java +++ b/src/main/java/org/terasology/moduletestingenvironment/ModuleTestingEnvironment.java @@ -90,7 +90,7 @@ public interface ModuleTestingEnvironment { /** * Get the host context for this module testing environment. *

- * The host context will be null if the testing environment has not been set up via {@link ModuleTestingHelper#setup()} + * The host context will be null if the testing environment has not been set up via {@link Engines#setup()} * beforehand. * * @return the engine's host context, or null if not set up yet diff --git a/src/main/java/org/terasology/moduletestingenvironment/ModuleTestingHelper.java b/src/main/java/org/terasology/moduletestingenvironment/ModuleTestingHelper.java index a9067f4..0206377 100644 --- a/src/main/java/org/terasology/moduletestingenvironment/ModuleTestingHelper.java +++ b/src/main/java/org/terasology/moduletestingenvironment/ModuleTestingHelper.java @@ -13,7 +13,6 @@ import java.io.IOException; import java.util.List; -import java.util.Set; import java.util.function.Supplier; /** @@ -82,27 +81,9 @@ public class ModuleTestingHelper implements ModuleTestingEnvironment { final Engines engines; final MainLoop mainLoop; - protected ModuleTestingHelper(Set dependencies, String worldGeneratorUri) { - engines = new Engines(dependencies, worldGeneratorUri); - mainLoop = new MainLoop(engines); - } - - /** - * Set up and start the engine as configured via this environment. - *

- * Every instance should be shut down properly by calling {@link #tearDown()}. - */ - protected void setup() { - engines.setup(); - } - - /** - * Shut down a previously started testing environment. - *

- * Used to properly shut down and clean up a testing environment set up and started with {@link #setup()}. - */ - protected void tearDown() { - engines.tearDown(); + ModuleTestingHelper(Engines engines) { + this.engines = engines; + this.mainLoop = new MainLoop(engines); } @Override From 2323e70de82283440abe77d5f7128a655970c159 Mon Sep 17 00:00:00 2001 From: Kevin Turner <83819+keturn@users.noreply.github.com> Date: Thu, 4 Nov 2021 17:17:18 -0700 Subject: [PATCH 5/5] doc: Update docs for MainLoop class. Remove references to JUnit 4 methods. --- README.md | 26 ++------- .../ChunkRegionFuture.java | 10 ++++ .../moduletestingenvironment/Engines.java | 8 +++ .../MTEExtension.java | 53 +++++++++++++++--- .../moduletestingenvironment/MainLoop.java | 37 ++++++++++++ .../ModuleTestingHelper.java | 56 +------------------ .../extension/Dependencies.java | 26 +++------ .../extension/UseWorldGenerator.java | 27 +++------ .../package-info.java | 16 ++++++ .../moduletestingenvironment/ExampleTest.java | 4 +- 10 files changed, 143 insertions(+), 120 deletions(-) create mode 100644 src/main/java/org/terasology/moduletestingenvironment/package-info.java diff --git a/README.md b/README.md index b77562c..1512d5f 100644 --- a/README.md +++ b/README.md @@ -2,24 +2,8 @@ A test helper to instantiate a full headless TerasologyEngine instance in JUnit tests. -## ⚠️ Issues - -On using MTE tests, you might face the following error: -``` -java.lang.IllegalStateException: Modules only available if resolution was successful -``` -In the logs, you should see something along the following lines: -``` -08:11:52.856 [Test worker] ERROR org.terasology.moduletestingenvironment.TestingStateHeadlessSetup - Unable to resolve modules: [engine, DynamicCities, ModuleTestingEnvironment] -``` - -We do not know, yet, why this happens, but hope that with the migration to gestalt v7, the situation will improve or at least clarify a bit. -Until the migration is complete, we suggest disabling the test using the `@Disable` annotation. - ## Usage -For complete JavaDoc please see the [documentation on Github Pages](https://terasology.github.io/ModuleTestingEnvironment/). - For more examples see [the test suite](https://github.com/terasology/ModuleTestingEnvironment/tree/master/src/test/java/org/terasology/moduletestingenvironment). @@ -27,7 +11,7 @@ Here's an example taken from the test suite: ```java @ExtendWith(MTEExtension.class) -@Dependencies({"engine", "MyModule"}) +@Dependencies("MyModule") @UseWorldGenerator("CoreWorlds:flat") public class ExampleTest { @@ -48,21 +32,21 @@ public class ExampleTest { // wait for both clients to be known to the server helper.runUntil(()-> Lists.newArrayList(entityManager.getEntitiesWith(ClientComponent.class)).size() == 2); - Assertions.assertEquals(2, Lists.newArrayList(entityManager.getEntitiesWith(ClientComponent.class)).size()); + assertEquals(2, Lists.newArrayList(entityManager.getEntitiesWith(ClientComponent.class)).size()); // run while a condition is true or until a timeout passes boolean timedOut = helper.runWhile(1000, ()-> true); - Assertions.assertTrue(timedOut); + assertTrue(timedOut); // send an event to a client's local player just for fun clientContext1.get(LocalPlayer.class).getClientEntity().send(new ResetCameraEvent()); // wait for a chunk to be generated - helper.forceAndWaitForGeneration(Vector3i.zero()); + helper.forceAndWaitForGeneration(new Vector3i()); // set a block's type and immediately read it back worldProvider.setBlock(Vector3i.zero(), blockManager.getBlock("engine:air")); - Assertions.assertEquals("engine:air", worldProvider.getBlock(Vector3f.zero()).getURI().toString()); + assertEquals("engine:air", worldProvider.getBlock(Vector3f.zero()).getURI().toString()); } } ``` diff --git a/src/main/java/org/terasology/moduletestingenvironment/ChunkRegionFuture.java b/src/main/java/org/terasology/moduletestingenvironment/ChunkRegionFuture.java index 39e01a4..d34ea9b 100644 --- a/src/main/java/org/terasology/moduletestingenvironment/ChunkRegionFuture.java +++ b/src/main/java/org/terasology/moduletestingenvironment/ChunkRegionFuture.java @@ -26,6 +26,12 @@ import java.util.function.Consumer; import java.util.function.Function; +/** + * Completes when all the chunks in a region are loaded. + * + * @see MainLoop#makeBlocksRelevant + * @see MainLoop#makeChunksRelevant + */ @SuppressWarnings("checkstyle:finalclass") public class ChunkRegionFuture { public static final int REQUIRED_CHUNK_MARGIN = 1; @@ -44,6 +50,10 @@ private ChunkRegionFuture(EntityRef entity, Function + * The area is defined as a {@index "relevance region"} and will not be unloaded as long as {@link #entity} exists + * and has a {@link LocationComponent}. * * @param entityManager used to create the entity that depends on this region * @param relevanceSystem the authority on what is relevant diff --git a/src/main/java/org/terasology/moduletestingenvironment/Engines.java b/src/main/java/org/terasology/moduletestingenvironment/Engines.java index d1dc2b8..866b451 100644 --- a/src/main/java/org/terasology/moduletestingenvironment/Engines.java +++ b/src/main/java/org/terasology/moduletestingenvironment/Engines.java @@ -58,6 +58,14 @@ * simulating remote clients. *

* Most tests run with a single host and do not need to make direct references to this class. + *

+ * This class is available via dependency injection with the {@link org.terasology.engine.registry.In} annotation + * or as a parameter to a JUnit {@link org.junit.jupiter.api.Test} method; see {@link MTEExtension}. + * + *

Client Engine Instances

+ * Client instances can be easily created via {@link #createClient} which returns the in-game context of the created + * engine instance. When this method returns, the client will be in the {@link StateIngame} state and connected to the + * host. Currently all engine instances are headless, though it is possible to use headed engines in the future. */ public class Engines { private static final Logger logger = LoggerFactory.getLogger(Engines.class); diff --git a/src/main/java/org/terasology/moduletestingenvironment/MTEExtension.java b/src/main/java/org/terasology/moduletestingenvironment/MTEExtension.java index 73806e7..f50341c 100644 --- a/src/main/java/org/terasology/moduletestingenvironment/MTEExtension.java +++ b/src/main/java/org/terasology/moduletestingenvironment/MTEExtension.java @@ -31,21 +31,60 @@ import java.util.function.Function; /** - * Junit 5 Extension for using {@link ModuleTestingHelper} in your test. + * Sets up a Terasology environment for use with your {@index JUnit} 5 test. *

- * Supports Terasology's DI as in usual Systems. You can inject Managers via {@link In} annotation, constructor's or - * test's parameters. Also you can inject {@link ModuleTestingHelper} itself. + * Supports Terasology's DI as in usual Systems. You can inject Managers via {@link In} annotation, constructors or + * test parameters. Also you can inject {@link MainLoop} or {@link Engines} to interact with the environment's engine. *

- * Every class annotated with this will create a single {@link ModuleTestingHelper} and use it during execution of + * Example: + *


+ * import org.junit.jupiter.api.extension.ExtendWith;
+ * import org.junit.jupiter.api.Test;
+ * import org.terasology.engine.registry.In;
+ *
+ * @{@link org.junit.jupiter.api.extension.ExtendWith}(MTEExtension.class)
+ * @{@link Dependencies}("MyModule")
+ * @{@link UseWorldGenerator}("Pathfinding:pathfinder")
+ * public class ExampleTest {
+ *
+ *     @In
+ *     EntityManager entityManager;
+ *
+ *     @In
+ *     {@link MainLoop} mainLoop;
+ *
+ *     @Test
+ *     public void testSomething() {
+ *         // …
+ *     }
+ *
+ *     // Injection is also applied to the parameters of individual tests:
+ *     @Test
+ *     public void testSomething({@link Engines} engines, WorldProvider worldProvider) {
+ *         // …
+ *     }
+ * }
+ * 
+ *

+ * You can configure the environment with these additional annotations: + *

+ *
{@link Dependencies @Dependencies}
+ *
Specify which modules to include in the environment. Put the name of your module under test here. + * Any dependencies these modules declare in module.txt will be pulled in as well.
+ *
{@link UseWorldGenerator @UseWorldGenerator}
+ *
The URN of the world generator to use. Defaults to {@link org.terasology.moduletestingenvironment.fixtures.DummyWorldGenerator}, + * a flat world.
+ *
+ * + *

+ * Every class annotated with this will create a single instance of {@link Engines} and use it during execution of * all tests in the class. This also means that all engine instances are shared between all tests in the class. If you - * want isolated engine instances try {@link IsolatedMTEExtension} + * want isolated engine instances try {@link IsolatedMTEExtension}. *

* Note that classes marked {@link Nested} will share the engine context with their parent. *

* This will configure the logger and the current implementation is not subtle or polite about it, see * {@link #setupLogging()} for notes. - *

- * Use this within {@link org.junit.jupiter.api.extension.ExtendWith} */ public class MTEExtension implements BeforeAllCallback, ParameterResolver, TestInstancePostProcessor { diff --git a/src/main/java/org/terasology/moduletestingenvironment/MainLoop.java b/src/main/java/org/terasology/moduletestingenvironment/MainLoop.java index e1a4c8f..65f7e51 100644 --- a/src/main/java/org/terasology/moduletestingenvironment/MainLoop.java +++ b/src/main/java/org/terasology/moduletestingenvironment/MainLoop.java @@ -31,13 +31,29 @@ /** * Methods to run the main loop of the game. *

+ * Engines can be run while a condition is true via {@link #runWhile(Supplier)}
{@code mainLoop.runWhile(()-> true);} + *

+ * or conversely run until a condition is true via {@link #runUntil(Supplier)}
{@code mainLoop.runUntil(()-> false);} + *

+ * Test scenarios which take place in a particular location of the world must first make sure that location is loaded + * with {@link #makeBlocksRelevant makeBlocksRelevant} or {@link #makeChunksRelevant makeChunksRelevant}. + *


+ *     // Load everything within 40 blocks of x=123, z=456
+ *     mainLoop.runUntil(makeBlocksRelevant(
+ *         new BlockRegion(123, SURFACE_HEIGHT, 456).expand(40, 40, 40)));
+ * 
+ *

* {@link MTEExtension} provides tests with a game engine, configured with a module environment * and a world. The engine is ready by the time a test method is executed, but does not run * until you use one of these methods. *

* If there are multiple engines (a host and one or more clients), they will tick in a round-robin fashion. + *

+ * This class is available via dependency injection with the {@link org.terasology.engine.registry.In} annotation + * or as a parameter to a JUnit {@link org.junit.jupiter.api.Test} method; see {@link MTEExtension}. */ public class MainLoop { + // TODO: Can we get rid of this by making sure our main loop is compatible with JUnit's timeout spec? long safetyTimeoutMs = ModuleTestingEnvironment.DEFAULT_SAFETY_TIMEOUT; private final Engines engines; @@ -63,6 +79,12 @@ public void forceAndWaitForGeneration(Vector3ic blockPos) { } /** + * Makes sure the area containing these blocks is loaded. + *

+ * This method is asynchronous. Pass the result to {@link #runUntil(ListenableFuture)} if you need to wait until the area is ready. + * + * @see #makeChunksRelevant(BlockRegion) makeChunksRelevant if you have chunk coordinates instead of block coordinates. + * * @param blocks blocks to mark as relevant * @return relevant chunks */ @@ -71,6 +93,16 @@ public ListenableFuture makeBlocksRelevant(BlockRegionc block return makeChunksRelevant(desiredChunkRegion, blocks.center(new Vector3f())); } + /** + * Makes sure the area containing these chunks is loaded. + *

+ * This method is asynchronous. Pass the result to {@link #runUntil(ListenableFuture)} if you need to wait until the area is ready. + * + * @see #makeBlocksRelevant(BlockRegionc) makeBlocksRelevant if you have block coordinates instead of chunk coordinates. + * + * @param chunks to mark as relevant + * @return relevant chunks + */ @SuppressWarnings("unused") public ListenableFuture makeChunksRelevant(BlockRegion chunks) { // Pick a central point (in block coordinates). @@ -96,6 +128,11 @@ BlockRegionc chunkRegionToNewBlockRegion(BlockRegionc chunks) { return blocks.transform(new Matrix4f().scaling(new Vector3f(Chunks.CHUNK_SIZE))); } + /** + * Runs until this future is complete. + * + * @return the result of the future + */ public T runUntil(ListenableFuture future) { boolean timedOut = runUntil(future::isDone); if (timedOut) { diff --git a/src/main/java/org/terasology/moduletestingenvironment/ModuleTestingHelper.java b/src/main/java/org/terasology/moduletestingenvironment/ModuleTestingHelper.java index 0206377..28b2883 100644 --- a/src/main/java/org/terasology/moduletestingenvironment/ModuleTestingHelper.java +++ b/src/main/java/org/terasology/moduletestingenvironment/ModuleTestingHelper.java @@ -16,65 +16,15 @@ import java.util.function.Supplier; /** - * Base class for tests involving full {@link TerasologyEngine} instances. View the tests included in this module for - * simple usage examples. - * - *

Introduction

- * If test classes extend this class will create a new host engine for each {@code @Test} method. If the testing - * environment is used by composition {@link #setup()} and {@link #tearDown()} need to be called explicitly. This can be - * done once for the test class or for each test. + * Methods for interacting with the engine in the test environment. *

- * The in-game {@link Context} for this engine can be accessed via {@link #getHostContext()}. The result of this getter - * is equivalent to the CoreRegistry available to module code at runtime. However, it is very important that you do not - * use CoreRegistry in your test code, as this is manipulated by the test environment to allow multiple instances of the - * engine to peacefully coexist. You should always use the returned context reference to manipulate or inspect the - * CoreRegistry of a given engine instance. + * Most tests only need the methods of {@link MainLoop}. Expect this class to be deprecated after we figure out better + * asynchronous methods for {@link #createClient()}. * *

Client Engine Instances

* Client instances can be easily created via {@link #createClient()} which returns the in-game context of the created * engine instance. When this method returns, the client will be in the {@link StateIngame} state and connected to the * host. Currently all engine instances are headless, though it is possible to use headed engines in the future. - *

- * Engines can be run while a condition is true via {@link #runWhile(Supplier)}
{@code runWhile(()-> true);} - *

- * or conversely run until a condition is true via {@link #runUntil(Supplier)}
{@code runUntil(()-> false);} - * - *

Specifying Dependencies

- * By default the environment will load only the engine itself. FIXME - * - *

Specifying World Generator

- * By default the environment will use a dummy world generator which creates nothing but air. To specify a more useful - * world generator you must FIXME - * - *

Reuse the MTE for Multiple Tests

- * To use the same engine for multiple tests the testing environment can be set up explicitly and shared between tests. - * To configure module dependencies or the world generator an anonymous class may be used. - *
- * private static ModuleTestingHelper context;
- *
- * @BeforeAll
- * public static void setup() throws Exception {
- *     context = new ModuleTestingHelper() {
- *     @Override
- *     public Set<String> getDependencies() {
- *         return Sets.newHashSet("ModuleTestingHelper");
- *     }
- *     };
- *     context.setup();
- * }
- *
- * @AfterAll
- * public static void tearDown() throws Exception {
- *     context.tearDown();
- * }
- *
- * @Test
- * public void someTest() {
- *     Context hostContext = context.getHostContext();
- *     EntityManager entityManager = hostContext.get(EntityManager.class);
- *     // ...
- * }
- * 
*/ public class ModuleTestingHelper implements ModuleTestingEnvironment { diff --git a/src/main/java/org/terasology/moduletestingenvironment/extension/Dependencies.java b/src/main/java/org/terasology/moduletestingenvironment/extension/Dependencies.java index 56bac9b..ce94d83 100644 --- a/src/main/java/org/terasology/moduletestingenvironment/extension/Dependencies.java +++ b/src/main/java/org/terasology/moduletestingenvironment/extension/Dependencies.java @@ -1,18 +1,5 @@ -/* - * Copyright 2020 MovingBlocks - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ +// Copyright 2021 The Terasology Foundation +// SPDX-License-Identifier: Apache-2.0 package org.terasology.moduletestingenvironment.extension; @@ -21,13 +8,16 @@ import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; +/** + * Declares the modules to load in the environment. + * + * @see org.terasology.moduletestingenvironment.MTEExtension + */ @Target(ElementType.TYPE) @Retention(RetentionPolicy.RUNTIME) public @interface Dependencies { /** - * Override this to change which modules must be loaded for the environment - * - * @return The set of module names to load + * Names of modules, as defined by the id in their module.txt. */ String[] value(); } diff --git a/src/main/java/org/terasology/moduletestingenvironment/extension/UseWorldGenerator.java b/src/main/java/org/terasology/moduletestingenvironment/extension/UseWorldGenerator.java index 8bcc1da..69b124f 100644 --- a/src/main/java/org/terasology/moduletestingenvironment/extension/UseWorldGenerator.java +++ b/src/main/java/org/terasology/moduletestingenvironment/extension/UseWorldGenerator.java @@ -1,18 +1,5 @@ -/* - * Copyright 2020 MovingBlocks - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ +// Copyright 2021 The Terasology Foundation +// SPDX-License-Identifier: Apache-2.0 package org.terasology.moduletestingenvironment.extension; @@ -21,15 +8,17 @@ import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; +/** + * Declares which {@index "world generator"} to use. + * + * @see org.terasology.moduletestingenvironment.MTEExtension + */ @Target(ElementType.TYPE) @Retention(RetentionPolicy.RUNTIME) public @interface UseWorldGenerator { /** - * Override this to change which world generator to use. Defaults to a dummy generator that leaves all blocks as - * air - * - * @return the uri of the desired world generator + * The URN of the world generator, e.g. "CoreWorlds:facetedPerlin" */ String value(); } diff --git a/src/main/java/org/terasology/moduletestingenvironment/package-info.java b/src/main/java/org/terasology/moduletestingenvironment/package-info.java new file mode 100644 index 0000000..67c0a04 --- /dev/null +++ b/src/main/java/org/terasology/moduletestingenvironment/package-info.java @@ -0,0 +1,16 @@ +// Copyright 2021 The Terasology Foundation +// SPDX-License-Identifier: Apache-2.0 + +/** + * Provides a Terasology engine for use with unit tests. + *

+ * Key points of interest for test authors are: + *

    + *
  • {@link org.terasology.moduletestingenvironment.MTEExtension MTEExtension}: Use this on your JUnit 5 test classes. + *
  • {@link org.terasology.moduletestingenvironment.MainLoop MainLoop}: Methods for running the engine during your test scenarios. + *
  • {@link org.terasology.moduletestingenvironment.Engines}: You can add additional engines to simulate remote connections to the + * host. [Experimental] + *
+ */ +package org.terasology.moduletestingenvironment; + diff --git a/src/test/java/org/terasology/moduletestingenvironment/ExampleTest.java b/src/test/java/org/terasology/moduletestingenvironment/ExampleTest.java index c0e1906..85b59b0 100644 --- a/src/test/java/org/terasology/moduletestingenvironment/ExampleTest.java +++ b/src/test/java/org/terasology/moduletestingenvironment/ExampleTest.java @@ -13,17 +13,17 @@ import org.terasology.engine.entitySystem.entity.EntityManager; import org.terasology.engine.logic.players.LocalPlayer; import org.terasology.engine.logic.players.event.ResetCameraEvent; -import org.terasology.moduletestingenvironment.extension.Dependencies; import org.terasology.engine.network.ClientComponent; import org.terasology.engine.registry.In; import org.terasology.engine.world.WorldProvider; import org.terasology.engine.world.block.BlockManager; +import org.terasology.moduletestingenvironment.extension.Dependencies; import java.io.IOException; @Tag("MteTest") @ExtendWith(MTEExtension.class) -@Dependencies({"engine", "ModuleTestingEnvironment"}) +@Dependencies("ModuleTestingEnvironment") public class ExampleTest { @In