From 0ff6b0ca70fcef52ed0acca424b61c1601922d05 Mon Sep 17 00:00:00 2001 From: Jonathan Coates Date: Fri, 20 Aug 2021 13:32:42 +0100 Subject: [PATCH] Client-side tests This spins up a Minecraft instance (much like we do for the server) and instructs the client to take screenshots at particular times. We then compare those screenshots and assert they match (modulo some small delta). --- .github/workflows/main-ci.yml | 11 +- build.gradle | 92 +-- .../computercraft/ingame/MonitorTest.kt | 22 + .../ingame/api/ComputerState.java | 2 +- .../ingame/api/TestExtensions.kt | 132 +++- .../ingame/mod/CCTestCommand.java | 68 +- .../computercraft/ingame/mod/ClientHooks.java | 101 +++ .../computercraft/ingame/mod/Copier.java | 17 +- .../computercraft/ingame/mod/ImageUtils.java | 82 +++ .../monitor_test.looks_acceptable.png | Bin 0 -> 35796 bytes .../monitor_test.looks_acceptable.snbt | 657 ++++++++++++++++++ 11 files changed, 1125 insertions(+), 59 deletions(-) create mode 100644 src/testMod/java/dan200/computercraft/ingame/mod/ClientHooks.java create mode 100644 src/testMod/java/dan200/computercraft/ingame/mod/ImageUtils.java create mode 100644 src/testMod/server-files/screenshots/monitor_test.looks_acceptable.png create mode 100644 src/testMod/server-files/structures/monitor_test.looks_acceptable.snbt diff --git a/.github/workflows/main-ci.yml b/.github/workflows/main-ci.yml index d3a482960..8dc8e9e22 100644 --- a/.github/workflows/main-ci.yml +++ b/.github/workflows/main-ci.yml @@ -32,7 +32,7 @@ jobs: run: | ./gradlew assemble || ./gradlew assemble ./gradlew downloadAssets || ./gradlew downloadAssets - ./gradlew build + xvfb-run ./gradlew build - name: Upload Jar uses: actions/upload-artifact@v2 @@ -40,6 +40,15 @@ jobs: name: CC-Tweaked path: build/libs + - name: Upload Screnshots + uses: actions/upload-artifact@v2 + with: + name: Screenshots + path: test-files/client/screenshots + if-no-files-found: ignore + retention-days: 5 + if: failure() + - name: Upload Coverage uses: codecov/codecov-action@v1 diff --git a/build.gradle b/build.gradle index 290595250..4f578960a 100644 --- a/build.gradle +++ b/build.gradle @@ -80,6 +80,17 @@ minecraft { args '--mod', 'computercraft', '--all', '--output', file('src/generated/resources/'), '--existing', file('src/main/resources/') } + testClient { + workingDirectory project.file('test-files/client') + parent runs.client + + mods { + cctest { + source sourceSets.testMod + } + } + } + testServer { workingDirectory project.file('test-files/server') parent runs.server @@ -352,50 +363,53 @@ task setupServer(type: Copy) { into "test-files/server" } -tasks.register('testInGame', JavaExec.class).configure { - it.group('test server') - it.description("Runs tests on a temporary Minecraft server.") - it.dependsOn(setupServer, 'prepareRunTestServer', 'cleanTestInGame', 'compileTestModJava') +["Client", "Server"].forEach {name -> + tasks.register("test$name", JavaExec.class).configure { + it.group('In-game tests') + it.description("Runs tests on a temporary Minecraft instance.") + it.dependsOn(setupServer, "prepareRunTest$name", "cleanTest$name", 'compileTestModJava') - // Copy from runTestServer. We do it in this slightly odd way as runTestServer - // isn't created until the task is configured (which is no good for us). - JavaExec exec = tasks.getByName('runTestServer') - exec.copyTo(it) - it.setClasspath(exec.getClasspath()) - it.mainClass = exec.mainClass - it.setArgs(exec.getArgs()) + // Copy from runTestServer. We do it in this slightly odd way as runTestServer + // isn't created until the task is configured (which is no good for us). + JavaExec exec = tasks.getByName("runTest$name") + exec.copyTo(it) + it.setClasspath(exec.getClasspath()) + it.mainClass = exec.mainClass + it.setArgs(exec.getArgs()) - it.systemProperty('forge.logging.console.level', 'info') - it.systemProperty('cctest.run', 'true') + it.systemProperty('forge.logging.console.level', 'info') + it.systemProperty('cctest.run', 'true') - // Jacoco and modlauncher don't play well together as the classes loaded in-game don't - // match up with those written to disk. We get Jacoco to dump all classes to disk, and - // use that when generating the report. - def coverageOut = new File(buildDir, 'jacocoClassDump/testInGame') - jacoco.applyTo(it) - it.jacoco.setIncludes(["dan200.computercraft.*"]) - it.jacoco.setClassDumpDir(coverageOut) - it.outputs.dir(coverageOut) - // Older versions of modlauncher don't include a protection domain (and thus no code - // source). Jacoco skips such classes by default, so we need to explicitly include them. - it.jacoco.setIncludeNoLocationClasses(true) -} - -tasks.register('jacocoTestInGameReport', JacocoReport.class).configure { - it.group('test server') - it.description('Generate coverage reports for in-game tests (testInGame)') - it.dependsOn('testInGame') - - it.executionData(new File(buildDir, 'jacoco/testInGame.exec')) - it.sourceDirectories.from(sourceSets.main.allJava.srcDirs) - it.classDirectories.from(new File(buildDir, 'jacocoClassDump/testInGame')) - - it.reports { - xml.enabled true - html.enabled true + // Jacoco and modlauncher don't play well together as the classes loaded in-game don't + // match up with those written to disk. We get Jacoco to dump all classes to disk, and + // use that when generating the report. + def coverageOut = new File(buildDir, "jacocoClassDump/test$name") + jacoco.applyTo(it) + it.jacoco.setIncludes(["dan200.computercraft.*"]) + it.jacoco.setClassDumpDir(coverageOut) + it.outputs.dir(coverageOut) + // Older versions of modlauncher don't include a protection domain (and thus no code + // source). Jacoco skips such classes by default, so we need to explicitly include them. + it.jacoco.setIncludeNoLocationClasses(true) } + + tasks.register("jacocoTest${name}Report", JacocoReport.class).configure { + it.group('In-game') + it.description("Generate coverage reports for test$name") + it.dependsOn("test$name") + + it.executionData(new File(buildDir, "jacoco/test${name}.exec")) + it.sourceDirectories.from(sourceSets.main.allJava.srcDirs) + it.classDirectories.from(new File(buildDir, "jacocoClassDump/$name")) + + it.reports { + xml.enabled true + html.enabled true + } + } + + check.dependsOn("jacocoTest${name}Report") } -check.dependsOn('jacocoTestInGameReport') // Upload tasks diff --git a/src/testMod/java/dan200/computercraft/ingame/MonitorTest.kt b/src/testMod/java/dan200/computercraft/ingame/MonitorTest.kt index b4f1ca9fa..644dc9e44 100644 --- a/src/testMod/java/dan200/computercraft/ingame/MonitorTest.kt +++ b/src/testMod/java/dan200/computercraft/ingame/MonitorTest.kt @@ -1,6 +1,7 @@ package dan200.computercraft.ingame import dan200.computercraft.ingame.api.* +import dan200.computercraft.shared.Capabilities import dan200.computercraft.shared.Registry import dan200.computercraft.shared.peripheral.monitor.TileMonitor import net.minecraft.block.Blocks @@ -40,4 +41,25 @@ class Monitor_Test { } } } + + @GameTest(batch = "client:Monitor_Test.Looks_acceptable", timeoutTicks = 400) + fun Looks_acceptable(helper: GameTestHelper) = helper.sequence { + this + .thenExecute { helper.normaliseScene() } + .thenExecute { + 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, 2)) as TileMonitor + monitor.getCapability(Capabilities.CAPABILITY_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/src/testMod/java/dan200/computercraft/ingame/api/ComputerState.java b/src/testMod/java/dan200/computercraft/ingame/api/ComputerState.java index 84bf31f4b..7727fa177 100644 --- a/src/testMod/java/dan200/computercraft/ingame/api/ComputerState.java +++ b/src/testMod/java/dan200/computercraft/ingame/api/ComputerState.java @@ -37,7 +37,7 @@ public class ComputerState public void check( @Nonnull String marker ) { if( !markers.contains( marker ) ) throw new IllegalStateException( "Not yet at " + marker ); - if( error != null ) throw new AssertionError( error ); + if( error != null ) throw new RuntimeException( error ); } public static ComputerState get( int id ) diff --git a/src/testMod/java/dan200/computercraft/ingame/api/TestExtensions.kt b/src/testMod/java/dan200/computercraft/ingame/api/TestExtensions.kt index f826f8f12..fa9256f05 100644 --- a/src/testMod/java/dan200/computercraft/ingame/api/TestExtensions.kt +++ b/src/testMod/java/dan200/computercraft/ingame/api/TestExtensions.kt @@ -1,9 +1,22 @@ package dan200.computercraft.ingame.api -import dan200.computercraft.ComputerCraft +import dan200.computercraft.ingame.mod.ImageUtils +import dan200.computercraft.ingame.mod.TestMod import net.minecraft.block.BlockState +import net.minecraft.block.Blocks +import net.minecraft.client.Minecraft import net.minecraft.command.arguments.BlockStateInput +import net.minecraft.entity.item.ArmorStandEntity +import net.minecraft.util.ScreenShotHelper import net.minecraft.util.math.BlockPos +import net.minecraft.world.gen.Heightmap +import java.nio.file.Files +import java.util.concurrent.CompletableFuture +import java.util.concurrent.ExecutionException +import java.util.concurrent.atomic.AtomicInteger +import java.util.function.Supplier +import javax.imageio.ImageIO + /** * Wait until a computer has finished running and check it is OK. @@ -16,6 +29,74 @@ fun GameTestSequence.thenComputerOk(id: Int, marker: String = ComputerState.DONE ComputerState.get(id).check(marker) } +/** + * Run a task on the client + */ +fun GameTestSequence.thenOnClient(task: ClientTestHelper.() -> Unit): GameTestSequence { + var future: CompletableFuture? = null + return this + .thenExecute { future = Minecraft.getInstance().submit(Supplier { task(ClientTestHelper()) }) } + .thenWaitUntil { if (!future!!.isDone) throw GameTestAssertException("Not done task yet") } + .thenExecute { + try { + future!!.get() + } catch (e: ExecutionException) { + throw e.cause ?: e + } + } +} + +/** + * Idle for one tick to allow the client to catch up, then take a screenshot. + */ +fun GameTestSequence.thenScreenshot(name: String? = null): GameTestSequence { + val suffix = if (name == null) "" else "-$name" + val fullName = "${parent.testName}$suffix" + + val counter = AtomicInteger() + return this + // Wait until all chunks have been rendered and we're idle for an extended period. + .thenExecute { counter.set(0) } + .thenWaitUntil { + if (Minecraft.getInstance().levelRenderer.hasRenderedAllChunks()) { + val idleFor = counter.getAndIncrement() + if (idleFor <= 20) throw GameTestAssertException("Only idle for $idleFor ticks") + } else { + counter.set(0) + throw GameTestAssertException("Waiting for client to finish rendering") + } + } + // Now disable the GUI, take a screenshot and reenable it. We sleep either side to give the client time to do + // its thing. + .thenExecute { Minecraft.getInstance().options.hideGui = true } + .thenIdle(5) // Some delay before/after to ensure the render thread has caught up. + .thenOnClient { screenshot("$fullName.png") } + .thenIdle(2) + .thenExecute { + Minecraft.getInstance().options.hideGui = false + + val screenshotsPath = Minecraft.getInstance().gameDirectory.toPath().resolve("screenshots") + val screenshotPath = screenshotsPath.resolve("$fullName.png") + val originalPath = TestMod.sourceDir.resolve("screenshots").resolve("$fullName.png") + + if (!Files.exists(originalPath)) throw GameTestAssertException("$fullName does not exist. Use `/cctest promote' to create it."); + + val screenshot = ImageIO.read(screenshotPath.toFile()) + val original = ImageIO.read(originalPath.toFile()) + + if (screenshot.width != original.width || screenshot.height != original.height) { + throw GameTestAssertException("$fullName screenshot is ${screenshot.width}x${screenshot.height} but original is ${original.width}x${original.height}") + } + + if (ImageUtils.areSame(screenshot, original)) return@thenExecute + + ImageUtils.writeDifference(screenshotsPath.resolve("$fullName.diff.png"), screenshot, original) + throw GameTestAssertException("Images are different.") + } +} + +val GameTestHelper.testName: String get() = tracker.testName + /** * Modify a block state within the test. */ @@ -32,14 +113,45 @@ fun GameTestHelper.sequence(run: GameTestSequence.() -> GameTestSequence) { */ fun GameTestHelper.setBlock(pos: BlockPos, state: BlockStateInput) = state.place(level, absolutePos(pos), 3) - -fun GameTestSequence.thenExecuteSafe(runnable: Runnable) { - try { - runnable.run() - } catch (e: GameTestAssertException) { - throw e - } catch (e: RuntimeException) { - ComputerCraft.log.error("Error in test", e) - throw GameTestAssertException(e.toString()) +/** + * "Normalise" the current world in preparation for screenshots. + * + * Basically removes any dirt and replaces it with concrete. + */ +fun GameTestHelper.normaliseScene() { + val y = level.getHeightmapPos(Heightmap.Type.WORLD_SURFACE, absolutePos(BlockPos.ZERO)) + for (x in -100..100) { + for (z in -100..100) { + val pos = y.offset(x, -3, z) + val block = level.getBlockState(pos).block + if (block == Blocks.DIRT || block == Blocks.GRASS_BLOCK) { + level.setBlock(pos, Blocks.WHITE_CONCRETE.defaultBlockState(), 3) + } + } + } +} + +/** + * Position the player at an armor stand. + */ +fun GameTestHelper.positionAtArmorStand() { + val entities = level.getEntities(null, bounds) { it.name.string == testName } + if (entities.size <= 0 || entities[0] !is ArmorStandEntity) throw IllegalStateException("Cannot find armor stand") + + val stand = entities[0] as ArmorStandEntity + val player = level.randomPlayer ?: throw NullPointerException("Player does not exist") + + player.connection.teleport(stand.x, stand.y, stand.z, stand.yRot, stand.xRot) +} + + +class ClientTestHelper { + val minecraft: Minecraft = Minecraft.getInstance() + + fun screenshot(name: String) { + ScreenShotHelper.grab( + minecraft.gameDirectory, name, + minecraft.window.width, minecraft.window.height, minecraft.mainRenderTarget + ) { TestMod.log.info(it.string) } } } diff --git a/src/testMod/java/dan200/computercraft/ingame/mod/CCTestCommand.java b/src/testMod/java/dan200/computercraft/ingame/mod/CCTestCommand.java index d05880b05..0a726c409 100644 --- a/src/testMod/java/dan200/computercraft/ingame/mod/CCTestCommand.java +++ b/src/testMod/java/dan200/computercraft/ingame/mod/CCTestCommand.java @@ -7,14 +7,23 @@ package dan200.computercraft.ingame.mod; import com.mojang.brigadier.CommandDispatcher; import dan200.computercraft.ComputerCraft; +import net.minecraft.client.Minecraft; import net.minecraft.command.CommandSource; import net.minecraft.data.NBTToSNBTConverter; +import net.minecraft.entity.Entity; +import net.minecraft.entity.EntityType; +import net.minecraft.entity.item.ArmorStandEntity; +import net.minecraft.entity.player.ServerPlayerEntity; +import net.minecraft.nbt.CompoundNBT; import net.minecraft.server.MinecraftServer; import net.minecraft.test.*; +import net.minecraft.tileentity.StructureBlockTileEntity; import net.minecraft.util.ResourceLocation; +import net.minecraft.util.math.BlockPos; import net.minecraft.util.text.StringTextComponent; import net.minecraft.util.text.TextFormatting; import net.minecraft.world.storage.FolderName; +import net.minecraftforge.fml.loading.FMLLoader; import javax.annotation.Nonnull; import java.io.IOException; @@ -58,6 +67,37 @@ class CCTestCommand result.addFailureListener( x -> TestRegistry.rememberFailedTest( x.getTestFunction() ) ); return 0; } ) ) + + .then( literal( "promote" ).executes( context -> { + if( !FMLLoader.getDist().isClient() ) return error( context.getSource(), "Cannot run on server" ); + + promote(); + return 0; + } ) ) + .then( literal( "marker" ).executes( context -> { + ServerPlayerEntity player = context.getSource().getPlayerOrException(); + BlockPos pos = StructureHelper.findNearestStructureBlock( player.blockPosition(), 15, player.getLevel() ); + if( pos == null ) return error( context.getSource(), "No nearby test" ); + + StructureBlockTileEntity structureBlock = (StructureBlockTileEntity) player.getLevel().getBlockEntity( pos ); + TestFunctionInfo info = TestRegistry.getTestFunction( structureBlock.getStructurePath() ); + + // Kill the existing armor stand + player + .getLevel().getEntities() + .filter( x -> x.isAlive() && x instanceof ArmorStandEntity && x.getName().getString().equals( info.getTestName() ) ) + .forEach( Entity::remove ); + + // And create a new one + CompoundNBT nbt = new CompoundNBT(); + nbt.putBoolean( "Marker", true ); + nbt.putBoolean( "Invisible", true ); + ArmorStandEntity armorStand = EntityType.ARMOR_STAND.create( player.getLevel() ); + armorStand.readAdditionalSaveData( nbt ); + armorStand.copyPosition( player ); + armorStand.setCustomName( new StringTextComponent( info.getTestName() ) ); + return 0; + } ) ) ); } @@ -73,7 +113,7 @@ class CCTestCommand } } - public static void exportFiles( MinecraftServer server ) + static void exportFiles( MinecraftServer server ) { try { @@ -85,12 +125,28 @@ class CCTestCommand } } + private static void promote() + { + try + { + Copier.replicate( + Minecraft.getInstance().gameDirectory.toPath().resolve( "screenshots" ), + TestMod.sourceDir.resolve( "screenshots" ), + x -> !x.toFile().getName().endsWith( ".diff.png" ) + ); + } + catch( IOException e ) + { + throw new UncheckedIOException( e ); + } + } + private static class Callback implements ITestCallback { private final CommandSource source; private final TestResultList result; - public Callback( CommandSource source, TestResultList result ) + Callback( CommandSource source, TestResultList result ) { this.source = source; this.result = result; @@ -106,7 +162,13 @@ class CCTestCommand { if( !tracker.isDone() ) return; - source.sendFailure( new StringTextComponent( result.getFailedRequiredCount() + " required tests failed" ).withStyle( TextFormatting.RED ) ); + error( source, result.getFailedRequiredCount() + " required tests failed" ); } } + + private static int error( CommandSource source, String message ) + { + source.sendFailure( new StringTextComponent( message ).withStyle( TextFormatting.RED ) ); + return 0; + } } diff --git a/src/testMod/java/dan200/computercraft/ingame/mod/ClientHooks.java b/src/testMod/java/dan200/computercraft/ingame/mod/ClientHooks.java new file mode 100644 index 000000000..c9e75c669 --- /dev/null +++ b/src/testMod/java/dan200/computercraft/ingame/mod/ClientHooks.java @@ -0,0 +1,101 @@ +/* + * This file is part of ComputerCraft - http://www.computercraft.info + * Copyright Daniel Ratcliffe, 2011-2021. Do not distribute without permission. + * Send enquiries to dratcliffe@gmail.com + */ +package dan200.computercraft.ingame.mod; + +import net.minecraft.block.Blocks; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.screen.MainMenuScreen; +import net.minecraft.client.settings.CloudOption; +import net.minecraft.client.settings.ParticleStatus; +import net.minecraft.client.tutorial.TutorialSteps; +import net.minecraft.util.datafix.codec.DatapackCodec; +import net.minecraft.util.registry.DynamicRegistries; +import net.minecraft.util.registry.Registry; +import net.minecraft.world.*; +import net.minecraft.world.biome.Biome; +import net.minecraft.world.biome.Biomes; +import net.minecraft.world.gen.FlatChunkGenerator; +import net.minecraft.world.gen.FlatGenerationSettings; +import net.minecraft.world.gen.FlatLayerInfo; +import net.minecraft.world.gen.settings.DimensionGeneratorSettings; +import net.minecraft.world.gen.settings.DimensionStructuresSettings; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.client.event.GuiScreenEvent; +import net.minecraftforge.eventbus.api.SubscribeEvent; +import net.minecraftforge.fml.common.Mod; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import java.util.Collections; +import java.util.Optional; + +import static net.minecraft.world.gen.settings.DimensionGeneratorSettings.withOverworld; + +@Mod.EventBusSubscriber( modid = TestMod.MOD_ID, 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( GuiScreenEvent.InitGuiEvent event ) + { + if( triggered || !(event.getGui() instanceof MainMenuScreen) ) return; + triggered = true; + + ClientHooks.openWorld(); + } + + private static void openWorld() + { + Minecraft minecraft = Minecraft.getInstance(); + + // Clear some options before we get any further. + minecraft.options.autoJump = false; + minecraft.options.renderClouds = CloudOption.OFF; + minecraft.options.particles = ParticleStatus.MINIMAL; + minecraft.options.tutorialStep = TutorialSteps.NONE; + minecraft.options.renderDistance = 6; + minecraft.options.gamma = 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" ); + + DynamicRegistries.Impl registries = DynamicRegistries.builtin(); + + Registry dimensions = registries.registryOrThrow( Registry.DIMENSION_TYPE_REGISTRY ); + Registry biomes = registries.registryOrThrow( Registry.BIOME_REGISTRY ); + DimensionGeneratorSettings generator = new DimensionGeneratorSettings( 0, false, false, withOverworld( + dimensions, + DimensionType.defaultDimensions( dimensions, biomes, registries.registryOrThrow( Registry.NOISE_GENERATOR_SETTINGS_REGISTRY ), 0 ), + new FlatChunkGenerator( new FlatGenerationSettings( + biomes, + new DimensionStructuresSettings( Optional.empty(), Collections.emptyMap() ), + Collections.singletonList( new FlatLayerInfo( 4, Blocks.WHITE_CONCRETE ) ), + false, false, + Optional.of( () -> biomes.getOrThrow( Biomes.DESERT ) ) + ) ) + ) ); + + WorldSettings settings = new WorldSettings( + "test", GameType.CREATIVE, false, Difficulty.PEACEFUL, true, + new GameRules(), DatapackCodec.DEFAULT + ); + Minecraft.getInstance().createLevel( "test", settings, registries, generator ); + } + } +} diff --git a/src/testMod/java/dan200/computercraft/ingame/mod/Copier.java b/src/testMod/java/dan200/computercraft/ingame/mod/Copier.java index 00b2fb9a2..baef379bc 100644 --- a/src/testMod/java/dan200/computercraft/ingame/mod/Copier.java +++ b/src/testMod/java/dan200/computercraft/ingame/mod/Copier.java @@ -13,23 +13,25 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.SimpleFileVisitor; import java.nio.file.attribute.BasicFileAttributes; +import java.util.function.Predicate; public final class Copier extends SimpleFileVisitor { private final Path sourceDir; private final Path targetDir; + private final Predicate predicate; - private Copier( Path sourceDir, Path targetDir ) + private Copier( Path sourceDir, Path targetDir, Predicate predicate ) { this.sourceDir = sourceDir; this.targetDir = targetDir; + this.predicate = predicate; } @Override public FileVisitResult visitFile( Path file, BasicFileAttributes attributes ) throws IOException { - Path targetFile = targetDir.resolve( sourceDir.relativize( file ) ); - Files.copy( file, targetFile ); + if( predicate.test( file ) ) Files.copy( file, targetDir.resolve( sourceDir.relativize( file ) ) ); return FileVisitResult.CONTINUE; } @@ -43,12 +45,17 @@ public final class Copier extends SimpleFileVisitor public static void copy( Path from, Path to ) throws IOException { - Files.walkFileTree( from, new Copier( from, to ) ); + Files.walkFileTree( from, new Copier( from, to, p -> true ) ); } public static void replicate( Path from, Path to ) throws IOException + { + replicate( from, to, p -> true ); + } + + public static void replicate( Path from, Path to, Predicate check ) throws IOException { if( Files.exists( to ) ) MoreFiles.deleteRecursively( to ); - copy( from, to ); + Files.walkFileTree( from, new Copier( from, to, check ) ); } } diff --git a/src/testMod/java/dan200/computercraft/ingame/mod/ImageUtils.java b/src/testMod/java/dan200/computercraft/ingame/mod/ImageUtils.java new file mode 100644 index 000000000..0a5dff2f9 --- /dev/null +++ b/src/testMod/java/dan200/computercraft/ingame/mod/ImageUtils.java @@ -0,0 +1,82 @@ +/* + * This file is part of ComputerCraft - http://www.computercraft.info + * Copyright Daniel Ratcliffe, 2011-2021. Do not distribute without permission. + * Send enquiries to dratcliffe@gmail.com + */ +package dan200.computercraft.ingame.mod; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.io.IOException; +import java.nio.file.Path; + +public final class ImageUtils +{ + private static final Logger LOG = LogManager.getLogger( ImageUtils.class ); + + /** + * Allow 0.05% of pixels to fail. This allows for slight differences at the edges. + */ + private static final double PIXEL_THRESHOLD = 0.0005; + + /** + * Maximum possible distance between two colours. Floating point differences means we need some fuzziness here. + */ + public static final int DISTANCE_THRESHOLD = 5; + + private ImageUtils() + { + } + + public static boolean areSame( BufferedImage left, BufferedImage right ) + { + int width = left.getWidth(), height = left.getHeight(); + if( width != right.getWidth() || height != right.getHeight() ) return false; + + int failed = 0, threshold = (int) (width * height * PIXEL_THRESHOLD); + for( int x = 0; x < width; x++ ) + { + for( int y = 0; y < height; y++ ) + { + int l = left.getRGB( x, y ), r = right.getRGB( x, y ); + if( (l & 0xFFFFFF) != (r & 0xFFFFFF) && distance( l, r, 0 ) + distance( l, r, 8 ) + distance( l, r, 16 ) >= DISTANCE_THRESHOLD ) + { + failed++; + } + } + } + + if( failed > 0 ) LOG.warn( "{} pixels failed comparing (threshold is {})", failed, threshold ); + return failed <= threshold; + } + + public static void writeDifference( Path path, BufferedImage left, BufferedImage right ) throws IOException + { + int width = left.getWidth(), height = left.getHeight(); + + BufferedImage copy = new BufferedImage( width, height, left.getType() ); + for( int x = 0; x < width; x++ ) + { + for( int y = 0; y < height; y++ ) + { + int l = left.getRGB( x, y ), r = right.getRGB( x, y ); + copy.setRGB( x, y, difference( l, r, 0 ) | difference( l, r, 8 ) | difference( l, r, 16 ) | 0xFF000000 ); + } + } + + ImageIO.write( copy, "png", path.toFile() ); + } + + private static int difference( int l, int r, int shift ) + { + return Math.abs( ((l >> shift) & 0xFF) - ((r >> shift) & 0xFF) ) << shift; + } + + private static int distance( int l, int r, int shift ) + { + return Math.abs( ((l >> shift) & 0xFF) - ((r >> shift) & 0xFF) ); + } +} diff --git a/src/testMod/server-files/screenshots/monitor_test.looks_acceptable.png b/src/testMod/server-files/screenshots/monitor_test.looks_acceptable.png new file mode 100644 index 0000000000000000000000000000000000000000..85d194053f48418002a2cdde4dbc01e515499e3e GIT binary patch literal 35796 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