test(MTE): allow a test to add its own EngineSubsystem (#5044)

develop
Kevin Turner 2022-06-12 16:44:39 -07:00 committed by GitHub
parent 7dbe8724bf
commit 558c8418ab
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 243 additions and 50 deletions

View File

@ -78,6 +78,9 @@ dependencies {
api("org.junit.jupiter:junit-jupiter-api") {
because("we export jupiter Extensions for module tests")
}
api("com.google.truth:truth:1.1.3") {
because("we provide some helper classes")
}
implementation("org.mockito:mockito-inline:3.12.4") {
because("classes like HeadlessEnvironment use mocks")
}

View File

@ -9,12 +9,10 @@ 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;
@ -40,20 +38,18 @@ import org.terasology.engine.network.NetworkMode;
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.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Set;
import static org.junit.platform.commons.support.ReflectionSupport.newInstance;
/**
* Manages game engines for tests.
* <p>
@ -78,15 +74,18 @@ public class Engines {
protected boolean doneLoading;
protected Context hostContext;
protected final List<TerasologyEngine> engines = Lists.newArrayList();
protected final List<Class<? extends EngineSubsystem>> subsystems = Lists.newArrayList();
PathManager pathManager;
PathManagerProvider.Cleaner pathManagerCleaner;
TerasologyEngine host;
private final NetworkMode networkMode;
public Engines(List<String> dependencies, String worldGeneratorUri, NetworkMode networkMode) {
public Engines(List<String> dependencies, String worldGeneratorUri, NetworkMode networkMode,
List<Class<? extends EngineSubsystem>> subsystems) {
this.networkMode = networkMode;
this.dependencies.addAll(dependencies);
this.subsystems.addAll(subsystems);
if (worldGeneratorUri != null) {
this.worldGeneratorUri = worldGeneratorUri;
@ -142,7 +141,6 @@ public class Engines {
*/
public Context createClient(MainLoop mainLoop) throws IOException {
TerasologyEngine client = createHeadlessEngine();
client.getFromEngineContext(Config.class).getRendering().setViewDistance(ViewDistance.LEGALLY_BLIND);
client.changeState(new StateMainMenu());
if (!connectToHost(client, mainLoop)) {
@ -182,11 +180,12 @@ public class Engines {
TerasologyEngine createHeadlessEngine() throws IOException {
TerasologyEngineBuilder terasologyEngineBuilder = new TerasologyEngineBuilder();
terasologyEngineBuilder
.add(new WithUnittestModule())
.add(new IntegrationEnvironmentSubsystem())
.add(new HeadlessGraphics())
.add(new HeadlessTimer())
.add(new HeadlessAudio())
.add(new HeadlessInput());
createExtraSubsystems().forEach(terasologyEngineBuilder::add);
return createEngine(terasologyEngineBuilder);
}
@ -195,16 +194,31 @@ public class Engines {
TerasologyEngine createHeadedEngine() throws IOException {
EngineSubsystem audio = new LwjglAudio();
TerasologyEngineBuilder terasologyEngineBuilder = new TerasologyEngineBuilder()
.add(new WithUnittestModule())
.add(new IntegrationEnvironmentSubsystem())
.add(audio)
.add(new LwjglGraphics())
.add(new LwjglTimer())
.add(new LwjglInput())
.add(new OpenVRInput());
createExtraSubsystems().forEach(terasologyEngineBuilder::add);
return createEngine(terasologyEngineBuilder);
}
List<EngineSubsystem> createExtraSubsystems() {
List<EngineSubsystem> instances = new ArrayList<>();
for (Class<? extends EngineSubsystem> clazz : subsystems) {
try {
EngineSubsystem subsystem = newInstance(clazz);
instances.add(subsystem);
logger.debug("Created new {}", subsystem);
} catch (RuntimeException e) {
throw new RuntimeException("Failed creating new " + clazz.getName(), e);
}
}
return instances;
}
TerasologyEngine createEngine(TerasologyEngineBuilder terasologyEngineBuilder) throws IOException {
System.setProperty(ModuleManager.LOAD_CLASSPATH_MODULES_PROPERTY, "true");
@ -220,43 +234,11 @@ public class Engines {
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.
* <p>
* For these cases we try to load the CWD (via the installPath) as a module and put it in the global module registry.
* <p>
* 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);
@ -265,11 +247,10 @@ public class Engines {
PathManagerProvider.setPathManager(pathManager);
}
TerasologyEngine createHost(NetworkMode networkMode) throws IOException {
TerasologyEngine createHost(NetworkMode hostNetworkMode) throws IOException {
TerasologyEngine host = createHeadlessEngine();
host.getFromEngineContext(SystemConfig.class).writeSaveGamesEnabled.set(false);
host.subscribeToStateChange(new HeadlessStateChangeListener(host));
host.changeState(new TestingStateHeadlessSetup(dependencies, worldGeneratorUri, networkMode));
host.changeState(new TestingStateHeadlessSetup(dependencies, worldGeneratorUri, hostNetworkMode));
doneLoading = false;
host.subscribeToStateChange(() -> {

View File

@ -0,0 +1,84 @@
// Copyright 2022 The Terasology Foundation
// SPDX-License-Identifier: Apache-2.0
package org.terasology.engine.integrationenvironment;
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.TerasologyConstants;
import org.terasology.engine.core.module.ModuleManager;
import org.terasology.engine.core.subsystem.EngineSubsystem;
import org.terasology.engine.integrationenvironment.jupiter.IntegrationEnvironment;
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.nio.file.Path;
final class IntegrationEnvironmentSubsystem implements EngineSubsystem {
private static final Logger logger = LoggerFactory.getLogger(IntegrationEnvironmentSubsystem.class);
@Override
public String getName() {
return this.getClass().getSimpleName();
}
@Override
public void initialise(GameEngine engine, Context rootContext) {
ModuleManager moduleManager = rootContext.getValue(ModuleManager.class);
WithUnittestModule.registerUnittestModule(moduleManager);
registerCurrentDirectoryIfModule(moduleManager);
configure(rootContext);
}
/**
* Apply test environment default configuration.
* <p>
* You can override this by defining your own EngineSubsystem and passing it to
* {@link IntegrationEnvironment#subsystem()}; it will run after this does.
*/
static void configure(Context context) {
Config config = context.getValue(Config.class);
config.getRendering().setViewDistance(ViewDistance.LEGALLY_BLIND);
SystemConfig sys = context.getValue(SystemConfig.class);
sys.writeSaveGamesEnabled.set(false);
}
/**
* 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.
* <p>
* For these cases we try to load the CWD (via the installPath) as a module and put it in the global module registry.
* <p>
* This process is based on how ModuleManagerImpl uses ModulePathScanner to scan for available modules.
*/
static void registerCurrentDirectoryIfModule(ModuleManager moduleManager) {
Path installPath = PathManager.getInstance().getInstallPath();
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);
}
}
}

View File

@ -3,6 +3,8 @@
package org.terasology.engine.integrationenvironment.jupiter;
import org.terasology.engine.core.subsystem.EngineSubsystem;
import org.terasology.engine.logic.players.LocalPlayer;
import org.terasology.engine.network.NetworkMode;
import java.lang.annotation.ElementType;
@ -13,5 +15,34 @@ import java.lang.annotation.Target;
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface IntegrationEnvironment {
/**
* The network mode the host engine starts with.
* <p>
* See {@link NetworkMode} for details on the options.
* <p>
* Some modes automatically include a {@link LocalPlayer}.
* <p>
* If you want to simulate multiple players with
* {@link org.terasology.engine.integrationenvironment.Engines#createClient Engines.createClient},
* you will need a mode with a {@linkplain NetworkMode#isServer() server}.
*/
NetworkMode networkMode() default NetworkMode.NONE;
/**
* Add an additional subsystem to the engine.
* <p>
* A new instance will be included in the engine's subsystems when it is created.
* <p>
* Implementing {@link EngineSubsystem#initialise} gives you the opportunity to
* make changes to the configuration <em>before</em> it would otherwise be available.
*/
Class<? extends EngineSubsystem> subsystem() default NO_SUBSYSTEM.class;
/**
* Do not add an extra subsystem to the integration environment.
* <p>
* [Odd marker interface because annotation fields cannot default to null.]
*/
@SuppressWarnings("checkstyle:TypeName")
abstract class NO_SUBSYSTEM implements EngineSubsystem { }
}

View File

@ -13,6 +13,7 @@ import org.junit.jupiter.api.extension.ParameterResolutionException;
import org.junit.jupiter.api.extension.ParameterResolver;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.terasology.engine.core.subsystem.EngineSubsystem;
import org.terasology.engine.integrationenvironment.Engines;
import org.terasology.engine.integrationenvironment.MainLoop;
import org.terasology.engine.integrationenvironment.ModuleTestingHelper;
@ -66,6 +67,8 @@ import static org.terasology.engine.registry.InjectionHelper.inject;
* <p>
* You can configure the environment with these additional annotations:
* <dl>
* <dt>{@link IntegrationEnvironment @IntegrationEnvironment}</dt>
* <dd>Configure the network mode and add subsystems.</dd>
* <dt>{@link Dependencies @Dependencies}</dt>
* <dd>Specify which modules to include in the environment. Put the name of your module under test here.
* Any dependencies these modules declare in <code>module.txt</code> will be pulled in as well.</dd>
@ -174,6 +177,12 @@ public class MTEExtension implements BeforeAllCallback, BeforeEachCallback, Para
return getAnnotationWithDefault(context, IntegrationEnvironment::networkMode);
}
public List<Class<? extends EngineSubsystem>> getSubsystems(ExtensionContext context) {
var subsystem = getAnnotationWithDefault(context, IntegrationEnvironment::subsystem);
return subsystem.equals(IntegrationEnvironment.NO_SUBSYSTEM.class)
? Collections.emptyList() : List.of(subsystem);
}
private <T> T getAnnotationWithDefault(ExtensionContext context, Function<IntegrationEnvironment, T> method) {
var ann =
findAnnotation(context.getRequiredTestClass(), IntegrationEnvironment.class)
@ -200,7 +209,8 @@ public class MTEExtension implements BeforeAllCallback, BeforeEachCallback, Para
EnginesCleaner.class, k -> new EnginesCleaner(
getDependencyNames(context),
getWorldGeneratorUri(context),
getNetworkMode(context)
getNetworkMode(context),
getSubsystems(context)
),
EnginesCleaner.class);
return autoCleaner.engines;
@ -225,8 +235,9 @@ public class MTEExtension implements BeforeAllCallback, BeforeEachCallback, Para
static class EnginesCleaner implements ExtensionContext.Store.CloseableResource {
protected Engines engines;
EnginesCleaner(List<String> dependencyNames, String worldGeneratorUri, NetworkMode networkMode) {
engines = new Engines(dependencyNames, worldGeneratorUri, networkMode);
EnginesCleaner(List<String> dependencyNames, String worldGeneratorUri, NetworkMode networkMode,
List<Class<? extends EngineSubsystem>> subsystems) {
engines = new Engines(dependencyNames, worldGeneratorUri, networkMode, subsystems);
engines.setup();
}

View File

@ -0,0 +1,17 @@
// Copyright 2022 The Terasology Foundation
// SPDX-License-Identifier: Apache-2.0
package org.terasology.engine.testUtil;
import com.google.common.truth.Correspondence;
public final class Correspondences {
private Correspondences() {
}
public static <A, E extends Class<?>> Correspondence<A, E> instanceOfExpected() {
// This is literally the example in the documentation of Correspondence.from.
// They could have included the implementation in the library for us to use!
return Correspondence.from((A a, E e) -> e.isInstance(a), "is an instance of");
}
}

View File

@ -1,4 +1,4 @@
// Copyright 2021 The Terasology Foundation
// Copyright 2022 The Terasology Foundation
// SPDX-License-Identifier: Apache-2.0
package org.terasology.engine.testUtil;
@ -30,6 +30,10 @@ public class WithUnittestModule implements EngineSubsystem {
public void initialise(GameEngine engine, Context rootContext) {
EngineSubsystem.super.initialise(engine, rootContext);
ModuleManager manager = rootContext.get(ModuleManager.class);
registerUnittestModule(manager);
}
public static void registerUnittestModule(ModuleManager manager) {
Module unittestModule = manager.registerPackageModule("org.terasology.unittest");
manager.resolveAndLoadEnvironment(unittestModule.getId());
}

View File

@ -0,0 +1,62 @@
// Copyright 2022 The Terasology Foundation
// SPDX-License-Identifier: Apache-2.0
package org.terasology.engine.integrationenvironment;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.terasology.engine.config.PlayerConfig;
import org.terasology.engine.context.Context;
import org.terasology.engine.core.GameEngine;
import org.terasology.engine.core.TerasologyEngine;
import org.terasology.engine.core.subsystem.EngineSubsystem;
import org.terasology.engine.integrationenvironment.jupiter.IntegrationEnvironment;
import org.terasology.engine.integrationenvironment.jupiter.MTEExtension;
import static com.google.common.truth.Truth.assertThat;
import static org.terasology.engine.testUtil.Correspondences.instanceOfExpected;
@Tag("MteTest")
@ExtendWith(MTEExtension.class)
@IntegrationEnvironment(subsystem = CustomSubsystemTest.MySubsystem.class)
public class CustomSubsystemTest {
static final String PLAYER_NAME = "Customized Name Just For This";
@Test
void testSubsystemExists(GameEngine engine) {
assertThat(((TerasologyEngine) engine).getSubsystems())
.comparingElementsUsing(instanceOfExpected())
.contains(MySubsystem.class);
}
@Test
void testConfigurationBySubsystemInitialisation(PlayerConfig config) {
assertThat(config.playerName.get()).isEqualTo(PLAYER_NAME);
}
/**
* Configure the name of the player.
* <p>
* The subsystem class doesn't necessarily need to be an inner class of the test class, but
* it's a convenient way to keep it close to the test code and still be something we can give
* to an annotation.
*/
static class MySubsystem implements EngineSubsystem {
@Override
public void initialise(GameEngine engine, Context rootContext) {
var config = rootContext.getValue(PlayerConfig.class);
config.playerName.set(PLAYER_NAME);
}
@Override
public String getName() {
// TODO: provide default implementation of EngineSubsystem.getName.
// The interface requires we implement this method, but test-only subsystems aren't
// player-visible.
return this.getClass().getSimpleName();
}
}
}