diff --git a/uhabits-core/karma.config.d/karma.conf.js b/uhabits-core/karma.config.d/karma.conf.js index 7b5e0d2b..5170a9be 100644 --- a/uhabits-core/karma.config.d/karma.conf.js +++ b/uhabits-core/karma.config.d/karma.conf.js @@ -1,9 +1,33 @@ const path = require("path"); +const fs = require("fs"); const migrationsDir = path.resolve(__dirname, "../../../../uhabits-core/assets/main/migrations"); const testAssetsDir = path.resolve(__dirname, "../../../../uhabits-core/assets/test"); 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({ + beforeMiddleware: ["saveFile"], + plugins: (config.plugins || []).concat([ + { "middleware:saveFile": ["value", saveFileMiddleware] } + ]), browserNoActivityTimeout: 120000, browserDisconnectTimeout: 30000, browserDisconnectTolerance: 3, diff --git a/uhabits-core/src/commonTest/kotlin/org/isoron/platform/gui/CanvasTest.kt b/uhabits-core/src/commonTest/kotlin/org/isoron/platform/gui/CanvasTest.kt new file mode 100644 index 00000000..e6bc2d12 --- /dev/null +++ b/uhabits-core/src/commonTest/kotlin/org/isoron/platform/gui/CanvasTest.kt @@ -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) + } +} diff --git a/uhabits-core/src/jsMain/kotlin/org/isoron/platform/gui/JsCanvas.kt b/uhabits-core/src/jsMain/kotlin/org/isoron/platform/gui/JsCanvas.kt index b569d23d..c2fe5df0 100644 --- a/uhabits-core/src/jsMain/kotlin/org/isoron/platform/gui/JsCanvas.kt +++ b/uhabits-core/src/jsMain/kotlin/org/isoron/platform/gui/JsCanvas.kt @@ -45,12 +45,15 @@ class JsCanvas( val py = toPixel(y) val metrics = ctx.measureText(text) val textWidth = metrics.width + val ascent = metrics.asDynamic().actualBoundingBoxAscent as Double + val descent = metrics.asDynamic().actualBoundingBoxDescent as Double val xPos = when (textAlign) { TextAlign.CENTER -> px - textWidth / 2 TextAlign.RIGHT -> px - textWidth 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) { @@ -148,7 +151,6 @@ class JsCanvas( } val weight = if (font == Font.BOLD) "bold" else "normal" ctx.font = "$weight ${sizePx}px $family" - ctx.asDynamic().textBaseline = "middle" } companion object { diff --git a/uhabits-core/src/jsMain/kotlin/org/isoron/platform/gui/JsImage.kt b/uhabits-core/src/jsMain/kotlin/org/isoron/platform/gui/JsImage.kt index d0a132e2..edfdfe79 100644 --- a/uhabits-core/src/jsMain/kotlin/org/isoron/platform/gui/JsImage.kt +++ b/uhabits-core/src/jsMain/kotlin/org/isoron/platform/gui/JsImage.kt @@ -1,8 +1,14 @@ package org.isoron.platform.gui import kotlinx.browser.document +import kotlinx.browser.window import org.w3c.dom.CanvasRenderingContext2D 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 { 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) { - // No-op on JS browser; images can't be exported to filesystem + val blob = suspendCoroutine { cont -> + canvas.asDynamic().toBlob { b: dynamic -> cont.resume(b) } + } + val init = RequestInit( + method = "POST", + body = blob, + headers = json("X-File-Path" to path), + ) + suspendCoroutine { cont -> + window.fetch("/save-file", init).then( + { cont.resume(Unit) }, + { err -> cont.resumeWithException(RuntimeException("$err")) } + ) + } } companion object { diff --git a/uhabits-core/src/jsTest/kotlin/org/isoron/platform/io/TestPlatformHelper.kt b/uhabits-core/src/jsTest/kotlin/org/isoron/platform/io/TestPlatformHelper.kt index d04d1489..7d51caf2 100644 --- a/uhabits-core/src/jsTest/kotlin/org/isoron/platform/io/TestPlatformHelper.kt +++ b/uhabits-core/src/jsTest/kotlin/org/isoron/platform/io/TestPlatformHelper.kt @@ -6,6 +6,7 @@ import org.isoron.platform.gui.JsCanvas import org.isoron.platform.time.JsLocalDateFormatter import org.isoron.platform.time.LocalDateFormatter import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException import kotlin.coroutines.suspendCoroutine val sharedTestStorage = JsFileStorage() @@ -48,10 +49,14 @@ actual suspend fun ensureFontsLoaded() { fontsLoaded = true } -private suspend fun loadFontAsync(fontSpec: String) = suspendCoroutine { cont -> - val promise = document.asDynamic().fonts.load(fontSpec) - promise.then( - { _: dynamic -> cont.resume(Unit) }, - { _: dynamic -> cont.resume(Unit) } - ) +private suspend fun loadFontAsync(fontSpec: String) { + suspendCoroutine { cont -> + val promise = document.asDynamic().fonts.load(fontSpec) + promise.then( + { _: 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'") } diff --git a/uhabits-core/src/jvmTest/java/org/isoron/platform/gui/JavaCanvasTest.kt b/uhabits-core/src/jvmTest/java/org/isoron/platform/gui/JavaCanvasTest.kt deleted file mode 100644 index fb51b952..00000000 --- a/uhabits-core/src/jvmTest/java/org/isoron/platform/gui/JavaCanvasTest.kt +++ /dev/null @@ -1,77 +0,0 @@ -/* - * Copyright (C) 2016-2025 Álinson Santos Xavier - * - * 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 . - */ - -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) -}