Add cross-platform canvas test and fix JS text rendering
This commit is contained in:
parent
aa9bd1082b
commit
bb3440279e
@ -1,9 +1,33 @@
|
|||||||
const path = require("path");
|
const path = require("path");
|
||||||
|
const fs = require("fs");
|
||||||
const migrationsDir = path.resolve(__dirname, "../../../../uhabits-core/assets/main/migrations");
|
const migrationsDir = path.resolve(__dirname, "../../../../uhabits-core/assets/main/migrations");
|
||||||
const testAssetsDir = path.resolve(__dirname, "../../../../uhabits-core/assets/test");
|
const testAssetsDir = path.resolve(__dirname, "../../../../uhabits-core/assets/test");
|
||||||
const fontsDir = path.resolve(__dirname, "../../../../uhabits-core/assets/main/fonts");
|
const fontsDir = path.resolve(__dirname, "../../../../uhabits-core/assets/main/fonts");
|
||||||
|
|
||||||
|
function saveFileMiddleware(req, res, next) {
|
||||||
|
if (req.url !== "/save-file" || req.method !== "POST") return next();
|
||||||
|
const filePath = req.headers["x-file-path"];
|
||||||
|
if (!filePath) {
|
||||||
|
res.writeHead(400);
|
||||||
|
res.end("Missing X-File-Path header");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const chunks = [];
|
||||||
|
req.on("data", chunk => chunks.push(chunk));
|
||||||
|
req.on("end", () => {
|
||||||
|
const dir = path.dirname(filePath);
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
fs.writeFileSync(filePath, Buffer.concat(chunks));
|
||||||
|
res.writeHead(200);
|
||||||
|
res.end("OK");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
config.set({
|
config.set({
|
||||||
|
beforeMiddleware: ["saveFile"],
|
||||||
|
plugins: (config.plugins || []).concat([
|
||||||
|
{ "middleware:saveFile": ["value", saveFileMiddleware] }
|
||||||
|
]),
|
||||||
browserNoActivityTimeout: 120000,
|
browserNoActivityTimeout: 120000,
|
||||||
browserDisconnectTimeout: 30000,
|
browserDisconnectTimeout: 30000,
|
||||||
browserDisconnectTolerance: 3,
|
browserDisconnectTolerance: 3,
|
||||||
|
|||||||
@ -0,0 +1,16 @@
|
|||||||
|
package org.isoron.platform.gui
|
||||||
|
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.isoron.platform.io.createTestCanvas
|
||||||
|
import org.isoron.platform.io.ensureFontsLoaded
|
||||||
|
import kotlin.test.Test
|
||||||
|
|
||||||
|
class CanvasTest {
|
||||||
|
@Test
|
||||||
|
fun testDrawTestImage() = runTest {
|
||||||
|
ensureFontsLoaded()
|
||||||
|
val canvas = createTestCanvas(500, 400)
|
||||||
|
canvas.drawTestImage()
|
||||||
|
assertRenders("views/CanvasTest.png", canvas)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -45,12 +45,15 @@ class JsCanvas(
|
|||||||
val py = toPixel(y)
|
val py = toPixel(y)
|
||||||
val metrics = ctx.measureText(text)
|
val metrics = ctx.measureText(text)
|
||||||
val textWidth = metrics.width
|
val textWidth = metrics.width
|
||||||
|
val ascent = metrics.asDynamic().actualBoundingBoxAscent as Double
|
||||||
|
val descent = metrics.asDynamic().actualBoundingBoxDescent as Double
|
||||||
val xPos = when (textAlign) {
|
val xPos = when (textAlign) {
|
||||||
TextAlign.CENTER -> px - textWidth / 2
|
TextAlign.CENTER -> px - textWidth / 2
|
||||||
TextAlign.RIGHT -> px - textWidth
|
TextAlign.RIGHT -> px - textWidth
|
||||||
TextAlign.LEFT -> px
|
TextAlign.LEFT -> px
|
||||||
}
|
}
|
||||||
ctx.fillText(text, xPos, py)
|
val yPos = py + (ascent - descent) / 2
|
||||||
|
ctx.fillText(text, xPos, yPos)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun fillRect(x: Double, y: Double, width: Double, height: Double) {
|
override fun fillRect(x: Double, y: Double, width: Double, height: Double) {
|
||||||
@ -148,7 +151,6 @@ class JsCanvas(
|
|||||||
}
|
}
|
||||||
val weight = if (font == Font.BOLD) "bold" else "normal"
|
val weight = if (font == Font.BOLD) "bold" else "normal"
|
||||||
ctx.font = "$weight ${sizePx}px $family"
|
ctx.font = "$weight ${sizePx}px $family"
|
||||||
ctx.asDynamic().textBaseline = "middle"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|||||||
@ -1,8 +1,14 @@
|
|||||||
package org.isoron.platform.gui
|
package org.isoron.platform.gui
|
||||||
|
|
||||||
import kotlinx.browser.document
|
import kotlinx.browser.document
|
||||||
|
import kotlinx.browser.window
|
||||||
import org.w3c.dom.CanvasRenderingContext2D
|
import org.w3c.dom.CanvasRenderingContext2D
|
||||||
import org.w3c.dom.HTMLCanvasElement
|
import org.w3c.dom.HTMLCanvasElement
|
||||||
|
import org.w3c.fetch.RequestInit
|
||||||
|
import kotlin.coroutines.resume
|
||||||
|
import kotlin.coroutines.resumeWithException
|
||||||
|
import kotlin.coroutines.suspendCoroutine
|
||||||
|
import kotlin.js.json
|
||||||
|
|
||||||
class JsImage(private val canvas: HTMLCanvasElement) : Image {
|
class JsImage(private val canvas: HTMLCanvasElement) : Image {
|
||||||
private val ctx = canvas.getContext("2d") as CanvasRenderingContext2D
|
private val ctx = canvas.getContext("2d") as CanvasRenderingContext2D
|
||||||
@ -31,7 +37,20 @@ class JsImage(private val canvas: HTMLCanvasElement) : Image {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun export(path: String) {
|
override suspend fun export(path: String) {
|
||||||
// No-op on JS browser; images can't be exported to filesystem
|
val blob = suspendCoroutine<dynamic> { cont ->
|
||||||
|
canvas.asDynamic().toBlob { b: dynamic -> cont.resume(b) }
|
||||||
|
}
|
||||||
|
val init = RequestInit(
|
||||||
|
method = "POST",
|
||||||
|
body = blob,
|
||||||
|
headers = json("X-File-Path" to path),
|
||||||
|
)
|
||||||
|
suspendCoroutine<Unit> { cont ->
|
||||||
|
window.fetch("/save-file", init).then(
|
||||||
|
{ cont.resume(Unit) },
|
||||||
|
{ err -> cont.resumeWithException(RuntimeException("$err")) }
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import org.isoron.platform.gui.JsCanvas
|
|||||||
import org.isoron.platform.time.JsLocalDateFormatter
|
import org.isoron.platform.time.JsLocalDateFormatter
|
||||||
import org.isoron.platform.time.LocalDateFormatter
|
import org.isoron.platform.time.LocalDateFormatter
|
||||||
import kotlin.coroutines.resume
|
import kotlin.coroutines.resume
|
||||||
|
import kotlin.coroutines.resumeWithException
|
||||||
import kotlin.coroutines.suspendCoroutine
|
import kotlin.coroutines.suspendCoroutine
|
||||||
|
|
||||||
val sharedTestStorage = JsFileStorage()
|
val sharedTestStorage = JsFileStorage()
|
||||||
@ -48,10 +49,14 @@ actual suspend fun ensureFontsLoaded() {
|
|||||||
fontsLoaded = true
|
fontsLoaded = true
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun loadFontAsync(fontSpec: String) = suspendCoroutine<Unit> { cont ->
|
private suspend fun loadFontAsync(fontSpec: String) {
|
||||||
val promise = document.asDynamic().fonts.load(fontSpec)
|
suspendCoroutine<Unit> { cont ->
|
||||||
promise.then(
|
val promise = document.asDynamic().fonts.load(fontSpec)
|
||||||
{ _: dynamic -> cont.resume(Unit) },
|
promise.then(
|
||||||
{ _: dynamic -> cont.resume(Unit) }
|
{ _: dynamic -> cont.resume(Unit) },
|
||||||
)
|
{ err: dynamic -> cont.resumeWithException(RuntimeException("Failed to load font '$fontSpec': $err")) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val loaded = document.asDynamic().fonts.check(fontSpec) as Boolean
|
||||||
|
if (!loaded) error("Font not available after loading: '$fontSpec'")
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,77 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright (C) 2016-2025 Álinson Santos Xavier <git@axavier.org>
|
|
||||||
*
|
|
||||||
* This file is part of Loop Habit Tracker.
|
|
||||||
*
|
|
||||||
* Loop Habit Tracker is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU General Public License as published by the
|
|
||||||
* Free Software Foundation, either version 3 of the License, or (at your
|
|
||||||
* option) any later version.
|
|
||||||
*
|
|
||||||
* Loop Habit Tracker is distributed in the hope that it will be useful, but
|
|
||||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
|
||||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
|
|
||||||
* more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU General Public License along
|
|
||||||
* with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package org.isoron.platform.gui
|
|
||||||
|
|
||||||
import kotlinx.coroutines.runBlocking
|
|
||||||
import org.isoron.platform.io.JavaFileOpener
|
|
||||||
import org.junit.Assert.fail
|
|
||||||
import org.junit.Test
|
|
||||||
import java.awt.image.BufferedImage
|
|
||||||
import java.awt.image.BufferedImage.TYPE_INT_ARGB
|
|
||||||
|
|
||||||
class JavaCanvasTest {
|
|
||||||
@Test
|
|
||||||
fun run() = runBlocking {
|
|
||||||
assertRenders("views/CanvasTest.png", createCanvas(500, 400).apply { drawTestImage() })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun createCanvas(w: Int, h: Int) = JavaCanvas(BufferedImage(2 * w, 2 * h, TYPE_INT_ARGB), 2.0)
|
|
||||||
|
|
||||||
private suspend fun assertRenders(
|
|
||||||
path: String,
|
|
||||||
canvas: Canvas
|
|
||||||
) {
|
|
||||||
val actualImage = canvas.toImage()
|
|
||||||
val failedActualPath = "/tmp/failed/$path"
|
|
||||||
val failedExpectedPath = failedActualPath.replace(
|
|
||||||
".png",
|
|
||||||
".expected.png"
|
|
||||||
)
|
|
||||||
val failedDiffPath = failedActualPath.replace(".png", ".diff.png")
|
|
||||||
val fileOpener = JavaFileOpener()
|
|
||||||
val expectedFile = fileOpener.openResourceFile(path)
|
|
||||||
if (expectedFile.exists()) {
|
|
||||||
val expectedImage = expectedFile.toImage()
|
|
||||||
val diffImage = expectedFile.toImage()
|
|
||||||
diffImage.diff(actualImage)
|
|
||||||
val distance = diffImage.averageLuminosity * 100
|
|
||||||
if (distance >= 1.0) {
|
|
||||||
expectedImage.export(failedExpectedPath)
|
|
||||||
actualImage.export(failedActualPath)
|
|
||||||
diffImage.export(failedDiffPath)
|
|
||||||
fail("Images differ (distance=$distance)")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
actualImage.export(failedActualPath)
|
|
||||||
fail("Expected image file is missing. Actual image: $failedActualPath")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun assertRenders(
|
|
||||||
width: Int,
|
|
||||||
height: Int,
|
|
||||||
expectedPath: String,
|
|
||||||
view: View
|
|
||||||
) {
|
|
||||||
val canvas = createCanvas(width, height)
|
|
||||||
view.draw(canvas)
|
|
||||||
assertRenders(expectedPath, canvas)
|
|
||||||
}
|
|
||||||
Loading…
Reference in New Issue
Block a user