1
0
mirror of https://github.com/SquidDev-CC/CC-Tweaked synced 2024-12-15 12:40:30 +00:00

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).
This commit is contained in:
Jonathan Coates 2021-08-20 13:32:42 +01:00
parent 4b33306940
commit 0ff6b0ca70
No known key found for this signature in database
GPG Key ID: B9E431FF07C98D06
11 changed files with 1125 additions and 59 deletions

View File

@ -32,7 +32,7 @@ jobs:
run: | run: |
./gradlew assemble || ./gradlew assemble ./gradlew assemble || ./gradlew assemble
./gradlew downloadAssets || ./gradlew downloadAssets ./gradlew downloadAssets || ./gradlew downloadAssets
./gradlew build xvfb-run ./gradlew build
- name: Upload Jar - name: Upload Jar
uses: actions/upload-artifact@v2 uses: actions/upload-artifact@v2
@ -40,6 +40,15 @@ jobs:
name: CC-Tweaked name: CC-Tweaked
path: build/libs 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 - name: Upload Coverage
uses: codecov/codecov-action@v1 uses: codecov/codecov-action@v1

View File

@ -80,6 +80,17 @@ minecraft {
args '--mod', 'computercraft', '--all', '--output', file('src/generated/resources/'), '--existing', file('src/main/resources/') 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 { testServer {
workingDirectory project.file('test-files/server') workingDirectory project.file('test-files/server')
parent runs.server parent runs.server
@ -352,50 +363,53 @@ task setupServer(type: Copy) {
into "test-files/server" into "test-files/server"
} }
tasks.register('testInGame', JavaExec.class).configure { ["Client", "Server"].forEach {name ->
it.group('test server') tasks.register("test$name", JavaExec.class).configure {
it.description("Runs tests on a temporary Minecraft server.") it.group('In-game tests')
it.dependsOn(setupServer, 'prepareRunTestServer', 'cleanTestInGame', 'compileTestModJava') 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 // 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). // isn't created until the task is configured (which is no good for us).
JavaExec exec = tasks.getByName('runTestServer') JavaExec exec = tasks.getByName("runTest$name")
exec.copyTo(it) exec.copyTo(it)
it.setClasspath(exec.getClasspath()) it.setClasspath(exec.getClasspath())
it.mainClass = exec.mainClass it.mainClass = exec.mainClass
it.setArgs(exec.getArgs()) it.setArgs(exec.getArgs())
it.systemProperty('forge.logging.console.level', 'info') it.systemProperty('forge.logging.console.level', 'info')
it.systemProperty('cctest.run', 'true') it.systemProperty('cctest.run', 'true')
// Jacoco and modlauncher don't play well together as the classes loaded in-game don't // 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 // match up with those written to disk. We get Jacoco to dump all classes to disk, and
// use that when generating the report. // use that when generating the report.
def coverageOut = new File(buildDir, 'jacocoClassDump/testInGame') def coverageOut = new File(buildDir, "jacocoClassDump/test$name")
jacoco.applyTo(it) jacoco.applyTo(it)
it.jacoco.setIncludes(["dan200.computercraft.*"]) it.jacoco.setIncludes(["dan200.computercraft.*"])
it.jacoco.setClassDumpDir(coverageOut) it.jacoco.setClassDumpDir(coverageOut)
it.outputs.dir(coverageOut) it.outputs.dir(coverageOut)
// Older versions of modlauncher don't include a protection domain (and thus no code // 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. // source). Jacoco skips such classes by default, so we need to explicitly include them.
it.jacoco.setIncludeNoLocationClasses(true) 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
} }
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 // Upload tasks

View File

@ -1,6 +1,7 @@
package dan200.computercraft.ingame package dan200.computercraft.ingame
import dan200.computercraft.ingame.api.* import dan200.computercraft.ingame.api.*
import dan200.computercraft.shared.Capabilities
import dan200.computercraft.shared.Registry import dan200.computercraft.shared.Registry
import dan200.computercraft.shared.peripheral.monitor.TileMonitor import dan200.computercraft.shared.peripheral.monitor.TileMonitor
import net.minecraft.block.Blocks 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()
}
} }

View File

@ -37,7 +37,7 @@ public class ComputerState
public void check( @Nonnull String marker ) public void check( @Nonnull String marker )
{ {
if( !markers.contains( marker ) ) throw new IllegalStateException( "Not yet at " + 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 ) public static ComputerState get( int id )

View File

@ -1,9 +1,22 @@
package dan200.computercraft.ingame.api 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.BlockState
import net.minecraft.block.Blocks
import net.minecraft.client.Minecraft
import net.minecraft.command.arguments.BlockStateInput 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.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. * 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) ComputerState.get(id).check(marker)
} }
/**
* Run a task on the client
*/
fun GameTestSequence.thenOnClient(task: ClientTestHelper.() -> Unit): GameTestSequence {
var future: CompletableFuture<Unit>? = 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. * 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 GameTestHelper.setBlock(pos: BlockPos, state: BlockStateInput) = state.place(level, absolutePos(pos), 3)
/**
fun GameTestSequence.thenExecuteSafe(runnable: Runnable) { * "Normalise" the current world in preparation for screenshots.
try { *
runnable.run() * Basically removes any dirt and replaces it with concrete.
} catch (e: GameTestAssertException) { */
throw e fun GameTestHelper.normaliseScene() {
} catch (e: RuntimeException) { val y = level.getHeightmapPos(Heightmap.Type.WORLD_SURFACE, absolutePos(BlockPos.ZERO))
ComputerCraft.log.error("Error in test", e) for (x in -100..100) {
throw GameTestAssertException(e.toString()) 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) }
} }
} }

View File

@ -7,14 +7,23 @@ package dan200.computercraft.ingame.mod;
import com.mojang.brigadier.CommandDispatcher; import com.mojang.brigadier.CommandDispatcher;
import dan200.computercraft.ComputerCraft; import dan200.computercraft.ComputerCraft;
import net.minecraft.client.Minecraft;
import net.minecraft.command.CommandSource; import net.minecraft.command.CommandSource;
import net.minecraft.data.NBTToSNBTConverter; 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.server.MinecraftServer;
import net.minecraft.test.*; import net.minecraft.test.*;
import net.minecraft.tileentity.StructureBlockTileEntity;
import net.minecraft.util.ResourceLocation; import net.minecraft.util.ResourceLocation;
import net.minecraft.util.math.BlockPos;
import net.minecraft.util.text.StringTextComponent; import net.minecraft.util.text.StringTextComponent;
import net.minecraft.util.text.TextFormatting; import net.minecraft.util.text.TextFormatting;
import net.minecraft.world.storage.FolderName; import net.minecraft.world.storage.FolderName;
import net.minecraftforge.fml.loading.FMLLoader;
import javax.annotation.Nonnull; import javax.annotation.Nonnull;
import java.io.IOException; import java.io.IOException;
@ -58,6 +67,37 @@ class CCTestCommand
result.addFailureListener( x -> TestRegistry.rememberFailedTest( x.getTestFunction() ) ); result.addFailureListener( x -> TestRegistry.rememberFailedTest( x.getTestFunction() ) );
return 0; 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 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 static class Callback implements ITestCallback
{ {
private final CommandSource source; private final CommandSource source;
private final TestResultList result; private final TestResultList result;
public Callback( CommandSource source, TestResultList result ) Callback( CommandSource source, TestResultList result )
{ {
this.source = source; this.source = source;
this.result = result; this.result = result;
@ -106,7 +162,13 @@ class CCTestCommand
{ {
if( !tracker.isDone() ) return; 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;
}
} }

View File

@ -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<DimensionType> dimensions = registries.registryOrThrow( Registry.DIMENSION_TYPE_REGISTRY );
Registry<Biome> 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 );
}
}
}

View File

@ -13,23 +13,25 @@ import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.file.SimpleFileVisitor; import java.nio.file.SimpleFileVisitor;
import java.nio.file.attribute.BasicFileAttributes; import java.nio.file.attribute.BasicFileAttributes;
import java.util.function.Predicate;
public final class Copier extends SimpleFileVisitor<Path> public final class Copier extends SimpleFileVisitor<Path>
{ {
private final Path sourceDir; private final Path sourceDir;
private final Path targetDir; private final Path targetDir;
private final Predicate<Path> predicate;
private Copier( Path sourceDir, Path targetDir ) private Copier( Path sourceDir, Path targetDir, Predicate<Path> predicate )
{ {
this.sourceDir = sourceDir; this.sourceDir = sourceDir;
this.targetDir = targetDir; this.targetDir = targetDir;
this.predicate = predicate;
} }
@Override @Override
public FileVisitResult visitFile( Path file, BasicFileAttributes attributes ) throws IOException public FileVisitResult visitFile( Path file, BasicFileAttributes attributes ) throws IOException
{ {
Path targetFile = targetDir.resolve( sourceDir.relativize( file ) ); if( predicate.test( file ) ) Files.copy( file, targetDir.resolve( sourceDir.relativize( file ) ) );
Files.copy( file, targetFile );
return FileVisitResult.CONTINUE; return FileVisitResult.CONTINUE;
} }
@ -43,12 +45,17 @@ public final class Copier extends SimpleFileVisitor<Path>
public static void copy( Path from, Path to ) throws IOException 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 public static void replicate( Path from, Path to ) throws IOException
{
replicate( from, to, p -> true );
}
public static void replicate( Path from, Path to, Predicate<Path> check ) throws IOException
{ {
if( Files.exists( to ) ) MoreFiles.deleteRecursively( to ); if( Files.exists( to ) ) MoreFiles.deleteRecursively( to );
copy( from, to ); Files.walkFileTree( from, new Copier( from, to, check ) );
} }
} }

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

View File

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