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()