From 71f81e12013edf82e8a815c0938cbbb3a3c8c2e5 Mon Sep 17 00:00:00 2001 From: Jonathan Coates Date: Sat, 29 Oct 2022 18:17:02 +0100 Subject: [PATCH] Move some test support code into testFixtues This offers very few advantages now, but helps support the following in the future: - Reuse test support code across multiple projects (useful for multi-loader). - Allow using test fixture code in testMod. We've got a version of our gametest which use Kotlin instead of Lua for asserting computer behaviour. We can't use java-test-fixtures here for Forge reasons, so have to roll our own version. Alas. - Add an ILuaMachine implementation which runs Kotlin coroutines instead. We can use this for testing asynchronous APIs. This also replaces the FakeComputerManager. - Move most things in the .support module to .test.core. We need to use a separate package in order to cope with Java 9 modules (again, thanks Forge). --- build.gradle.kts | 28 +- buildSrc/build.gradle.kts | 1 + .../kotlin/cc-tweaked.gametest.gradle.kts | 53 ++++ .../cc-tweaked.java-convention.gradle.kts | 3 +- .../cc-tweaked.kotlin-convention.gradle.kts | 21 ++ .../cc/tweaked/gradle/CCTweakedPlugin.kt | 5 + gradle/libs.versions.toml | 1 + .../core/computer/ComputerThread.java | 4 +- .../core/ComputerTestDelegate.java | 4 +- .../computercraft/core/apis/AsyncRunner.kt | 123 --------- .../computercraft/core/asm/GeneratorTest.java | 2 +- .../core/computer/ComputerBootstrap.java | 3 +- .../core/computer/ComputerThreadTest.java | 5 +- .../core/computer/FakeComputerManager.java | 259 ------------------ .../core/terminal/TerminalTest.java | 5 +- .../network/server/UploadFileMessageTest.java | 8 +- .../computercraft/core/http}/TestHttpApi.kt | 14 +- .../test/core}/ArbitraryByteBuffer.java | 2 +- .../test/core}/ByteBufferMatcher.java | 2 +- .../computercraft/test/core}/CallCounter.java | 2 +- .../test/core}/ContramapMatcher.java | 2 +- .../test/core}/CustomMatchers.java | 2 +- .../test/core/apis/BasicApiEnvironment.java | 161 +++++++++++ .../test}/core/computer/BasicEnvironment.java | 9 +- .../computer/FakeMainThreadScheduler.java | 2 +- .../test}/core/filesystem/MemoryMount.java | 2 +- .../test}/core/terminal/TerminalMatchers.java | 6 +- .../computercraft/test/core/Assertions.kt | 63 +++++ .../core/computer/KotlinComputerManager.kt | 188 +++++++++++++ .../test/core/computer/KotlinLuaMachine.kt | 41 +++ .../test/core/computer/LuaTaskContext.kt | 87 ++++++ .../test/core/computer/LuaTaskRunner.kt | 64 +++++ 32 files changed, 745 insertions(+), 427 deletions(-) create mode 100644 buildSrc/src/main/kotlin/cc-tweaked.gametest.gradle.kts create mode 100644 buildSrc/src/main/kotlin/cc-tweaked.kotlin-convention.gradle.kts delete mode 100644 src/test/java/dan200/computercraft/core/apis/AsyncRunner.kt delete mode 100644 src/test/java/dan200/computercraft/core/computer/FakeComputerManager.java rename src/test/{java/dan200/computercraft/core/apis/http/options => kotlin/dan200/computercraft/core/http}/TestHttpApi.kt (75%) rename src/{test/java/dan200/computercraft/support => testFixtures/java/dan200/computercraft/test/core}/ArbitraryByteBuffer.java (99%) rename src/{test/java/dan200/computercraft/support => testFixtures/java/dan200/computercraft/test/core}/ByteBufferMatcher.java (98%) rename src/{test/java/dan200/computercraft/support => testFixtures/java/dan200/computercraft/test/core}/CallCounter.java (95%) rename src/{test/java/dan200/computercraft/support => testFixtures/java/dan200/computercraft/test/core}/ContramapMatcher.java (97%) rename src/{test/java/dan200/computercraft/support => testFixtures/java/dan200/computercraft/test/core}/CustomMatchers.java (96%) create mode 100644 src/testFixtures/java/dan200/computercraft/test/core/apis/BasicApiEnvironment.java rename src/{test/java/dan200/computercraft => testFixtures/java/dan200/computercraft/test}/core/computer/BasicEnvironment.java (91%) rename src/{test/java/dan200/computercraft => testFixtures/java/dan200/computercraft/test}/core/computer/FakeMainThreadScheduler.java (96%) rename src/{test/java/dan200/computercraft => testFixtures/java/dan200/computercraft/test}/core/filesystem/MemoryMount.java (98%) rename src/{test/java/dan200/computercraft => testFixtures/java/dan200/computercraft/test}/core/terminal/TerminalMatchers.java (88%) create mode 100644 src/testFixtures/kotlin/dan200/computercraft/test/core/Assertions.kt create mode 100644 src/testFixtures/kotlin/dan200/computercraft/test/core/computer/KotlinComputerManager.kt create mode 100644 src/testFixtures/kotlin/dan200/computercraft/test/core/computer/KotlinLuaMachine.kt create mode 100644 src/testFixtures/kotlin/dan200/computercraft/test/core/computer/LuaTaskContext.kt create mode 100644 src/testFixtures/kotlin/dan200/computercraft/test/core/computer/LuaTaskRunner.kt diff --git a/build.gradle.kts b/build.gradle.kts index 20fdf53e9..7a5c1f845 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,9 +1,9 @@ import cc.tweaked.gradle.* import net.darkhax.curseforgegradle.TaskPublishCurseForge +import net.minecraftforge.gradle.common.util.RunConfig plugins { // Build - alias(libs.plugins.kotlin) alias(libs.plugins.forgeGradle) alias(libs.plugins.mixinGradle) alias(libs.plugins.librarian) @@ -18,7 +18,7 @@ plugins { id("cc-tweaked.illuaminate") id("cc-tweaked.node") - id("cc-tweaked.java-convention") + id("cc-tweaked.gametest") id("cc-tweaked") } @@ -36,8 +36,6 @@ sourceSets { main { resources.srcDir("src/generated/resources") } - - register("testMod") } minecraft { @@ -76,21 +74,26 @@ minecraft { property("cct.pretty-json", "true") } + fun RunConfig.configureForGameTest() { + mods.register("cctest") { + source(sourceSets["testMod"]) + source(sourceSets["testFixtures"]) + } + } + val testClient by registering { workingDirectory(file("run/testClient")) parent(client.get()) - - mods.register("cctest") { source(sourceSets["testMod"]) } + configureForGameTest() } val testServer by registering { workingDirectory(file("run/testServer")) parent(server.get()) + configureForGameTest() property("cctest.run", "true") property("forge.logging.console.level", "info") - - mods.register("cctest") { source(sourceSets["testMod"]) } } } @@ -113,8 +116,6 @@ configurations { val shade by registering { isTransitive = false } implementation { extendsFrom(shade.get()) } register("cctJavadoc") - - named("testModImplementation") { extendsFrom(implementation.get(), testImplementation.get()) } } dependencies { @@ -132,12 +133,13 @@ dependencies { "shade"(libs.cobalt) + testFixturesApi(libs.bundles.test) + testFixturesApi(libs.bundles.kotlin) + testImplementation(libs.bundles.test) testImplementation(libs.bundles.kotlin) testRuntimeOnly(libs.bundles.testRuntime) - "testModImplementation"(sourceSets.main.get().output) - "cctJavadoc"(libs.cctJavadoc) } @@ -205,7 +207,7 @@ tasks.jar { "Specification-Title" to "computercraft", "Specification-Vendor" to "SquidDev", "Specification-Version" to "1", - "specificationVersion" to "cctweaked", + "Implementation-Title" to "cctweaked", "Implementation-Version" to modVersion, "Implementation-Vendor" to "SquidDev", ) diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts index 6ba68e361..7e14fdeac 100644 --- a/buildSrc/build.gradle.kts +++ b/buildSrc/build.gradle.kts @@ -9,6 +9,7 @@ repositories { } dependencies { + implementation(libs.kotlin.plugin) implementation(libs.spotless) } diff --git a/buildSrc/src/main/kotlin/cc-tweaked.gametest.gradle.kts b/buildSrc/src/main/kotlin/cc-tweaked.gametest.gradle.kts new file mode 100644 index 000000000..bc8a0e2ba --- /dev/null +++ b/buildSrc/src/main/kotlin/cc-tweaked.gametest.gradle.kts @@ -0,0 +1,53 @@ +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +/** + * Sets up the configurations for writing game tests. + * + * See notes in [cc.tweaked.gradle.MinecraftConfigurations] for the general design behind these cursed ideas. + */ + +plugins { + id("cc-tweaked.kotlin-convention") + id("cc-tweaked.java-convention") +} + +val main = sourceSets.main.get() + +// Both testMod and testFixtures inherit from the main classpath, just so we have access to Minecraft classes. +val testMod by sourceSets.creating { + compileClasspath += main.compileClasspath + runtimeClasspath += main.runtimeClasspath +} + +configurations { + named(testMod.compileClasspathConfigurationName) { + shouldResolveConsistentlyWith(compileClasspath.get()) + } + + named(testMod.runtimeClasspathConfigurationName) { + shouldResolveConsistentlyWith(runtimeClasspath.get()) + } +} + +// Like the main test configurations, we're safe to depend on source set outputs. +dependencies { + add(testMod.implementationConfigurationName, main.output) +} + +// Similar to java-test-fixtures, but tries to avoid putting the obfuscated jar on the classpath. + +val testFixtures by sourceSets.creating { + compileClasspath += main.compileClasspath +} + +java.registerFeature("testFixtures") { + usingSourceSet(testFixtures) + disablePublication() +} + +dependencies { + add(testFixtures.implementationConfigurationName, main.output) + + testImplementation(testFixtures(project)) + add(testMod.implementationConfigurationName, testFixtures(project)) +} diff --git a/buildSrc/src/main/kotlin/cc-tweaked.java-convention.gradle.kts b/buildSrc/src/main/kotlin/cc-tweaked.java-convention.gradle.kts index 0580ad5b3..e52bbf060 100644 --- a/buildSrc/src/main/kotlin/cc-tweaked.java-convention.gradle.kts +++ b/buildSrc/src/main/kotlin/cc-tweaked.java-convention.gradle.kts @@ -1,3 +1,4 @@ +import cc.tweaked.gradle.CCTweakedPlugin import cc.tweaked.gradle.LicenseHeader import com.diffplug.gradle.spotless.FormatExtension import com.diffplug.spotless.LineEnding @@ -12,7 +13,7 @@ plugins { java { toolchain { - languageVersion.set(JavaLanguageVersion.of(8)) + languageVersion.set(CCTweakedPlugin.JAVA_VERSION) } withSourcesJar() diff --git a/buildSrc/src/main/kotlin/cc-tweaked.kotlin-convention.gradle.kts b/buildSrc/src/main/kotlin/cc-tweaked.kotlin-convention.gradle.kts new file mode 100644 index 000000000..040204d0c --- /dev/null +++ b/buildSrc/src/main/kotlin/cc-tweaked.kotlin-convention.gradle.kts @@ -0,0 +1,21 @@ +import cc.tweaked.gradle.CCTweakedPlugin +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +plugins { + kotlin("jvm") +} + +kotlin { + jvmToolchain { + languageVersion.set(CCTweakedPlugin.JAVA_VERSION) + } +} + +tasks.withType(KotlinCompile::class.java).configureEach { + // So technically we shouldn't need to do this as the toolchain sets it above. However, the option only appears + // to be set when the task executes, so doesn't get picked up by IDEs. + kotlinOptions.jvmTarget = when { + CCTweakedPlugin.JAVA_VERSION.asInt() > 8 -> CCTweakedPlugin.JAVA_VERSION.toString() + else -> "1.${CCTweakedPlugin.JAVA_VERSION.asInt()}" + } +} diff --git a/buildSrc/src/main/kotlin/cc/tweaked/gradle/CCTweakedPlugin.kt b/buildSrc/src/main/kotlin/cc/tweaked/gradle/CCTweakedPlugin.kt index c2bd83b49..ca453a841 100644 --- a/buildSrc/src/main/kotlin/cc/tweaked/gradle/CCTweakedPlugin.kt +++ b/buildSrc/src/main/kotlin/cc/tweaked/gradle/CCTweakedPlugin.kt @@ -2,6 +2,7 @@ package cc.tweaked.gradle import org.gradle.api.Plugin import org.gradle.api.Project +import org.gradle.jvm.toolchain.JavaLanguageVersion /** * Configures projects to match a shared configuration. @@ -10,4 +11,8 @@ class CCTweakedPlugin : Plugin { override fun apply(project: Project) { project.extensions.create("cct", CCTweakedExtension::class.java) } + + companion object { + val JAVA_VERSION = JavaLanguageVersion.of(8) + } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3d7dcd42b..35e818d5c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -49,6 +49,7 @@ junit-jupiter-params = { module = "org.junit.jupiter:junit-jupiter-params", vers # Build tools cctJavadoc = { module = "cc.tweaked:cct-javadoc", version.ref = "cctJavadoc" } checkstyle = { module = "com.puppycrawl.tools:checkstyle", version.ref = "checkstyle" } +kotlin-plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } spotless = { module = "com.diffplug.spotless:spotless-plugin-gradle", version.ref = "spotless" } [plugins] diff --git a/src/main/java/dan200/computercraft/core/computer/ComputerThread.java b/src/main/java/dan200/computercraft/core/computer/ComputerThread.java index c891ed72e..e3afd0e48 100644 --- a/src/main/java/dan200/computercraft/core/computer/ComputerThread.java +++ b/src/main/java/dan200/computercraft/core/computer/ComputerThread.java @@ -5,6 +5,7 @@ */ package dan200.computercraft.core.computer; +import com.google.common.annotations.VisibleForTesting; import com.google.errorprone.annotations.concurrent.GuardedBy; import dan200.computercraft.ComputerCraft; import dan200.computercraft.core.ComputerContext; @@ -443,7 +444,8 @@ public final class ComputerThread * * @return If we have work queued up. */ - boolean hasPendingWork() + @VisibleForTesting + public boolean hasPendingWork() { // FIXME: See comment in scaledPeriod. Again, we access this in multiple threads but not clear if it matters! return !computerQueue.isEmpty(); diff --git a/src/test/java/dan200/computercraft/core/ComputerTestDelegate.java b/src/test/java/dan200/computercraft/core/ComputerTestDelegate.java index 7a97d55a7..6ac766bc7 100644 --- a/src/test/java/dan200/computercraft/core/ComputerTestDelegate.java +++ b/src/test/java/dan200/computercraft/core/ComputerTestDelegate.java @@ -11,16 +11,16 @@ import dan200.computercraft.api.lua.ILuaAPI; import dan200.computercraft.api.lua.LuaException; import dan200.computercraft.api.lua.LuaFunction; import dan200.computercraft.api.peripheral.IPeripheral; -import dan200.computercraft.core.computer.BasicEnvironment; import dan200.computercraft.core.computer.Computer; import dan200.computercraft.core.computer.ComputerSide; -import dan200.computercraft.core.computer.FakeMainThreadScheduler; import dan200.computercraft.core.filesystem.FileMount; import dan200.computercraft.core.filesystem.FileSystemException; import dan200.computercraft.core.terminal.Terminal; import dan200.computercraft.shared.peripheral.modem.ModemState; import dan200.computercraft.shared.peripheral.modem.wireless.WirelessModemPeripheral; import dan200.computercraft.support.TestFiles; +import dan200.computercraft.test.core.computer.BasicEnvironment; +import dan200.computercraft.test.core.computer.FakeMainThreadScheduler; import net.minecraft.util.math.vector.Vector3d; import net.minecraft.world.World; import org.apache.logging.log4j.LogManager; diff --git a/src/test/java/dan200/computercraft/core/apis/AsyncRunner.kt b/src/test/java/dan200/computercraft/core/apis/AsyncRunner.kt deleted file mode 100644 index 1839a8efb..000000000 --- a/src/test/java/dan200/computercraft/core/apis/AsyncRunner.kt +++ /dev/null @@ -1,123 +0,0 @@ -package dan200.computercraft.core.apis - -import dan200.computercraft.ComputerCraft -import dan200.computercraft.api.lua.ILuaAPI -import dan200.computercraft.api.lua.MethodResult -import dan200.computercraft.api.peripheral.IPeripheral -import dan200.computercraft.api.peripheral.IWorkMonitor -import dan200.computercraft.core.computer.BasicEnvironment -import dan200.computercraft.core.computer.ComputerEnvironment -import dan200.computercraft.core.computer.ComputerSide -import dan200.computercraft.core.computer.GlobalEnvironment -import dan200.computercraft.core.filesystem.FileSystem -import dan200.computercraft.core.metrics.Metric -import dan200.computercraft.core.terminal.Terminal -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.withTimeout -import kotlin.time.Duration -import kotlin.time.Duration.Companion.seconds -import kotlin.time.ExperimentalTime - -abstract class NullApiEnvironment : IAPIEnvironment { - private val computerEnv = BasicEnvironment() - - override fun getComputerID(): Int = 0 - override fun getComputerEnvironment(): ComputerEnvironment = computerEnv - override fun getGlobalEnvironment(): GlobalEnvironment = computerEnv - override fun getMainThreadMonitor(): IWorkMonitor = throw IllegalStateException("Work monitor not available") - override fun getTerminal(): Terminal = throw IllegalStateException("Terminal not available") - override fun getFileSystem(): FileSystem = throw IllegalStateException("Terminal not available") - override fun shutdown() {} - override fun reboot() {} - override fun setOutput(side: ComputerSide?, output: Int) {} - override fun getOutput(side: ComputerSide?): Int = 0 - override fun getInput(side: ComputerSide?): Int = 0 - override fun setBundledOutput(side: ComputerSide?, output: Int) {} - override fun getBundledOutput(side: ComputerSide?): Int = 0 - override fun getBundledInput(side: ComputerSide?): Int = 0 - override fun setPeripheralChangeListener(listener: IAPIEnvironment.IPeripheralChangeListener?) {} - override fun getPeripheral(side: ComputerSide?): IPeripheral? = null - override fun getLabel(): String? = null - override fun setLabel(label: String?) {} - override fun startTimer(ticks: Long): Int = 0 - override fun cancelTimer(id: Int) {} - override fun observe(field: Metric.Counter) {} - override fun observe(field: Metric.Event, change: Long) {} -} - -class EventResult(val name: String, val args: Array) - -class AsyncRunner : NullApiEnvironment() { - private val eventStream: Channel> = Channel(Int.MAX_VALUE) - private val apis: MutableList = mutableListOf() - - override fun queueEvent(event: String?, vararg args: Any?) { - ComputerCraft.log.debug("Queue event $event ${args.contentToString()}") - if (!eventStream.trySend(arrayOf(event, *args)).isSuccess) { - throw IllegalStateException("Queue is full") - } - } - - override fun shutdown() { - super.shutdown() - eventStream.close() - apis.forEach { it.shutdown() } - } - - fun addApi(api: T): T { - apis.add(api) - api.startup() - return api - } - - suspend fun resultOf(toRun: MethodResult): Array { - var running = toRun - while (running.callback != null) running = runOnce(running) - return running.result ?: empty - } - - private suspend fun runOnce(obj: MethodResult): MethodResult { - val callback = obj.callback ?: throw NullPointerException("Callback cannot be null") - - val result = obj.result - val filter: String? = if (result.isNullOrEmpty() || result[0] !is String) { - null - } else { - result[0] as String - } - - return callback.resume(pullEventImpl(filter)) - } - - private suspend fun pullEventImpl(filter: String?): Array { - for (event in eventStream) { - ComputerCraft.log.debug("Pulled event ${event.contentToString()}") - val eventName = event[0] as String - if (filter == null || eventName == filter || eventName == "terminate") return event - } - - throw IllegalStateException("No more events") - } - - suspend fun pullEvent(filter: String? = null): EventResult { - val result = pullEventImpl(filter) - return EventResult(result[0] as String, result.copyOfRange(1, result.size)) - } - - companion object { - private val empty: Array = arrayOf() - - @OptIn(ExperimentalTime::class) - fun runTest(timeout: Duration = 5.seconds, fn: suspend AsyncRunner.() -> Unit) { - runBlocking { - val runner = AsyncRunner() - try { - withTimeout(timeout) { fn(runner) } - } finally { - runner.shutdown() - } - } - } - } -} diff --git a/src/test/java/dan200/computercraft/core/asm/GeneratorTest.java b/src/test/java/dan200/computercraft/core/asm/GeneratorTest.java index 4ebe80167..0fc8e7607 100644 --- a/src/test/java/dan200/computercraft/core/asm/GeneratorTest.java +++ b/src/test/java/dan200/computercraft/core/asm/GeneratorTest.java @@ -18,7 +18,7 @@ import java.util.List; import java.util.Map; import java.util.Optional; -import static dan200.computercraft.support.ContramapMatcher.contramap; +import static dan200.computercraft.test.core.ContramapMatcher.contramap; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.*; import static org.junit.jupiter.api.Assertions.assertThrows; diff --git a/src/test/java/dan200/computercraft/core/computer/ComputerBootstrap.java b/src/test/java/dan200/computercraft/core/computer/ComputerBootstrap.java index f1e86f464..25f7eeff1 100644 --- a/src/test/java/dan200/computercraft/core/computer/ComputerBootstrap.java +++ b/src/test/java/dan200/computercraft/core/computer/ComputerBootstrap.java @@ -13,8 +13,9 @@ import dan200.computercraft.api.lua.LuaException; import dan200.computercraft.api.lua.LuaFunction; import dan200.computercraft.core.ComputerContext; import dan200.computercraft.core.computer.mainthread.MainThread; -import dan200.computercraft.core.filesystem.MemoryMount; import dan200.computercraft.core.terminal.Terminal; +import dan200.computercraft.test.core.computer.BasicEnvironment; +import dan200.computercraft.test.core.filesystem.MemoryMount; import org.junit.jupiter.api.Assertions; import java.util.Arrays; diff --git a/src/test/java/dan200/computercraft/core/computer/ComputerThreadTest.java b/src/test/java/dan200/computercraft/core/computer/ComputerThreadTest.java index e3597b6dc..108d69274 100644 --- a/src/test/java/dan200/computercraft/core/computer/ComputerThreadTest.java +++ b/src/test/java/dan200/computercraft/core/computer/ComputerThreadTest.java @@ -8,6 +8,7 @@ package dan200.computercraft.core.computer; import dan200.computercraft.ComputerCraft; import dan200.computercraft.core.lua.MachineResult; import dan200.computercraft.support.ConcurrentHelpers; +import dan200.computercraft.test.core.computer.KotlinComputerManager; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -25,12 +26,12 @@ import static org.junit.jupiter.api.Assertions.*; @Execution( ExecutionMode.CONCURRENT ) public class ComputerThreadTest { - private FakeComputerManager manager; + private KotlinComputerManager manager; @BeforeEach public void before() { - manager = new FakeComputerManager(); + manager = new KotlinComputerManager(); } @AfterEach diff --git a/src/test/java/dan200/computercraft/core/computer/FakeComputerManager.java b/src/test/java/dan200/computercraft/core/computer/FakeComputerManager.java deleted file mode 100644 index e2a9e7fb6..000000000 --- a/src/test/java/dan200/computercraft/core/computer/FakeComputerManager.java +++ /dev/null @@ -1,259 +0,0 @@ -/* - * This file is part of ComputerCraft - http://www.computercraft.info - * Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission. - * Send enquiries to dratcliffe@gmail.com - */ -package dan200.computercraft.core.computer; - -import dan200.computercraft.api.lua.ILuaAPI; -import dan200.computercraft.core.ComputerContext; -import dan200.computercraft.core.lua.ILuaMachine; -import dan200.computercraft.core.lua.MachineResult; -import dan200.computercraft.core.terminal.Terminal; - -import javax.annotation.Nonnull; -import javax.annotation.Nullable; -import java.io.InputStream; -import java.util.HashMap; -import java.util.Map; -import java.util.Queue; -import java.util.concurrent.ConcurrentLinkedQueue; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.locks.Condition; -import java.util.concurrent.locks.Lock; -import java.util.concurrent.locks.ReentrantLock; - -/** - * Creates "fake" computers, which just run user-defined tasks rather than Lua code. - */ -public class FakeComputerManager implements AutoCloseable -{ - interface Task - { - MachineResult run( TimeoutState state ) throws Exception; - } - - private final Map> machines = new HashMap<>(); - private final ComputerContext context = new ComputerContext( - new BasicEnvironment(), - new ComputerThread( 1 ), - new FakeMainThreadScheduler(), - args -> new DummyLuaMachine( args.timeout ) - ); - - private final Lock errorLock = new ReentrantLock(); - private final Condition hasError = errorLock.newCondition(); - private volatile @Nullable Throwable error; - - @Override - public void close() - { - try - { - context.ensureClosed( 1, TimeUnit.SECONDS ); - } - catch( InterruptedException e ) - { - throw new IllegalStateException( "Runtime thread was interrupted", e ); - } - } - - public ComputerContext context() - { - return context; - } - - /** - * Create a new computer which pulls from our task queue. - * - * @return The computer. This will not be started yet, you must call {@link Computer#turnOn()} and - * {@link Computer#tick()} to do so. - */ - public Computer create() - { - Queue queue = new ConcurrentLinkedQueue<>(); - Computer computer = new Computer( context, new BasicEnvironment(), new Terminal( 51, 19, true ), 0 ); - computer.addApi( new QueuePassingAPI( queue ) ); // Inject an extra API to pass the queue to the machine. - machines.put( computer, queue ); - return computer; - } - - /** - * Create and start a new computer which loops forever. - */ - public void createLoopingComputer() - { - Computer computer = create(); - enqueueForever( computer, t -> { - Thread.sleep( 100 ); - return MachineResult.OK; - } ); - computer.turnOn(); - computer.tick(); - } - - /** - * Enqueue a task on a computer. - * - * @param computer The computer to enqueue the work on. - * @param task The task to run. - */ - public void enqueue( Computer computer, Task task ) - { - machines.get( computer ).offer( task ); - } - - /** - * Enqueue a repeated task on a computer. This is automatically requeued when the task finishes, meaning the task - * queue is never empty. - * - * @param computer The computer to enqueue the work on. - * @param task The task to run. - */ - private void enqueueForever( Computer computer, Task task ) - { - machines.get( computer ).offer( t -> { - MachineResult result = task.run( t ); - - enqueueForever( computer, task ); - computer.queueEvent( "some_event", null ); - return result; - } ); - } - - /** - * Sleep for a given period, immediately propagating any exceptions thrown by a computer. - * - * @param delay The duration to sleep for. - * @param unit The time unit the duration is measured in. - * @throws Exception An exception thrown by a running computer. - */ - public void sleep( long delay, TimeUnit unit ) throws Exception - { - errorLock.lock(); - try - { - rethrowIfNeeded(); - if( hasError.await( delay, unit ) ) rethrowIfNeeded(); - } - finally - { - errorLock.unlock(); - } - } - - /** - * Start a computer and wait for it to finish. - * - * @param computer The computer to wait for. - * @throws Exception An exception thrown by a running computer. - */ - public void startAndWait( Computer computer ) throws Exception - { - computer.turnOn(); - computer.tick(); - - do - { - sleep( 100, TimeUnit.MILLISECONDS ); - } while( context.computerScheduler().hasPendingWork() || computer.isOn() ); - - rethrowIfNeeded(); - } - - private void rethrowIfNeeded() throws Exception - { - Throwable error = this.error; - if( error == null ) return; - if( error instanceof Exception ) throw (Exception) error; - rethrow( error ); - } - - @SuppressWarnings( "unchecked" ) - private static void rethrow( Throwable e ) throws T - { - throw (T) e; - } - - private static final class QueuePassingAPI implements ILuaAPI - { - final Queue tasks; - - private QueuePassingAPI( Queue tasks ) - { - this.tasks = tasks; - } - - @Override - public String[] getNames() - { - return new String[0]; - } - } - - private final class DummyLuaMachine implements ILuaMachine - { - private final TimeoutState state; - private @Nullable Queue tasks; - - DummyLuaMachine( TimeoutState state ) - { - this.state = state; - } - - @Override - public void addAPI( @Nonnull ILuaAPI api ) - { - if( api instanceof QueuePassingAPI ) tasks = ((QueuePassingAPI) api).tasks; - } - - @Override - public MachineResult loadBios( @Nonnull InputStream bios ) - { - return MachineResult.OK; - } - - @Override - public MachineResult handleEvent( @Nullable String eventName, @Nullable Object[] arguments ) - { - try - { - if( tasks == null ) throw new IllegalStateException( "Not received tasks yet" ); - return tasks.remove().run( state ); - } - catch( Throwable e ) - { - errorLock.lock(); - try - { - if( error == null ) - { - error = e; - hasError.signal(); - } - else - { - error.addSuppressed( e ); - } - } - finally - { - errorLock.unlock(); - } - - if( !(e instanceof Exception) && !(e instanceof AssertionError) ) rethrow( e ); - return MachineResult.error( e.getMessage() ); - } - } - - @Override - public void printExecutionState( StringBuilder out ) - { - } - - @Override - public void close() - { - } - } -} diff --git a/src/test/java/dan200/computercraft/core/terminal/TerminalTest.java b/src/test/java/dan200/computercraft/core/terminal/TerminalTest.java index c60574dd7..c9674af08 100644 --- a/src/test/java/dan200/computercraft/core/terminal/TerminalTest.java +++ b/src/test/java/dan200/computercraft/core/terminal/TerminalTest.java @@ -7,7 +7,8 @@ package dan200.computercraft.core.terminal; import dan200.computercraft.api.lua.LuaValues; import dan200.computercraft.shared.util.Colour; -import dan200.computercraft.support.CallCounter; +import dan200.computercraft.test.core.CallCounter; +import dan200.computercraft.test.core.terminal.TerminalMatchers; import io.netty.buffer.Unpooled; import net.minecraft.nbt.CompoundNBT; import net.minecraft.network.PacketBuffer; @@ -16,7 +17,7 @@ import org.junit.jupiter.api.Test; import java.nio.ByteBuffer; -import static dan200.computercraft.core.terminal.TerminalMatchers.*; +import static dan200.computercraft.test.core.terminal.TerminalMatchers.*; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.allOf; import static org.hamcrest.Matchers.equalTo; diff --git a/src/test/java/dan200/computercraft/shared/network/server/UploadFileMessageTest.java b/src/test/java/dan200/computercraft/shared/network/server/UploadFileMessageTest.java index 21f012cbe..3441e02d2 100644 --- a/src/test/java/dan200/computercraft/shared/network/server/UploadFileMessageTest.java +++ b/src/test/java/dan200/computercraft/shared/network/server/UploadFileMessageTest.java @@ -7,7 +7,7 @@ package dan200.computercraft.shared.network.server; import dan200.computercraft.shared.computer.upload.FileSlice; import dan200.computercraft.shared.computer.upload.FileUpload; -import dan200.computercraft.support.ArbitraryByteBuffer; +import dan200.computercraft.test.core.ArbitraryByteBuffer; import dan200.computercraft.support.FakeContainer; import io.netty.buffer.Unpooled; import net.jqwik.api.*; @@ -21,9 +21,9 @@ import java.util.List; import java.util.stream.Collectors; import static dan200.computercraft.shared.network.server.UploadFileMessage.*; -import static dan200.computercraft.support.ByteBufferMatcher.bufferEqual; -import static dan200.computercraft.support.ContramapMatcher.contramap; -import static dan200.computercraft.support.CustomMatchers.containsWith; +import static dan200.computercraft.test.core.ByteBufferMatcher.bufferEqual; +import static dan200.computercraft.test.core.ContramapMatcher.contramap; +import static dan200.computercraft.test.core.CustomMatchers.containsWith; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.*; import static org.junit.jupiter.api.Assertions.assertEquals; diff --git a/src/test/java/dan200/computercraft/core/apis/http/options/TestHttpApi.kt b/src/test/kotlin/dan200/computercraft/core/http/TestHttpApi.kt similarity index 75% rename from src/test/java/dan200/computercraft/core/apis/http/options/TestHttpApi.kt rename to src/test/kotlin/dan200/computercraft/core/http/TestHttpApi.kt index 334af4b5e..718e393fa 100644 --- a/src/test/java/dan200/computercraft/core/apis/http/options/TestHttpApi.kt +++ b/src/test/kotlin/dan200/computercraft/core/http/TestHttpApi.kt @@ -1,8 +1,10 @@ -package dan200.computercraft.core.apis.http.options +package dan200.computercraft.core.http import dan200.computercraft.ComputerCraft -import dan200.computercraft.core.apis.AsyncRunner import dan200.computercraft.core.apis.HTTPAPI +import dan200.computercraft.core.apis.http.options.Action +import dan200.computercraft.core.apis.http.options.AddressRule +import dan200.computercraft.test.core.computer.LuaTaskRunner import org.junit.jupiter.api.AfterAll import org.junit.jupiter.api.Assertions.assertArrayEquals import org.junit.jupiter.api.Assertions.assertEquals @@ -36,15 +38,15 @@ class TestHttpApi { @Test fun `Connects to websocket`() { - AsyncRunner.runTest { - val httpApi = addApi(HTTPAPI(this)) + LuaTaskRunner.runTest { + val httpApi = addApi(HTTPAPI(environment)) val result = httpApi.websocket(WS_ADDRESS, Optional.empty()) assertArrayEquals(arrayOf(true), result, "Should have created websocket") val event = pullEvent() - assertEquals("websocket_success", event.name) { - "Websocket failed to connect: ${event.args.contentToString()}" + assertEquals("websocket_success", event[0]) { + "Websocket failed to connect: ${event.contentToString()}" } } } diff --git a/src/test/java/dan200/computercraft/support/ArbitraryByteBuffer.java b/src/testFixtures/java/dan200/computercraft/test/core/ArbitraryByteBuffer.java similarity index 99% rename from src/test/java/dan200/computercraft/support/ArbitraryByteBuffer.java rename to src/testFixtures/java/dan200/computercraft/test/core/ArbitraryByteBuffer.java index de60f2f3b..ea5585e3b 100644 --- a/src/test/java/dan200/computercraft/support/ArbitraryByteBuffer.java +++ b/src/testFixtures/java/dan200/computercraft/test/core/ArbitraryByteBuffer.java @@ -3,7 +3,7 @@ * Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission. * Send enquiries to dratcliffe@gmail.com */ -package dan200.computercraft.support; +package dan200.computercraft.test.core; import net.jqwik.api.*; import net.jqwik.api.arbitraries.SizableArbitrary; diff --git a/src/test/java/dan200/computercraft/support/ByteBufferMatcher.java b/src/testFixtures/java/dan200/computercraft/test/core/ByteBufferMatcher.java similarity index 98% rename from src/test/java/dan200/computercraft/support/ByteBufferMatcher.java rename to src/testFixtures/java/dan200/computercraft/test/core/ByteBufferMatcher.java index b94bf96b4..c0f801b4c 100644 --- a/src/test/java/dan200/computercraft/support/ByteBufferMatcher.java +++ b/src/testFixtures/java/dan200/computercraft/test/core/ByteBufferMatcher.java @@ -3,7 +3,7 @@ * Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission. * Send enquiries to dratcliffe@gmail.com */ -package dan200.computercraft.support; +package dan200.computercraft.test.core; import org.hamcrest.Description; import org.hamcrest.Matcher; diff --git a/src/test/java/dan200/computercraft/support/CallCounter.java b/src/testFixtures/java/dan200/computercraft/test/core/CallCounter.java similarity index 95% rename from src/test/java/dan200/computercraft/support/CallCounter.java rename to src/testFixtures/java/dan200/computercraft/test/core/CallCounter.java index 213ad13e0..5916f865b 100644 --- a/src/test/java/dan200/computercraft/support/CallCounter.java +++ b/src/testFixtures/java/dan200/computercraft/test/core/CallCounter.java @@ -3,7 +3,7 @@ * Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission. * Send enquiries to dratcliffe@gmail.com */ -package dan200.computercraft.support; +package dan200.computercraft.test.core; import static org.junit.jupiter.api.Assertions.assertEquals; diff --git a/src/test/java/dan200/computercraft/support/ContramapMatcher.java b/src/testFixtures/java/dan200/computercraft/test/core/ContramapMatcher.java similarity index 97% rename from src/test/java/dan200/computercraft/support/ContramapMatcher.java rename to src/testFixtures/java/dan200/computercraft/test/core/ContramapMatcher.java index 6f09aab96..4a982d558 100644 --- a/src/test/java/dan200/computercraft/support/ContramapMatcher.java +++ b/src/testFixtures/java/dan200/computercraft/test/core/ContramapMatcher.java @@ -3,7 +3,7 @@ * Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission. * Send enquiries to dratcliffe@gmail.com */ -package dan200.computercraft.support; +package dan200.computercraft.test.core; import org.hamcrest.FeatureMatcher; import org.hamcrest.Matcher; diff --git a/src/test/java/dan200/computercraft/support/CustomMatchers.java b/src/testFixtures/java/dan200/computercraft/test/core/CustomMatchers.java similarity index 96% rename from src/test/java/dan200/computercraft/support/CustomMatchers.java rename to src/testFixtures/java/dan200/computercraft/test/core/CustomMatchers.java index 7a1d4e71a..f3335efd7 100644 --- a/src/test/java/dan200/computercraft/support/CustomMatchers.java +++ b/src/testFixtures/java/dan200/computercraft/test/core/CustomMatchers.java @@ -3,7 +3,7 @@ * Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission. * Send enquiries to dratcliffe@gmail.com */ -package dan200.computercraft.support; +package dan200.computercraft.test.core; import org.hamcrest.Matcher; import org.hamcrest.Matchers; diff --git a/src/testFixtures/java/dan200/computercraft/test/core/apis/BasicApiEnvironment.java b/src/testFixtures/java/dan200/computercraft/test/core/apis/BasicApiEnvironment.java new file mode 100644 index 000000000..28ecb964d --- /dev/null +++ b/src/testFixtures/java/dan200/computercraft/test/core/apis/BasicApiEnvironment.java @@ -0,0 +1,161 @@ +/* + * This file is part of ComputerCraft - http://www.computercraft.info + * Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission. + * Send enquiries to dratcliffe@gmail.com + */ +package dan200.computercraft.test.core.apis; + +import dan200.computercraft.api.peripheral.IPeripheral; +import dan200.computercraft.api.peripheral.IWorkMonitor; +import dan200.computercraft.core.apis.IAPIEnvironment; +import dan200.computercraft.core.computer.ComputerEnvironment; +import dan200.computercraft.core.computer.ComputerSide; +import dan200.computercraft.core.computer.GlobalEnvironment; +import dan200.computercraft.core.filesystem.FileSystem; +import dan200.computercraft.core.metrics.Metric; +import dan200.computercraft.core.terminal.Terminal; +import dan200.computercraft.test.core.computer.BasicEnvironment; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +public abstract class BasicApiEnvironment implements IAPIEnvironment +{ + private final BasicEnvironment environment; + private @Nullable String label; + + public BasicApiEnvironment( BasicEnvironment environment ) + { + this.environment = environment; + } + + @Override + public int getComputerID() + { + return 0; + } + + @Nonnull + @Override + public ComputerEnvironment getComputerEnvironment() + { + return environment; + } + + @Nonnull + @Override + public GlobalEnvironment getGlobalEnvironment() + { + return environment; + } + + @Nonnull + @Override + public IWorkMonitor getMainThreadMonitor() + { + throw new IllegalStateException( "Main thread monitor not available" ); + } + + @Nonnull + @Override + public Terminal getTerminal() + { + throw new IllegalStateException( "Terminal not available" ); + } + + @Override + public FileSystem getFileSystem() + { + throw new IllegalStateException( "Filesystem not available" ); + } + + @Override + public void shutdown() + { + } + + @Override + public void reboot() + { + } + + @Override + public void setOutput( ComputerSide side, int output ) + { + } + + @Override + public int getOutput( ComputerSide side ) + { + return 0; + } + + @Override + public int getInput( ComputerSide side ) + { + return 0; + } + + @Override + public void setBundledOutput( ComputerSide side, int output ) + { + } + + @Override + public int getBundledOutput( ComputerSide side ) + { + return 0; + } + + @Override + public int getBundledInput( ComputerSide side ) + { + return 0; + } + + @Override + public void setPeripheralChangeListener( @Nullable IPeripheralChangeListener listener ) + { + } + + @Nullable + @Override + public IPeripheral getPeripheral( ComputerSide side ) + { + return null; + } + + @Nullable + @Override + public String getLabel() + { + return label; + } + + @Override + public void setLabel( @Nullable String label ) + { + this.label = label; + } + + @Override + public int startTimer( long ticks ) + { + throw new IllegalStateException( "Cannot start timers" ); + } + + @Override + public void cancelTimer( int id ) + { + } + + @Override + public void observe( @Nonnull Metric.Event summary, long value ) + { + } + + @Override + public void observe( @Nonnull Metric.Counter counter ) + { + } +} diff --git a/src/test/java/dan200/computercraft/core/computer/BasicEnvironment.java b/src/testFixtures/java/dan200/computercraft/test/core/computer/BasicEnvironment.java similarity index 91% rename from src/test/java/dan200/computercraft/core/computer/BasicEnvironment.java rename to src/testFixtures/java/dan200/computercraft/test/core/computer/BasicEnvironment.java index e763f44b8..8d48343a9 100644 --- a/src/test/java/dan200/computercraft/core/computer/BasicEnvironment.java +++ b/src/testFixtures/java/dan200/computercraft/test/core/computer/BasicEnvironment.java @@ -3,16 +3,18 @@ * Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission. * Send enquiries to dratcliffe@gmail.com */ -package dan200.computercraft.core.computer; +package dan200.computercraft.test.core.computer; import dan200.computercraft.ComputerCraft; import dan200.computercraft.api.filesystem.IMount; import dan200.computercraft.api.filesystem.IWritableMount; +import dan200.computercraft.core.computer.ComputerEnvironment; +import dan200.computercraft.core.computer.GlobalEnvironment; import dan200.computercraft.core.filesystem.FileMount; import dan200.computercraft.core.filesystem.JarMount; -import dan200.computercraft.core.filesystem.MemoryMount; import dan200.computercraft.core.metrics.Metric; import dan200.computercraft.core.metrics.MetricsObserver; +import dan200.computercraft.test.core.filesystem.MemoryMount; import javax.annotation.Nonnull; import java.io.File; @@ -24,7 +26,8 @@ import java.net.URISyntaxException; import java.net.URL; /** - * A very basic environment. + * A basic implementation of {@link ComputerEnvironment} and {@link GlobalEnvironment}, suitable for a context which + * will only run a single computer. */ public class BasicEnvironment implements ComputerEnvironment, GlobalEnvironment, MetricsObserver { diff --git a/src/test/java/dan200/computercraft/core/computer/FakeMainThreadScheduler.java b/src/testFixtures/java/dan200/computercraft/test/core/computer/FakeMainThreadScheduler.java similarity index 96% rename from src/test/java/dan200/computercraft/core/computer/FakeMainThreadScheduler.java rename to src/testFixtures/java/dan200/computercraft/test/core/computer/FakeMainThreadScheduler.java index fbb758ab4..5735146d0 100644 --- a/src/test/java/dan200/computercraft/core/computer/FakeMainThreadScheduler.java +++ b/src/testFixtures/java/dan200/computercraft/test/core/computer/FakeMainThreadScheduler.java @@ -3,7 +3,7 @@ * Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission. * Send enquiries to dratcliffe@gmail.com */ -package dan200.computercraft.core.computer; +package dan200.computercraft.test.core.computer; import dan200.computercraft.core.computer.mainthread.MainThreadScheduler; import dan200.computercraft.core.metrics.MetricsObserver; diff --git a/src/test/java/dan200/computercraft/core/filesystem/MemoryMount.java b/src/testFixtures/java/dan200/computercraft/test/core/filesystem/MemoryMount.java similarity index 98% rename from src/test/java/dan200/computercraft/core/filesystem/MemoryMount.java rename to src/testFixtures/java/dan200/computercraft/test/core/filesystem/MemoryMount.java index dc785de3a..8219d75db 100644 --- a/src/test/java/dan200/computercraft/core/filesystem/MemoryMount.java +++ b/src/testFixtures/java/dan200/computercraft/test/core/filesystem/MemoryMount.java @@ -3,7 +3,7 @@ * Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission. * Send enquiries to dratcliffe@gmail.com */ -package dan200.computercraft.core.filesystem; +package dan200.computercraft.test.core.filesystem; import dan200.computercraft.api.filesystem.IWritableMount; import dan200.computercraft.core.apis.handles.ArrayByteChannel; diff --git a/src/test/java/dan200/computercraft/core/terminal/TerminalMatchers.java b/src/testFixtures/java/dan200/computercraft/test/core/terminal/TerminalMatchers.java similarity index 88% rename from src/test/java/dan200/computercraft/core/terminal/TerminalMatchers.java rename to src/testFixtures/java/dan200/computercraft/test/core/terminal/TerminalMatchers.java index 3359f5c16..248883339 100644 --- a/src/test/java/dan200/computercraft/core/terminal/TerminalMatchers.java +++ b/src/testFixtures/java/dan200/computercraft/test/core/terminal/TerminalMatchers.java @@ -3,9 +3,11 @@ * Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission. * Send enquiries to dratcliffe@gmail.com */ -package dan200.computercraft.core.terminal; +package dan200.computercraft.test.core.terminal; -import dan200.computercraft.support.ContramapMatcher; +import dan200.computercraft.core.terminal.Terminal; +import dan200.computercraft.core.terminal.TextBuffer; +import dan200.computercraft.test.core.ContramapMatcher; import org.hamcrest.Matcher; import org.hamcrest.Matchers; diff --git a/src/testFixtures/kotlin/dan200/computercraft/test/core/Assertions.kt b/src/testFixtures/kotlin/dan200/computercraft/test/core/Assertions.kt new file mode 100644 index 000000000..e83e80381 --- /dev/null +++ b/src/testFixtures/kotlin/dan200/computercraft/test/core/Assertions.kt @@ -0,0 +1,63 @@ +/* + * This file is part of ComputerCraft - http://www.computercraft.info + * Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission. + * Send enquiries to dratcliffe@gmail.com + */ +package dan200.computercraft.test.core + +import org.hamcrest.BaseMatcher +import org.hamcrest.Description +import org.hamcrest.Matcher +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.collection.IsArray +import org.junit.jupiter.api.Assertions + +/** Postfix version of [Assertions.assertArrayEquals] */ +fun Array?.assertArrayEquals(vararg expected: Any?, message: String? = null) { + assertThat( + message ?: "", + this, + IsArrayVerbose(expected.map { FuzzyEqualTo(it) }.toTypedArray()), + ) +} + +/** + * Extension of [IsArray] which always prints the array, not just when the items are mismatched. + */ +internal class IsArrayVerbose(private val elementMatchers: Array>) : IsArray(elementMatchers) { + override fun describeMismatchSafely(actual: Array, description: Description) { + description.appendText("array was ").appendValue(actual) + if (actual.size != elementMatchers.size) { + description.appendText(" with length ").appendValue(actual.size) + return + } + + for (i in actual.indices) { + if (!elementMatchers[i].matches(actual[i])) { + description.appendText("with element ").appendValue(i).appendText(" ") + elementMatchers[i].describeMismatch(actual[i], description) + return + } + } + } +} + +/** + * An equality matcher which is slightly more relaxed on comparing some values. + */ +internal class FuzzyEqualTo(private val expected: Any?) : BaseMatcher() { + override fun describeTo(description: Description) { + description.appendValue(expected) + } + + override fun matches(actual: Any?): Boolean { + if (actual == null) return false + + if (actual is Number && expected is Number && actual.javaClass != expected.javaClass) { + // Allow equating integers and floats. + return actual.toDouble() == expected.toDouble() + } + + return actual == expected + } +} diff --git a/src/testFixtures/kotlin/dan200/computercraft/test/core/computer/KotlinComputerManager.kt b/src/testFixtures/kotlin/dan200/computercraft/test/core/computer/KotlinComputerManager.kt new file mode 100644 index 000000000..f9964da9c --- /dev/null +++ b/src/testFixtures/kotlin/dan200/computercraft/test/core/computer/KotlinComputerManager.kt @@ -0,0 +1,188 @@ +/* + * This file is part of ComputerCraft - http://www.computercraft.info + * Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission. + * Send enquiries to dratcliffe@gmail.com + */ +package dan200.computercraft.test.core.computer + +import dan200.computercraft.api.lua.ILuaAPI +import dan200.computercraft.core.ComputerContext +import dan200.computercraft.core.computer.Computer +import dan200.computercraft.core.computer.ComputerThread +import dan200.computercraft.core.computer.TimeoutState +import dan200.computercraft.core.lua.MachineEnvironment +import dan200.computercraft.core.lua.MachineResult +import dan200.computercraft.core.terminal.Terminal +import java.util.* +import java.util.concurrent.ConcurrentLinkedQueue +import java.util.concurrent.TimeUnit +import java.util.concurrent.locks.Lock +import java.util.concurrent.locks.ReentrantLock + +typealias FakeComputerTask = (state: TimeoutState) -> MachineResult + +/** + * Creates "fake" computers, which just run user-defined tasks rather than Lua code. + */ +class KotlinComputerManager : AutoCloseable { + + private val machines: MutableMap> = HashMap() + private val context = ComputerContext(BasicEnvironment(), ComputerThread(1), FakeMainThreadScheduler()) { DummyLuaMachine(it) } + private val errorLock: Lock = ReentrantLock() + private val hasError = errorLock.newCondition() + + @Volatile + private var error: Throwable? = null + override fun close() { + try { + context.ensureClosed(1, TimeUnit.SECONDS) + } catch (e: InterruptedException) { + throw IllegalStateException("Runtime thread was interrupted", e) + } + } + + fun context(): ComputerContext { + return context + } + + /** + * Create a new computer which pulls from our task queue. + * + * @return The computer. This will not be started yet, you must call [Computer.turnOn] and + * [Computer.tick] to do so. + */ + fun create(): Computer { + val queue: Queue = ConcurrentLinkedQueue() + val computer = Computer(context, BasicEnvironment(), Terminal(51, 19, true), 0) + computer.addApi(QueuePassingAPI(queue)) // Inject an extra API to pass the queue to the machine. + machines[computer] = queue + return computer + } + + /** + * Create and start a new computer which loops forever. + */ + fun createLoopingComputer() { + val computer = create() + enqueueForever(computer) { + Thread.sleep(100) + MachineResult.OK + } + computer.turnOn() + computer.tick() + } + + /** + * Enqueue a task on a computer. + * + * @param computer The computer to enqueue the work on. + * @param task The task to run. + */ + fun enqueue(computer: Computer, task: FakeComputerTask) { + machines[computer]!!.offer(task) + } + + /** + * Enqueue a repeated task on a computer. This is automatically requeued when the task finishes, meaning the task + * queue is never empty. + * + * @param computer The computer to enqueue the work on. + * @param task The task to run. + */ + private fun enqueueForever(computer: Computer, task: FakeComputerTask) { + machines[computer]!!.offer { + val result = task(it) + enqueueForever(computer, task) + computer.queueEvent("some_event", null) + result + } + } + + /** + * Sleep for a given period, immediately propagating any exceptions thrown by a computer. + * + * @param delay The duration to sleep for. + * @param unit The time unit the duration is measured in. + * @throws Exception An exception thrown by a running computer. + */ + @Throws(Exception::class) + fun sleep(delay: Long, unit: TimeUnit?) { + errorLock.lock() + try { + rethrowIfNeeded() + if (hasError.await(delay, unit)) rethrowIfNeeded() + } finally { + errorLock.unlock() + } + } + + /** + * Start a computer and wait for it to finish. + * + * @param computer The computer to wait for. + * @throws Exception An exception thrown by a running computer. + */ + @Throws(Exception::class) + fun startAndWait(computer: Computer) { + computer.turnOn() + computer.tick() + do { + sleep(100, TimeUnit.MILLISECONDS) + } while (context.computerScheduler().hasPendingWork() || computer.isOn) + + rethrowIfNeeded() + } + + @Throws(Exception::class) + private fun rethrowIfNeeded() { + val error = error ?: return + throw error + } + + private class QueuePassingAPI constructor(val tasks: Queue) : ILuaAPI { + override fun getNames(): Array = arrayOf() + } + + private inner class DummyLuaMachine(private val environment: MachineEnvironment) : KotlinLuaMachine(environment) { + private var tasks: Queue? = null + override fun addAPI(api: ILuaAPI) { + super.addAPI(api) + if (api is QueuePassingAPI) tasks = api.tasks + } + + override fun getTask(): (suspend KotlinLuaMachine.() -> Unit)? { + try { + val tasks = this.tasks ?: throw NullPointerException("Not received tasks yet") + val task = tasks.remove() + return { + try { + task(environment.timeout) + } catch (e: Throwable) { + reportError(e) + } + } + } catch (e: Throwable) { + reportError(e) + return null + } + } + + override fun close() {} + + private fun reportError(e: Throwable) { + errorLock.lock() + try { + if (error == null) { + error = e + hasError.signal() + } else { + error!!.addSuppressed(e) + } + } finally { + errorLock.unlock() + } + + if (e is Exception || e is AssertionError) return else throw e + } + } +} diff --git a/src/testFixtures/kotlin/dan200/computercraft/test/core/computer/KotlinLuaMachine.kt b/src/testFixtures/kotlin/dan200/computercraft/test/core/computer/KotlinLuaMachine.kt new file mode 100644 index 000000000..ad36ae258 --- /dev/null +++ b/src/testFixtures/kotlin/dan200/computercraft/test/core/computer/KotlinLuaMachine.kt @@ -0,0 +1,41 @@ +package dan200.computercraft.test.core.computer + +import dan200.computercraft.api.lua.ILuaAPI +import dan200.computercraft.api.lua.ILuaContext +import dan200.computercraft.core.lua.ILuaMachine +import dan200.computercraft.core.lua.MachineEnvironment +import dan200.computercraft.core.lua.MachineResult +import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import java.io.InputStream + +/** + * An [ILuaMachine] which runs Kotlin functions instead. + */ +abstract class KotlinLuaMachine(environment: MachineEnvironment) : ILuaMachine, AbstractLuaTaskContext() { + override val context: ILuaContext = environment.context + + override fun addAPI(api: ILuaAPI) = addApi(api) + + override fun loadBios(bios: InputStream): MachineResult = MachineResult.OK + + override fun handleEvent(eventName: String?, arguments: Array?): MachineResult { + if (hasEventListeners) { + queueEvent(eventName, arguments) + } else { + val task = getTask() + if (task != null) CoroutineScope(Dispatchers.Unconfined + CoroutineName("Computer")).launch { task() } + } + + return MachineResult.OK + } + + override fun printExecutionState(out: StringBuilder) {} + + /** + * Get the next task to execute on this computer. + */ + protected abstract fun getTask(): (suspend KotlinLuaMachine.() -> Unit)? +} diff --git a/src/testFixtures/kotlin/dan200/computercraft/test/core/computer/LuaTaskContext.kt b/src/testFixtures/kotlin/dan200/computercraft/test/core/computer/LuaTaskContext.kt new file mode 100644 index 000000000..28ab11c3a --- /dev/null +++ b/src/testFixtures/kotlin/dan200/computercraft/test/core/computer/LuaTaskContext.kt @@ -0,0 +1,87 @@ +package dan200.computercraft.test.core.computer + +import dan200.computercraft.api.lua.ILuaAPI +import dan200.computercraft.api.lua.ILuaContext +import dan200.computercraft.api.lua.MethodResult +import dan200.computercraft.api.lua.ObjectArguments +import dan200.computercraft.core.apis.PeripheralAPI +import kotlinx.coroutines.CancellableContinuation +import kotlinx.coroutines.suspendCancellableCoroutine + +/** + * The context for tasks which consume Lua objects. + * + * This provides helpers for converting CC's callback-based code into a more direct style based on Kotlin coroutines. + */ +interface LuaTaskContext { + /** The current Lua context, to be passed to method calls. */ + val context: ILuaContext + + /** Get a registered API. */ + fun getApi(api: Class): T + + /** Pull a Lua event */ + suspend fun pullEvent(event: String? = null): Array + + /** Resolve a [MethodResult] until completion, returning the resulting values. */ + suspend fun MethodResult.await(): Array? { + var result = this + while (true) { + val callback = result.callback + val values = result.result + + if (callback == null) return values + + val filter = if (values == null) null else values[0] as String? + result = callback.resume(pullEvent(filter)) + } + } + + /** Call a peripheral method. */ + suspend fun LuaTaskContext.callPeripheral(name: String, method: String, vararg args: Any?): Array? = + getApi().call(context, ObjectArguments(name, method, *args)).await() +} + +/** Get a registered API. */ +inline fun LuaTaskContext.getApi(): T = getApi(T::class.java) + +abstract class AbstractLuaTaskContext : LuaTaskContext, AutoCloseable { + private val pullEvents = mutableListOf() + private val apis = mutableMapOf, ILuaAPI>() + + protected fun addApi(api: ILuaAPI) { + apis[api.javaClass] = api + } + + protected val hasEventListeners + get() = pullEvents.isNotEmpty() + + protected fun queueEvent(eventName: String?, arguments: Array?) { + val fullEvent: Array = when { + eventName == null && arguments == null -> arrayOf() + eventName != null && arguments == null -> arrayOf(eventName) + eventName == null && arguments != null -> arguments + else -> arrayOf(eventName, *arguments!!) + } + for (i in pullEvents.size - 1 downTo 0) { + val puller = pullEvents[i] + if (puller.name == null || puller.name == eventName || eventName == "terminate") { + pullEvents.removeAt(i) + puller.cont.resumeWith(Result.success(fullEvent)) + } + } + } + + override fun close() { + for (pullEvent in pullEvents) pullEvent.cont.cancel() + pullEvents.clear() + } + + final override fun getApi(api: Class): T = + api.cast(apis[api] ?: throw IllegalStateException("No API of type ${api.name}")) + + final override suspend fun pullEvent(event: String?): Array = + suspendCancellableCoroutine { cont -> pullEvents.add(PullEvent(event, cont)) } + + private class PullEvent(val name: String?, val cont: CancellableContinuation>) +} diff --git a/src/testFixtures/kotlin/dan200/computercraft/test/core/computer/LuaTaskRunner.kt b/src/testFixtures/kotlin/dan200/computercraft/test/core/computer/LuaTaskRunner.kt new file mode 100644 index 000000000..4ac65d1cb --- /dev/null +++ b/src/testFixtures/kotlin/dan200/computercraft/test/core/computer/LuaTaskRunner.kt @@ -0,0 +1,64 @@ +package dan200.computercraft.test.core.computer + +import dan200.computercraft.api.lua.ILuaAPI +import dan200.computercraft.api.lua.ILuaContext +import dan200.computercraft.api.lua.LuaException +import dan200.computercraft.core.apis.IAPIEnvironment +import dan200.computercraft.test.core.apis.BasicApiEnvironment +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +class LuaTaskRunner : AbstractLuaTaskContext() { + private val eventStream: Channel = Channel(Channel.UNLIMITED) + private val apis = mutableListOf() + + val environment: IAPIEnvironment = object : BasicApiEnvironment(BasicEnvironment()) { + override fun queueEvent(event: String?, vararg args: Any?) { + if (eventStream.trySend(Event(event, args)).isFailure) { + throw IllegalStateException("Queue is full") + } + } + + override fun shutdown() { + super.shutdown() + eventStream.close() + } + } + override val context = + ILuaContext { throw LuaException("Cannot queue main thread task") } + + fun addApi(api: T): T { + super.addApi(api) + apis.add(api) + api.startup() + return api + } + + override fun close() { + environment.shutdown() + } + + private suspend fun run() { + for (event in eventStream) { + queueEvent(event.name, event.args) + } + } + + private class Event(val name: String?, val args: Array) + + companion object { + fun runTest(timeout: Duration = 5.seconds, fn: suspend LuaTaskRunner.() -> Unit) { + runBlocking { + withTimeout(timeout) { + val runner = LuaTaskRunner() + launch { runner.run() } + runner.use { fn(runner) } + } + } + } + } +}