Skip to content

Commit

Permalink
Replace reference tests with screenshot tests and do some fixes and i…
Browse files Browse the repository at this point in the history
…mprovements (#1320)
  • Loading branch information
soywiz authored Feb 12, 2023
1 parent ed46111 commit 82f56dd
Show file tree
Hide file tree
Showing 85 changed files with 424 additions and 4,112 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -672,6 +672,14 @@ object RootKorlibsPlugin {
if (!JvmAddOpens.beforeJava9) jvmArgs(*JvmAddOpens.createAddOpensTypedArray())
if (headlessTests) systemProperty("java.awt.headless", "true")
}
val jvmTestInteractive = tasks.createThis<Test>("jvmTestInteractive") {
group = "verification"
environment("INTERACTIVE_SCREENSHOT", "true")
testClassesDirs = jvmTest.testClassesDirs
classpath = jvmTest.classpath
bootstrapClasspath = jvmTest.bootstrapClasspath
if (!JvmAddOpens.beforeJava9) jvmArgs(*JvmAddOpens.createAddOpensTypedArray())
}
if (!JvmAddOpens.beforeJava9) jvmTest.jvmArgs(*JvmAddOpens.createAddOpensTypedArray())
if (headlessTests) jvmTest.systemProperty("java.awt.headless", "true")
}
Expand Down
4 changes: 4 additions & 0 deletions korge-sandbox/src/commonMain/kotlin/Main.kt
Original file line number Diff line number Diff line change
@@ -1,16 +1,20 @@

import com.soywiz.korge.*
import com.soywiz.korge.particle.*
import com.soywiz.korge.scene.*
import com.soywiz.korge.time.*
import com.soywiz.korge.ui.*
import com.soywiz.korge.view.*
import com.soywiz.korim.color.*
import com.soywiz.korio.async.*
import com.soywiz.korio.file.std.*
import com.soywiz.korio.lang.*
import samples.*
import samples.asteroids.*
import samples.connect4.*
import samples.minesweeper.*
import samples.pong.*
import kotlin.random.*

val DEFAULT_KORGE_BG_COLOR = Colors.DARKCYAN.mix(Colors.BLACK, 0.8)

Expand Down
8 changes: 5 additions & 3 deletions korge/src/commonMain/kotlin/com/soywiz/korge/Korge.kt
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,7 @@ object Korge {
batchMaxQuads: Int = BatchBuilder2D.DEFAULT_BATCH_QUADS,
multithreaded: Boolean? = null,
forceRenderEveryFrame: Boolean = true,
stageBuilder: (Views) -> Stage = { Stage(it) },
entry: suspend Stage.() -> Unit
) {
RegisteredImageFormats.register(imageFormats)
Expand Down Expand Up @@ -218,7 +219,8 @@ object Korge {
gameWindow = gameWindow,
gameId = gameId,
settingsFolder = settingsFolder,
batchMaxQuads = batchMaxQuads
batchMaxQuads = batchMaxQuads,
stageBuilder = stageBuilder
).also {
it.init()
}
Expand Down Expand Up @@ -526,6 +528,7 @@ object Korge {
val firstRenderDeferred = CompletableDeferred<Unit>()

fun renderBlock(event: RenderEvent) {
//println("renderBlock: $event")
try {
views.frameUpdateAndRender(
fixedSizeStep = fixedSizeStep,
Expand All @@ -549,10 +552,9 @@ object Korge {
}

views.gameWindow.onRenderEvent { event ->
//println("RenderEvent: $it")
//println("RenderEvent: $event")
if (!event.render) {
renderBlock(event)

} else {
views.renderContext.doRender {
if (!renderShown) {
Expand Down
37 changes: 32 additions & 5 deletions korge/src/commonMain/kotlin/com/soywiz/korge/KorgeHeadless.kt
Original file line number Diff line number Diff line change
Expand Up @@ -11,18 +11,43 @@ import com.soywiz.korim.color.*
import com.soywiz.korim.format.*
import com.soywiz.korinject.*
import com.soywiz.korma.geom.*
import kotlinx.coroutines.*

object KorgeHeadless {
class HeadlessGameWindowCoroutineDispatcher(val gameWindow: HeadlessGameWindow) : GameWindowCoroutineDispatcher() {
//init {
// frameRenderLoop()
//}
//
//fun frameRenderLoop() {
// this.invokeOnTimeout(16L, Runnable {
// //println("frameRenderLoop")
// gameWindow.frameRender()
// frameRenderLoop()
// }, gameWindow.coroutineDispatcher)
//}

override fun executePending(availableTime: TimeSpan) {
//println("HeadlessGameWindowCoroutineDispatcher.executePending: timedTasks=${_timedTasks.size}, tasks=${_tasks.size}")
super.executePending(availableTime)
}
}

class HeadlessGameWindow(
override val width: Int = 640,
override val height: Int = 480,
val draw: Boolean = false,
override val ag: AG = AGDummy(width, height),
exitProcessOnClose: Boolean = false
exitProcessOnClose: Boolean = false,
override val devicePixelRatio: Double = 1.0,
) : GameWindow() {
init {
this.exitProcessOnClose = exitProcessOnClose
}

override val coroutineDispatcher: GameWindowCoroutineDispatcher = HeadlessGameWindowCoroutineDispatcher(this)


//override val ag: AG = if (draw) AGSoftware(width, height) else DummyAG(width, height)
//override val ag: AG = AGDummy(width, height)
}
Expand Down Expand Up @@ -54,17 +79,19 @@ object KorgeHeadless {
debugAg: Boolean = false,
draw: Boolean = false,
ag: AG = AGDummy(width, height),
entry: suspend Stage.() -> Unit
devicePixelRatio: Double = 1.0,
stageBuilder: (Views) -> Stage = { Stage(it) },
entry: suspend Stage.() -> Unit,
): HeadlessGameWindow {
val gameWindow = HeadlessGameWindow(width, height, draw = draw, ag = ag)
val gameWindow = HeadlessGameWindow(width, height, draw = draw, ag = ag, devicePixelRatio = devicePixelRatio)
gameWindow.exitProcessOnClose = false
Korge(
title, width, height, virtualWidth, virtualHeight, icon, iconPath, /*iconDrawable,*/ imageFormats, quality,
targetFps, scaleAnchor, scaleMode, clipBorders, bgcolor, debug, debugFontExtraScale, debugFontColor,
fullscreen, args, gameWindow, timeProvider, injector,
blocking = blocking, debugAg = debugAg, entry = {
blocking = blocking, debugAg = debugAg, stageBuilder = stageBuilder, entry = {
entry()
}
}, forceRenderEveryFrame = true
)
return gameWindow
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -875,10 +875,13 @@ class BatchBuilder2D constructor(
private val batches = fastArrayListOf<AGBatch>()

private var lastIndexPos = 0
var batchCount = 0
var fullBatchCount = 0

fun createBatchIfRequired() {
if (lastIndexPos == indexPos) return
updateStandardUniforms()
batchCount++

//println("BATCH: currentBuffers.vertexData=${currentBuffers.vertexData}")
batches += AGBatch(
Expand Down Expand Up @@ -930,6 +933,7 @@ class BatchBuilder2D constructor(
ag.draw(AGMultiBatch(batches.toList()))
batches.clear()
beforeFlush(this)
fullBatchCount++

buffersListToReturn += currentBuffers
currentBuffers = buffersList.alloc()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,7 @@ import com.soywiz.kds.IntMap
import com.soywiz.kds.toIntMap
import com.soywiz.korim.bitmap.*
import com.soywiz.korim.font.BitmapFont
import com.soywiz.korim.format.PNG
import com.soywiz.korim.format.readBitmap
import com.soywiz.korim.format.*
import com.soywiz.korio.stream.openAsync
import com.soywiz.krypto.encoding.fromBase64
import kotlin.native.concurrent.ThreadLocal
Expand Down Expand Up @@ -41,7 +40,10 @@ fun debugBmpFont(tex: BmpSlice): BitmapFont {
}.toIntMap(), IntMap())
}

suspend fun debugBmpFont(): BitmapFont = debugBmpFont(DEBUG_FONT_BYTES.openAsync().readBitmap().slice())
suspend fun debugBmpFont(): BitmapFont {
//debugBmpFontSync
return debugBmpFont(DEBUG_FONT_BYTES.openAsync().readBitmap(ImageDecodingProps(preferKotlinDecoder = true)).slice())
}

@Deprecated("Use debugBmpFont() instead")
val debugBmpFontSync: BitmapFont get() {
Expand Down
2 changes: 1 addition & 1 deletion korge/src/commonMain/kotlin/com/soywiz/korge/view/Stage.kt
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import kotlinx.coroutines.*
* Singleton root [View] and [Container] that contains a reference to the [Views] singleton and doesn't have any parent.
*/
@RootViewDslMarker
class Stage(override val views: Views) : FixedSizeContainer()
open class Stage internal constructor(override val views: Views) : FixedSizeContainer()
, View.Reference
, CoroutineScope by views
, EventDispatcher by EventDispatcher.Mixin()
Expand Down
5 changes: 3 additions & 2 deletions korge/src/commonMain/kotlin/com/soywiz/korge/view/Views.kt
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ class Views constructor(
val settingsFolder: String? = null,
val batchMaxQuads: Int = BatchBuilder2D.DEFAULT_BATCH_QUADS,
val bp: BoundsProvider = BoundsProvider.Base(),
val stageBuilder: (Views) -> Stage = { Stage(it) }
) :
Extra by Extra.Mixin(),
EventDispatcher by EventDispatcher.Mixin(),
Expand Down Expand Up @@ -198,7 +199,7 @@ class Views constructor(
private val resizedEvent = ReshapeEvent(0, 0)

/** Reference to the root node [Stage] */
val stage: Stage = Stage(this)
val stage: Stage = stageBuilder(this)

/** Reference to the root node [Stage] (alias) */
val root = stage
Expand Down Expand Up @@ -531,7 +532,7 @@ class ViewsLog constructor(
suspend fun init() {
if (!initialized) {
initialized = true
RegisteredImageFormats.register(PNG) // This might be required for Node.JS debug bitmap font in tests
RegisteredImageFormats.register(QOI, PNG) // This might be required for Node.JS debug bitmap font in tests
views.init()
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ class KorgeHeadlessTest {
}

while (true) {
println("STEP")
//println("STEP")
image.tween(image::rotation[minDegrees], time = 0.5.seconds, easing = Easing.EASE_IN_OUT)
image.tween(image::rotation[maxDegrees], time = 0.5.seconds, easing = Easing.EASE_IN_OUT)
views.gameWindow.close() // We close the window, finalizing the test here
Expand Down
114 changes: 63 additions & 51 deletions korge/src/jvmMain/kotlin/com/soywiz/korge/testing/BitmapAsserter.kt
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.soywiz.korge.testing

import com.soywiz.kds.*
import com.soywiz.korge.view.*
import com.soywiz.korim.awt.*
import com.soywiz.korim.bitmap.*
Expand All @@ -9,23 +10,31 @@ import com.soywiz.korio.file.std.*
import com.soywiz.korio.lang.*
import java.awt.*
import java.io.*
import java.nio.file.Files
import javax.swing.*
import kotlin.math.*

object BitmapComparer {
data class CompareResult(
val pixelDiffCount: Int = 0,
val pixelTotalDistance: Int = 0,
val pixelMaxDistance: Int = 0,
val pixelDiffCount: Int = -1,
val pixelTotalDistance: Int = -1,
val pixelMaxDistance: Int = -1,
val psnr: Double = 0.0,
val error: String = ""
) {
val strictEquals: Boolean get() = pixelDiffCount == 0
val reasonablySimilar: Boolean get() = pixelMaxDistance <= 3 || psnr >= 45.0
//val strictEquals: Boolean get() = pixelDiffCount == 0
//val reasonablySimilar: Boolean get() = pixelMaxDistance <= 3 || psnr >= 45.0
}

fun compare(left: Bitmap, right: Bitmap): CompareResult {
if (left.premultiplied != right.premultiplied) error("premultiplied left=${left.premultiplied}, right=${right.premultiplied}")
if (left.width != right.width || left.height != right.height) error("dimensions left=${left.width}x${left.height}, right=${right.width}x${right.height}")
if (left.premultiplied != right.premultiplied) {
return CompareResult(error = "premultiplied left=${left.premultiplied}, right=${right.premultiplied}")
}
if (left.width != right.width || left.height != right.height) {
return CompareResult(
error = "dimensions left=${left.width}x${left.height}, right=${right.width}x${right.height}"
)
}
var pixelDiffCount = 0
var pixelTotalDistance = 0
var pixelMaxDistance = 0
Expand All @@ -51,28 +60,9 @@ object BitmapComparer {
}
}

private fun showBitmapDiffDialog(referenceBitmap: Bitmap32, actualBitmap: Bitmap32, title: String): Boolean? {
private fun showBitmapDiffDialog(referenceBitmap: Bitmap32, actualBitmap: Bitmap32, title: String): Boolean {
var doAccept: Boolean? = null
val diff = Bitmap32.diff(actualBitmap, referenceBitmap)
var maxR = 0
var maxG = 0
var maxB = 0

diff.forEach { n, x, y ->
val c = diff[x, y]
maxR = max(maxR, c.r)
maxG = max(maxG, c.g)
maxB = max(maxB, c.b)
}
diff.forEach { n, x, y ->
val c = diff[x, y]
diff[x, y] = RGBA.float(
if (maxR == 0) 0f else c.rf / maxR.toFloat(),
if (maxG == 0) 0f else c.gf / maxG.toFloat(),
if (maxB == 0) 0f else c.bf / maxB.toFloat(),
1f
)
}
val diff = Bitmap32.diffEx(actualBitmap, referenceBitmap)
val frame = JFrame()
lateinit var accept: JButton
lateinit var discard: JButton
Expand All @@ -94,41 +84,63 @@ private fun showBitmapDiffDialog(referenceBitmap: Bitmap32, actualBitmap: Bitmap
frame.toFront()
frame.repaint()
while (frame.isVisible) Thread.sleep(100L)
return doAccept
return doAccept ?: false
}

suspend fun Stage.assertScreenshot(view: View, name: String, psnr: Double = 50.0, scale: Double = 1.0, posterize: Int = 0) {
private var OffscreenStage.testIndex: Int by extraProperty { 0 }

suspend fun OffscreenStage.assertScreenshot(
view: View = this,
name: String = "$testIndex",
psnr: Double = 40.0,
//scale: Double = 1.0,
posterize: Int = 0,
includeBackground: Boolean = true
) {
testIndex++
val updateTestRef = Environment["UPDATE_TEST_REF"] == "true"
val context = injector.getOrNull<OffscreenContext>() ?: OffscreenContext()
val interactive = Environment["INTERACTIVE_SCREENSHOT"] == "true"
val context = injector.getSyncOrNull<OffscreenContext>() ?: OffscreenContext()
val outFile = File("testGoldens/${context.testClassName}/${context.testMethodName}_$name.png")
val actualBitmap = view.renderToBitmap(views).depremultiplied().posterizeInplace(posterize)
var doAccept: Boolean? = null
var updateReference = true
val actualBitmap = views.ag.startEndFrame { view.unsafeRenderToBitmapSync(views.renderContext, bgcolor = if (includeBackground) views.clearColor else Colors.TRANSPARENT).depremultiplied().posterizeInplace(posterize) }
var doAccept: Boolean? = false
var updateReference = updateTestRef
if (outFile.exists()) {
val referenceBitmap = outFile.toVfs().readNativeImage(ImageDecodingProps.DEFAULT_STRAIGHT).toBMP32().posterizeInplace(posterize)
val ref = referenceBitmap.scaleLinear(scale, scale)
val act = actualBitmap.scaleLinear(scale)
val result = BitmapComparer.compare(ref, act)
val referenceBitmap = runBlockingNoJs { outFile.toVfs().readNativeImage(ImageDecodingProps.DEFAULT_STRAIGHT).toBMP32().posterizeInplace(posterize) }
//val ref = referenceBitmap.scaleLinear(scale, scale)
//val act = actualBitmap.scaleLinear(scale)
val result = BitmapComparer.compare(referenceBitmap, actualBitmap)
if (!updateTestRef) {
val similar = result.psnr >= psnr
if (!similar) {
//if (true) {
if (Environment["INTERACTIVE_SCREENSHOT"] == "true") {
//if (true) {
doAccept = showBitmapDiffDialog(referenceBitmap, actualBitmap, "Bitmaps are not equal $referenceBitmap-$actualBitmap\n$result")

}
}
if (doAccept != null) {
assert(similar) { "Bitmaps are not equal $ref-$act : $result.\nRun ./gradlew jvmTestFix to update goldens\nOr set INTERACTIVE_SCREENSHOT=true" }
if (!similar && interactive) {
updateReference = showBitmapDiffDialog(referenceBitmap, actualBitmap, "Bitmaps are not equal $referenceBitmap-$actualBitmap\n$result\n${result.error}")
}

if (doAccept == true) {
updateReference = false
if (!updateReference) {
val baseName = "${context.testClassName}_${context.testMethodName}_$name.png"
val tempFile = File(Environment.tempPath, baseName)
val tempDiffFile = File(Environment.tempPath, "diff_$baseName")
if (!similar) {
tempFile.writeBytes(PNG.encode(actualBitmap.tryToExactBitmap8() ?: actualBitmap, ImageEncodingProps(quality = 1.0)))
tempDiffFile.writeBytes(PNG.encode(Bitmap32.diffEx(actualBitmap, referenceBitmap), ImageEncodingProps(quality = 1.0)))
}
assert(similar) {
"Bitmaps are not equal $referenceBitmap-$actualBitmap : $result.\n" +
"${result.error}\n" +
"Run ./gradlew jvmTestFix to update goldens\n" +
"Or set INTERACTIVE_SCREENSHOT=true\n" +
"\n" +
"Generated: ${tempFile.absoluteFile}\n" +
"Diff: ${tempDiffFile.absoluteFile}\n" +
"Expected Directory: ${outFile.parentFile.absoluteFile}\n" +
"Expected File: ${outFile.absoluteFile}"
}
}
}
}
if (updateReference) {
outFile.parentFile.mkdirs()
outFile.writeBytes(PNG.encode(actualBitmap.tryToExactBitmap8() ?: actualBitmap, ImageEncodingProps(quality = 1.0)))
println("Folder ${outFile.parentFile.absoluteFile}")
println("Updated ${outFile.absoluteFile}")
}
}
Loading

0 comments on commit 82f56dd

Please sign in to comment.