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
This commit is contained in:
Jonathan Coates 2021-01-09 19:50:27 +00:00
parent 34b5ede326
commit 331031be45
24 changed files with 1950 additions and 2 deletions

1
.gitattributes vendored
View File

@ -1,2 +1,3 @@
# Ignore changes in generated files
src/generated/resources/data/** linguist-generated
src/test/server-files/structures linguist-generated

View File

@ -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:

View File

@ -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 {

View File

@ -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")
}
}

View File

@ -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)
}

View File

@ -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<Integer, ComputerState> 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 );
}
}

View File

@ -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;
}

View File

@ -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();
}
}

View File

@ -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)))
}

View File

@ -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<CommandSource> 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 );
}
}
}

View File

@ -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;
}
}

View File

@ -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<TestFunctionInfo> testFunctions = ObfuscationReflectionHelper.getPrivateValue( TestRegistry.class, null, "field_229526_a_" );
private static final Set<String> 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<TestTrackerHolder> function,
int maxTicks,
long setupTicks
)
{
try
{
Constructor<TestFunctionInfo> 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 );
}
}

View File

@ -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<TestFunctionInfo> 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 );
}
}

View File

@ -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<TestTrackerHolder> {
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 <T> interceptContinuation(continuation: Continuation<T>): Continuation<T> = MainThreadInterception(continuation)
private class MainThreadInterception<T>(val cont: Continuation<T>) : Continuation<T> {
override val context: CoroutineContext get() = cont.context
override fun resumeWith(result: Result<T>) {
queue.add { cont.resumeWith(result) }
}
}
}

View File

@ -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<Path>
{
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 );
}
}

View File

@ -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.
'''

View File

@ -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

6
src/test/resources/pack.mcmeta Executable file
View File

@ -0,0 +1,6 @@
{
"pack": {
"pack_format": 4,
"description": "CC: Test"
}
}

View File

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

View File

@ -0,0 +1,3 @@
{
"computer": 2
}

View File

@ -0,0 +1,2 @@
# Automatically generated EULA. Please don't use this for a real server.
eula=true

View File

@ -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

View File

@ -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
}

View File

@ -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
}