From 331031be450a2eab062ea7b71d3a98758f57f230 Mon Sep 17 00:00:00 2001 From: Jonathan Coates Date: Sat, 9 Jan 2021 19:50:27 +0000 Subject: [PATCH] Run integration tests in-game Name a more iconic duo than @SquidDev and over-engineered test frameworks. This uses Minecraft's test core[1] plus a home-grown framework to run tests against computers in-world. The general idea is: - Build a structure in game. - Save the structure to a file. This will be spawned in every time the test is run. - Write some code which asserts the structure behaves in a particular way. This is done in Kotlin (shock, horror), as coroutines give us a nice way to run asynchronous code while still running on the main thread. As with all my testing efforts, I still haven't actually written any tests! It'd be good to go through some of the historic ones and write some tests though. Turtle block placing and computer redstone interactions are probably a good place to start. [1]: https://www.youtube.com/watch?v=vXaWOJTCYNg --- .gitattributes | 1 + .github/workflows/main-ci.yml | 5 + build.gradle | 35 +- .../computercraft/ingame/ComputerTest.kt | 27 + .../dan200/computercraft/ingame/TurtleTest.kt | 10 + .../ingame/api/ComputerState.java | 37 ++ .../computercraft/ingame/api/GameTest.java | 45 ++ .../computercraft/ingame/api/TestContext.java | 58 ++ .../ingame/api/TestExtensions.kt | 61 ++ .../ingame/mod/CCTestCommand.java | 56 ++ .../computercraft/ingame/mod/TestAPI.java | 63 ++ .../computercraft/ingame/mod/TestLoader.java | 119 ++++ .../computercraft/ingame/mod/TestMod.java | 117 ++++ .../computercraft/ingame/mod/TestRunner.kt | 62 ++ .../dan200/computercraft/utils/Copier.java | 49 ++ src/test/resources/META-INF/mods.toml | 18 + .../computercraft/lua/rom/autorun/cctest.lua | 17 + src/test/resources/pack.mcmeta | 6 + .../computers/computer/1/startup.lua | 5 + src/test/server-files/computers/ids.json | 3 + src/test/server-files/eula.txt | 2 + src/test/server-files/server.properties | 46 ++ .../computer_test.no_through_signal.snbt | 560 ++++++++++++++++++ ...tle_test.unequip_refreshes_peripheral.snbt | 550 +++++++++++++++++ 24 files changed, 1950 insertions(+), 2 deletions(-) create mode 100644 src/test/java/dan200/computercraft/ingame/ComputerTest.kt create mode 100644 src/test/java/dan200/computercraft/ingame/TurtleTest.kt create mode 100644 src/test/java/dan200/computercraft/ingame/api/ComputerState.java create mode 100644 src/test/java/dan200/computercraft/ingame/api/GameTest.java create mode 100644 src/test/java/dan200/computercraft/ingame/api/TestContext.java create mode 100644 src/test/java/dan200/computercraft/ingame/api/TestExtensions.kt create mode 100644 src/test/java/dan200/computercraft/ingame/mod/CCTestCommand.java create mode 100644 src/test/java/dan200/computercraft/ingame/mod/TestAPI.java create mode 100644 src/test/java/dan200/computercraft/ingame/mod/TestLoader.java create mode 100644 src/test/java/dan200/computercraft/ingame/mod/TestMod.java create mode 100644 src/test/java/dan200/computercraft/ingame/mod/TestRunner.kt create mode 100644 src/test/java/dan200/computercraft/utils/Copier.java create mode 100644 src/test/resources/META-INF/mods.toml create mode 100644 src/test/resources/data/computercraft/lua/rom/autorun/cctest.lua create mode 100755 src/test/resources/pack.mcmeta create mode 100644 src/test/server-files/computers/computer/1/startup.lua create mode 100644 src/test/server-files/computers/ids.json create mode 100644 src/test/server-files/eula.txt create mode 100644 src/test/server-files/server.properties create mode 100644 src/test/server-files/structures/computer_test.no_through_signal.snbt create mode 100644 src/test/server-files/structures/turtle_test.unequip_refreshes_peripheral.snbt diff --git a/.gitattributes b/.gitattributes index e05a3a6dd..6e40cf02e 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,3 @@ # Ignore changes in generated files src/generated/resources/data/** linguist-generated +src/test/server-files/structures linguist-generated diff --git a/.github/workflows/main-ci.yml b/.github/workflows/main-ci.yml index 51895fcec..df76fca79 100644 --- a/.github/workflows/main-ci.yml +++ b/.github/workflows/main-ci.yml @@ -26,6 +26,11 @@ jobs: - name: Build with Gradle run: ./gradlew build --no-daemon || ./gradlew build --no-daemon + - name: Run in-game tests + run: | + ./gradlew setupServer --no-daemon + ./gradlew runTestServerRun --no-daemon + - name: Upload Jar uses: actions/upload-artifact@v1 with: diff --git a/build.gradle b/build.gradle index 559e9b54c..f0df19a88 100644 --- a/build.gradle +++ b/build.gradle @@ -21,6 +21,7 @@ id "com.github.hierynomus.license" version "0.15.0" id "com.matthewprenger.cursegradle" version "1.3.0" id "com.github.breadmoirai.github-release" version "2.2.4" + id "org.jetbrains.kotlin.jvm" version "1.3.72" } apply plugin: 'net.minecraftforge.gradle' @@ -51,8 +52,9 @@ server { workingDirectory project.file("run/server") - property 'forge.logging.markers', 'REGISTRIES,REGISTRYDUMP' + property 'forge.logging.markers', 'REGISTRIES' property 'forge.logging.console.level', 'debug' + arg "--nogui" mods { computercraft { @@ -63,7 +65,7 @@ data { workingDirectory project.file('run') - property 'forge.logging.markers', 'REGISTRIES,REGISTRYDUMP' + property 'forge.logging.markers', 'REGISTRIES' property 'forge.logging.console.level', 'debug' args '--mod', 'computercraft', '--all', '--output', file('src/generated/resources/'), '--existing', file('src/main/resources/') @@ -73,6 +75,24 @@ } } } + + testServer { + workingDirectory project.file('test-files/server') + parent runs.server + + mods { + cctest { + source sourceSets.test + } + } + } + + testServerRun { + parent runs.testServer + property 'forge.logging.console.level', 'info' + property 'cctest.run', 'true' + forceExit false + } } mappings channel: 'official', version: project.mc_version @@ -120,6 +140,9 @@ accessTransformer file('src/main/resources/META-INF/accesstransformer.cfg') testImplementation 'org.junit.jupiter:junit-jupiter-params:5.7.0' testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.7.0' testImplementation 'org.hamcrest:hamcrest:2.2' + testImplementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.3.72' + testImplementation 'org.jetbrains.kotlin:kotlin-reflect:1.3.72' + testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.8' deployerJars "org.apache.maven.wagon:wagon-ssh:3.0.0" @@ -416,6 +439,14 @@ header file('config/license/api.txt') } } +task setupServer(type: Copy) { + from("src/test/server-files") { + include "eula.txt" + include "server.properties" + } + into "test-files/server" +} + // Upload tasks task checkRelease { diff --git a/src/test/java/dan200/computercraft/ingame/ComputerTest.kt b/src/test/java/dan200/computercraft/ingame/ComputerTest.kt new file mode 100644 index 000000000..48cd70204 --- /dev/null +++ b/src/test/java/dan200/computercraft/ingame/ComputerTest.kt @@ -0,0 +1,27 @@ +package dan200.computercraft.ingame + +import dan200.computercraft.ingame.api.* +import net.minecraft.block.LeverBlock +import net.minecraft.block.RedstoneLampBlock +import net.minecraft.util.math.BlockPos +import org.junit.jupiter.api.Assertions.assertFalse + +class ComputerTest { + /** + * Ensures redstone signals do not travel through computers. + * + * @see [Issue #548](https://github.com/SquidDev-CC/CC-Tweaked/issues/548) + */ + @GameTest + suspend fun `No through signal`(context: TestContext) { + val lamp = BlockPos(2, 0, 4) + val lever = BlockPos(2, 0, 0) + + assertFalse(context.getBlock(lamp).getValue(RedstoneLampBlock.LIT), "Lamp should not be lit") + + context.modifyBlock(lever) { x -> x.setValue(LeverBlock.POWERED, true) } + context.sleep(3) + + assertFalse(context.getBlock(lamp).getValue(RedstoneLampBlock.LIT), "Lamp should still not be lit") + } +} diff --git a/src/test/java/dan200/computercraft/ingame/TurtleTest.kt b/src/test/java/dan200/computercraft/ingame/TurtleTest.kt new file mode 100644 index 000000000..37102388b --- /dev/null +++ b/src/test/java/dan200/computercraft/ingame/TurtleTest.kt @@ -0,0 +1,10 @@ +package dan200.computercraft.ingame + +import dan200.computercraft.ingame.api.GameTest +import dan200.computercraft.ingame.api.TestContext +import dan200.computercraft.ingame.api.checkComputerOk + +class TurtleTest { + @GameTest(required = false) + suspend fun `Unequip refreshes peripheral`(context: TestContext) = context.checkComputerOk(1) +} diff --git a/src/test/java/dan200/computercraft/ingame/api/ComputerState.java b/src/test/java/dan200/computercraft/ingame/api/ComputerState.java new file mode 100644 index 000000000..311945d8b --- /dev/null +++ b/src/test/java/dan200/computercraft/ingame/api/ComputerState.java @@ -0,0 +1,37 @@ +package dan200.computercraft.ingame.api; + +import dan200.computercraft.ingame.mod.TestAPI; +import kotlin.coroutines.Continuation; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Assertion state of a computer. + * + * @see TestAPI For the Lua interface for this. + * @see TestExtensionsKt#checkComputerOk(TestContext, int, Continuation) + */ +public class ComputerState +{ + protected static final Map lookup = new ConcurrentHashMap<>(); + + protected boolean done; + protected String error; + + public boolean isDone() + { + return done; + } + + public void check() + { + if( !done ) throw new IllegalStateException( "Not yet done" ); + if( error != null ) throw new AssertionError( error ); + } + + public static ComputerState get( int id ) + { + return lookup.get( id ); + } +} diff --git a/src/test/java/dan200/computercraft/ingame/api/GameTest.java b/src/test/java/dan200/computercraft/ingame/api/GameTest.java new file mode 100644 index 000000000..25707c14e --- /dev/null +++ b/src/test/java/dan200/computercraft/ingame/api/GameTest.java @@ -0,0 +1,45 @@ +package dan200.computercraft.ingame.api; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * A test which manipulates the game. This should applied on an instance function, and should accept a single + * {@link TestContext} argument. + * + * Tests may/should be written as Kotlin coroutines. + */ +@Retention( RetentionPolicy.RUNTIME ) +@Target( ElementType.METHOD ) +public @interface GameTest +{ + /** + * Maximum time the test can run, in ticks. + * + * @return The time the test can run in ticks. + */ + int timeout() default 200; + + /** + * Number of ticks to delay between building the structure and running the test code. + * + * @return Test delay in ticks. + */ + long setup() default 5; + + /** + * The batch to run tests in. This may be used to run tests which manipulate other bits of state. + * + * @return This test's batch. + */ + String batch() default "default"; + + /** + * If this test must pass. When false, test failures do not cause a build failure. + * + * @return If this test is required. + */ + boolean required() default true; +} diff --git a/src/test/java/dan200/computercraft/ingame/api/TestContext.java b/src/test/java/dan200/computercraft/ingame/api/TestContext.java new file mode 100644 index 000000000..750497931 --- /dev/null +++ b/src/test/java/dan200/computercraft/ingame/api/TestContext.java @@ -0,0 +1,58 @@ +package dan200.computercraft.ingame.api; + +import net.minecraft.block.Block; +import net.minecraft.block.Blocks; +import net.minecraft.test.TestTracker; +import net.minecraft.test.TestTrackerHolder; +import net.minecraft.test.TestUtils; +import net.minecraftforge.fml.common.ObfuscationReflectionHelper; + +import java.lang.reflect.Method; + +/** + * The context a test is run within. + * + * @see TestExtensionsKt For additional test helper methods. + */ +public final class TestContext +{ + private final TestTracker tracker; + + public TestContext( TestTrackerHolder holder ) + { + this.tracker = ObfuscationReflectionHelper.getPrivateValue( TestTrackerHolder.class, holder, "field_229487_a_" ); + } + + public TestTracker getTracker() + { + return tracker; + } + + public void ok() + { + try + { + Method finish = TestTracker.class.getDeclaredMethod( "finish" ); + finish.setAccessible( true ); + finish.invoke( tracker ); + + Method spawn = TestUtils.class.getDeclaredMethod( "spawnBeacon", TestTracker.class, Block.class ); + spawn.setAccessible( true ); + spawn.invoke( null, tracker, Blocks.LIME_STAINED_GLASS ); + } + catch( ReflectiveOperationException e ) + { + throw new RuntimeException( e ); + } + } + + public void fail( Throwable e ) + { + if( !tracker.isDone() ) tracker.fail( e ); + } + + public boolean isDone() + { + return tracker.isDone(); + } +} diff --git a/src/test/java/dan200/computercraft/ingame/api/TestExtensions.kt b/src/test/java/dan200/computercraft/ingame/api/TestExtensions.kt new file mode 100644 index 000000000..963addb55 --- /dev/null +++ b/src/test/java/dan200/computercraft/ingame/api/TestExtensions.kt @@ -0,0 +1,61 @@ +package dan200.computercraft.ingame.api + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.delay +import net.minecraft.block.BlockState +import net.minecraft.util.math.BlockPos + +/** + * Wait until a predicate matches (or the test times out). + */ +suspend inline fun TestContext.waitUntil(fn: () -> Boolean) { + while (true) { + if (isDone) throw CancellationException() + if (fn()) return + + delay(50) + } +} + +/** + * Wait until a computer has finished running and check it is OK. + */ +suspend fun TestContext.checkComputerOk(id: Int) { + waitUntil { + val computer = ComputerState.get(id) + computer != null && computer.isDone + } + + ComputerState.get(id).check() +} + +/** + * Sleep for a given number of ticks. + */ +suspend fun TestContext.sleep(ticks: Int = 1) { + val target = tracker.level.gameTime + ticks + waitUntil { tracker.level.gameTime >= target } +} + +private fun TestContext.offset(pos: BlockPos): BlockPos = tracker.testPos.offset(pos.x, pos.y + 2, pos.z) + +/** + * Get a block within the test structure. + */ +fun TestContext.getBlock(pos: BlockPos): BlockState = tracker.level.getBlockState(offset(pos)) + +/** + * Set a block within the test structure. + */ +fun TestContext.setBlock(pos: BlockPos, state: BlockState) { + tracker.level.setBlockAndUpdate(offset(pos), state) +} + +/** + * Modify a block state within the test. + */ +fun TestContext.modifyBlock(pos: BlockPos, modify: (BlockState) -> BlockState) { + val level = tracker.level + val offset = offset(pos) + level.setBlockAndUpdate(offset, modify(level.getBlockState(offset))) +} diff --git a/src/test/java/dan200/computercraft/ingame/mod/CCTestCommand.java b/src/test/java/dan200/computercraft/ingame/mod/CCTestCommand.java new file mode 100644 index 000000000..71c19d099 --- /dev/null +++ b/src/test/java/dan200/computercraft/ingame/mod/CCTestCommand.java @@ -0,0 +1,56 @@ +package dan200.computercraft.ingame.mod; + +import com.mojang.brigadier.CommandDispatcher; +import dan200.computercraft.utils.Copier; +import net.minecraft.command.CommandSource; +import net.minecraft.server.MinecraftServer; + +import java.io.IOException; +import java.io.UncheckedIOException; + +import static dan200.computercraft.shared.command.builder.HelpingArgumentBuilder.choice; +import static net.minecraft.command.Commands.literal; + +/** + * Helper commands for importing/exporting the computer directory. + */ +class CCTestCommand +{ + public static void register( CommandDispatcher dispatcher ) + { + dispatcher.register( choice( "cctest" ) + .then( literal( "import" ).executes( context -> { + importFiles( context.getSource().getServer() ); + return 0; + } ) ) + .then( literal( "export" ).executes( context -> { + exportFiles( context.getSource().getServer() ); + return 0; + } ) ) + ); + } + + public static void importFiles( MinecraftServer server ) + { + try + { + Copier.replicate( TestMod.sourceDir.resolve( "computers" ), server.getServerDirectory().toPath().resolve( "world/computercraft" ) ); + } + catch( IOException e ) + { + throw new UncheckedIOException( e ); + } + } + + public static void exportFiles( MinecraftServer server ) + { + try + { + Copier.replicate( server.getServerDirectory().toPath().resolve( "world/computercraft" ), TestMod.sourceDir.resolve( "computers" ) ); + } + catch( IOException e ) + { + throw new UncheckedIOException( e ); + } + } +} diff --git a/src/test/java/dan200/computercraft/ingame/mod/TestAPI.java b/src/test/java/dan200/computercraft/ingame/mod/TestAPI.java new file mode 100644 index 000000000..a8479dbf7 --- /dev/null +++ b/src/test/java/dan200/computercraft/ingame/mod/TestAPI.java @@ -0,0 +1,63 @@ +package dan200.computercraft.ingame.mod; + +import dan200.computercraft.api.lua.IComputerSystem; +import dan200.computercraft.api.lua.ILuaAPI; +import dan200.computercraft.api.lua.LuaException; +import dan200.computercraft.api.lua.LuaFunction; +import dan200.computercraft.ingame.api.ComputerState; +import dan200.computercraft.ingame.api.TestContext; +import dan200.computercraft.ingame.api.TestExtensionsKt; +import kotlin.coroutines.Continuation; + +/** + * API exposed to computers to help write tests. + * + * Note, we extend this API within startup file of computers (see {@code cctest.lua}). + * + * @see TestExtensionsKt#checkComputerOk(TestContext, int, Continuation) To check tests on the computer have passed. + */ +public class TestAPI extends ComputerState implements ILuaAPI +{ + private final int id; + + TestAPI( IComputerSystem system ) + { + id = system.getID(); + } + + @Override + public void startup() + { + done = false; + error = null; + lookup.put( id, this ); + } + + @Override + public void shutdown() + { + if( lookup.get( id ) == this ) lookup.remove( id ); + } + + @Override + public String[] getNames() + { + return new String[] { "test" }; + } + + @LuaFunction + public final void fail( String message ) throws LuaException + { + if( done ) throw new LuaException( "Cannot call fail/ok multiple times." ); + done = true; + error = message; + throw new LuaException( message ); + } + + @LuaFunction + public final void ok() throws LuaException + { + if( done ) throw new LuaException( "Cannot call fail/ok multiple times." ); + done = true; + } +} diff --git a/src/test/java/dan200/computercraft/ingame/mod/TestLoader.java b/src/test/java/dan200/computercraft/ingame/mod/TestLoader.java new file mode 100644 index 000000000..ab740e5f1 --- /dev/null +++ b/src/test/java/dan200/computercraft/ingame/mod/TestLoader.java @@ -0,0 +1,119 @@ +package dan200.computercraft.ingame.mod; + +import com.google.common.base.CaseFormat; +import dan200.computercraft.ingame.api.GameTest; +import net.minecraft.test.TestFunctionInfo; +import net.minecraft.test.TestRegistry; +import net.minecraft.test.TestTrackerHolder; +import net.minecraftforge.fml.ModList; +import net.minecraftforge.fml.common.ObfuscationReflectionHelper; +import net.minecraftforge.forgespi.language.ModFileScanData; +import org.objectweb.asm.Type; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.util.Arrays; +import java.util.Collection; +import java.util.Set; +import java.util.function.Consumer; + +/** + * Loads methods annotated with {@link GameTest} and adds them to the {@link TestRegistry}. This involves some horrible + * reflection hacks, as Proguard makes many methods (and constructors) private. + */ +class TestLoader +{ + private static final Type gameTest = Type.getType( GameTest.class ); + private static final Collection testFunctions = ObfuscationReflectionHelper.getPrivateValue( TestRegistry.class, null, "field_229526_a_" ); + private static final Set testClassNames = ObfuscationReflectionHelper.getPrivateValue( TestRegistry.class, null, "field_229527_b_" ); + + public static void setup() + { + ModList.get().getAllScanData().stream() + .flatMap( x -> x.getAnnotations().stream() ) + .filter( x -> x.getAnnotationType().equals( gameTest ) ) + .forEach( TestLoader::loadTest ); + } + + + private static void loadTest( ModFileScanData.AnnotationData annotation ) + { + Class klass; + Method method; + try + { + klass = TestLoader.class.getClassLoader().loadClass( annotation.getClassType().getClassName() ); + + // We don't know the exact signature (could suspend or not), so find something with the correct descriptor instead. + String methodName = annotation.getMemberName(); + method = Arrays.stream( klass.getMethods() ).filter( x -> (x.getName() + Type.getMethodDescriptor( x )).equals( methodName ) ).findFirst() + .orElseThrow( () -> new NoSuchMethodException( "No method " + annotation.getClassType().getClassName() + "." + annotation.getMemberName() ) ); + } + catch( ReflectiveOperationException e ) + { + throw new RuntimeException( e ); + } + + String className = CaseFormat.UPPER_CAMEL.to( CaseFormat.LOWER_UNDERSCORE, klass.getSimpleName() ); + String name = className + "." + method.getName().toLowerCase().replace( ' ', '_' ); + + GameTest test = method.getAnnotation( GameTest.class ); + + TestMod.log.info( "Adding test " + name ); + testClassNames.add( className ); + testFunctions.add( createTestFunction( + test.batch(), name, name, + test.required(), + new TestRunner( name, method ), + test.timeout(), + test.setup() + ) ); + } + + private static TestFunctionInfo createTestFunction( + String batchName, + String testName, + String structureName, + boolean required, + Consumer function, + int maxTicks, + long setupTicks + ) + { + try + { + Constructor ctor = TestFunctionInfo.class.getDeclaredConstructor(); + ctor.setAccessible( true ); + + TestFunctionInfo func = ctor.newInstance(); + setFinalField( func, "batchName", batchName ); + setFinalField( func, "testName", testName ); + setFinalField( func, "structureName", structureName ); + setFinalField( func, "required", required ); + setFinalField( func, "function", function ); + setFinalField( func, "maxTicks", maxTicks ); + setFinalField( func, "setupTicks", setupTicks ); + return func; + } + catch( ReflectiveOperationException e ) + { + throw new RuntimeException( e ); + } + } + + private static void setFinalField( TestFunctionInfo func, String name, Object value ) throws ReflectiveOperationException + { + Field field = TestFunctionInfo.class.getDeclaredField( name ); + if( (field.getModifiers() & Modifier.FINAL) != 0 ) + { + Field modifiers = Field.class.getDeclaredField( "modifiers" ); + modifiers.setAccessible( true ); + modifiers.set( field, field.getModifiers() & ~Modifier.FINAL ); + } + + field.setAccessible( true ); + field.set( func, value ); + } +} diff --git a/src/test/java/dan200/computercraft/ingame/mod/TestMod.java b/src/test/java/dan200/computercraft/ingame/mod/TestMod.java new file mode 100644 index 000000000..65a7162c7 --- /dev/null +++ b/src/test/java/dan200/computercraft/ingame/mod/TestMod.java @@ -0,0 +1,117 @@ +package dan200.computercraft.ingame.mod; + +import dan200.computercraft.api.ComputerCraftAPI; +import net.minecraft.command.CommandSource; +import net.minecraft.server.MinecraftServer; +import net.minecraft.test.*; +import net.minecraft.util.math.BlockPos; +import net.minecraft.world.GameRules; +import net.minecraft.world.gen.Heightmap; +import net.minecraftforge.common.MinecraftForge; +import net.minecraftforge.event.TickEvent; +import net.minecraftforge.fml.common.Mod; +import net.minecraftforge.fml.event.server.FMLServerStartedEvent; +import net.minecraftforge.fml.event.server.FMLServerStartingEvent; +import net.minecraftforge.fml.server.ServerLifecycleHooks; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Collection; + +@Mod( TestMod.MOD_ID ) +public class TestMod +{ + public static final Path sourceDir = Paths.get( "../../src/test/server-files/" ).toAbsolutePath(); + + public static final String MOD_ID = "cctest"; + + public static final Logger log = LogManager.getLogger( MOD_ID ); + + private TestResultList runningTests = null; + private int countdown = 20; + + public TestMod() + { + log.info( "CC: Test initialised" ); + ComputerCraftAPI.registerAPIFactory( TestAPI::new ); + TestLoader.setup(); + + StructureHelper.testStructuresDir = sourceDir.resolve( "structures" ).toString(); + + MinecraftForge.EVENT_BUS.addListener( ( FMLServerStartingEvent event ) -> { + log.info( "Starting server, registering command helpers." ); + TestCommand.register( event.getCommandDispatcher() ); + CCTestCommand.register( event.getCommandDispatcher() ); + } ); + + MinecraftForge.EVENT_BUS.addListener( ( FMLServerStartedEvent event ) -> { + MinecraftServer server = event.getServer(); + GameRules rules = server.getGameRules(); + rules.getRule( GameRules.RULE_DAYLIGHT ).set( false, server ); + rules.getRule( GameRules.RULE_WEATHER_CYCLE ).set( false, server ); + rules.getRule( GameRules.RULE_DOMOBSPAWNING ).set( false, server ); + + log.info( "Cleaning up after last run" ); + CommandSource source = server.createCommandSourceStack(); + TestUtils.clearAllTests( source.getLevel(), getStart( source ), TestCollection.singleton, 200 ); + + log.info( "Importing files" ); + CCTestCommand.importFiles( server ); + } ); + + MinecraftForge.EVENT_BUS.addListener( ( TickEvent.ServerTickEvent event ) -> { + if( event.phase != TickEvent.Phase.START ) return; + + // Let the world settle a bit before starting tests. + countdown--; + if( countdown == 0 && System.getProperty( "cctest.run", "false" ).equals( "true" ) ) startTests(); + + TestCollection.singleton.tick(); + MainThread.INSTANCE.tick(); + + if( runningTests != null && runningTests.isDone() ) finishTests(); + } ); + } + + private void startTests() + { + MinecraftServer server = ServerLifecycleHooks.getCurrentServer(); + CommandSource source = server.createCommandSourceStack(); + Collection tests = TestRegistry.getAllTestFunctions(); + + log.info( "Running {} tests...", tests.size() ); + runningTests = new TestResultList( TestUtils.runTests( tests, getStart( source ), source.getLevel(), TestCollection.singleton ) ); + } + + private void finishTests() + { + log.info( "Finished tests - {} were run", runningTests.getTotalCount() ); + if( runningTests.hasFailedRequired() ) + { + log.error( "{} required tests failed", runningTests.getFailedRequiredCount() ); + } + if( runningTests.hasFailedOptional() ) + { + log.warn( "{} optional tests failed", runningTests.getFailedOptionalCount() ); + } + + if( ServerLifecycleHooks.getCurrentServer().isDedicatedServer() ) + { + log.info( "Stopping server." ); + + // We can't exit in the main thread, as Minecraft registers a shutdown hook which results + // in a deadlock. So we do this weird janky thing! + Thread thread = new Thread( () -> System.exit( runningTests.hasFailedRequired() ? 1 : 0 ) ); + thread.setDaemon( true ); + thread.start(); + } + } + + private BlockPos getStart( CommandSource source ) + { + BlockPos pos = new BlockPos( source.getPosition() ); + return new BlockPos( pos.getX(), source.getLevel().getHeightmapPos( Heightmap.Type.WORLD_SURFACE, pos ).getY(), pos.getZ() + 3 ); + } +} diff --git a/src/test/java/dan200/computercraft/ingame/mod/TestRunner.kt b/src/test/java/dan200/computercraft/ingame/mod/TestRunner.kt new file mode 100644 index 000000000..4c2c8be8f --- /dev/null +++ b/src/test/java/dan200/computercraft/ingame/mod/TestRunner.kt @@ -0,0 +1,62 @@ +package dan200.computercraft.ingame.mod + +import dan200.computercraft.ingame.api.TestContext +import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import net.minecraft.test.TestCollection +import net.minecraft.test.TestTrackerHolder +import java.lang.reflect.Method +import java.util.* +import java.util.concurrent.ConcurrentLinkedDeque +import java.util.function.Consumer +import kotlin.coroutines.AbstractCoroutineContextElement +import kotlin.coroutines.Continuation +import kotlin.coroutines.ContinuationInterceptor +import kotlin.coroutines.CoroutineContext +import kotlin.reflect.full.callSuspend +import kotlin.reflect.jvm.kotlinFunction + +internal class TestRunner(private val name: String, private val method: Method) : Consumer { + override fun accept(t: TestTrackerHolder) { + GlobalScope.launch(MainThread + CoroutineName(name)) { + val testContext = TestContext(t) + try { + val instance = method.declaringClass.newInstance() + val function = method.kotlinFunction; + if (function == null) { + method.invoke(instance, testContext) + } else { + function.callSuspend(instance, testContext) + } + testContext.ok() + } catch (e: Exception) { + testContext.fail(e) + } + } + } +} + +/** + * A coroutine scope which runs everything on the main thread. + */ +internal object MainThread : AbstractCoroutineContextElement(ContinuationInterceptor), ContinuationInterceptor { + private val queue: Queue<() -> Unit> = ConcurrentLinkedDeque() + + fun tick() { + while (true) { + val q = queue.poll() ?: break; + q.invoke() + } + } + + override fun interceptContinuation(continuation: Continuation): Continuation = MainThreadInterception(continuation) + + private class MainThreadInterception(val cont: Continuation) : Continuation { + override val context: CoroutineContext get() = cont.context + + override fun resumeWith(result: Result) { + queue.add { cont.resumeWith(result) } + } + } +} diff --git a/src/test/java/dan200/computercraft/utils/Copier.java b/src/test/java/dan200/computercraft/utils/Copier.java new file mode 100644 index 000000000..fe9c94d35 --- /dev/null +++ b/src/test/java/dan200/computercraft/utils/Copier.java @@ -0,0 +1,49 @@ +package dan200.computercraft.utils; + +import com.google.common.io.MoreFiles; + +import java.io.IOException; +import java.nio.file.FileVisitResult; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.SimpleFileVisitor; +import java.nio.file.attribute.BasicFileAttributes; + +public class Copier extends SimpleFileVisitor +{ + private final Path sourceDir; + private final Path targetDir; + + private Copier( Path sourceDir, Path targetDir ) + { + this.sourceDir = sourceDir; + this.targetDir = targetDir; + } + + @Override + public FileVisitResult visitFile( Path file, BasicFileAttributes attributes ) throws IOException + { + Path targetFile = targetDir.resolve( sourceDir.relativize( file ) ); + Files.copy( file, targetFile ); + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult preVisitDirectory( Path dir, BasicFileAttributes attributes ) throws IOException + { + Path newDir = targetDir.resolve( sourceDir.relativize( dir ) ); + Files.createDirectories( newDir ); + return FileVisitResult.CONTINUE; + } + + public static void copy( Path from, Path to ) throws IOException + { + Files.walkFileTree( from, new Copier( from, to ) ); + } + + public static void replicate( Path from, Path to ) throws IOException + { + if( Files.exists( to ) ) MoreFiles.deleteRecursively( to ); + copy( from, to ); + } +} diff --git a/src/test/resources/META-INF/mods.toml b/src/test/resources/META-INF/mods.toml new file mode 100644 index 000000000..520f3542f --- /dev/null +++ b/src/test/resources/META-INF/mods.toml @@ -0,0 +1,18 @@ +modLoader="javafml" +loaderVersion="[30,)" + +issueTrackerURL="https://github.com/SquidDev-CC/CC-Tweaked/issues" +displayURL="https://github.com/SquidDev-CC/CC-Tweaked" +logoFile="pack.png" + +credits="Created by Daniel Ratcliffe (@DanTwoHundred)" +authors="Daniel Ratcliffe, Aaron Mills, SquidDev" + +[[mods]] +modId="cctest" +version="1.0.0" +displayName="CC: Tweaked test framework" +description=''' +A test framework for ensuring CC: Tweaked works correctly. +''' + diff --git a/src/test/resources/data/computercraft/lua/rom/autorun/cctest.lua b/src/test/resources/data/computercraft/lua/rom/autorun/cctest.lua new file mode 100644 index 000000000..1b4f6d778 --- /dev/null +++ b/src/test/resources/data/computercraft/lua/rom/autorun/cctest.lua @@ -0,0 +1,17 @@ +--- Extend the test API with some convenience functions. +-- +-- It's much easier to declare these in Lua rather than Java. + +function test.assert(ok, ...) + if ok then return ... end + + test.fail(... and tostring(...) or "Assertion failed") +end + +function test.eq(expected, actual, msg) + if expected == actual then return end + + local message = ("Assertion failed:\nExpected %s,\ngot %s"):format(expected, actual) + if msg then message = ("%s - %s"):format(msg, message) end + test.fail(message) +end diff --git a/src/test/resources/pack.mcmeta b/src/test/resources/pack.mcmeta new file mode 100755 index 000000000..0958f2f90 --- /dev/null +++ b/src/test/resources/pack.mcmeta @@ -0,0 +1,6 @@ +{ + "pack": { + "pack_format": 4, + "description": "CC: Test" + } +} diff --git a/src/test/server-files/computers/computer/1/startup.lua b/src/test/server-files/computers/computer/1/startup.lua new file mode 100644 index 000000000..ac2e8f8f5 --- /dev/null +++ b/src/test/server-files/computers/computer/1/startup.lua @@ -0,0 +1,5 @@ +test.eq("modem", peripheral.getType("right"), "Starts with a modem") +turtle.equipRight() +test.eq("drive", peripheral.getType("right"), "Unequipping gives a drive") + +test.ok() diff --git a/src/test/server-files/computers/ids.json b/src/test/server-files/computers/ids.json new file mode 100644 index 000000000..234976137 --- /dev/null +++ b/src/test/server-files/computers/ids.json @@ -0,0 +1,3 @@ +{ + "computer": 2 +} diff --git a/src/test/server-files/eula.txt b/src/test/server-files/eula.txt new file mode 100644 index 000000000..e6765d6c9 --- /dev/null +++ b/src/test/server-files/eula.txt @@ -0,0 +1,2 @@ +# Automatically generated EULA. Please don't use this for a real server. +eula=true diff --git a/src/test/server-files/server.properties b/src/test/server-files/server.properties new file mode 100644 index 000000000..c7efd2230 --- /dev/null +++ b/src/test/server-files/server.properties @@ -0,0 +1,46 @@ +#Minecraft server properties +#Fri Jan 08 18:54:30 GMT 2021 +allow-flight=false +allow-nether=true +broadcast-console-to-ops=true +broadcast-rcon-to-ops=true +difficulty=easy +enable-command-block=true +enable-query=false +enable-rcon=false +enforce-whitelist=false +force-gamemode=false +function-permission-level=2 +gamemode=creative +generate-structures=true +generator-settings= +hardcore=false +level-name=world +level-seed= +level-type=flat +max-build-height=256 +max-players=20 +max-tick-time=60000 +max-world-size=29999984 +motd=A testing server +network-compression-threshold=256 +online-mode=false +op-permission-level=4 +player-idle-timeout=0 +prevent-proxy-connections=false +pvp=true +query.port=25565 +rcon.password= +rcon.port=25575 +resource-pack= +resource-pack-sha1= +server-ip= +server-port=25565 +snooper-enabled=true +spawn-animals=true +spawn-monsters=true +spawn-npcs=true +spawn-protection=16 +use-native-transport=true +view-distance=10 +white-list=false diff --git a/src/test/server-files/structures/computer_test.no_through_signal.snbt b/src/test/server-files/structures/computer_test.no_through_signal.snbt new file mode 100644 index 000000000..1d66e7b51 --- /dev/null +++ b/src/test/server-files/structures/computer_test.no_through_signal.snbt @@ -0,0 +1,560 @@ +{ + size: [5, 5, 5], + entities: [], + blocks: [ + { + pos: [0, 0, 0], + state: 0 + }, + { + pos: [1, 0, 0], + state: 0 + }, + { + pos: [2, 0, 0], + state: 0 + }, + { + pos: [3, 0, 0], + state: 0 + }, + { + pos: [4, 0, 0], + state: 0 + }, + { + pos: [0, 0, 1], + state: 0 + }, + { + pos: [1, 0, 1], + state: 0 + }, + { + pos: [2, 0, 1], + state: 0 + }, + { + pos: [3, 0, 1], + state: 0 + }, + { + pos: [4, 0, 1], + state: 0 + }, + { + pos: [0, 0, 2], + state: 0 + }, + { + pos: [1, 0, 2], + state: 0 + }, + { + pos: [2, 0, 2], + state: 0 + }, + { + pos: [3, 0, 2], + state: 0 + }, + { + pos: [4, 0, 2], + state: 0 + }, + { + pos: [0, 0, 3], + state: 0 + }, + { + pos: [1, 0, 3], + state: 0 + }, + { + pos: [2, 0, 3], + state: 0 + }, + { + pos: [3, 0, 3], + state: 0 + }, + { + pos: [4, 0, 3], + state: 0 + }, + { + pos: [0, 0, 4], + state: 0 + }, + { + pos: [1, 0, 4], + state: 0 + }, + { + pos: [2, 0, 4], + state: 0 + }, + { + pos: [3, 0, 4], + state: 0 + }, + { + pos: [4, 0, 4], + state: 0 + }, + { + pos: [2, 1, 4], + state: 1 + }, + { + nbt: { + id: "computercraft:computer_advanced", + ComputerId: 2, + On: 1b + }, + pos: [2, 1, 2], + state: 2 + }, + { + pos: [0, 1, 0], + state: 3 + }, + { + pos: [1, 1, 0], + state: 3 + }, + { + pos: [2, 1, 0], + state: 4 + }, + { + pos: [3, 1, 0], + state: 3 + }, + { + pos: [4, 1, 0], + state: 3 + }, + { + pos: [0, 2, 0], + state: 3 + }, + { + pos: [1, 2, 0], + state: 3 + }, + { + pos: [2, 2, 0], + state: 3 + }, + { + pos: [3, 2, 0], + state: 3 + }, + { + pos: [4, 2, 0], + state: 3 + }, + { + pos: [0, 3, 0], + state: 3 + }, + { + pos: [1, 3, 0], + state: 3 + }, + { + pos: [2, 3, 0], + state: 3 + }, + { + pos: [3, 3, 0], + state: 3 + }, + { + pos: [4, 3, 0], + state: 3 + }, + { + pos: [0, 4, 0], + state: 3 + }, + { + pos: [1, 4, 0], + state: 3 + }, + { + pos: [2, 4, 0], + state: 3 + }, + { + pos: [3, 4, 0], + state: 3 + }, + { + pos: [4, 4, 0], + state: 3 + }, + { + pos: [0, 1, 1], + state: 3 + }, + { + pos: [1, 1, 1], + state: 3 + }, + { + pos: [2, 1, 1], + state: 5 + }, + { + pos: [3, 1, 1], + state: 3 + }, + { + pos: [4, 1, 1], + state: 3 + }, + { + pos: [0, 2, 1], + state: 3 + }, + { + pos: [1, 2, 1], + state: 3 + }, + { + pos: [2, 2, 1], + state: 3 + }, + { + pos: [3, 2, 1], + state: 3 + }, + { + pos: [4, 2, 1], + state: 3 + }, + { + pos: [0, 3, 1], + state: 3 + }, + { + pos: [1, 3, 1], + state: 3 + }, + { + pos: [2, 3, 1], + state: 3 + }, + { + pos: [3, 3, 1], + state: 3 + }, + { + pos: [4, 3, 1], + state: 3 + }, + { + pos: [0, 4, 1], + state: 3 + }, + { + pos: [1, 4, 1], + state: 3 + }, + { + pos: [2, 4, 1], + state: 3 + }, + { + pos: [3, 4, 1], + state: 3 + }, + { + pos: [4, 4, 1], + state: 3 + }, + { + pos: [0, 1, 2], + state: 3 + }, + { + pos: [1, 1, 2], + state: 3 + }, + { + pos: [3, 1, 2], + state: 3 + }, + { + pos: [4, 1, 2], + state: 3 + }, + { + pos: [0, 2, 2], + state: 3 + }, + { + pos: [1, 2, 2], + state: 3 + }, + { + pos: [2, 2, 2], + state: 3 + }, + { + pos: [3, 2, 2], + state: 3 + }, + { + pos: [4, 2, 2], + state: 3 + }, + { + pos: [0, 3, 2], + state: 3 + }, + { + pos: [1, 3, 2], + state: 3 + }, + { + pos: [2, 3, 2], + state: 3 + }, + { + pos: [3, 3, 2], + state: 3 + }, + { + pos: [4, 3, 2], + state: 3 + }, + { + pos: [0, 4, 2], + state: 3 + }, + { + pos: [1, 4, 2], + state: 3 + }, + { + pos: [2, 4, 2], + state: 3 + }, + { + pos: [3, 4, 2], + state: 3 + }, + { + pos: [4, 4, 2], + state: 3 + }, + { + pos: [0, 1, 3], + state: 3 + }, + { + pos: [1, 1, 3], + state: 3 + }, + { + pos: [2, 1, 3], + state: 6 + }, + { + pos: [3, 1, 3], + state: 3 + }, + { + pos: [4, 1, 3], + state: 3 + }, + { + pos: [0, 2, 3], + state: 3 + }, + { + pos: [1, 2, 3], + state: 3 + }, + { + pos: [2, 2, 3], + state: 3 + }, + { + pos: [3, 2, 3], + state: 3 + }, + { + pos: [4, 2, 3], + state: 3 + }, + { + pos: [0, 3, 3], + state: 3 + }, + { + pos: [1, 3, 3], + state: 3 + }, + { + pos: [2, 3, 3], + state: 3 + }, + { + pos: [3, 3, 3], + state: 3 + }, + { + pos: [4, 3, 3], + state: 3 + }, + { + pos: [0, 4, 3], + state: 3 + }, + { + pos: [1, 4, 3], + state: 3 + }, + { + pos: [2, 4, 3], + state: 3 + }, + { + pos: [3, 4, 3], + state: 3 + }, + { + pos: [4, 4, 3], + state: 3 + }, + { + pos: [0, 1, 4], + state: 3 + }, + { + pos: [1, 1, 4], + state: 3 + }, + { + pos: [3, 1, 4], + state: 3 + }, + { + pos: [4, 1, 4], + state: 3 + }, + { + pos: [0, 2, 4], + state: 3 + }, + { + pos: [1, 2, 4], + state: 3 + }, + { + pos: [2, 2, 4], + state: 3 + }, + { + pos: [3, 2, 4], + state: 3 + }, + { + pos: [4, 2, 4], + state: 3 + }, + { + pos: [0, 3, 4], + state: 3 + }, + { + pos: [1, 3, 4], + state: 3 + }, + { + pos: [2, 3, 4], + state: 3 + }, + { + pos: [3, 3, 4], + state: 3 + }, + { + pos: [4, 3, 4], + state: 3 + }, + { + pos: [0, 4, 4], + state: 3 + }, + { + pos: [1, 4, 4], + state: 3 + }, + { + pos: [2, 4, 4], + state: 3 + }, + { + pos: [3, 4, 4], + state: 3 + }, + { + pos: [4, 4, 4], + state: 3 + } + ], + palette: [ + { + Name: "minecraft:polished_andesite" + }, + { + Properties: { + lit: "false" + }, + Name: "minecraft:redstone_lamp" + }, + { + Properties: { + facing: "north", + state: "blinking" + }, + Name: "computercraft:computer_advanced" + }, + { + Name: "minecraft:air" + }, + { + Properties: { + face: "floor", + powered: "false", + facing: "south" + }, + Name: "minecraft:lever" + }, + { + Properties: { + delay: "1", + powered: "false", + facing: "north", + locked: "false" + }, + Name: "minecraft:repeater" + }, + { + Properties: { + east: "none", + south: "none", + north: "side", + west: "none", + power: "0" + }, + Name: "minecraft:redstone_wire" + } + ], + DataVersion: 2230 +} diff --git a/src/test/server-files/structures/turtle_test.unequip_refreshes_peripheral.snbt b/src/test/server-files/structures/turtle_test.unequip_refreshes_peripheral.snbt new file mode 100644 index 000000000..ff9613efd --- /dev/null +++ b/src/test/server-files/structures/turtle_test.unequip_refreshes_peripheral.snbt @@ -0,0 +1,550 @@ +{ + size: [5, 5, 5], + entities: [], + blocks: [ + { + pos: [0, 0, 0], + state: 0 + }, + { + pos: [1, 0, 0], + state: 0 + }, + { + pos: [2, 0, 0], + state: 0 + }, + { + pos: [3, 0, 0], + state: 0 + }, + { + pos: [4, 0, 0], + state: 0 + }, + { + pos: [0, 0, 1], + state: 0 + }, + { + pos: [1, 0, 1], + state: 0 + }, + { + pos: [2, 0, 1], + state: 0 + }, + { + pos: [3, 0, 1], + state: 0 + }, + { + pos: [4, 0, 1], + state: 0 + }, + { + pos: [0, 0, 2], + state: 0 + }, + { + pos: [1, 0, 2], + state: 0 + }, + { + pos: [2, 0, 2], + state: 0 + }, + { + pos: [3, 0, 2], + state: 0 + }, + { + pos: [4, 0, 2], + state: 0 + }, + { + pos: [0, 0, 3], + state: 0 + }, + { + pos: [1, 0, 3], + state: 0 + }, + { + pos: [2, 0, 3], + state: 0 + }, + { + pos: [3, 0, 3], + state: 0 + }, + { + pos: [4, 0, 3], + state: 0 + }, + { + pos: [0, 0, 4], + state: 0 + }, + { + pos: [1, 0, 4], + state: 0 + }, + { + pos: [2, 0, 4], + state: 0 + }, + { + pos: [3, 0, 4], + state: 0 + }, + { + pos: [4, 0, 4], + state: 0 + }, + { + nbt: { + id: "computercraft:disk_drive" + }, + pos: [1, 1, 2], + state: 1 + }, + { + nbt: { + Owner: { + UpperId: 4039158846114182220L, + LowerId: -6876936588741668278L, + Name: "Dev" + }, + RightUpgrade: "computercraft:wireless_modem_normal", + Fuel: 0, + Label: "Unequip refreshes peripheral", + Slot: 0, + Items: [], + id: "computercraft:turtle_normal", + RightUpgradeNbt: { + active: 0b + }, + ComputerId: 1, + On: 1b + }, + pos: [2, 1, 2], + state: 2 + }, + { + pos: [0, 1, 0], + state: 3 + }, + { + pos: [1, 1, 0], + state: 3 + }, + { + pos: [2, 1, 0], + state: 3 + }, + { + pos: [3, 1, 0], + state: 3 + }, + { + pos: [4, 1, 0], + state: 3 + }, + { + pos: [0, 2, 0], + state: 3 + }, + { + pos: [1, 2, 0], + state: 3 + }, + { + pos: [2, 2, 0], + state: 3 + }, + { + pos: [3, 2, 0], + state: 3 + }, + { + pos: [4, 2, 0], + state: 3 + }, + { + pos: [0, 3, 0], + state: 3 + }, + { + pos: [1, 3, 0], + state: 3 + }, + { + pos: [2, 3, 0], + state: 3 + }, + { + pos: [3, 3, 0], + state: 3 + }, + { + pos: [4, 3, 0], + state: 3 + }, + { + pos: [0, 4, 0], + state: 3 + }, + { + pos: [1, 4, 0], + state: 3 + }, + { + pos: [2, 4, 0], + state: 3 + }, + { + pos: [3, 4, 0], + state: 3 + }, + { + pos: [4, 4, 0], + state: 3 + }, + { + pos: [0, 1, 1], + state: 3 + }, + { + pos: [1, 1, 1], + state: 3 + }, + { + pos: [2, 1, 1], + state: 3 + }, + { + pos: [3, 1, 1], + state: 3 + }, + { + pos: [4, 1, 1], + state: 3 + }, + { + pos: [0, 2, 1], + state: 3 + }, + { + pos: [1, 2, 1], + state: 3 + }, + { + pos: [2, 2, 1], + state: 3 + }, + { + pos: [3, 2, 1], + state: 3 + }, + { + pos: [4, 2, 1], + state: 3 + }, + { + pos: [0, 3, 1], + state: 3 + }, + { + pos: [1, 3, 1], + state: 3 + }, + { + pos: [2, 3, 1], + state: 3 + }, + { + pos: [3, 3, 1], + state: 3 + }, + { + pos: [4, 3, 1], + state: 3 + }, + { + pos: [0, 4, 1], + state: 3 + }, + { + pos: [1, 4, 1], + state: 3 + }, + { + pos: [2, 4, 1], + state: 3 + }, + { + pos: [3, 4, 1], + state: 3 + }, + { + pos: [4, 4, 1], + state: 3 + }, + { + pos: [0, 1, 2], + state: 3 + }, + { + pos: [3, 1, 2], + state: 3 + }, + { + pos: [4, 1, 2], + state: 3 + }, + { + pos: [0, 2, 2], + state: 3 + }, + { + pos: [1, 2, 2], + state: 3 + }, + { + pos: [2, 2, 2], + state: 3 + }, + { + pos: [3, 2, 2], + state: 3 + }, + { + pos: [4, 2, 2], + state: 3 + }, + { + pos: [0, 3, 2], + state: 3 + }, + { + pos: [1, 3, 2], + state: 3 + }, + { + pos: [2, 3, 2], + state: 3 + }, + { + pos: [3, 3, 2], + state: 3 + }, + { + pos: [4, 3, 2], + state: 3 + }, + { + pos: [0, 4, 2], + state: 3 + }, + { + pos: [1, 4, 2], + state: 3 + }, + { + pos: [2, 4, 2], + state: 3 + }, + { + pos: [3, 4, 2], + state: 3 + }, + { + pos: [4, 4, 2], + state: 3 + }, + { + pos: [0, 1, 3], + state: 3 + }, + { + pos: [1, 1, 3], + state: 3 + }, + { + pos: [2, 1, 3], + state: 3 + }, + { + pos: [3, 1, 3], + state: 3 + }, + { + pos: [4, 1, 3], + state: 3 + }, + { + pos: [0, 2, 3], + state: 3 + }, + { + pos: [1, 2, 3], + state: 3 + }, + { + pos: [2, 2, 3], + state: 3 + }, + { + pos: [3, 2, 3], + state: 3 + }, + { + pos: [4, 2, 3], + state: 3 + }, + { + pos: [0, 3, 3], + state: 3 + }, + { + pos: [1, 3, 3], + state: 3 + }, + { + pos: [2, 3, 3], + state: 3 + }, + { + pos: [3, 3, 3], + state: 3 + }, + { + pos: [4, 3, 3], + state: 3 + }, + { + pos: [0, 4, 3], + state: 3 + }, + { + pos: [1, 4, 3], + state: 3 + }, + { + pos: [2, 4, 3], + state: 3 + }, + { + pos: [3, 4, 3], + state: 3 + }, + { + pos: [4, 4, 3], + state: 3 + }, + { + pos: [0, 1, 4], + state: 3 + }, + { + pos: [1, 1, 4], + state: 3 + }, + { + pos: [2, 1, 4], + state: 3 + }, + { + pos: [3, 1, 4], + state: 3 + }, + { + pos: [4, 1, 4], + state: 3 + }, + { + pos: [0, 2, 4], + state: 3 + }, + { + pos: [1, 2, 4], + state: 3 + }, + { + pos: [2, 2, 4], + state: 3 + }, + { + pos: [3, 2, 4], + state: 3 + }, + { + pos: [4, 2, 4], + state: 3 + }, + { + pos: [0, 3, 4], + state: 3 + }, + { + pos: [1, 3, 4], + state: 3 + }, + { + pos: [2, 3, 4], + state: 3 + }, + { + pos: [3, 3, 4], + state: 3 + }, + { + pos: [4, 3, 4], + state: 3 + }, + { + pos: [0, 4, 4], + state: 3 + }, + { + pos: [1, 4, 4], + state: 3 + }, + { + pos: [2, 4, 4], + state: 3 + }, + { + pos: [3, 4, 4], + state: 3 + }, + { + pos: [4, 4, 4], + state: 3 + } + ], + palette: [ + { + Name: "minecraft:polished_andesite" + }, + { + Properties: { + facing: "north", + state: "empty" + }, + Name: "computercraft:disk_drive" + }, + { + Properties: { + waterlogged: "false", + facing: "south" + }, + Name: "computercraft:turtle_normal" + }, + { + Name: "minecraft:air" + } + ], + DataVersion: 2230 +}