From 8f92417a2f222572a175a72573c75ba90eb26a9a Mon Sep 17 00:00:00 2001 From: Jonathan Coates Date: Fri, 18 Nov 2022 23:57:25 +0000 Subject: [PATCH] Add a system for client-side tests (#1219) - Add a new ClientJavaExec Gradle task, which is used for client-side tests. This: - Copies the exec spec from another JavaExec task. - Sets some additional system properties to configure on gametest framework. - Runs Java inside an X framebuffer (when available), meaning we don't need to spin up a new window. We also configure this task so that only one instance can run at once, meaning we don't spawn multiple MC windows at once! - Port our 1.16 client test framework to 1.19. This is mostly the same as before, but screenshots no longer do a golden test: they /just/ write to a folder. Screenshots are compared manually afterwards. This is still pretty brittle, and there's a lot of sleeps scattered around in the code. It's not clear how well this will play on CI. - Roll our own game test loader, rather than relying on the mod loader to do it for us. This ensures that loading is consistent between platforms (we already had to do some hacks for Forge) and makes it easier to provide custom logic for loading client-only tests. - Run several client tests (namely those involving monitor rendering) against Sodium and Iris too. There's some nastiness here to set up new Loom run configurations and automatically configure Iris to use Complementary Shaders, but it's not too bad. These tests /don't/ run on CI, so it doesn't need to be as reliable. --- .github/workflows/main-ci.yml | 3 + .../cc/tweaked/gradle/CCTweakedExtension.kt | 53 ++++- .../cc/tweaked/gradle/CheckChangelog.kt | 1 - .../kotlin/cc/tweaked/gradle/CheckLicense.kt | 1 - .../kotlin/cc/tweaked/gradle/Extensions.kt | 85 +++++++- .../tweaked/gradle/IdeaRunConfigurations.kt | 3 +- .../kotlin/cc/tweaked/gradle/MinecraftExec.kt | 202 ++++++++++++++++++ .../cc/tweaked/gradle/ProcessHelpers.kt | 12 ++ gradle/libs.versions.toml | 6 +- .../shared/platform/Registries.java | 2 + .../gametest/api/GameTestHolder.java | 23 -- .../gametest/core/CCTestCommand.java | 2 +- .../computercraft/gametest/core/TestAPI.java | 14 +- .../gametest/core/TestHooks.java | 55 ----- .../gametest/GameTestSequenceAccessor.java | 3 + .../mixin/gametest/SharedConstantsMixin.java | 23 ++ .../computercraft/gametest/Computer_Test.kt | 73 ++++++- .../computercraft/gametest/CraftOs_Test.kt | 2 - .../computercraft/gametest/Disk_Drive_Test.kt | 1 - .../computercraft/gametest/Loot_Test.kt | 2 - .../computercraft/gametest/Modem_Test.kt | 1 - .../computercraft/gametest/Monitor_Test.kt | 65 +++++- .../computercraft/gametest/Printer_Test.kt | 6 +- .../computercraft/gametest/Recipe_Test.kt | 2 - .../computercraft/gametest/Turtle_Test.kt | 3 +- .../gametest/api/ClientGameTest.kt | 28 +++ .../gametest/api/ClientTestExtensions.kt | 132 ++++++++++++ .../gametest/api/TestExtensions.kt | 18 +- .../computercraft/gametest/api/TestTags.kt | 16 ++ .../gametest/core/ClientTestHooks.kt | 189 ++++++++++++++++ .../computercraft/gametest/core/TestHooks.kt | 168 +++++++++++++++ .../gametest/core/TestReporters.kt | 43 ++++ .../computercraft-gametest.mixins.json | 1 + .../computer_test.open_on_client.snbt | 137 ++++++++++++ .../monitor_test.render_monitor.snbt | 144 +++++++++++++ projects/fabric/build.gradle.kts | 79 ++++++- .../computercraft/gametest/core/TestMod.java | 8 +- .../src/testMod/resources/fabric.mod.json | 11 - projects/forge/build.gradle.kts | 20 +- .../gametest/core/ClientHooks.java | 85 -------- .../computercraft/gametest/core/TestMod.java | 111 ++-------- tools/parse-reports.py | 17 +- tools/screenshots.py | 113 ++++++++++ 43 files changed, 1634 insertions(+), 329 deletions(-) create mode 100644 buildSrc/src/main/kotlin/cc/tweaked/gradle/MinecraftExec.kt delete mode 100644 projects/common/src/testMod/java/dan200/computercraft/gametest/api/GameTestHolder.java delete mode 100644 projects/common/src/testMod/java/dan200/computercraft/gametest/core/TestHooks.java create mode 100644 projects/common/src/testMod/java/dan200/computercraft/mixin/gametest/SharedConstantsMixin.java create mode 100644 projects/common/src/testMod/kotlin/dan200/computercraft/gametest/api/ClientGameTest.kt create mode 100644 projects/common/src/testMod/kotlin/dan200/computercraft/gametest/api/ClientTestExtensions.kt create mode 100644 projects/common/src/testMod/kotlin/dan200/computercraft/gametest/api/TestTags.kt create mode 100644 projects/common/src/testMod/kotlin/dan200/computercraft/gametest/core/ClientTestHooks.kt create mode 100644 projects/common/src/testMod/kotlin/dan200/computercraft/gametest/core/TestHooks.kt create mode 100644 projects/common/src/testMod/kotlin/dan200/computercraft/gametest/core/TestReporters.kt create mode 100644 projects/common/src/testMod/resources/data/cctest/structures/computer_test.open_on_client.snbt create mode 100644 projects/common/src/testMod/resources/data/cctest/structures/monitor_test.render_monitor.snbt delete mode 100644 projects/forge/src/testMod/java/dan200/computercraft/gametest/core/ClientHooks.java create mode 100755 tools/screenshots.py diff --git a/.github/workflows/main-ci.yml b/.github/workflows/main-ci.yml index ae14ee79f..5f3ce5eae 100644 --- a/.github/workflows/main-ci.yml +++ b/.github/workflows/main-ci.yml @@ -33,6 +33,9 @@ jobs: ./gradlew downloadAssets || ./gradlew downloadAssets ./gradlew build + - name: Run client tests + run: ./gradlew runGametestClient # Not checkClient, as no point running rendering tests. + - name: Upload Jar uses: actions/upload-artifact@v2 with: diff --git a/buildSrc/src/main/kotlin/cc/tweaked/gradle/CCTweakedExtension.kt b/buildSrc/src/main/kotlin/cc/tweaked/gradle/CCTweakedExtension.kt index 0e9f1b124..16e568fab 100644 --- a/buildSrc/src/main/kotlin/cc/tweaked/gradle/CCTweakedExtension.kt +++ b/buildSrc/src/main/kotlin/cc/tweaked/gradle/CCTweakedExtension.kt @@ -4,22 +4,21 @@ import net.ltgt.gradle.errorprone.errorprone import org.gradle.api.NamedDomainObjectProvider import org.gradle.api.Project +import org.gradle.api.Task import org.gradle.api.attributes.TestSuiteType import org.gradle.api.file.FileSystemOperations import org.gradle.api.plugins.JavaPluginExtension import org.gradle.api.provider.Provider import org.gradle.api.provider.SetProperty import org.gradle.api.reporting.ReportingExtension -import org.gradle.api.tasks.JavaExec import org.gradle.api.tasks.SourceSet -import org.gradle.api.tasks.SourceSetContainer import org.gradle.api.tasks.bundling.Jar import org.gradle.api.tasks.compile.JavaCompile import org.gradle.api.tasks.javadoc.Javadoc import org.gradle.configurationcache.extensions.capitalized -import org.gradle.kotlin.dsl.get import org.gradle.language.base.plugins.LifecycleBasePlugin import org.gradle.language.jvm.tasks.ProcessResources +import org.gradle.process.JavaForkOptions import org.gradle.testing.jacoco.plugins.JacocoCoverageReport import org.gradle.testing.jacoco.plugins.JacocoPluginExtension import org.gradle.testing.jacoco.plugins.JacocoTaskExtension @@ -27,8 +26,11 @@ import org.jetbrains.kotlin.gradle.dsl.KotlinProjectExtension import org.jetbrains.kotlin.gradle.tasks.KotlinCompile import java.io.BufferedWriter +import java.io.File import java.io.IOException import java.io.OutputStreamWriter +import java.net.URI +import java.net.URL import java.util.regex.Pattern abstract class CCTweakedExtension( @@ -79,8 +81,7 @@ abstract class CCTweakedExtension( /** * References to other sources */ - val sourceDirectories: SetProperty = - project.objects.setProperty(SourceSetReference::class.java) + val sourceDirectories: SetProperty = project.objects.setProperty(SourceSetReference::class.java) /** All source sets referenced by this project. */ val sourceSets = sourceDirectories.map { x -> x.map { it.sourceSet } } @@ -181,7 +182,7 @@ fun linters(@Suppress("UNUSED_PARAMETER") vararg unused: UseNamedArgs, minecraft } } - fun jacoco(task: NamedDomainObjectProvider) { + fun jacoco(task: NamedDomainObjectProvider) where T : Task, T : JavaForkOptions { val classDump = project.buildDir.resolve("jacocoClassDump/${task.name}") val reportTaskName = "jacoco${task.name.capitalized()}Report" @@ -210,8 +211,7 @@ fun jacoco(task: NamedDomainObjectProvider) { classDirectories.from(classDump) // Don't want to use sourceSets(...) here as we have a custom class directory. - val sourceSets = project.extensions.getByType(SourceSetContainer::class.java) - sourceDirectories.from(sourceSets["main"].allSource.sourceDirectories) + for (ref in sourceSets.get()) sourceDirectories.from(ref.allSource.sourceDirectories) } project.extensions.configure(ReportingExtension::class.java) { @@ -221,6 +221,41 @@ fun jacoco(task: NamedDomainObjectProvider) { } } + /** + * Download a file by creating a dummy Ivy repository. + * + * This should only be used for one-off downloads. Using a more conventional Ivy or Maven repository is preferred + * where possible. + */ + fun downloadFile(label: String, url: String): File { + val url = URL(url) + val path = File(url.path) + + project.repositories.ivy { + name = label + setUrl(URI(url.protocol, url.userInfo, url.host, url.port, path.parent, null, null)) + patternLayout { + artifact("[artifact].[ext]") + } + metadataSources { + artifact() + } + content { + includeModule("cc.tweaked.internal", path.nameWithoutExtension) + } + } + + return project.configurations.detachedConfiguration( + project.dependencies.create( + mapOf( + "group" to "cc.tweaked.internal", + "name" to path.nameWithoutExtension, + "ext" to path.extension, + ), + ), + ).resolve().single() + } + companion object { private val EMAIL = Pattern.compile("^([^<]+) <.+>$") private val IGNORED_USERS = setOf( @@ -238,7 +273,7 @@ private fun gitProvider(project: Project, default: T, supplier: () -> T): Pr } } - internal val isIdeSync: Boolean + private val isIdeSync: Boolean get() = java.lang.Boolean.parseBoolean(System.getProperty("idea.sync.active", "false")) } } diff --git a/buildSrc/src/main/kotlin/cc/tweaked/gradle/CheckChangelog.kt b/buildSrc/src/main/kotlin/cc/tweaked/gradle/CheckChangelog.kt index b164c82cc..c636bff96 100644 --- a/buildSrc/src/main/kotlin/cc/tweaked/gradle/CheckChangelog.kt +++ b/buildSrc/src/main/kotlin/cc/tweaked/gradle/CheckChangelog.kt @@ -6,7 +6,6 @@ import org.gradle.api.provider.Property import org.gradle.api.tasks.* import org.gradle.language.base.plugins.LifecycleBasePlugin -import java.nio.charset.StandardCharsets /** * Checks the `changelog.md` and `whatsnew.md` files are well-formed. diff --git a/buildSrc/src/main/kotlin/cc/tweaked/gradle/CheckLicense.kt b/buildSrc/src/main/kotlin/cc/tweaked/gradle/CheckLicense.kt index a29d13880..a73fbcaf0 100644 --- a/buildSrc/src/main/kotlin/cc/tweaked/gradle/CheckLicense.kt +++ b/buildSrc/src/main/kotlin/cc/tweaked/gradle/CheckLicense.kt @@ -5,7 +5,6 @@ import com.diffplug.spotless.generic.LicenseHeaderStep import java.io.File import java.io.Serializable -import java.nio.charset.StandardCharsets /** * Similar to [LicenseHeaderStep], but supports multiple licenses. diff --git a/buildSrc/src/main/kotlin/cc/tweaked/gradle/Extensions.kt b/buildSrc/src/main/kotlin/cc/tweaked/gradle/Extensions.kt index 612715506..cb51eef38 100644 --- a/buildSrc/src/main/kotlin/cc/tweaked/gradle/Extensions.kt +++ b/buildSrc/src/main/kotlin/cc/tweaked/gradle/Extensions.kt @@ -1,11 +1,10 @@ package cc.tweaked.gradle -import org.gradle.api.artifacts.ResolvedDependency import org.gradle.api.artifacts.dsl.DependencyHandler -import org.gradle.api.publish.maven.MavenPublication -import org.gradle.api.specs.Spec import org.gradle.api.tasks.JavaExec +import org.gradle.process.BaseExecSpec import org.gradle.process.JavaExecSpec +import org.gradle.process.ProcessForkOptions /** * Add an annotation processor to all source sets. @@ -26,10 +25,28 @@ */ fun JavaExec.copyToFull(spec: JavaExec) { copyTo(spec) - spec.classpath = classpath - spec.mainClass.set(mainClass) - spec.javaLauncher.set(javaLauncher) + + // Additional Java options + spec.jvmArgs = jvmArgs // Fabric overrides getJvmArgs so copyTo doesn't do the right thing. spec.args = args + spec.argumentProviders.addAll(argumentProviders) + spec.mainClass.set(mainClass) + spec.classpath = classpath + spec.javaLauncher.set(javaLauncher) + if (executable != null) spec.setExecutable(executable!!) + + // Additional ExecSpec options + copyToExec(spec) +} + +/** + * Copy additional [BaseExecSpec] options which aren't handled by [ProcessForkOptions.copyTo]. + */ +fun BaseExecSpec.copyToExec(spec: BaseExecSpec) { + spec.isIgnoreExitValue = isIgnoreExitValue + if (standardInput != null) spec.standardInput = standardInput + if (standardOutput != null) spec.standardOutput = standardOutput + if (errorOutput != null) spec.errorOutput = errorOutput } /** @@ -42,3 +59,59 @@ * ``` */ class UseNamedArgs private constructor() + +/** + * An [AutoCloseable] implementation which can be used to combine other [AutoCloseable] instances. + * + * Values which implement [AutoCloseable] can be dynamically registered with [CloseScope.add]. When the scope is closed, + * each value is closed in the opposite order. + * + * This is largely intended for cases where it's not appropriate to nest [AutoCloseable.use], for instance when nested + * would be too deep. + */ +class CloseScope : AutoCloseable { + private val toClose = ArrayDeque() + + /** + * Add a value to be closed when this scope is closed. + */ + public fun add(value: AutoCloseable) { + toClose.addLast(value) + } + + override fun close() { + close(null) + } + + @PublishedApi + internal fun close(baseException: Throwable?) { + var exception = baseException + + while (true) { + var toClose = toClose.removeLastOrNull() ?: break + try { + toClose.close() + } catch (e: Throwable) { + if (exception == null) { + exception = e + } else { + exception.addSuppressed(e) + } + } + } + + if (exception != null) throw exception + } + + inline fun use(block: (CloseScope) -> R): R { + var exception: Throwable? = null + try { + return block(this) + } catch (e: Throwable) { + exception = e + throw e + } finally { + close(exception) + } + } +} diff --git a/buildSrc/src/main/kotlin/cc/tweaked/gradle/IdeaRunConfigurations.kt b/buildSrc/src/main/kotlin/cc/tweaked/gradle/IdeaRunConfigurations.kt index 1ed7d57b8..dbd172342 100644 --- a/buildSrc/src/main/kotlin/cc/tweaked/gradle/IdeaRunConfigurations.kt +++ b/buildSrc/src/main/kotlin/cc/tweaked/gradle/IdeaRunConfigurations.kt @@ -6,7 +6,6 @@ import org.w3c.dom.Document import org.w3c.dom.Node import org.xml.sax.InputSource -import java.nio.charset.StandardCharsets import java.nio.file.Files import java.nio.file.Path import javax.xml.parsers.DocumentBuilderFactory @@ -36,7 +35,7 @@ internal class IdeaRunConfigurations(project: Project) { val ideaMisc = ideaDir.resolve("misc.xml") try { - val doc = Files.newBufferedReader(ideaMisc.toPath(), StandardCharsets.UTF_8).use { + val doc = Files.newBufferedReader(ideaMisc.toPath()).use { documentBuilder.parse(InputSource(it)) } val node = diff --git a/buildSrc/src/main/kotlin/cc/tweaked/gradle/MinecraftExec.kt b/buildSrc/src/main/kotlin/cc/tweaked/gradle/MinecraftExec.kt new file mode 100644 index 000000000..c7a7185ab --- /dev/null +++ b/buildSrc/src/main/kotlin/cc/tweaked/gradle/MinecraftExec.kt @@ -0,0 +1,202 @@ +package cc.tweaked.gradle + +import org.gradle.api.GradleException +import org.gradle.api.file.FileSystemOperations +import org.gradle.api.invocation.Gradle +import org.gradle.api.provider.Provider +import org.gradle.api.services.BuildService +import org.gradle.api.services.BuildServiceParameters +import org.gradle.api.tasks.* +import org.gradle.kotlin.dsl.getByName +import org.gradle.language.base.plugins.LifecycleBasePlugin +import java.io.File +import java.nio.file.Files +import java.util.concurrent.TimeUnit +import java.util.function.Supplier +import javax.inject.Inject +import kotlin.random.Random + +/** + * A [JavaExec] task for client-tests. This sets some common setup, and uses [MinecraftRunnerService] to ensure only one + * test runs at once. + */ +abstract class ClientJavaExec : JavaExec() { + private val clientRunner: Provider = MinecraftRunnerService.get(project.gradle) + + init { + group = LifecycleBasePlugin.VERIFICATION_GROUP + usesService(clientRunner) + } + + /** + * When [false], tests will not be run automatically, allowing the user to debug rendering. + */ + @get:Input + val clientDebug get() = project.hasProperty("clientDebug") + + /** + * When [false], tests will not run under a framebuffer. + */ + @get:Input + val useFramebuffer get() = !clientDebug && !project.hasProperty("clientNoFramebuffer") + + /** + * The folder screenshots are written to. + */ + @get:OutputDirectory + val screenshots = project.layout.buildDirectory.dir("testScreenshots") + + /** + * The path test results are written to. + */ + @get:OutputFile + val testResults = project.layout.buildDirectory.file("test-results/$name.xml") + + /** + * Copy configuration from a task with the given name. + */ + fun copyFrom(path: String) = copyFrom(project.tasks.getByName(path, JavaExec::class)) + + /** + * Copy configuration from an existing [JavaExec] task. + */ + fun copyFrom(task: JavaExec) { + for (dep in task.dependsOn) dependsOn(dep) + task.copyToFull(this) + + if (!clientDebug) systemProperty("cctest.client", "") + systemProperty("cctest.gametest-report", testResults.get().asFile.absoluteFile) + workingDir(project.buildDir.resolve("gametest").resolve(name)) + } + + /** + * Only run tests with the given tags. + */ + fun tags(vararg tags: String) { + systemProperty("cctest.tags", tags.joinToString(",")) + } + + /** + * Write a file with the given contents before starting Minecraft. This may be useful for writing config files. + */ + fun withFileContents(path: Any, contents: Supplier) { + val file = project.file(path).toPath() + doFirst { + Files.createDirectories(file.parent) + Files.writeString(file, contents.get()) + } + } + + /** + * Copy a file to the provided path before starting Minecraft. This copy only occurs if the file does not already + * exist. + */ + fun withFileFrom(path: Any, source: Supplier) { + val file = project.file(path).toPath() + doFirst { + Files.createDirectories(file.parent) + if (!Files.exists(file)) Files.copy(source.get().toPath(), file) + } + } + + @TaskAction + override fun exec() { + Files.createDirectories(workingDir.toPath()) + fsOperations.delete { delete(workingDir.resolve("screenshots")) } + + if (useFramebuffer) { + clientRunner.get().wrapClient(this) { super.exec() } + } else { + super.exec() + } + + fsOperations.copy { + from(workingDir.resolve("screenshots")) + into(screenshots) + } + } + + @get:Inject + protected abstract val fsOperations: FileSystemOperations +} + +/** + * A service for [JavaExec] tasks which start Minecraft. + * + * Tasks may run `usesService(MinecraftRunnerService.get(gradle))` to ensure that only one Minecraft-related task runs + * at once. + */ +abstract class MinecraftRunnerService : BuildService { + private val hasXvfb = lazy { + System.getProperty("os.name", "").equals("linux", ignoreCase = true) && ProcessHelpers.onPath("xvfb-run") + } + + internal fun wrapClient(exec: JavaExec, run: () -> Unit) = when { + hasXvfb.value -> runXvfb(exec, run) + else -> run() + } + + /** + * Run a program under Xvfb, preventing it spawning a window. + */ + private fun runXvfb(exec: JavaExec, run: () -> Unit) { + fun ProcessBuilder.startVerbose(): Process { + exec.logger.info("Running ${this.command()}") + return start() + } + + CloseScope().use { scope -> + val dir = Files.createTempDirectory("cctweaked").toAbsolutePath() + scope.add { fsOperations.delete { delete(dir) } } + + val authFile = Files.createTempFile(dir, "Xauthority", "").toAbsolutePath() + + val cookie = StringBuilder().also { + for (i in 0..31) it.append("0123456789abcdef"[Random.nextInt(16)]) + }.toString() + + val xvfb = + ProcessBuilder("Xvfb", "-displayfd", "1", "-screen", "0", "640x480x24", "-nolisten", "tcp").also { + it.inheritIO() + it.environment()["XAUTHORITY"] = authFile.toString() + it.redirectOutput(ProcessBuilder.Redirect.PIPE) + }.startVerbose() + scope.add { xvfb.destroyForcibly().waitFor() } + + val server = xvfb.inputReader().use { it.readLine().trim() } + exec.logger.info("Running at :$server (XAUTHORITY=$authFile.toA") + + ProcessBuilder("xauth", "add", ":$server", ".", cookie).also { + it.inheritIO() + it.environment()["XAUTHORITY"] = authFile.toString() + }.startVerbose().waitForOrThrow("Failed to setup XAuthority file") + + scope.add { + ProcessBuilder("xauth", "remove", ":$server").also { + it.inheritIO() + it.environment()["XAUTHORITY"] = authFile.toString() + }.startVerbose().waitFor() + } + + // Wait a few seconds for Xvfb to start. Ugly, but identical to xvfb-run. + if (xvfb.waitFor(3, TimeUnit.SECONDS)) { + throw GradleException("Xvfb unexpectedly exited (with status code ${xvfb.exitValue()})") + } + + exec.environment("XAUTHORITY", authFile.toString()) + exec.environment("DISPLAY", ":$server") + + run() + } + } + + @get:Inject + protected abstract val fsOperations: FileSystemOperations + + companion object { + fun get(gradle: Gradle): Provider = + gradle.sharedServices.registerIfAbsent("cc.tweaked.gradle.ClientJavaExec", MinecraftRunnerService::class.java) { + maxParallelUsages.set(1) + } + } +} diff --git a/buildSrc/src/main/kotlin/cc/tweaked/gradle/ProcessHelpers.kt b/buildSrc/src/main/kotlin/cc/tweaked/gradle/ProcessHelpers.kt index 15643de3e..f6c565e2f 100644 --- a/buildSrc/src/main/kotlin/cc/tweaked/gradle/ProcessHelpers.kt +++ b/buildSrc/src/main/kotlin/cc/tweaked/gradle/ProcessHelpers.kt @@ -1,7 +1,9 @@ package cc.tweaked.gradle import org.codehaus.groovy.runtime.ProcessGroovyMethods +import org.gradle.api.GradleException import java.io.BufferedReader +import java.io.File import java.io.IOException import java.io.InputStreamReader @@ -31,4 +33,14 @@ fun captureLines(process: Process): List { if (process.waitFor() != 0) throw IOException("Command exited with a non-0 status") return out } + + fun onPath(name: String): Boolean { + val path = System.getenv("PATH") ?: return false + return path.splitToSequence(File.pathSeparator).any { File(it, name).exists() } + } +} + +internal fun Process.waitForOrThrow(message: String) { + val ret = waitFor() + if (ret != 0) throw GradleException("$message (exited with $ret)") } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e3f38cda9..84ddc61c8 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -28,10 +28,12 @@ slf4j = "1.7.36" # Minecraft mods forgeConfig = "4.2.6" -iris = "1.19.x-v1.2.6" +iris = "1.19.x-v1.4.0" jei = "11.3.0.262" oculus = "1.2.5" rei = "9.1.550" +rubidium = "0.6.1" +sodium = "mc1.19.2-0.4.4" # Testing byteBuddy = "1.12.18" @@ -91,6 +93,8 @@ oculus = { module = "maven.modrinth:oculus", version.ref = "oculus" } rei-api = { module = "me.shedaniel:RoughlyEnoughItems-api", version.ref = "rei" } rei-builtin = { module = "me.shedaniel:RoughlyEnoughItems-default-plugin", version.ref = "rei" } rei-fabric = { module = "me.shedaniel:RoughlyEnoughItems-fabric", version.ref = "rei" } +rubidium = { module = "maven.modrinth:rubidium", version.ref = "rubidium" } +sodium = { module = "maven.modrinth:sodium", version.ref = "sodium" } # Testing byteBuddyAgent = { module ="net.bytebuddy:byte-buddy-agent", version.ref = "byteBuddy" } diff --git a/projects/common/src/main/java/dan200/computercraft/shared/platform/Registries.java b/projects/common/src/main/java/dan200/computercraft/shared/platform/Registries.java index bb3a948c3..34aaf82dd 100644 --- a/projects/common/src/main/java/dan200/computercraft/shared/platform/Registries.java +++ b/projects/common/src/main/java/dan200/computercraft/shared/platform/Registries.java @@ -10,6 +10,7 @@ import net.minecraft.network.FriendlyByteBuf; import net.minecraft.resources.ResourceLocation; import net.minecraft.sounds.SoundEvent; +import net.minecraft.world.inventory.MenuType; import net.minecraft.world.item.Item; import net.minecraft.world.item.crafting.RecipeSerializer; import net.minecraft.world.item.enchantment.Enchantment; @@ -31,6 +32,7 @@ public final class Registries { public static final RegistryWrapper> COMMAND_ARGUMENT_TYPES = PlatformHelper.get().wrap(Registry.COMMAND_ARGUMENT_TYPE_REGISTRY); public static final RegistryWrapper SOUND_EVENTS = PlatformHelper.get().wrap(Registry.SOUND_EVENT_REGISTRY); public static final RegistryWrapper> RECIPE_SERIALIZERS = PlatformHelper.get().wrap(Registry.RECIPE_SERIALIZER_REGISTRY); + public static final RegistryWrapper> MENU = PlatformHelper.get().wrap(Registry.MENU_REGISTRY); public interface RegistryWrapper extends Iterable { int getId(T object); diff --git a/projects/common/src/testMod/java/dan200/computercraft/gametest/api/GameTestHolder.java b/projects/common/src/testMod/java/dan200/computercraft/gametest/api/GameTestHolder.java deleted file mode 100644 index e0cc3a6a4..000000000 --- a/projects/common/src/testMod/java/dan200/computercraft/gametest/api/GameTestHolder.java +++ /dev/null @@ -1,23 +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.gametest.api; - -import net.minecraft.gametest.framework.GameTest; - -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; - -/** - * Marks classes containing {@linkplain GameTest game tests}. - *

- * This is used by Forge to automatically load and test classes. - */ -@Target(ElementType.TYPE) -@Retention(RetentionPolicy.RUNTIME) -public @interface GameTestHolder { -} diff --git a/projects/common/src/testMod/java/dan200/computercraft/gametest/core/CCTestCommand.java b/projects/common/src/testMod/java/dan200/computercraft/gametest/core/CCTestCommand.java index 5faf73229..2406a643f 100644 --- a/projects/common/src/testMod/java/dan200/computercraft/gametest/core/CCTestCommand.java +++ b/projects/common/src/testMod/java/dan200/computercraft/gametest/core/CCTestCommand.java @@ -105,7 +105,7 @@ private static Path getWorldComputerPath(MinecraftServer server) { } private static Path getSourceComputerPath() { - return TestHooks.sourceDir.resolve("computer"); + return TestHooks.getSourceDir().resolve("computer"); } private static int error(CommandSourceStack source, String message) { diff --git a/projects/common/src/testMod/java/dan200/computercraft/gametest/core/TestAPI.java b/projects/common/src/testMod/java/dan200/computercraft/gametest/core/TestAPI.java index 6666c44a8..3f76d4288 100644 --- a/projects/common/src/testMod/java/dan200/computercraft/gametest/core/TestAPI.java +++ b/projects/common/src/testMod/java/dan200/computercraft/gametest/core/TestAPI.java @@ -12,6 +12,8 @@ import dan200.computercraft.gametest.api.ComputerState; import dan200.computercraft.gametest.api.TestExtensionsKt; import net.minecraft.gametest.framework.GameTestSequence; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import javax.annotation.Nullable; import java.util.Optional; @@ -24,6 +26,8 @@ * @see TestExtensionsKt#thenComputerOk(GameTestSequence, String, String) To check tests on the computer have passed. */ public class TestAPI extends ComputerState implements ILuaAPI { + private static final Logger LOG = LoggerFactory.getLogger(TestAPI.class); + private final IComputerSystem system; private @Nullable String label; @@ -36,10 +40,10 @@ public void startup() { if (label == null) label = system.getLabel(); if (label == null) { label = "#" + system.getID(); - TestHooks.LOG.warn("Computer {} has no label", label); + LOG.warn("Computer {} has no label", label); } - TestHooks.LOG.info("Computer '{}' has turned on.", label); + LOG.info("Computer '{}' has turned on.", label); markers.clear(); error = null; lookup.put(label, this); @@ -47,7 +51,7 @@ public void startup() { @Override public void shutdown() { - TestHooks.LOG.info("Computer '{}' has shut down.", label); + LOG.info("Computer '{}' has shut down.", label); if (lookup.get(label) == this) lookup.remove(label); } @@ -58,7 +62,7 @@ public String[] getNames() { @LuaFunction public final void fail(String message) throws LuaException { - TestHooks.LOG.error("Computer '{}' failed with {}", label, message); + LOG.error("Computer '{}' failed with {}", label, message); if (markers.contains(ComputerState.DONE)) throw new LuaException("Cannot call fail/ok multiple times."); markers.add(ComputerState.DONE); error = message; @@ -77,6 +81,6 @@ public final void ok(Optional marker) throws LuaException { @LuaFunction public final void log(String message) { - TestHooks.LOG.info("[Computer '{}'] {}", label, message); + LOG.info("[Computer '{}'] {}", label, message); } } diff --git a/projects/common/src/testMod/java/dan200/computercraft/gametest/core/TestHooks.java b/projects/common/src/testMod/java/dan200/computercraft/gametest/core/TestHooks.java deleted file mode 100644 index cda6b4fc4..000000000 --- a/projects/common/src/testMod/java/dan200/computercraft/gametest/core/TestHooks.java +++ /dev/null @@ -1,55 +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.gametest.core; - -import dan200.computercraft.api.ComputerCraftAPI; -import dan200.computercraft.gametest.api.Times; -import dan200.computercraft.shared.computer.core.ServerContext; -import net.minecraft.core.BlockPos; -import net.minecraft.gametest.framework.GameTestRunner; -import net.minecraft.gametest.framework.GameTestTicker; -import net.minecraft.gametest.framework.StructureUtils; -import net.minecraft.server.MinecraftServer; -import net.minecraft.world.level.GameRules; -import net.minecraft.world.level.Level; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.nio.file.Path; -import java.nio.file.Paths; - -public class TestHooks { - public static final Logger LOG = LoggerFactory.getLogger(TestHooks.class); - - public static final Path sourceDir = Paths.get(System.getProperty("cctest.sources")).normalize().toAbsolutePath(); - - public static void init() { - ServerContext.luaMachine = ManagedComputers.INSTANCE; - ComputerCraftAPI.registerAPIFactory(TestAPI::new); - StructureUtils.testStructuresDir = sourceDir.resolve("structures").toString(); - } - - public static void onServerStarted(MinecraftServer server) { - var rules = server.getGameRules(); - rules.getRule(GameRules.RULE_DAYLIGHT).set(false, server); - - var world = server.getLevel(Level.OVERWORLD); - if (world != null) world.setDayTime(Times.NOON); - - LOG.info("Cleaning up after last run"); - GameTestRunner.clearAllTests(server.overworld(), new BlockPos(0, -60, 0), GameTestTicker.SINGLETON, 200); - - // Delete server context and add one with a mutable machine factory. This allows us to set the factory for - // specific test batches without having to reset all computers. - for (var computer : ServerContext.get(server).registry().getComputers()) { - var label = computer.getLabel() == null ? "#" + computer.getID() : computer.getLabel(); - LOG.warn("Unexpected computer {}", label); - } - - LOG.info("Importing files"); - CCTestCommand.importFiles(server); - } -} diff --git a/projects/common/src/testMod/java/dan200/computercraft/mixin/gametest/GameTestSequenceAccessor.java b/projects/common/src/testMod/java/dan200/computercraft/mixin/gametest/GameTestSequenceAccessor.java index a23a707fb..b2fedd62d 100644 --- a/projects/common/src/testMod/java/dan200/computercraft/mixin/gametest/GameTestSequenceAccessor.java +++ b/projects/common/src/testMod/java/dan200/computercraft/mixin/gametest/GameTestSequenceAccessor.java @@ -14,4 +14,7 @@ public interface GameTestSequenceAccessor { @Accessor GameTestInfo getParent(); + + @Accessor + long getLastTick(); } diff --git a/projects/common/src/testMod/java/dan200/computercraft/mixin/gametest/SharedConstantsMixin.java b/projects/common/src/testMod/java/dan200/computercraft/mixin/gametest/SharedConstantsMixin.java new file mode 100644 index 000000000..55381d347 --- /dev/null +++ b/projects/common/src/testMod/java/dan200/computercraft/mixin/gametest/SharedConstantsMixin.java @@ -0,0 +1,23 @@ +/* + * 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.mixin.gametest; + +import net.minecraft.SharedConstants; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Overwrite; + +@Mixin(SharedConstants.class) +class SharedConstantsMixin { + /** + * Disable DFU initialisation. + * + * @author SquidDev + * @reason This doesn't have any impact on gameplay, and slightly speeds up tests. + */ + @Overwrite + public static void enableDataFixerOptimizations() { + } +} diff --git a/projects/common/src/testMod/kotlin/dan200/computercraft/gametest/Computer_Test.kt b/projects/common/src/testMod/kotlin/dan200/computercraft/gametest/Computer_Test.kt index 8eec0fabf..5403eb4c6 100644 --- a/projects/common/src/testMod/kotlin/dan200/computercraft/gametest/Computer_Test.kt +++ b/projects/common/src/testMod/kotlin/dan200/computercraft/gametest/Computer_Test.kt @@ -5,18 +5,25 @@ */ package dan200.computercraft.gametest +import dan200.computercraft.api.lua.ObjectArguments +import dan200.computercraft.client.gui.AbstractComputerScreen import dan200.computercraft.core.apis.RedstoneAPI +import dan200.computercraft.core.apis.TermAPI import dan200.computercraft.core.computer.ComputerSide import dan200.computercraft.gametest.api.* +import dan200.computercraft.shared.ModRegistry import dan200.computercraft.test.core.computer.getApi import net.minecraft.core.BlockPos import net.minecraft.gametest.framework.GameTest import net.minecraft.gametest.framework.GameTestHelper +import net.minecraft.world.InteractionHand import net.minecraft.world.level.block.Blocks import net.minecraft.world.level.block.LeverBlock import net.minecraft.world.level.block.RedstoneLampBlock +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.lwjgl.glfw.GLFW -@GameTestHolder class Computer_Test { /** * Ensures redstone signals do not travel through computers. @@ -50,6 +57,9 @@ fun No_through_signal_reverse(context: GameTestHelper) = context.sequence { thenExecute { context.assertBlockHas(lamp, RedstoneLampBlock.LIT, false, "Lamp should still not be lit") } } + /** + * Check computers propagate redstone to surrounding blocks. + */ @GameTest fun Set_and_destroy(context: GameTestHelper) = context.sequence { val lamp = BlockPos(2, 2, 3) @@ -62,6 +72,9 @@ fun Set_and_destroy(context: GameTestHelper) = context.sequence { thenExecute { context.assertBlockHas(lamp, RedstoneLampBlock.LIT, false, "Lamp should not be lit") } } + /** + * Check computers and turtles expose peripherals. + */ @GameTest fun Computer_peripheral(context: GameTestHelper) = context.sequence { thenExecute { @@ -69,4 +82,62 @@ fun Computer_peripheral(context: GameTestHelper) = context.sequence { context.assertPeripheral(BlockPos(1, 2, 2), type = "turtle") } } + + /** + * Check the client can open the computer UI and interact with it. + */ + @ClientGameTest + fun Open_on_client(context: GameTestHelper) = context.sequence { + // Write "Hello, world!" and then print each event to the terminal. + thenOnComputer { getApi().write(ObjectArguments("Hello, world!")) } + thenStartComputer { + val term = getApi().terminal + while (true) { + val event = pullEvent() + if (term.cursorY >= term.height) { + term.scroll(1) + term.setCursorPos(0, term.height) + } else { + term.setCursorPos(0, term.cursorY + 1) + } + + term.write(event.contentToString()) + } + } + // Teleport the player to the computer and then open it. + thenExecute { + context.positionAt(BlockPos(2, 2, 1)) + val computer = context.getBlockEntity(BlockPos(2, 2, 2), ModRegistry.BlockEntities.COMPUTER_ADVANCED.get()) + computer.use(context.level.randomPlayer!!, InteractionHand.MAIN_HAND) + } + // Assert the terminal is synced to the client. + thenIdle(2) + thenOnClient { + val menu = getOpenMenu(ModRegistry.Menus.COMPUTER.get()) + val term = menu.terminal + assertEquals("Hello, world!", term.getLine(0).toString().trim(), "Terminal contents is synced") + assertTrue(menu.isOn, "Computer is on") + } + // Press a key on the client + thenOnClient { + val screen = minecraft.screen as AbstractComputerScreen<*> + screen.keyPressed(GLFW.GLFW_KEY_A, 0, 0) + screen.keyReleased(GLFW.GLFW_KEY_A, 0, 0) + } + // And assert it is handled and sent back to the client + thenIdle(2) + thenOnClient { + val term = getOpenMenu(ModRegistry.Menus.COMPUTER.get()).terminal + assertEquals( + "[key, ${GLFW.GLFW_KEY_A}, false]", + term.getLine(1).toString().trim(), + "Terminal contents is synced", + ) + assertEquals( + "[key_up, ${GLFW.GLFW_KEY_A}]", + term.getLine(2).toString().trim(), + "Terminal contents is synced", + ) + } + } } diff --git a/projects/common/src/testMod/kotlin/dan200/computercraft/gametest/CraftOs_Test.kt b/projects/common/src/testMod/kotlin/dan200/computercraft/gametest/CraftOs_Test.kt index 0ec7e3427..447d3380b 100644 --- a/projects/common/src/testMod/kotlin/dan200/computercraft/gametest/CraftOs_Test.kt +++ b/projects/common/src/testMod/kotlin/dan200/computercraft/gametest/CraftOs_Test.kt @@ -5,14 +5,12 @@ */ package dan200.computercraft.gametest -import dan200.computercraft.gametest.api.GameTestHolder import dan200.computercraft.gametest.api.Timeouts import dan200.computercraft.gametest.api.sequence import dan200.computercraft.gametest.api.thenComputerOk import net.minecraft.gametest.framework.GameTest import net.minecraft.gametest.framework.GameTestHelper -@GameTestHolder class CraftOs_Test { /** * Sends a rednet message to another a computer and back again. diff --git a/projects/common/src/testMod/kotlin/dan200/computercraft/gametest/Disk_Drive_Test.kt b/projects/common/src/testMod/kotlin/dan200/computercraft/gametest/Disk_Drive_Test.kt index ce823c37b..db07bde53 100644 --- a/projects/common/src/testMod/kotlin/dan200/computercraft/gametest/Disk_Drive_Test.kt +++ b/projects/common/src/testMod/kotlin/dan200/computercraft/gametest/Disk_Drive_Test.kt @@ -21,7 +21,6 @@ import net.minecraft.world.level.block.RedStoneWireBlock import org.junit.jupiter.api.Assertions.assertEquals -@GameTestHolder class Disk_Drive_Test { /** * Ensure audio disks exist and we can play them. diff --git a/projects/common/src/testMod/kotlin/dan200/computercraft/gametest/Loot_Test.kt b/projects/common/src/testMod/kotlin/dan200/computercraft/gametest/Loot_Test.kt index 8aa6aa004..b327df467 100644 --- a/projects/common/src/testMod/kotlin/dan200/computercraft/gametest/Loot_Test.kt +++ b/projects/common/src/testMod/kotlin/dan200/computercraft/gametest/Loot_Test.kt @@ -5,7 +5,6 @@ */ package dan200.computercraft.gametest -import dan200.computercraft.gametest.api.GameTestHolder import dan200.computercraft.gametest.api.Structures import dan200.computercraft.gametest.api.sequence import dan200.computercraft.shared.ModRegistry @@ -16,7 +15,6 @@ import net.minecraft.world.level.block.entity.ChestBlockEntity import net.minecraft.world.level.storage.loot.BuiltInLootTables -@GameTestHolder class Loot_Test { /** * Test that the loot tables will spawn in treasure disks. diff --git a/projects/common/src/testMod/kotlin/dan200/computercraft/gametest/Modem_Test.kt b/projects/common/src/testMod/kotlin/dan200/computercraft/gametest/Modem_Test.kt index 09bbb35c5..71ae60e79 100644 --- a/projects/common/src/testMod/kotlin/dan200/computercraft/gametest/Modem_Test.kt +++ b/projects/common/src/testMod/kotlin/dan200/computercraft/gametest/Modem_Test.kt @@ -20,7 +20,6 @@ import org.junit.jupiter.api.Assertions.assertEquals import kotlin.time.Duration.Companion.milliseconds -@GameTestHolder class Modem_Test { @GameTest fun Have_peripherals(helper: GameTestHelper) = helper.sequence { diff --git a/projects/common/src/testMod/kotlin/dan200/computercraft/gametest/Monitor_Test.kt b/projects/common/src/testMod/kotlin/dan200/computercraft/gametest/Monitor_Test.kt index b5d0c30d8..205b20be0 100644 --- a/projects/common/src/testMod/kotlin/dan200/computercraft/gametest/Monitor_Test.kt +++ b/projects/common/src/testMod/kotlin/dan200/computercraft/gametest/Monitor_Test.kt @@ -7,17 +7,20 @@ import dan200.computercraft.gametest.api.* import dan200.computercraft.shared.ModRegistry +import dan200.computercraft.shared.config.Config import dan200.computercraft.shared.peripheral.monitor.MonitorBlock import dan200.computercraft.shared.peripheral.monitor.MonitorEdgeState +import dan200.computercraft.shared.peripheral.monitor.MonitorRenderer import net.minecraft.commands.arguments.blocks.BlockInput import net.minecraft.core.BlockPos import net.minecraft.gametest.framework.GameTest +import net.minecraft.gametest.framework.GameTestGenerator import net.minecraft.gametest.framework.GameTestHelper +import net.minecraft.gametest.framework.TestFunction import net.minecraft.nbt.CompoundTag import net.minecraft.world.level.block.Blocks import java.util.* -@GameTestHolder class Monitor_Test { @GameTest fun Ensures_valid_on_place(context: GameTestHelper) = context.sequence { @@ -58,4 +61,64 @@ fun Contract_on_destroy(helper: GameTestHelper) = helper.sequence { helper.assertBlockHas(BlockPos(3, 2, 2), MonitorBlock.STATE, MonitorEdgeState.NONE) } } + + /** + * Test + */ + @GameTestGenerator + fun Render_monitor_tests(): List { + val tests = mutableListOf() + + fun addTest(label: String, renderer: MonitorRenderer, time: Long = Times.NOON, tag: String = TestTags.CLIENT) { + if (!TestTags.isEnabled(tag)) return + + val className = Monitor_Test::class.java.simpleName.lowercase() + val testName = "$className.render_monitor" + + tests.add( + TestFunction( + "$testName.$label", + "$testName.$label", + testName, + Timeouts.DEFAULT, + 0, + true, + ) { renderMonitor(it, renderer, time) }, + ) + } + + addTest("tbo_noon", MonitorRenderer.TBO, Times.NOON) + addTest("tbo_midnight", MonitorRenderer.TBO, Times.MIDNIGHT) + addTest("vbo_noon", MonitorRenderer.VBO, Times.NOON) + addTest("vbo_midnight", MonitorRenderer.VBO, Times.MIDNIGHT) + + addTest("sodium_tbo", MonitorRenderer.TBO, tag = "sodium") + addTest("sodium_vbo", MonitorRenderer.VBO, tag = "sodium") + + addTest("iris_noon", MonitorRenderer.BEST, Times.NOON, tag = "iris") + addTest("iris_midnight", MonitorRenderer.BEST, Times.MIDNIGHT, tag = "iris") + + return tests + } + + private fun renderMonitor(helper: GameTestHelper, renderer: MonitorRenderer, time: Long) = helper.sequence { + thenExecute { + Config.monitorRenderer = renderer + helper.level.dayTime = time + helper.positionAtArmorStand() + + // Get the monitor and peripheral. This forces us to create a server monitor at this location. + val monitor = helper.getBlockEntity(BlockPos(2, 2, 3), ModRegistry.BlockEntities.MONITOR_ADVANCED.get()) + monitor.peripheral() + + val terminal = monitor.cachedServerMonitor!!.terminal!! + terminal.write("Hello, world!") + terminal.setCursorPos(1, 2) + terminal.textColour = 2 + terminal.backgroundColour = 3 + terminal.write("Some coloured text") + } + + thenScreenshot() + } } diff --git a/projects/common/src/testMod/kotlin/dan200/computercraft/gametest/Printer_Test.kt b/projects/common/src/testMod/kotlin/dan200/computercraft/gametest/Printer_Test.kt index d44d954ed..782757004 100644 --- a/projects/common/src/testMod/kotlin/dan200/computercraft/gametest/Printer_Test.kt +++ b/projects/common/src/testMod/kotlin/dan200/computercraft/gametest/Printer_Test.kt @@ -1,6 +1,9 @@ package dan200.computercraft.gametest -import dan200.computercraft.gametest.api.* +import dan200.computercraft.gametest.api.assertBlockHas +import dan200.computercraft.gametest.api.assertExactlyItems +import dan200.computercraft.gametest.api.getBlockEntity +import dan200.computercraft.gametest.api.sequence import dan200.computercraft.shared.ModRegistry import dan200.computercraft.shared.peripheral.printer.PrinterBlock import net.minecraft.core.BlockPos @@ -11,7 +14,6 @@ import net.minecraft.world.item.Items import net.minecraft.world.level.block.RedStoneWireBlock -@GameTestHolder class Printer_Test { /** * Check comparators can read the contents of the disk drive diff --git a/projects/common/src/testMod/kotlin/dan200/computercraft/gametest/Recipe_Test.kt b/projects/common/src/testMod/kotlin/dan200/computercraft/gametest/Recipe_Test.kt index e0bce56eb..6f7fbebcc 100644 --- a/projects/common/src/testMod/kotlin/dan200/computercraft/gametest/Recipe_Test.kt +++ b/projects/common/src/testMod/kotlin/dan200/computercraft/gametest/Recipe_Test.kt @@ -5,7 +5,6 @@ */ package dan200.computercraft.gametest -import dan200.computercraft.gametest.api.GameTestHolder import dan200.computercraft.gametest.api.Structures import dan200.computercraft.gametest.api.sequence import dan200.computercraft.shared.ModRegistry @@ -24,7 +23,6 @@ import org.junit.jupiter.api.Assertions.assertEquals import java.util.* -@GameTestHolder class Recipe_Test { /** * Test that crafting results contain NBT data. diff --git a/projects/common/src/testMod/kotlin/dan200/computercraft/gametest/Turtle_Test.kt b/projects/common/src/testMod/kotlin/dan200/computercraft/gametest/Turtle_Test.kt index 4cfc60928..7fff8c493 100644 --- a/projects/common/src/testMod/kotlin/dan200/computercraft/gametest/Turtle_Test.kt +++ b/projects/common/src/testMod/kotlin/dan200/computercraft/gametest/Turtle_Test.kt @@ -38,7 +38,6 @@ import java.util.* import kotlin.time.Duration.Companion.milliseconds -@GameTestHolder class Turtle_Test { @GameTest fun Unequip_refreshes_peripheral(helper: GameTestHelper) = helper.sequence { @@ -421,7 +420,7 @@ fun Peripheral_change(helper: GameTestHelper) = helper.sequence { turtle.back().await().assertArrayEquals(true, message = "Moved turtle forward") TestHooks.LOG.info("[{}] Finished turtle at {}", testInfo, testInfo.`computercraft$getTick`()) } - thenIdle(2) // Should happen immediately, but computers might be slow. + thenIdle(4) // Should happen immediately, but computers might be slow. thenExecute { assertEquals( listOf( diff --git a/projects/common/src/testMod/kotlin/dan200/computercraft/gametest/api/ClientGameTest.kt b/projects/common/src/testMod/kotlin/dan200/computercraft/gametest/api/ClientGameTest.kt new file mode 100644 index 000000000..0516278b7 --- /dev/null +++ b/projects/common/src/testMod/kotlin/dan200/computercraft/gametest/api/ClientGameTest.kt @@ -0,0 +1,28 @@ +package dan200.computercraft.gametest.api + +import net.minecraft.gametest.framework.GameTest + +/** + * Similar to [GameTest], this annotation defines a method which runs under Minecraft's gametest sequence. + * + * Unlike standard game tests, client game tests are only registered when running under the Minecraft client, and run + * sequentially rather than in parallel. + */ +@Target(AnnotationTarget.FUNCTION) +@Retention(AnnotationRetention.RUNTIME) +annotation class ClientGameTest( + /** + * The template to use for this test, identical to [GameTest.template] + */ + val template: String = "", + + /** + * The timeout for this test, identical to [GameTest.timeoutTicks]. + */ + val timeoutTicks: Int = Timeouts.DEFAULT, + + /** + * The tag associated with this test, denoting when it should run. + */ + val tag: String = TestTags.CLIENT, +) diff --git a/projects/common/src/testMod/kotlin/dan200/computercraft/gametest/api/ClientTestExtensions.kt b/projects/common/src/testMod/kotlin/dan200/computercraft/gametest/api/ClientTestExtensions.kt new file mode 100644 index 000000000..998102ac1 --- /dev/null +++ b/projects/common/src/testMod/kotlin/dan200/computercraft/gametest/api/ClientTestExtensions.kt @@ -0,0 +1,132 @@ +package dan200.computercraft.gametest.api + +import dan200.computercraft.mixin.gametest.GameTestSequenceAccessor +import dan200.computercraft.shared.platform.Registries +import net.minecraft.client.Minecraft +import net.minecraft.client.Screenshot +import net.minecraft.client.gui.screens.inventory.MenuAccess +import net.minecraft.core.BlockPos +import net.minecraft.gametest.framework.GameTestAssertException +import net.minecraft.gametest.framework.GameTestHelper +import net.minecraft.gametest.framework.GameTestSequence +import net.minecraft.server.level.ServerPlayer +import net.minecraft.world.entity.EntityType +import net.minecraft.world.inventory.AbstractContainerMenu +import net.minecraft.world.inventory.MenuType +import java.util.concurrent.CompletableFuture +import java.util.concurrent.ExecutionException +import java.util.concurrent.atomic.AtomicBoolean + +/** + * Attempt to guess whether all chunks have been rendered. + */ +fun Minecraft.isRenderingStable(): Boolean = level != null && player != null && + levelRenderer.isChunkCompiled(player!!.blockPosition()) && levelRenderer.countRenderedChunks() > 10 && + levelRenderer.hasRenderedAllChunks() + +/** + * Run a task on the client. + */ +fun GameTestSequence.thenOnClient(task: ClientTestHelper.() -> Unit): GameTestSequence { + var future: CompletableFuture? = null + thenExecute { future = Minecraft.getInstance().submit { task(ClientTestHelper()) } } + thenWaitUntil { if (!future!!.isDone) throw GameTestAssertException("Not done task yet") } + thenExecute { + try { + future!!.get() + } catch (e: ExecutionException) { + throw e.cause ?: e + } + } + return this +} + +/** + * Take a screenshot of the current game state. + */ +fun GameTestSequence.thenScreenshot(name: String? = null): GameTestSequence { + val suffix = if (name == null) "" else "-$name" + val test = (this as GameTestSequenceAccessor).parent + val fullName = "${test.testName}$suffix" + + // Wait until all chunks have been rendered and we're idle for an extended period. + var counter = 0 + thenWaitUntil { + if (Minecraft.getInstance().isRenderingStable()) { + val idleFor = ++counter + if (idleFor <= 20) throw GameTestAssertException("Only idle for $idleFor ticks") + } else { + counter = 0 + throw GameTestAssertException("Waiting for client to finish rendering") + } + } + + // Now disable the GUI, take a screenshot and reenable it. Sleep a little afterwards to ensure the render thread + // has caught up. + thenOnClient { minecraft.options.hideGui = true } + thenIdle(2) + + // Take a screenshot and wait for it to have finished. + val hasScreenshot = AtomicBoolean() + thenOnClient { screenshot("$fullName.png") { hasScreenshot.set(true) } } + thenWaitUntil { if (!hasScreenshot.get()) throw GameTestAssertException("Screenshot does not exist") } + thenOnClient { minecraft.options.hideGui = false } + + return this +} + +/** + * "Reset" the current player, ensuring. + */ +fun ServerPlayer.setupForTest() { + if (containerMenu != inventoryMenu) closeContainer() +} + +/** + * Position the player at an armor stand. + */ +fun GameTestHelper.positionAtArmorStand() { + val stand = getEntity(EntityType.ARMOR_STAND) + val player = level.randomPlayer ?: throw GameTestAssertException("Player does not exist") + + player.setupForTest() + player.connection.teleport(stand.x, stand.y, stand.z, stand.yRot, stand.xRot) +} + +/** + * Position the player at a given coordinate. + */ +fun GameTestHelper.positionAt(pos: BlockPos) { + val absolutePos = absolutePos(pos) + val player = level.randomPlayer ?: throw GameTestAssertException("Player does not exist") + + player.setupForTest() + player.connection.teleport(absolutePos.x + 0.5, absolutePos.y + 0.5, absolutePos.z + 0.5, 0.0f, 0.0f) +} + +/** + * The equivalent of a [GameTestHelper] on the client. + */ +class ClientTestHelper { + val minecraft: Minecraft = Minecraft.getInstance() + + fun screenshot(name: String, callback: () -> Unit = {}) { + Screenshot.grab(minecraft.gameDirectory, name, minecraft.mainRenderTarget) { callback() } + } + + /** + * Get the currently open [AbstractContainerMenu], ensuring it is of a specific type. + */ + fun getOpenMenu(type: MenuType): T { + fun getName(type: MenuType<*>) = Registries.MENU.getKey(type) + + val screen = minecraft.screen + @Suppress("UNCHECKED_CAST") + when { + screen == null -> throw GameTestAssertException("Expected a ${getName(type)} menu, but no screen is open") + screen !is MenuAccess<*> -> throw GameTestAssertException("Expected a ${getName(type)} menu, but a $screen is open") + screen.menu.type != type -> throw GameTestAssertException("Expected a ${getName(type)} menu, but a ${getName(screen.menu.type)} is open") + else -> return screen.menu as T + } + } +} diff --git a/projects/common/src/testMod/kotlin/dan200/computercraft/gametest/api/TestExtensions.kt b/projects/common/src/testMod/kotlin/dan200/computercraft/gametest/api/TestExtensions.kt index 18bcfbf4e..080dd8b09 100644 --- a/projects/common/src/testMod/kotlin/dan200/computercraft/gametest/api/TestExtensions.kt +++ b/projects/common/src/testMod/kotlin/dan200/computercraft/gametest/api/TestExtensions.kt @@ -7,6 +7,7 @@ import dan200.computercraft.gametest.core.ManagedComputers import dan200.computercraft.mixin.gametest.GameTestHelperAccessor +import dan200.computercraft.mixin.gametest.GameTestInfoAccessor import dan200.computercraft.mixin.gametest.GameTestSequenceAccessor import dan200.computercraft.shared.platform.PlatformHelper import dan200.computercraft.shared.platform.Registries @@ -41,6 +42,8 @@ /** Pre-set in-game times */ object Times { const val NOON: Long = 6000 + + const val MIDNIGHT: Long = 18000 } /** @@ -49,7 +52,9 @@ * @see GameTest.timeoutTicks */ object Timeouts { - private const val SECOND: Int = 20 + const val SECOND: Int = 20 + + const val DEFAULT: Int = SECOND * 5 const val COMPUTER_TIMEOUT: Int = SECOND * 15 } @@ -90,11 +95,18 @@ * Run a task on a computer and wait for it to finish. */ fun GameTestSequence.thenOnComputer(name: String? = null, action: suspend LuaTaskContext.() -> Unit): GameTestSequence { - val test = (this as GameTestSequenceAccessor).parent + val self = (this as GameTestSequenceAccessor) + val test = self.parent + val label = test.testName + (if (name == null) "" else ".$name") var monitor: ManagedComputers.Monitor? = null thenExecuteFailFast { monitor = ManagedComputers.enqueue(test, label, action) } - thenWaitUntil { if (!monitor!!.isFinished) throw GameTestAssertException("Computer '$label' has not finished yet.") } + thenWaitUntil { + if (!monitor!!.isFinished) { + val runningFor = (test as GameTestInfoAccessor).`computercraft$getTick`() - self.lastTick + throw GameTestAssertException("Computer '$label' has not finished yet (running for $runningFor ticks).") + } + } thenExecuteFailFast { monitor!!.check() } return this } diff --git a/projects/common/src/testMod/kotlin/dan200/computercraft/gametest/api/TestTags.kt b/projects/common/src/testMod/kotlin/dan200/computercraft/gametest/api/TestTags.kt new file mode 100644 index 000000000..d2632b359 --- /dev/null +++ b/projects/common/src/testMod/kotlin/dan200/computercraft/gametest/api/TestTags.kt @@ -0,0 +1,16 @@ +package dan200.computercraft.gametest.api + +/** + * "Tags" associated with each test, denoting whether a specific test should be registered for the current Minecraft + * session. + * + * This is used to only run some tests on the client, or when a specific mod is loaded. + */ +object TestTags { + const val COMMON = "common" + const val CLIENT = "client" + + private val tags: Set = System.getProperty("cctest.tags", COMMON).split(',').toSet() + + fun isEnabled(tag: String) = tags.contains(tag) +} diff --git a/projects/common/src/testMod/kotlin/dan200/computercraft/gametest/core/ClientTestHooks.kt b/projects/common/src/testMod/kotlin/dan200/computercraft/gametest/core/ClientTestHooks.kt new file mode 100644 index 000000000..85cd2d5b4 --- /dev/null +++ b/projects/common/src/testMod/kotlin/dan200/computercraft/gametest/core/ClientTestHooks.kt @@ -0,0 +1,189 @@ +package dan200.computercraft.gametest.core + +import dan200.computercraft.gametest.api.Timeouts +import dan200.computercraft.gametest.api.isRenderingStable +import dan200.computercraft.gametest.api.setupForTest +import net.minecraft.client.CloudStatus +import net.minecraft.client.Minecraft +import net.minecraft.client.ParticleStatus +import net.minecraft.client.gui.screens.Screen +import net.minecraft.client.gui.screens.TitleScreen +import net.minecraft.client.tutorial.TutorialSteps +import net.minecraft.core.BlockPos +import net.minecraft.core.Registry +import net.minecraft.core.RegistryAccess +import net.minecraft.gametest.framework.* +import net.minecraft.server.MinecraftServer +import net.minecraft.sounds.SoundSource +import net.minecraft.util.RandomSource +import net.minecraft.world.Difficulty +import net.minecraft.world.level.DataPackConfig +import net.minecraft.world.level.GameRules +import net.minecraft.world.level.GameType +import net.minecraft.world.level.LevelSettings +import net.minecraft.world.level.block.Rotation +import net.minecraft.world.level.levelgen.presets.WorldPresets +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import kotlin.system.exitProcess + +/** + * Client-side hooks for game tests. + * + * This mirrors Minecraft's + */ +object ClientTestHooks { + private val LOG: Logger = LoggerFactory.getLogger(ClientTestHooks::class.java) + + private const val LEVEL_NAME = "test" + + /** + * Time (in ticks) that we wait after the client joins the world + */ + private const val STARTUP_DELAY = 5 * Timeouts.SECOND + + /** + * Whether our client-side game test driver is enabled. + */ + private val enabled: Boolean = System.getProperty("cctest.client") != null + + private var loadedWorld: Boolean = false + + @JvmStatic + fun onOpenScreen(screen: Screen): Boolean = when { + enabled && !loadedWorld && screen is TitleScreen -> { + loadedWorld = true + openWorld() + true + } + + else -> false + } + + /** + * Open or create our test world immediately on game launch. + */ + private fun openWorld() { + val minecraft = Minecraft.getInstance() + + // Clear some options before we get any further. + minecraft.options.autoJump().set(false) + minecraft.options.cloudStatus().set(CloudStatus.OFF) + minecraft.options.particles().set(ParticleStatus.MINIMAL) + minecraft.options.tutorialStep = TutorialSteps.NONE + minecraft.options.renderDistance().set(6) + minecraft.options.gamma().set(1.0) + minecraft.options.setSoundCategoryVolume(SoundSource.MUSIC, 0.0f) + minecraft.options.setSoundCategoryVolume(SoundSource.AMBIENT, 0.0f) + + if (minecraft.levelSource.levelExists(LEVEL_NAME)) { + LOG.info("World already exists, opening.") + minecraft.createWorldOpenFlows().loadLevel(minecraft.screen, LEVEL_NAME) + } else { + LOG.info("World does not exist, creating it.") + val rules = GameRules() + rules.getRule(GameRules.RULE_DOMOBSPAWNING).set(false, null) + rules.getRule(GameRules.RULE_DAYLIGHT).set(false, null) + rules.getRule(GameRules.RULE_WEATHER_CYCLE).set(false, null) + + val registries = RegistryAccess.builtinCopy().freeze() + minecraft.createWorldOpenFlows().createFreshLevel( + LEVEL_NAME, + LevelSettings("Test Level", GameType.CREATIVE, false, Difficulty.EASY, true, rules, DataPackConfig.DEFAULT), + registries, + registries + .registryOrThrow(Registry.WORLD_PRESET_REGISTRY) + .getHolderOrThrow(WorldPresets.FLAT).value() + .createWorldGenSettings(RandomSource.create().nextLong(), false, false), + ) + } + } + + private var testTracker: MultipleTestTracker? = null + private var hasFinished: Boolean = false + private var startupDelay: Int = STARTUP_DELAY + + @JvmStatic + fun onServerTick(server: MinecraftServer) { + if (!enabled || hasFinished) return + + val testTracker = when (val tracker = this.testTracker) { + null -> { + if (server.overworld().players().isEmpty()) return + if (!Minecraft.getInstance().isRenderingStable()) return + if (startupDelay >= 0) { + // TODO: Is there a better way? Maybe set a flag when the client starts rendering? + startupDelay-- + return + } + + LOG.info("Server ready, starting.") + + val tests = GameTestRunner.runTestBatches( + GameTestRunner.groupTestsIntoBatches(GameTestRegistry.getAllTestFunctions()), + BlockPos(0, -60, 0), + Rotation.NONE, + server.overworld(), + GameTestTicker.SINGLETON, + 8, + ) + val testTracker = MultipleTestTracker(tests) + testTracker.addListener( + object : GameTestListener { + fun testFinished() { + for (it in server.playerList.players) it.setupForTest() + } + + override fun testPassed(test: GameTestInfo) = testFinished() + override fun testFailed(test: GameTestInfo) = testFinished() + override fun testStructureLoaded(test: GameTestInfo) = Unit + }, + ) + + LOG.info("{} tests are now running!", testTracker.totalCount) + this.testTracker = testTracker + testTracker + } + + else -> tracker + } + + if (server.overworld().gameTime % 20L == 0L) LOG.info(testTracker.progressBar) + + if (testTracker.isDone) { + hasFinished = true + LOG.info(testTracker.progressBar) + + GlobalTestReporter.finish() + LOG.info("========= {} GAME TESTS COMPLETE ======================", testTracker.totalCount) + if (testTracker.hasFailedRequired()) { + LOG.info("{} required tests failed :(", testTracker.failedRequiredCount) + for (test in testTracker.failedRequired) LOG.info(" - {}", test.testName) + } else { + LOG.info("All {} required tests passed :)", testTracker.totalCount) + } + if (testTracker.hasFailedOptional()) { + LOG.info("{} optional tests failed", testTracker.failedOptionalCount) + for (test in testTracker.failedOptional) LOG.info(" - {}", test.testName) + } + LOG.info("====================================================") + + // Stop Minecraft *from the client thread*. We need to do this to avoid deadlocks in stopping the server. + val minecraft = Minecraft.getInstance() + minecraft.execute { + LOG.info("Stopping client.") + minecraft.level!!.disconnect() + minecraft.clearLevel() + minecraft.stop() + + exitProcess( + when { + testTracker.totalCount == 0 -> 1 + testTracker.hasFailedRequired() -> 2 + else -> 0 + }, + ) + } + } + } +} diff --git a/projects/common/src/testMod/kotlin/dan200/computercraft/gametest/core/TestHooks.kt b/projects/common/src/testMod/kotlin/dan200/computercraft/gametest/core/TestHooks.kt new file mode 100644 index 000000000..16f4badbe --- /dev/null +++ b/projects/common/src/testMod/kotlin/dan200/computercraft/gametest/core/TestHooks.kt @@ -0,0 +1,168 @@ +package dan200.computercraft.gametest.core + +import dan200.computercraft.api.ComputerCraftAPI +import dan200.computercraft.gametest.* +import dan200.computercraft.gametest.api.ClientGameTest +import dan200.computercraft.gametest.api.TestTags +import dan200.computercraft.gametest.api.Times +import dan200.computercraft.shared.computer.core.ServerContext +import net.minecraft.core.BlockPos +import net.minecraft.gametest.framework.* +import net.minecraft.server.MinecraftServer +import net.minecraft.world.level.GameRules +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import java.io.File +import java.lang.reflect.InvocationTargetException +import java.lang.reflect.Method +import java.lang.reflect.Modifier +import java.nio.file.Path +import java.nio.file.Paths +import java.util.function.Consumer +import javax.xml.parsers.ParserConfigurationException + +object TestHooks { + @JvmField + val LOG: Logger = LoggerFactory.getLogger(TestHooks::class.java) + + @JvmStatic + val sourceDir: Path = Paths.get(System.getProperty("cctest.sources")).normalize().toAbsolutePath() + + @JvmStatic + fun init() { + ServerContext.luaMachine = ManagedComputers + ComputerCraftAPI.registerAPIFactory(::TestAPI) + StructureUtils.testStructuresDir = sourceDir.resolve("structures").toString() + + // Set up our test reporter if configured. + val outputPath = System.getProperty("cctest.gametest-report") + if (outputPath != null) { + try { + GlobalTestReporter.replaceWith( + MultiTestReporter( + JunitTestReporter(File(outputPath)), + LogTestReporter(), + ), + ) + } catch (e: ParserConfigurationException) { + throw RuntimeException(e) + } + } + } + + @JvmStatic + fun onServerStarted(server: MinecraftServer) { + val rules = server.gameRules + rules.getRule(GameRules.RULE_DAYLIGHT).set(false, server) + server.overworld().dayTime = Times.NOON + + LOG.info("Cleaning up after last run") + GameTestRunner.clearAllTests(server.overworld(), BlockPos(0, -60, 0), GameTestTicker.SINGLETON, 200) + + // Delete server context and add one with a mutable machine factory. This allows us to set the factory for + // specific test batches without having to reset all computers. + for (computer in ServerContext.get(server).registry().computers) { + val label = if (computer.label == null) "#" + computer.id else computer.label!! + LOG.warn("Unexpected computer {}", label) + } + + LOG.info("Importing files") + CCTestCommand.importFiles(server) + } + + private val testClasses = listOf( + Computer_Test::class.java, + CraftOs_Test::class.java, + Disk_Drive_Test::class.java, + Loot_Test::class.java, + Modem_Test::class.java, + Monitor_Test::class.java, + Printer_Test::class.java, + Recipe_Test::class.java, + Turtle_Test::class.java, + ) + + /** + * Register all of our gametests. + * + * This is super nasty, as it bypasses any loader-specific hooks for registering tests. However, it makes it much + * easier to ensure consistent behaviour between loaders (namely making [GameTest.template] point to a + * structure rather than a per-test-class one), as well as supporting our custom client tests. + * + * @param fallbackRegister A fallback function which registers non-test methods (such as [BeforeBatch]). This + * should be [GameTestRegistry.register] or equivalent. + */ + @JvmStatic + fun loadTests(fallbackRegister: Consumer) { + for (testClass in testClasses) { + for (method in testClass.declaredMethods) { + registerTest(testClass, method, fallbackRegister) + } + } + } + + private val isCi = System.getenv("CI") != null + + /** + * Adjust the timeout of a test. This makes it 1.5 times longer when run under CI, as CI servers are less powerful + * than our own. + */ + private fun adjustTimeout(timeout: Int): Int = if (isCi) timeout + (timeout / 2) else timeout + + private fun registerTest(testClass: Class<*>, method: Method, fallbackRegister: Consumer) { + val className = testClass.simpleName.lowercase() + val testName = className + "." + method.name.lowercase() + + method.getAnnotation(GameTest::class.java)?.let { testInfo -> + if (!TestTags.isEnabled(TestTags.COMMON)) return + + GameTestRegistry.getAllTestFunctions().add( + TestFunction( + testInfo.batch, testName, testInfo.template.ifEmpty { testName }, + StructureUtils.getRotationForRotationSteps(testInfo.rotationSteps), + adjustTimeout(testInfo.timeoutTicks), + testInfo.setupTicks, + testInfo.required, testInfo.requiredSuccesses, testInfo.attempts, + ) { value -> safeInvoke(method, value) }, + ) + GameTestRegistry.getAllTestClassNames().add(testClass.simpleName) + return + } + + method.getAnnotation(ClientGameTest::class.java)?.let { testInfo -> + if (!TestTags.isEnabled(testInfo.tag)) return + + GameTestRegistry.getAllTestFunctions().add( + TestFunction( + testName, + testName, + testInfo.template.ifEmpty { testName }, + adjustTimeout(testInfo.timeoutTicks), + 0, + true, + ) { value -> safeInvoke(method, value) }, + ) + GameTestRegistry.getAllTestClassNames().add(testClass.simpleName) + return + } + + fallbackRegister.accept(method) + } + + private fun safeInvoke(method: Method, value: Any) { + try { + var instance: Any? = null + if (!Modifier.isStatic(method.modifiers)) { + instance = method.declaringClass.getConstructor().newInstance() + } + method.invoke(instance, value) + } catch (e: InvocationTargetException) { + when (val cause = e.cause) { + is RuntimeException -> throw cause + else -> throw RuntimeException(cause) + } + } catch (e: ReflectiveOperationException) { + throw RuntimeException(e) + } + } +} diff --git a/projects/common/src/testMod/kotlin/dan200/computercraft/gametest/core/TestReporters.kt b/projects/common/src/testMod/kotlin/dan200/computercraft/gametest/core/TestReporters.kt new file mode 100644 index 000000000..a521d0836 --- /dev/null +++ b/projects/common/src/testMod/kotlin/dan200/computercraft/gametest/core/TestReporters.kt @@ -0,0 +1,43 @@ +package dan200.computercraft.gametest.core + +import net.minecraft.gametest.framework.GameTestInfo +import net.minecraft.gametest.framework.JUnitLikeTestReporter +import net.minecraft.gametest.framework.TestReporter +import java.io.File +import java.io.IOException +import java.nio.file.Files +import javax.xml.transform.TransformerException + +/** + * A test reporter which delegates to a list of other reporters. + */ +class MultiTestReporter(private val reporters: List) : TestReporter { + constructor(vararg reporters: TestReporter) : this(listOf(*reporters)) + + override fun onTestFailed(test: GameTestInfo) { + for (reporter in reporters) reporter.onTestFailed(test) + } + + override fun onTestSuccess(test: GameTestInfo) { + for (reporter in reporters) reporter.onTestSuccess(test) + } + + override fun finish() { + for (reporter in reporters) reporter.finish() + } +} + +/** + * Reports tests to a JUnit XML file. This is equivalent to [JUnitLikeTestReporter], except it ensures the destination + * directory exists. + */ +class JunitTestReporter constructor(destination: File) : JUnitLikeTestReporter(destination) { + override fun save(file: File) { + try { + Files.createDirectories(file.toPath().parent) + } catch (e: IOException) { + throw TransformerException("Failed to create parent directory", e) + } + super.save(file) + } +} diff --git a/projects/common/src/testMod/resources/computercraft-gametest.mixins.json b/projects/common/src/testMod/resources/computercraft-gametest.mixins.json index 76ac77997..a67f1f07e 100644 --- a/projects/common/src/testMod/resources/computercraft-gametest.mixins.json +++ b/projects/common/src/testMod/resources/computercraft-gametest.mixins.json @@ -11,6 +11,7 @@ "GameTestInfoAccessor", "GameTestSequenceAccessor", "GameTestSequenceMixin", + "SharedConstantsMixin", "TestCommandAccessor" ] } diff --git a/projects/common/src/testMod/resources/data/cctest/structures/computer_test.open_on_client.snbt b/projects/common/src/testMod/resources/data/cctest/structures/computer_test.open_on_client.snbt new file mode 100644 index 000000000..7cc11ed3f --- /dev/null +++ b/projects/common/src/testMod/resources/data/cctest/structures/computer_test.open_on_client.snbt @@ -0,0 +1,137 @@ +{ + DataVersion: 3120, + size: [5, 5, 5], + data: [ + {pos: [0, 0, 0], state: "minecraft:polished_andesite"}, + {pos: [0, 0, 1], state: "minecraft:polished_andesite"}, + {pos: [0, 0, 2], state: "minecraft:polished_andesite"}, + {pos: [0, 0, 3], state: "minecraft:polished_andesite"}, + {pos: [0, 0, 4], state: "minecraft:polished_andesite"}, + {pos: [1, 0, 0], state: "minecraft:polished_andesite"}, + {pos: [1, 0, 1], state: "minecraft:polished_andesite"}, + {pos: [1, 0, 2], state: "minecraft:polished_andesite"}, + {pos: [1, 0, 3], state: "minecraft:polished_andesite"}, + {pos: [1, 0, 4], state: "minecraft:polished_andesite"}, + {pos: [2, 0, 0], state: "minecraft:polished_andesite"}, + {pos: [2, 0, 1], state: "minecraft:polished_andesite"}, + {pos: [2, 0, 2], state: "minecraft:polished_andesite"}, + {pos: [2, 0, 3], state: "minecraft:polished_andesite"}, + {pos: [2, 0, 4], state: "minecraft:polished_andesite"}, + {pos: [3, 0, 0], state: "minecraft:polished_andesite"}, + {pos: [3, 0, 1], state: "minecraft:polished_andesite"}, + {pos: [3, 0, 2], state: "minecraft:polished_andesite"}, + {pos: [3, 0, 3], state: "minecraft:polished_andesite"}, + {pos: [3, 0, 4], state: "minecraft:polished_andesite"}, + {pos: [4, 0, 0], state: "minecraft:polished_andesite"}, + {pos: [4, 0, 1], state: "minecraft:polished_andesite"}, + {pos: [4, 0, 2], state: "minecraft:polished_andesite"}, + {pos: [4, 0, 3], state: "minecraft:polished_andesite"}, + {pos: [4, 0, 4], state: "minecraft:polished_andesite"}, + {pos: [0, 1, 0], state: "minecraft:air"}, + {pos: [0, 1, 1], state: "minecraft:air"}, + {pos: [0, 1, 2], state: "minecraft:air"}, + {pos: [0, 1, 3], state: "minecraft:air"}, + {pos: [0, 1, 4], state: "minecraft:air"}, + {pos: [1, 1, 0], state: "minecraft:air"}, + {pos: [1, 1, 1], state: "minecraft:air"}, + {pos: [1, 1, 2], state: "minecraft:air"}, + {pos: [1, 1, 3], state: "minecraft:air"}, + {pos: [1, 1, 4], state: "minecraft:air"}, + {pos: [2, 1, 0], state: "minecraft:air"}, + {pos: [2, 1, 1], state: "minecraft:air"}, + {pos: [2, 1, 2], state: "computercraft:computer_advanced{facing:north,state:on}", nbt: {ComputerId: 1, Label: "computer_test.open_on_client", On: 1b, id: "computercraft:computer_advanced"}}, + {pos: [2, 1, 3], state: "minecraft:air"}, + {pos: [2, 1, 4], state: "minecraft:air"}, + {pos: [3, 1, 0], state: "minecraft:air"}, + {pos: [3, 1, 1], state: "minecraft:air"}, + {pos: [3, 1, 2], state: "minecraft:air"}, + {pos: [3, 1, 3], state: "minecraft:air"}, + {pos: [3, 1, 4], state: "minecraft:air"}, + {pos: [4, 1, 0], state: "minecraft:air"}, + {pos: [4, 1, 1], state: "minecraft:air"}, + {pos: [4, 1, 2], state: "minecraft:air"}, + {pos: [4, 1, 3], state: "minecraft:air"}, + {pos: [4, 1, 4], state: "minecraft:air"}, + {pos: [0, 2, 0], state: "minecraft:air"}, + {pos: [0, 2, 1], state: "minecraft:air"}, + {pos: [0, 2, 2], state: "minecraft:air"}, + {pos: [0, 2, 3], state: "minecraft:air"}, + {pos: [0, 2, 4], state: "minecraft:air"}, + {pos: [1, 2, 0], state: "minecraft:air"}, + {pos: [1, 2, 1], state: "minecraft:air"}, + {pos: [1, 2, 2], state: "minecraft:air"}, + {pos: [1, 2, 3], state: "minecraft:air"}, + {pos: [1, 2, 4], state: "minecraft:air"}, + {pos: [2, 2, 0], state: "minecraft:air"}, + {pos: [2, 2, 1], state: "minecraft:air"}, + {pos: [2, 2, 2], state: "minecraft:air"}, + {pos: [2, 2, 3], state: "minecraft:air"}, + {pos: [2, 2, 4], state: "minecraft:air"}, + {pos: [3, 2, 0], state: "minecraft:air"}, + {pos: [3, 2, 1], state: "minecraft:air"}, + {pos: [3, 2, 2], state: "minecraft:air"}, + {pos: [3, 2, 3], state: "minecraft:air"}, + {pos: [3, 2, 4], state: "minecraft:air"}, + {pos: [4, 2, 0], state: "minecraft:air"}, + {pos: [4, 2, 1], state: "minecraft:air"}, + {pos: [4, 2, 2], state: "minecraft:air"}, + {pos: [4, 2, 3], state: "minecraft:air"}, + {pos: [4, 2, 4], state: "minecraft:air"}, + {pos: [0, 3, 0], state: "minecraft:air"}, + {pos: [0, 3, 1], state: "minecraft:air"}, + {pos: [0, 3, 2], state: "minecraft:air"}, + {pos: [0, 3, 3], state: "minecraft:air"}, + {pos: [0, 3, 4], state: "minecraft:air"}, + {pos: [1, 3, 0], state: "minecraft:air"}, + {pos: [1, 3, 1], state: "minecraft:air"}, + {pos: [1, 3, 2], state: "minecraft:air"}, + {pos: [1, 3, 3], state: "minecraft:air"}, + {pos: [1, 3, 4], state: "minecraft:air"}, + {pos: [2, 3, 0], state: "minecraft:air"}, + {pos: [2, 3, 1], state: "minecraft:air"}, + {pos: [2, 3, 2], state: "minecraft:air"}, + {pos: [2, 3, 3], state: "minecraft:air"}, + {pos: [2, 3, 4], state: "minecraft:air"}, + {pos: [3, 3, 0], state: "minecraft:air"}, + {pos: [3, 3, 1], state: "minecraft:air"}, + {pos: [3, 3, 2], state: "minecraft:air"}, + {pos: [3, 3, 3], state: "minecraft:air"}, + {pos: [3, 3, 4], state: "minecraft:air"}, + {pos: [4, 3, 0], state: "minecraft:air"}, + {pos: [4, 3, 1], state: "minecraft:air"}, + {pos: [4, 3, 2], state: "minecraft:air"}, + {pos: [4, 3, 3], state: "minecraft:air"}, + {pos: [4, 3, 4], state: "minecraft:air"}, + {pos: [0, 4, 0], state: "minecraft:air"}, + {pos: [0, 4, 1], state: "minecraft:air"}, + {pos: [0, 4, 2], state: "minecraft:air"}, + {pos: [0, 4, 3], state: "minecraft:air"}, + {pos: [0, 4, 4], state: "minecraft:air"}, + {pos: [1, 4, 0], state: "minecraft:air"}, + {pos: [1, 4, 1], state: "minecraft:air"}, + {pos: [1, 4, 2], state: "minecraft:air"}, + {pos: [1, 4, 3], state: "minecraft:air"}, + {pos: [1, 4, 4], state: "minecraft:air"}, + {pos: [2, 4, 0], state: "minecraft:air"}, + {pos: [2, 4, 1], state: "minecraft:air"}, + {pos: [2, 4, 2], state: "minecraft:air"}, + {pos: [2, 4, 3], state: "minecraft:air"}, + {pos: [2, 4, 4], state: "minecraft:air"}, + {pos: [3, 4, 0], state: "minecraft:air"}, + {pos: [3, 4, 1], state: "minecraft:air"}, + {pos: [3, 4, 2], state: "minecraft:air"}, + {pos: [3, 4, 3], state: "minecraft:air"}, + {pos: [3, 4, 4], state: "minecraft:air"}, + {pos: [4, 4, 0], state: "minecraft:air"}, + {pos: [4, 4, 1], state: "minecraft:air"}, + {pos: [4, 4, 2], state: "minecraft:air"}, + {pos: [4, 4, 3], state: "minecraft:air"}, + {pos: [4, 4, 4], state: "minecraft:air"} + ], + entities: [], + palette: [ + "minecraft:polished_andesite", + "minecraft:air", + "computercraft:computer_advanced{facing:north,state:on}" + ] +} diff --git a/projects/common/src/testMod/resources/data/cctest/structures/monitor_test.render_monitor.snbt b/projects/common/src/testMod/resources/data/cctest/structures/monitor_test.render_monitor.snbt new file mode 100644 index 000000000..832128116 --- /dev/null +++ b/projects/common/src/testMod/resources/data/cctest/structures/monitor_test.render_monitor.snbt @@ -0,0 +1,144 @@ +{ + DataVersion: 3120, + size: [5, 5, 5], + data: [ + {pos: [0, 0, 0], state: "minecraft:polished_andesite"}, + {pos: [0, 0, 1], state: "minecraft:polished_andesite"}, + {pos: [0, 0, 2], state: "minecraft:polished_andesite"}, + {pos: [0, 0, 3], state: "minecraft:polished_andesite"}, + {pos: [0, 0, 4], state: "minecraft:polished_andesite"}, + {pos: [1, 0, 0], state: "minecraft:polished_andesite"}, + {pos: [1, 0, 1], state: "minecraft:polished_andesite"}, + {pos: [1, 0, 2], state: "minecraft:polished_andesite"}, + {pos: [1, 0, 3], state: "minecraft:polished_andesite"}, + {pos: [1, 0, 4], state: "minecraft:polished_andesite"}, + {pos: [2, 0, 0], state: "minecraft:polished_andesite"}, + {pos: [2, 0, 1], state: "minecraft:polished_andesite"}, + {pos: [2, 0, 2], state: "minecraft:polished_andesite"}, + {pos: [2, 0, 3], state: "minecraft:polished_andesite"}, + {pos: [2, 0, 4], state: "minecraft:polished_andesite"}, + {pos: [3, 0, 0], state: "minecraft:polished_andesite"}, + {pos: [3, 0, 1], state: "minecraft:polished_andesite"}, + {pos: [3, 0, 2], state: "minecraft:polished_andesite"}, + {pos: [3, 0, 3], state: "minecraft:polished_andesite"}, + {pos: [3, 0, 4], state: "minecraft:polished_andesite"}, + {pos: [4, 0, 0], state: "minecraft:polished_andesite"}, + {pos: [4, 0, 1], state: "minecraft:polished_andesite"}, + {pos: [4, 0, 2], state: "minecraft:polished_andesite"}, + {pos: [4, 0, 3], state: "minecraft:polished_andesite"}, + {pos: [4, 0, 4], state: "minecraft:polished_andesite"}, + {pos: [0, 1, 0], state: "minecraft:air"}, + {pos: [0, 1, 1], state: "minecraft:air"}, + {pos: [0, 1, 2], state: "minecraft:air"}, + {pos: [0, 1, 3], state: "minecraft:air"}, + {pos: [0, 1, 4], state: "minecraft:air"}, + {pos: [1, 1, 0], state: "minecraft:air"}, + {pos: [1, 1, 1], state: "minecraft:air"}, + {pos: [1, 1, 2], state: "minecraft:air"}, + {pos: [1, 1, 3], state: "computercraft:monitor_advanced{facing:north,orientation:north,state:lu}", nbt: {Height: 2, Width: 3, XIndex: 2, YIndex: 0, id: "computercraft:monitor_advanced"}}, + {pos: [1, 1, 4], state: "minecraft:air"}, + {pos: [2, 1, 0], state: "minecraft:air"}, + {pos: [2, 1, 1], state: "minecraft:air"}, + {pos: [2, 1, 2], state: "minecraft:air"}, + {pos: [2, 1, 3], state: "computercraft:monitor_advanced{facing:north,orientation:north,state:lru}", nbt: {Height: 2, Width: 3, XIndex: 1, YIndex: 0, id: "computercraft:monitor_advanced"}}, + {pos: [2, 1, 4], state: "minecraft:air"}, + {pos: [3, 1, 0], state: "minecraft:air"}, + {pos: [3, 1, 1], state: "minecraft:air"}, + {pos: [3, 1, 2], state: "minecraft:air"}, + {pos: [3, 1, 3], state: "computercraft:monitor_advanced{facing:north,orientation:north,state:ru}", nbt: {Height: 2, Width: 3, XIndex: 0, YIndex: 0, id: "computercraft:monitor_advanced"}}, + {pos: [3, 1, 4], state: "minecraft:air"}, + {pos: [4, 1, 0], state: "minecraft:air"}, + {pos: [4, 1, 1], state: "minecraft:air"}, + {pos: [4, 1, 2], state: "minecraft:air"}, + {pos: [4, 1, 3], state: "minecraft:air"}, + {pos: [4, 1, 4], state: "minecraft:air"}, + {pos: [0, 2, 0], state: "minecraft:air"}, + {pos: [0, 2, 1], state: "minecraft:air"}, + {pos: [0, 2, 2], state: "minecraft:air"}, + {pos: [0, 2, 3], state: "minecraft:air"}, + {pos: [0, 2, 4], state: "minecraft:air"}, + {pos: [1, 2, 0], state: "minecraft:air"}, + {pos: [1, 2, 1], state: "minecraft:air"}, + {pos: [1, 2, 2], state: "minecraft:air"}, + {pos: [1, 2, 3], state: "computercraft:monitor_advanced{facing:north,orientation:north,state:ld}", nbt: {Height: 2, Width: 3, XIndex: 2, YIndex: 1, id: "computercraft:monitor_advanced"}}, + {pos: [1, 2, 4], state: "minecraft:air"}, + {pos: [2, 2, 0], state: "minecraft:air"}, + {pos: [2, 2, 1], state: "minecraft:air"}, + {pos: [2, 2, 2], state: "minecraft:air"}, + {pos: [2, 2, 3], state: "computercraft:monitor_advanced{facing:north,orientation:north,state:lrd}", nbt: {Height: 2, Width: 3, XIndex: 1, YIndex: 1, id: "computercraft:monitor_advanced"}}, + {pos: [2, 2, 4], state: "minecraft:air"}, + {pos: [3, 2, 0], state: "minecraft:air"}, + {pos: [3, 2, 1], state: "minecraft:air"}, + {pos: [3, 2, 2], state: "minecraft:air"}, + {pos: [3, 2, 3], state: "computercraft:monitor_advanced{facing:north,orientation:north,state:rd}", nbt: {Height: 2, Width: 3, XIndex: 0, YIndex: 1, id: "computercraft:monitor_advanced"}}, + {pos: [3, 2, 4], state: "minecraft:air"}, + {pos: [4, 2, 0], state: "minecraft:air"}, + {pos: [4, 2, 1], state: "minecraft:air"}, + {pos: [4, 2, 2], state: "minecraft:air"}, + {pos: [4, 2, 3], state: "minecraft:air"}, + {pos: [4, 2, 4], state: "minecraft:air"}, + {pos: [0, 3, 0], state: "minecraft:air"}, + {pos: [0, 3, 1], state: "minecraft:air"}, + {pos: [0, 3, 2], state: "minecraft:air"}, + {pos: [0, 3, 3], state: "minecraft:air"}, + {pos: [0, 3, 4], state: "minecraft:air"}, + {pos: [1, 3, 0], state: "minecraft:air"}, + {pos: [1, 3, 1], state: "minecraft:air"}, + {pos: [1, 3, 2], state: "minecraft:air"}, + {pos: [1, 3, 3], state: "minecraft:air"}, + {pos: [1, 3, 4], state: "minecraft:air"}, + {pos: [2, 3, 0], state: "minecraft:air"}, + {pos: [2, 3, 1], state: "minecraft:air"}, + {pos: [2, 3, 2], state: "minecraft:air"}, + {pos: [2, 3, 3], state: "minecraft:air"}, + {pos: [2, 3, 4], state: "minecraft:air"}, + {pos: [3, 3, 0], state: "minecraft:air"}, + {pos: [3, 3, 1], state: "minecraft:air"}, + {pos: [3, 3, 2], state: "minecraft:air"}, + {pos: [3, 3, 3], state: "minecraft:air"}, + {pos: [3, 3, 4], state: "minecraft:air"}, + {pos: [4, 3, 0], state: "minecraft:air"}, + {pos: [4, 3, 1], state: "minecraft:air"}, + {pos: [4, 3, 2], state: "minecraft:air"}, + {pos: [4, 3, 3], state: "minecraft:air"}, + {pos: [4, 3, 4], state: "minecraft:air"}, + {pos: [0, 4, 0], state: "minecraft:air"}, + {pos: [0, 4, 1], state: "minecraft:air"}, + {pos: [0, 4, 2], state: "minecraft:air"}, + {pos: [0, 4, 3], state: "minecraft:air"}, + {pos: [0, 4, 4], state: "minecraft:air"}, + {pos: [1, 4, 0], state: "minecraft:air"}, + {pos: [1, 4, 1], state: "minecraft:air"}, + {pos: [1, 4, 2], state: "minecraft:air"}, + {pos: [1, 4, 3], state: "minecraft:air"}, + {pos: [1, 4, 4], state: "minecraft:air"}, + {pos: [2, 4, 0], state: "minecraft:air"}, + {pos: [2, 4, 1], state: "minecraft:air"}, + {pos: [2, 4, 2], state: "minecraft:air"}, + {pos: [2, 4, 3], state: "minecraft:air"}, + {pos: [2, 4, 4], state: "minecraft:air"}, + {pos: [3, 4, 0], state: "minecraft:air"}, + {pos: [3, 4, 1], state: "minecraft:air"}, + {pos: [3, 4, 2], state: "minecraft:air"}, + {pos: [3, 4, 3], state: "minecraft:air"}, + {pos: [3, 4, 4], state: "minecraft:air"}, + {pos: [4, 4, 0], state: "minecraft:air"}, + {pos: [4, 4, 1], state: "minecraft:air"}, + {pos: [4, 4, 2], state: "minecraft:air"}, + {pos: [4, 4, 3], state: "minecraft:air"}, + {pos: [4, 4, 4], state: "minecraft:air"} + ], + entities: [ + {blockPos: [2, 1, 0], pos: [2.5773471891671482d, 1.0d, 0.31606672131648317d], nbt: {AbsorptionAmount: 0.0f, Air: 300s, ArmorItems: [{}, {}, {}, {}], Attributes: [{Base: 0.699999988079071d, Name: "minecraft:generic.movement_speed"}], Brain: {memories: {}}, DeathTime: 0s, DisabledSlots: 0, FallDistance: 0.0f, FallFlying: 0b, Fire: 0s, HandItems: [{}, {}], Health: 20.0f, HurtByTimestamp: 0, HurtTime: 0s, Invisible: 1b, Invulnerable: 0b, Marker: 1b, Motion: [0.0d, 0.0d, 0.0d], NoBasePlate: 0b, OnGround: 0b, PortalCooldown: 0, Pos: [-5.422652810832852d, -58.0d, 14.316066721316483d], Pose: {}, Rotation: [0.0f, 0.0f], ShowArms: 0b, Small: 0b, UUID: [I; -1438461995, 798246633, -1208936981, 1180880781], id: "minecraft:armor_stand"}} + ], + palette: [ + "minecraft:polished_andesite", + "minecraft:air", + "computercraft:monitor_advanced{facing:north,orientation:north,state:lu}", + "computercraft:monitor_advanced{facing:north,orientation:north,state:lru}", + "computercraft:monitor_advanced{facing:north,orientation:north,state:ru}", + "computercraft:monitor_advanced{facing:north,orientation:north,state:ld}", + "computercraft:monitor_advanced{facing:north,orientation:north,state:lrd}", + "computercraft:monitor_advanced{facing:north,orientation:north,state:rd}" + ] +} diff --git a/projects/fabric/build.gradle.kts b/projects/fabric/build.gradle.kts index c90ac7132..37e11fb93 100644 --- a/projects/fabric/build.gradle.kts +++ b/projects/fabric/build.gradle.kts @@ -1,8 +1,6 @@ -import cc.tweaked.gradle.annotationProcessorEverywhere -import cc.tweaked.gradle.clientClasses -import cc.tweaked.gradle.commonClasses -import cc.tweaked.gradle.mavenDependencies +import cc.tweaked.gradle.* import net.fabricmc.loom.configuration.ide.RunConfigSettings +import java.util.* plugins { id("cc-tweaked.fabric") @@ -19,6 +17,23 @@ cct { allProjects.forEach { externalSources(it) } } +fun addRemappedConfiguration(name: String) { + val original = configurations.create(name) { + isCanBeConsumed = false + isCanBeResolved = true + } + val capitalName = name.capitalize(Locale.ROOT) + loom.addRemapConfiguration("mod$capitalName") { + onCompileClasspath.set(false) + onRuntimeClasspath.set(false) + targetConfigurationName.set(name) + } + original.extendsFrom(configurations["mod${capitalName}Mapped"]) +} + +addRemappedConfiguration("testWithSodium") +addRemappedConfiguration("testWithIris") + dependencies { modImplementation(libs.bundles.externalMods.fabric) modCompileOnly(libs.bundles.externalMods.fabric.compile) { @@ -30,6 +45,10 @@ dependencies { exclude("net.fabricmc.fabric-api") } + "modTestWithSodium"(libs.sodium) + "modTestWithIris"(libs.iris) + "modTestWithIris"(libs.sodium) + include(libs.cobalt) include(libs.netty.http) // It might be better to shadowJar this, as we don't use half of it. include(libs.forgeConfig) @@ -120,6 +139,7 @@ loom { configureForGameTest(this) runDir("run/testClient") + property("cctest.tags", "client,common") } register("gametest") { @@ -128,7 +148,7 @@ loom { configureForGameTest(this) property("fabric-api.gametest") - property("fabric-api.gametest.report-file", project.buildDir.resolve("test-results/gametest/gametest.xml").absolutePath) + property("fabric-api.gametest.report-file", project.buildDir.resolve("test-results/runGametest.xml").absolutePath) runDir("run/gametest") } } @@ -158,14 +178,57 @@ val validateMixinNames by tasks.registering(net.fabricmc.loom.task.ValidateMixin source(sourceSets.client.get().output) source(sourceSets.testMod.get().output) } +tasks.check { dependsOn(validateMixinNames) } tasks.test { dependsOn(tasks.generateDLIConfig) } -val runGametest = tasks.named("runGametest") - +val runGametest = tasks.named("runGametest") { + usesService(MinecraftRunnerService.get(gradle)) +} cct.jacoco(runGametest) +tasks.check { dependsOn(runGametest) } -tasks.check { dependsOn(validateMixinNames, runGametest) } +val runGametestClient by tasks.registering(ClientJavaExec::class) { + description = "Runs client-side gametests with no mods" + copyFrom("runTestClient") + + tags("client") +} +cct.jacoco(runGametestClient) + +val runGametestClientWithSodium by tasks.registering(ClientJavaExec::class) { + description = "Runs client-side gametests with Sodium" + copyFrom("runTestClient") + + tags("sodium") + classpath += configurations["testWithSodium"] +} +cct.jacoco(runGametestClientWithSodium) + +val runGametestClientWithIris by tasks.registering(ClientJavaExec::class) { + description = "Runs client-side gametests with Iris" + copyFrom("runTestClient") + + tags("iris") + classpath += configurations["testWithIris"] + + withFileFrom(workingDir.resolve("shaderpacks/ComplementaryShaders_v4.6.zip")) { + cct.downloadFile("Complementary Shaders", "https://edge.forgecdn.net/files/3951/170/ComplementaryShaders_v4.6.zip") + } + withFileContents(workingDir.resolve("config/iris.properties")) { + """ + enableShaders=true + shaderPack=ComplementaryShaders_v4.6.zip + """.trimIndent() + } +} +cct.jacoco(runGametestClientWithIris) + +tasks.register("checkClient") { + group = LifecycleBasePlugin.VERIFICATION_GROUP + description = "Runs all client-only checks." + dependsOn(runGametestClient, runGametestClientWithSodium, runGametestClientWithIris) +} tasks.withType(GenerateModuleMetadata::class).configureEach { isEnabled = false } publishing { diff --git a/projects/fabric/src/testMod/java/dan200/computercraft/gametest/core/TestMod.java b/projects/fabric/src/testMod/java/dan200/computercraft/gametest/core/TestMod.java index c0f3b6d7c..9c76c12c7 100644 --- a/projects/fabric/src/testMod/java/dan200/computercraft/gametest/core/TestMod.java +++ b/projects/fabric/src/testMod/java/dan200/computercraft/gametest/core/TestMod.java @@ -10,9 +10,12 @@ import net.fabricmc.api.ClientModInitializer; import net.fabricmc.api.ModInitializer; import net.fabricmc.fabric.api.client.command.v2.ClientCommandRegistrationCallback; +import net.fabricmc.fabric.api.client.screen.v1.ScreenEvents; import net.fabricmc.fabric.api.command.v2.CommandRegistrationCallback; import net.fabricmc.fabric.api.event.Event; import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents; +import net.fabricmc.fabric.api.event.lifecycle.v1.ServerTickEvents; +import net.minecraft.gametest.framework.GameTestRegistry; import net.minecraft.resources.ResourceLocation; public class TestMod implements ModInitializer, ClientModInitializer { @@ -23,12 +26,15 @@ public void onInitialize() { var phase = new ResourceLocation(ComputerCraftAPI.MOD_ID, "test_mod"); ServerLifecycleEvents.SERVER_STARTED.addPhaseOrdering(Event.DEFAULT_PHASE, phase); ServerLifecycleEvents.SERVER_STARTED.register(phase, TestHooks::onServerStarted); - CommandRegistrationCallback.EVENT.register((dispatcher, registryAccess, environment) -> CCTestCommand.register(dispatcher)); + + TestHooks.loadTests(GameTestRegistry::register); } @Override public void onInitializeClient() { + ServerTickEvents.START_SERVER_TICK.register(ClientTestHooks::onServerTick); + ScreenEvents.AFTER_INIT.register((client, screen, scaledWidth, scaledHeight) -> ClientTestHooks.onOpenScreen(screen)); ClientCommandRegistrationCallback.EVENT.register((dispatcher, registryAccess) -> Exporter.register(dispatcher)); } } diff --git a/projects/fabric/src/testMod/resources/fabric.mod.json b/projects/fabric/src/testMod/resources/fabric.mod.json index d5ed77a50..f688bc382 100644 --- a/projects/fabric/src/testMod/resources/fabric.mod.json +++ b/projects/fabric/src/testMod/resources/fabric.mod.json @@ -8,17 +8,6 @@ ], "client": [ "dan200.computercraft.gametest.core.TestMod" - ], - "fabric-gametest": [ - "dan200.computercraft.gametest.Computer_Test", - "dan200.computercraft.gametest.CraftOs_Test", - "dan200.computercraft.gametest.Disk_Drive_Test", - "dan200.computercraft.gametest.Loot_Test", - "dan200.computercraft.gametest.Modem_Test", - "dan200.computercraft.gametest.Monitor_Test", - "dan200.computercraft.gametest.Printer_Test", - "dan200.computercraft.gametest.Recipe_Test", - "dan200.computercraft.gametest.Turtle_Test" ] }, "mixins": [ diff --git a/projects/forge/build.gradle.kts b/projects/forge/build.gradle.kts index 493d012fd..93faec638 100644 --- a/projects/forge/build.gradle.kts +++ b/projects/forge/build.gradle.kts @@ -101,6 +101,8 @@ minecraft { workingDirectory(file("run/testClient")) parent(client.get()) configureForGameTest() + + property("cctest.tags", "client,common") } val gameTestServer by registering { @@ -254,18 +256,32 @@ val runGametest by tasks.registering(JavaExec::class) { group = LifecycleBasePlugin.VERIFICATION_GROUP description = "Runs tests on a temporary Minecraft instance." dependsOn("cleanRunGametest") + usesService(MinecraftRunnerService.get(gradle)) // Copy from runGameTestServer. We do it in this slightly odd way as runGameTestServer // isn't created until the task is configured (which is no good for us). val exec = tasks.getByName("runGameTestServer") dependsOn(exec.dependsOn) exec.copyToFull(this) + + systemProperty("cctest.gametest-report", project.buildDir.resolve("test-results/$name.xml").absolutePath) } - cct.jacoco(runGametest) - tasks.check { dependsOn(runGametest) } +val runGametestClient by tasks.registering(ClientJavaExec::class) { + description = "Runs client-side gametests with no mods" + copyFrom("runTestClient") + tags("client") +} +cct.jacoco(runGametestClient) + +tasks.register("checkClient") { + group = LifecycleBasePlugin.VERIFICATION_GROUP + description = "Runs all client-only checks." + dependsOn(runGametestClient) +} + // Upload tasks val publishCurseForge by tasks.registering(TaskPublishCurseForge::class) { diff --git a/projects/forge/src/testMod/java/dan200/computercraft/gametest/core/ClientHooks.java b/projects/forge/src/testMod/java/dan200/computercraft/gametest/core/ClientHooks.java deleted file mode 100644 index ef8346a6a..000000000 --- a/projects/forge/src/testMod/java/dan200/computercraft/gametest/core/ClientHooks.java +++ /dev/null @@ -1,85 +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.gametest.core; - -import net.minecraft.client.CloudStatus; -import net.minecraft.client.Minecraft; -import net.minecraft.client.ParticleStatus; -import net.minecraft.client.gui.screens.TitleScreen; -import net.minecraft.client.tutorial.TutorialSteps; -import net.minecraftforge.api.distmarker.Dist; -import net.minecraftforge.client.event.ScreenEvent; -import net.minecraftforge.eventbus.api.SubscribeEvent; -import net.minecraftforge.fml.common.Mod; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; - -@Mod.EventBusSubscriber(modid = "cctest", value = Dist.CLIENT) -public final class ClientHooks { - private static final Logger LOG = LogManager.getLogger(TestHooks.class); - - private static boolean triggered = false; - - private ClientHooks() { - } - - @SubscribeEvent - public static void onGuiInit(ScreenEvent.Init event) { - if (triggered || !(event.getScreen() instanceof TitleScreen)) return; - triggered = true; - - ClientHooks.openWorld(); - } - - private static void openWorld() { - var minecraft = Minecraft.getInstance(); - - // Clear some options before we get any further. - minecraft.options.autoJump().set(false); - minecraft.options.cloudStatus().set(CloudStatus.OFF); - minecraft.options.particles().set(ParticleStatus.MINIMAL); - minecraft.options.tutorialStep = TutorialSteps.NONE; - minecraft.options.renderDistance().set(6); - minecraft.options.gamma().set(1.0); - - /* - if( minecraft.getLevelSource().levelExists( "test" ) ) - { - LOG.info( "World exists, loading it" ); - Minecraft.getInstance().loadLevel( "test" ); - } - else - { - LOG.info( "World does not exist, creating it for the first time" ); - - RegistryAccess registries = RegistryAccess.builtinCopy(); - - Registry dimensions = registries.registryOrThrow( Registry.DIMENSION_TYPE_REGISTRY ); - var biomes = registries.registryOrThrow( Registry.BIOME_REGISTRY ); - var structures = registries.registryOrThrow( Registry.STRUCTURE_SET_REGISTRY ); - - FlatLevelGeneratorSettings flatSettings = FlatLevelGeneratorSettings.getDefault( biomes, structures ) - .withLayers( - Collections.singletonList( new FlatLayerInfo( 4, Blocks.WHITE_CONCRETE ) ), - Optional.empty() - ); - flatSettings.setBiome( biomes.getHolderOrThrow( Biomes.DESERT ) ); - - WorldGenSettings generator = new WorldGenSettings( 0, false, false, withOverworld( - dimensions, - DimensionType.defaultDimensions( registries, 0 ), - new FlatLevelSource( structures, flatSettings ) - ) ); - - LevelSettings settings = new LevelSettings( - "test", GameType.CREATIVE, false, Difficulty.PEACEFUL, true, - new GameRules(), DataPackConfig.DEFAULT - ); - Minecraft.getInstance().createLevel( "test", settings, registries, generator ); - } - */ - } -} diff --git a/projects/forge/src/testMod/java/dan200/computercraft/gametest/core/TestMod.java b/projects/forge/src/testMod/java/dan200/computercraft/gametest/core/TestMod.java index 3a1ca3fc8..d92615057 100644 --- a/projects/forge/src/testMod/java/dan200/computercraft/gametest/core/TestMod.java +++ b/projects/forge/src/testMod/java/dan200/computercraft/gametest/core/TestMod.java @@ -6,31 +6,18 @@ package dan200.computercraft.gametest.core; import dan200.computercraft.export.Exporter; -import dan200.computercraft.gametest.api.GameTestHolder; -import net.minecraft.gametest.framework.GameTest; -import net.minecraft.gametest.framework.GameTestRegistry; -import net.minecraft.gametest.framework.StructureUtils; -import net.minecraft.gametest.framework.TestFunction; -import net.minecraft.resources.ResourceLocation; +import net.minecraftforge.api.distmarker.Dist; import net.minecraftforge.client.event.RegisterClientCommandsEvent; +import net.minecraftforge.client.event.ScreenEvent; import net.minecraftforge.common.MinecraftForge; import net.minecraftforge.event.RegisterCommandsEvent; import net.minecraftforge.event.RegisterGameTestsEvent; +import net.minecraftforge.event.TickEvent; import net.minecraftforge.event.server.ServerStartedEvent; import net.minecraftforge.eventbus.api.EventPriority; -import net.minecraftforge.fml.ModList; +import net.minecraftforge.fml.DistExecutor; import net.minecraftforge.fml.common.Mod; import net.minecraftforge.fml.javafmlmod.FMLJavaModLoadingContext; -import net.minecraftforge.forgespi.language.ModFileScanData; -import net.minecraftforge.gametest.PrefixGameTestTemplate; -import org.objectweb.asm.Type; - -import java.lang.reflect.InvocationTargetException; -import java.lang.reflect.Method; -import java.lang.reflect.Modifier; -import java.util.Collection; -import java.util.Locale; -import java.util.function.Consumer; @Mod("cctest") public class TestMod { @@ -40,85 +27,21 @@ public TestMod() { var bus = MinecraftForge.EVENT_BUS; bus.addListener(EventPriority.LOW, (ServerStartedEvent e) -> TestHooks.onServerStarted(e.getServer())); bus.addListener((RegisterCommandsEvent e) -> CCTestCommand.register(e.getDispatcher())); - bus.addListener((RegisterClientCommandsEvent e) -> Exporter.register(e.getDispatcher())); + DistExecutor.unsafeRunWhenOn(Dist.CLIENT, () -> TestMod::onInitializeClient); var modBus = FMLJavaModLoadingContext.get().getModEventBus(); - modBus.addListener((RegisterGameTestsEvent event) -> { - var holder = Type.getType(GameTestHolder.class); - ModList.get().getAllScanData().stream() - .map(ModFileScanData::getAnnotations) - .flatMap(Collection::stream) - .filter(a -> holder.equals(a.annotationType())) - .forEach(x -> registerClass(x.clazz().getClassName(), event::register)); + modBus.addListener((RegisterGameTestsEvent event) -> TestHooks.loadTests(event::register)); + } + + private static void onInitializeClient() { + var bus = MinecraftForge.EVENT_BUS; + + bus.addListener((TickEvent.ServerTickEvent e) -> { + if (e.phase == TickEvent.Phase.START) ClientTestHooks.onServerTick(e.getServer()); }); - } - - - private static Class loadClass(String name) { - try { - return Class.forName(name, true, TestMod.class.getClassLoader()); - } catch (ReflectiveOperationException e) { - throw new RuntimeException(e); - } - } - - private static void registerClass(String className, Consumer fallback) { - var klass = loadClass(className); - for (var method : klass.getDeclaredMethods()) { - var testInfo = method.getAnnotation(GameTest.class); - if (testInfo == null) { - fallback.accept(method); - continue; - } - - GameTestRegistry.getAllTestFunctions().add(turnMethodIntoTestFunction(method, testInfo)); - GameTestRegistry.getAllTestClassNames().add(className); - } - } - - /** - * Custom implementation of {@link GameTestRegistry#turnMethodIntoTestFunction(Method)} which makes - * {@link GameTest#template()} behave the same as Fabric, namely in that it points to a {@link ResourceLocation}, - * rather than a test-class-specific structure. - *

- * This effectively acts as a global version of {@link PrefixGameTestTemplate}, just one which doesn't require Forge - * to be present. - * - * @param method The method to register. - * @param testInfo The test info. - * @return The constructed test function. - */ - private static TestFunction turnMethodIntoTestFunction(Method method, GameTest testInfo) { - var className = method.getDeclaringClass().getSimpleName().toLowerCase(Locale.ROOT); - var testName = className + "." + method.getName().toLowerCase(Locale.ROOT); - return new TestFunction( - testInfo.batch(), - testName, - testInfo.template().isEmpty() ? testName : testInfo.template(), - StructureUtils.getRotationForRotationSteps(testInfo.rotationSteps()), testInfo.timeoutTicks(), testInfo.setupTicks(), - testInfo.required(), testInfo.requiredSuccesses(), testInfo.attempts(), - turnMethodIntoConsumer(method) - ); - } - - private static Consumer turnMethodIntoConsumer(Method method) { - return value -> { - try { - Object instance = null; - if (!Modifier.isStatic(method.getModifiers())) { - instance = method.getDeclaringClass().getConstructor().newInstance(); - } - - method.invoke(instance, value); - } catch (InvocationTargetException e) { - if (e.getCause() instanceof RuntimeException) { - throw (RuntimeException) e.getCause(); - } else { - throw new RuntimeException(e.getCause()); - } - } catch (ReflectiveOperationException e) { - throw new RuntimeException(e); - } - }; + bus.addListener((ScreenEvent.Opening e) -> { + if (ClientTestHooks.onOpenScreen(e.getScreen())) e.setCanceled(true); + }); + bus.addListener((RegisterClientCommandsEvent e) -> Exporter.register(e.getDispatcher())); } } diff --git a/tools/parse-reports.py b/tools/parse-reports.py index cd0c1999c..cba04df08 100755 --- a/tools/parse-reports.py +++ b/tools/parse-reports.py @@ -31,6 +31,8 @@ PROJECT_LOCATIONS = [ "projects/core", "projects/common-api", "projects/common", + "projects/fabric-api", + "projects/fabric", "projects/forge-api", "projects/forge", ] @@ -73,18 +75,16 @@ def find_location(message: str) -> Optional[Tuple[str, str]]: def _parse_junit_file(path: pathlib.Path): - for testcase in ET.parse(path).getroot(): - if testcase.tag != "testcase": - continue - + for testcase in ET.parse(path).findall(".//testcase"): for result in testcase: - if result.tag != "failure": + if result.tag == "skipped": continue name = f'{testcase.attrib["classname"]}.{testcase.attrib["name"]}' message = result.attrib.get("message") + full_message = result.text or message - location = find_location(result.text) + location = find_location(full_message) error = ERROR_MESSAGE.match(message) if error: error = error[1] @@ -97,7 +97,7 @@ def _parse_junit_file(path: pathlib.Path): print(f"::error::{name} failed") print("::group::Full error message") - print(result.text) + print(full_message) print("::endgroup") @@ -112,6 +112,9 @@ def parse_junit() -> None: for path in pathlib.Path(os.path.join(project, "build/test-results/test")).glob("TEST-*.xml"): _parse_junit_file(path) + for path in pathlib.Path(os.path.join(project, "build/test-results")).glob("run*.xml"): + _parse_junit_file(path) + print("::remove-matcher owner=junit::") diff --git a/tools/screenshots.py b/tools/screenshots.py new file mode 100755 index 000000000..3a70acd37 --- /dev/null +++ b/tools/screenshots.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +""" +Combines screenshots from the Forge and Fabric tests into a single HTML page. +""" +import os +import os.path +from dataclasses import dataclass +from typing import TextIO +from textwrap import dedent +import webbrowser +import argparse + +PROJECT_LOCATIONS = [ + "projects/fabric", + "projects/forge", +] + + +@dataclass(frozen=True) +class Image: + name: list[str] + path: str + + +def write_images(io: TextIO, images: list[Image]): + io.write( + dedent( + """\ + + + + + + CC:T test screenshots + + + +

+ """ + ) + ) + + for image in images: + io.write( + dedent( + f"""\ +
+ + + {" » ".join(image.name[:-2])} » + {image.name[-1]} + +
+ """ + ) + ) + + io.write("
") + + +def main(): + spec = argparse.ArgumentParser( + description="Combines screenshots from the Forge and Fabric tests into a single HTML page." + ) + spec.add_argument( + "--open", + default=False, + action="store_true", + help="Open the output file in a web browser.", + ) + args = spec.parse_args() + + images: list[Image] = [] + for project, dir in { + "Forge": "projects/forge", + "Fabric": "projects/fabric", + }.items(): + dir = os.path.join(dir, "build", "testScreenshots") + for file in sorted(os.listdir(dir)): + name = [project, *os.path.splitext(file)[0].split(".")] + images.append(Image(name, os.path.join(dir, file))) + + out_file = "build/screenshots.html" + with open(out_file, encoding="utf-8", mode="w") as out: + write_images(out, images) + + if args.open: + webbrowser.open(out_file) + + +if __name__ == "__main__": + main()