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 000000000..85d194053 Binary files /dev/null and b/src/testMod/server-files/screenshots/monitor_test.looks_acceptable.png differ diff --git a/src/testMod/server-files/structures/monitor_test.looks_acceptable.snbt b/src/testMod/server-files/structures/monitor_test.looks_acceptable.snbt new file mode 100644 index 000000000..c1c44576c --- /dev/null +++ b/src/testMod/server-files/structures/monitor_test.looks_acceptable.snbt @@ -0,0 +1,657 @@ +{ + size: [5, 5, 5], + entities: [ + { + nbt: { + Brain: { + memories: {} + }, + HurtByTimestamp: 0, + Attributes: [ + { + Base: 0.699999988079071d, + Name: "minecraft:generic.movement_speed" + } + ], + Invulnerable: 0b, + FallFlying: 0b, + ShowArms: 0b, + PortalCooldown: 0, + AbsorptionAmount: 0.0f, + FallDistance: 0.0f, + DisabledSlots: 0, + CanUpdate: 1b, + DeathTime: 0s, + Pose: {}, + id: "minecraft:armor_stand", + Invisible: 1b, + UUID: [I; -1245769654, -1089124211, -1971323071, 221540869], + Motion: [0.0d, 0.0d, 0.0d], + Small: 0b, + Health: 20.0f, + Air: 300s, + OnGround: 1b, + Marker: 1b, + Rotation: [-0.2999616f, 16.965813f], + HandItems: [ + {}, + {} + ], + CustomName: '{"text":"monitor_test.looks_acceptable"}', + Pos: [121.57308543720325d, 6.0d, 139.40392497295767d], + Fire: 0s, + ArmorItems: [ + {}, + {}, + {}, + {} + ], + NoBasePlate: 0b, + HurtTime: 0s + }, + blockPos: [2, 1, 0], + pos: [2.573085437203247d, 1.0d, 0.40392497295766816d] + } + ], + blocks: [ + { + pos: [0, 0, 0], + state: 0 + }, + { + pos: [0, 0, 1], + state: 0 + }, + { + pos: [0, 0, 2], + state: 0 + }, + { + pos: [0, 0, 3], + state: 0 + }, + { + pos: [0, 0, 4], + state: 0 + }, + { + pos: [1, 0, 0], + state: 0 + }, + { + pos: [1, 0, 1], + state: 0 + }, + { + pos: [1, 0, 2], + state: 0 + }, + { + pos: [1, 0, 3], + state: 0 + }, + { + pos: [1, 0, 4], + state: 0 + }, + { + pos: [2, 0, 0], + state: 0 + }, + { + pos: [2, 0, 1], + state: 0 + }, + { + pos: [2, 0, 2], + state: 0 + }, + { + pos: [2, 0, 3], + state: 0 + }, + { + pos: [2, 0, 4], + state: 0 + }, + { + pos: [3, 0, 0], + state: 0 + }, + { + pos: [3, 0, 1], + state: 0 + }, + { + pos: [3, 0, 2], + state: 0 + }, + { + pos: [3, 0, 3], + state: 0 + }, + { + pos: [3, 0, 4], + state: 0 + }, + { + pos: [4, 0, 0], + state: 0 + }, + { + pos: [4, 0, 1], + state: 0 + }, + { + pos: [4, 0, 2], + state: 0 + }, + { + pos: [4, 0, 3], + state: 0 + }, + { + pos: [4, 0, 4], + state: 0 + }, + { + pos: [0, 1, 0], + state: 1 + }, + { + pos: [0, 1, 1], + state: 1 + }, + { + pos: [0, 1, 2], + state: 1 + }, + { + pos: [0, 1, 3], + state: 1 + }, + { + pos: [0, 1, 4], + state: 1 + }, + { + pos: [1, 1, 0], + state: 1 + }, + { + pos: [1, 1, 1], + state: 1 + }, + { + pos: [1, 1, 3], + state: 1 + }, + { + pos: [1, 1, 4], + state: 1 + }, + { + pos: [2, 1, 0], + state: 1 + }, + { + pos: [2, 1, 1], + state: 1 + }, + { + pos: [2, 1, 3], + state: 1 + }, + { + pos: [2, 1, 4], + state: 1 + }, + { + pos: [3, 1, 0], + state: 1 + }, + { + pos: [3, 1, 1], + state: 1 + }, + { + pos: [3, 1, 3], + state: 1 + }, + { + pos: [3, 1, 4], + state: 1 + }, + { + pos: [4, 1, 0], + state: 1 + }, + { + pos: [4, 1, 1], + state: 1 + }, + { + pos: [4, 1, 2], + state: 1 + }, + { + pos: [4, 1, 3], + state: 1 + }, + { + pos: [4, 1, 4], + state: 1 + }, + { + pos: [0, 2, 0], + state: 1 + }, + { + pos: [0, 2, 1], + state: 1 + }, + { + pos: [0, 2, 2], + state: 1 + }, + { + pos: [0, 2, 3], + state: 1 + }, + { + pos: [0, 2, 4], + state: 1 + }, + { + pos: [1, 2, 0], + state: 1 + }, + { + pos: [1, 2, 1], + state: 1 + }, + { + pos: [1, 2, 3], + state: 1 + }, + { + pos: [1, 2, 4], + state: 1 + }, + { + pos: [2, 2, 0], + state: 1 + }, + { + pos: [2, 2, 1], + state: 1 + }, + { + pos: [2, 2, 3], + state: 1 + }, + { + pos: [2, 2, 4], + state: 1 + }, + { + pos: [3, 2, 0], + state: 1 + }, + { + pos: [3, 2, 1], + state: 1 + }, + { + pos: [3, 2, 3], + state: 1 + }, + { + pos: [3, 2, 4], + state: 1 + }, + { + pos: [4, 2, 0], + state: 1 + }, + { + pos: [4, 2, 1], + state: 1 + }, + { + pos: [4, 2, 2], + state: 1 + }, + { + pos: [4, 2, 3], + state: 1 + }, + { + pos: [4, 2, 4], + state: 1 + }, + { + pos: [0, 3, 0], + state: 1 + }, + { + pos: [0, 3, 1], + state: 1 + }, + { + pos: [0, 3, 2], + state: 1 + }, + { + pos: [0, 3, 3], + state: 1 + }, + { + pos: [0, 3, 4], + state: 1 + }, + { + pos: [1, 3, 0], + state: 1 + }, + { + pos: [1, 3, 1], + state: 1 + }, + { + pos: [1, 3, 2], + state: 1 + }, + { + pos: [1, 3, 3], + state: 1 + }, + { + pos: [1, 3, 4], + state: 1 + }, + { + pos: [2, 3, 0], + state: 1 + }, + { + pos: [2, 3, 1], + state: 1 + }, + { + pos: [2, 3, 2], + state: 1 + }, + { + pos: [2, 3, 3], + state: 1 + }, + { + pos: [2, 3, 4], + state: 1 + }, + { + pos: [3, 3, 0], + state: 1 + }, + { + pos: [3, 3, 1], + state: 1 + }, + { + pos: [3, 3, 2], + state: 1 + }, + { + pos: [3, 3, 3], + state: 1 + }, + { + pos: [3, 3, 4], + state: 1 + }, + { + pos: [4, 3, 0], + state: 1 + }, + { + pos: [4, 3, 1], + state: 1 + }, + { + pos: [4, 3, 2], + state: 1 + }, + { + pos: [4, 3, 3], + state: 1 + }, + { + pos: [4, 3, 4], + state: 1 + }, + { + pos: [0, 4, 0], + state: 1 + }, + { + pos: [0, 4, 1], + state: 1 + }, + { + pos: [0, 4, 2], + state: 1 + }, + { + pos: [0, 4, 3], + state: 1 + }, + { + pos: [0, 4, 4], + state: 1 + }, + { + pos: [1, 4, 0], + state: 1 + }, + { + pos: [1, 4, 1], + state: 1 + }, + { + pos: [1, 4, 2], + state: 1 + }, + { + pos: [1, 4, 3], + state: 1 + }, + { + pos: [1, 4, 4], + state: 1 + }, + { + pos: [2, 4, 0], + state: 1 + }, + { + pos: [2, 4, 1], + state: 1 + }, + { + pos: [2, 4, 2], + state: 1 + }, + { + pos: [2, 4, 3], + state: 1 + }, + { + pos: [2, 4, 4], + state: 1 + }, + { + pos: [3, 4, 0], + state: 1 + }, + { + pos: [3, 4, 1], + state: 1 + }, + { + pos: [3, 4, 2], + state: 1 + }, + { + pos: [3, 4, 3], + state: 1 + }, + { + pos: [3, 4, 4], + state: 1 + }, + { + pos: [4, 4, 0], + state: 1 + }, + { + pos: [4, 4, 1], + state: 1 + }, + { + pos: [4, 4, 2], + state: 1 + }, + { + pos: [4, 4, 3], + state: 1 + }, + { + pos: [4, 4, 4], + state: 1 + }, + { + nbt: { + XIndex: 2, + Height: 2, + id: "computercraft:monitor_advanced", + Width: 3, + YIndex: 0 + }, + pos: [1, 1, 2], + state: 2 + }, + { + nbt: { + XIndex: 1, + Height: 2, + id: "computercraft:monitor_advanced", + Width: 3, + YIndex: 0 + }, + pos: [2, 1, 2], + state: 3 + }, + { + nbt: { + XIndex: 0, + Height: 2, + id: "computercraft:monitor_advanced", + Width: 3, + YIndex: 0 + }, + pos: [3, 1, 2], + state: 4 + }, + { + nbt: { + XIndex: 2, + Height: 2, + id: "computercraft:monitor_advanced", + Width: 3, + YIndex: 1 + }, + pos: [1, 2, 2], + state: 5 + }, + { + nbt: { + XIndex: 1, + Height: 2, + id: "computercraft:monitor_advanced", + Width: 3, + YIndex: 1 + }, + pos: [2, 2, 2], + state: 6 + }, + { + nbt: { + XIndex: 0, + Height: 2, + id: "computercraft:monitor_advanced", + Width: 3, + YIndex: 1 + }, + pos: [3, 2, 2], + state: 7 + } + ], + palette: [ + { + Name: "minecraft:polished_andesite" + }, + { + Name: "minecraft:air" + }, + { + Properties: { + orientation: "north", + facing: "north", + state: "lu" + }, + Name: "computercraft:monitor_advanced" + }, + { + Properties: { + orientation: "north", + facing: "north", + state: "lru" + }, + Name: "computercraft:monitor_advanced" + }, + { + Properties: { + orientation: "north", + facing: "north", + state: "ru" + }, + Name: "computercraft:monitor_advanced" + }, + { + Properties: { + orientation: "north", + facing: "north", + state: "ld" + }, + Name: "computercraft:monitor_advanced" + }, + { + Properties: { + orientation: "north", + facing: "north", + state: "lrd" + }, + Name: "computercraft:monitor_advanced" + }, + { + Properties: { + orientation: "north", + facing: "north", + state: "rd" + }, + Name: "computercraft:monitor_advanced" + } + ], + DataVersion: 2586 +}