Fix JS platform issues: test isolation, dead code, canvas snapshot

This commit is contained in:
Alinson S. Xavier 2026-04-11 09:27:36 -05:00
parent bb3440279e
commit 15ec7eba0c
11 changed files with 145 additions and 190 deletions

View File

@ -194,12 +194,7 @@ abstract class HabitList : Iterable<Habit> {
habit.color.toCsvColor(), habit.color.toCsvColor(),
if (habit.isNumerical) habit.unit else "", if (habit.isNumerical) habit.unit else "",
if (habit.isNumerical) habit.targetType.name else "", if (habit.isNumerical) habit.targetType.name else "",
if (habit.isNumerical) { if (habit.isNumerical) format("%.1f", habit.targetValue) else "",
val s = habit.targetValue.toString()
if ('.' !in s) "$s.0" else s
} else {
""
},
habit.isArchived.toString() habit.isArchived.toString()
) )
sb.append(csvLine(cols)) sb.append(csvLine(cols))

View File

@ -0,0 +1,51 @@
package org.isoron.platform.io
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class FilesTest {
private val fileOpener = createTestFileOpener()
@Test
fun testWriteStringAndLines() = runTest {
val file = fileOpener.openUserFile("test-write-string")
file.writeString("hello\nworld\n")
val lines = file.lines()
assertEquals(listOf("hello", "world"), lines)
file.delete()
}
@Test
fun testWriteBytesAndReadBytes() = runTest {
val file = fileOpener.openUserFile("test-write-bytes")
val data = byteArrayOf(0x53, 0x51, 0x4C, 0x69, 0x74, 0x65)
file.writeBytes(data)
val read = file.readBytes(6)
assertTrue(data.contentEquals(read))
file.delete()
}
@Test
fun testReadBytesWithLimit() = runTest {
val file = fileOpener.openUserFile("test-read-limit")
file.writeBytes(byteArrayOf(1, 2, 3, 4, 5, 6, 7, 8))
val read = file.readBytes(3)
assertEquals(3, read.size)
assertEquals(1, read[0])
assertEquals(2, read[1])
assertEquals(3, read[2])
file.delete()
}
@Test
fun testExists() = runTest {
val file = fileOpener.openUserFile("test-exists")
file.writeString("data")
assertTrue(file.exists())
file.delete()
assertFalse(file.exists())
}
}

View File

@ -0,0 +1,53 @@
package org.isoron.platform.time
import org.isoron.platform.io.createTestDateFormatter
import kotlin.test.Test
import kotlin.test.assertEquals
class LocalDateFormatterTest {
private val formatter = createTestDateFormatter()
@Test
fun testShortWeekdayName() {
assertEquals("Sat", formatter.shortWeekdayName(DayOfWeek.SATURDAY))
assertEquals("Sun", formatter.shortWeekdayName(DayOfWeek.SUNDAY))
assertEquals("Mon", formatter.shortWeekdayName(DayOfWeek.MONDAY))
assertEquals("Tue", formatter.shortWeekdayName(DayOfWeek.TUESDAY))
assertEquals("Wed", formatter.shortWeekdayName(DayOfWeek.WEDNESDAY))
assertEquals("Thu", formatter.shortWeekdayName(DayOfWeek.THURSDAY))
assertEquals("Fri", formatter.shortWeekdayName(DayOfWeek.FRIDAY))
}
@Test
fun testLongWeekdayName() {
assertEquals("Saturday", formatter.longWeekdayName(DayOfWeek.SATURDAY))
assertEquals("Sunday", formatter.longWeekdayName(DayOfWeek.SUNDAY))
assertEquals("Monday", formatter.longWeekdayName(DayOfWeek.MONDAY))
assertEquals("Tuesday", formatter.longWeekdayName(DayOfWeek.TUESDAY))
assertEquals("Wednesday", formatter.longWeekdayName(DayOfWeek.WEDNESDAY))
assertEquals("Thursday", formatter.longWeekdayName(DayOfWeek.THURSDAY))
assertEquals("Friday", formatter.longWeekdayName(DayOfWeek.FRIDAY))
}
@Test
fun testShortWeekdayNameFromDate() {
// 2015-01-25 is a Sunday
assertEquals("Sun", formatter.shortWeekdayName(LocalDate(2015, 1, 25)))
// 2015-01-26 is a Monday
assertEquals("Mon", formatter.shortWeekdayName(LocalDate(2015, 1, 26)))
}
@Test
fun testShortMonthName() {
assertEquals("Jan", formatter.shortMonthName(LocalDate(2015, 1, 1)))
assertEquals("Feb", formatter.shortMonthName(LocalDate(2015, 2, 1)))
assertEquals("Dec", formatter.shortMonthName(LocalDate(2015, 12, 1)))
}
@Test
fun testLongMonthName() {
assertEquals("January", formatter.longMonthName(LocalDate(2015, 1, 1)))
assertEquals("February", formatter.longMonthName(LocalDate(2015, 2, 1)))
assertEquals("December", formatter.longMonthName(LocalDate(2015, 12, 1)))
}
}

View File

@ -135,7 +135,14 @@ class JsCanvas(
this.textAlign = align this.textAlign = align
} }
override fun toImage(): Image = JsImage(canvas) override fun toImage(): Image {
val copy = document.createElement("canvas") as HTMLCanvasElement
copy.width = canvas.width
copy.height = canvas.height
val copyCtx = copy.getContext("2d") as CanvasRenderingContext2D
copyCtx.drawImage(canvas, 0.0, 0.0)
return JsImage(copy)
}
override fun measureText(text: String): Double { override fun measureText(text: String): Double {
updateFont() updateFont()

View File

@ -43,7 +43,7 @@ class JsImage(private val canvas: HTMLCanvasElement) : Image {
val init = RequestInit( val init = RequestInit(
method = "POST", method = "POST",
body = blob, body = blob,
headers = json("X-File-Path" to path), headers = json("X-File-Path" to path)
) )
suspendCoroutine<Unit> { cont -> suspendCoroutine<Unit> { cont ->
window.fetch("/save-file", init).then( window.fetch("/save-file", init).then(

View File

@ -9,8 +9,11 @@ import kotlin.js.Promise
external fun initSqlJs(config: dynamic = definedExternally): Promise<dynamic> external fun initSqlJs(config: dynamic = definedExternally): Promise<dynamic>
private fun sqlJsNewDatabase(sqlJs: dynamic): dynamic { private fun sqlJsNewDatabase(sqlJs: dynamic): dynamic {
val ctor = sqlJs.Database return js("new sqlJs.Database()")
return js("new ctor()") }
private fun sqlJsOpenDatabase(sqlJs: dynamic, data: dynamic): dynamic {
return js("new sqlJs.Database(data)")
} }
class JsPreparedStatement( class JsPreparedStatement(
@ -63,6 +66,9 @@ class JsPreparedStatement(
} }
override fun bindLong(index: Int, value: Long) { override fun bindLong(index: Int, value: Long) {
require(value in -(9e15.toLong())..9e15.toLong()) {
"Long value $value exceeds JS safe integer range"
}
bindings[index - 1] = value.toDouble() bindings[index - 1] = value.toDouble()
needsBind = true needsBind = true
} }
@ -119,8 +125,7 @@ class JsDatabaseOpener(
for (i in bytes.indices) { for (i in bytes.indices) {
data[i] = bytes[i] data[i] = bytes[i]
} }
val ctor = sqlJs.Database val db = sqlJsOpenDatabase(sqlJs, data)
val db = js("new ctor(data)")
return JsDatabase(db) return JsDatabase(db)
} }
} }

View File

@ -8,13 +8,6 @@ import org.w3c.dom.HTMLCanvasElement
import kotlin.coroutines.resume import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException import kotlin.coroutines.resumeWithException
import kotlin.coroutines.suspendCoroutine import kotlin.coroutines.suspendCoroutine
import kotlin.js.Promise
private fun <T> Promise<T>.await(): suspend () -> T = {
suspendCoroutine { cont ->
then({ cont.resume(it) }, { cont.resumeWithException(it) })
}
}
class JsFileStorage { class JsFileStorage {
private val store = mutableMapOf<String, ByteArray>() private val store = mutableMapOf<String, ByteArray>()
@ -48,7 +41,8 @@ class JsUserFile(
override suspend fun lines(): List<String> { override suspend fun lines(): List<String> {
val bytes = storage.read(pathString) val bytes = storage.read(pathString)
?: error("File not found: $pathString") ?: error("File not found: $pathString")
return bytes.decodeToString().lines() val result = bytes.decodeToString().lines()
return if (result.lastOrNull() == "") result.dropLast(1) else result
} }
override suspend fun writeString(content: String) { override suspend fun writeString(content: String) {

View File

@ -13,9 +13,15 @@ actual fun computeToday(hourOffset: Int, minuteOffset: Int): LocalDate {
} }
actual fun getFirstWeekdayNumberAccordingToLocale(): Int { actual fun getFirstWeekdayNumberAccordingToLocale(): Int {
// JS Intl does not expose first day of week. Default to Sunday (1) return try {
// matching java.util.Calendar convention where Sunday=1. val locale = js("new Intl.Locale(navigator.language)")
return 1 val weekInfo = locale.getWeekInfo()
val firstDay = (weekInfo.firstDay as Number).toInt()
// CLDR uses 1=Monday..7=Sunday; convert to Calendar convention 1=Sunday..7=Saturday
firstDay % 7 + 1
} catch (e: Throwable) {
1 // Default to Sunday if Intl API unavailable
}
} }
private fun toLocaleDateString(date: Date, locale: String, options: dynamic): String { private fun toLocaleDateString(date: Date, locale: String, options: dynamic): String {

View File

@ -9,9 +9,17 @@ import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException import kotlin.coroutines.resumeWithException
import kotlin.coroutines.suspendCoroutine import kotlin.coroutines.suspendCoroutine
val sharedTestStorage = JsFileStorage() private var currentTestStorage = JsFileStorage()
actual fun createTestFileOpener(): FileOpener = JsFileOpener(sharedTestStorage) actual fun createTestFileOpener(): FileOpener {
currentTestStorage = JsFileStorage()
return JsFileOpener(currentTestStorage)
}
@Deprecated(
"Use createTestDatabaseOpenerSuspend() instead",
level = DeprecationLevel.ERROR
)
actual fun createTestDatabaseOpener(): DatabaseOpener { actual fun createTestDatabaseOpener(): DatabaseOpener {
throw UnsupportedOperationException( throw UnsupportedOperationException(
"createTestDatabaseOpener() requires async sql.js init. " + "createTestDatabaseOpener() requires async sql.js init. " +
@ -21,7 +29,7 @@ actual fun createTestDatabaseOpener(): DatabaseOpener {
actual suspend fun createTestDatabaseOpenerSuspend(): DatabaseOpener { actual suspend fun createTestDatabaseOpenerSuspend(): DatabaseOpener {
val sqlJs = TestDatabaseHelper.getInitializedSqlJs() val sqlJs = TestDatabaseHelper.getInitializedSqlJs()
return JsDatabaseOpener(sqlJs, sharedTestStorage) return JsDatabaseOpener(sqlJs, currentTestStorage)
} }
actual fun createTestCanvas(width: Int, height: Int): Canvas { actual fun createTestCanvas(width: Int, height: Int): Canvas {

View File

@ -1,88 +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 org.isoron.platform.time.DayOfWeek
import org.isoron.platform.time.JavaLocalDateFormatter
import org.isoron.platform.time.getFirstWeekdayNumberAccordingToLocale
import org.junit.Test
import java.util.Locale
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
class JavaLocalDateFormatterTest {
@Test
fun testShortWeekdayNames_us() {
val formatter = JavaLocalDateFormatter(Locale.US)
assertContentEquals(
arrayOf("Sat", "Sun", "Mon", "Tue", "Wed", "Thu", "Fri"),
formatter.shortWeekdayNames(DayOfWeek.SATURDAY)
)
}
@Test
fun testShortWeekdayNames_germany() {
val formatter = JavaLocalDateFormatter(Locale.GERMANY)
assertContentEquals(
arrayOf("Sa.", "So.", "Mo.", "Di.", "Mi.", "Do.", "Fr."),
formatter.shortWeekdayNames(DayOfWeek.SATURDAY)
)
}
@Test
fun testLongWeekdayNames_us() {
val formatter = JavaLocalDateFormatter(Locale.US)
assertContentEquals(
arrayOf("Saturday", "Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday"),
formatter.longWeekdayNames(DayOfWeek.SATURDAY)
)
}
@Test
fun testLongWeekdayNames_germany() {
val formatter = JavaLocalDateFormatter(Locale.GERMANY)
assertContentEquals(
arrayOf("Samstag", "Sonntag", "Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag"),
formatter.longWeekdayNames(DayOfWeek.SATURDAY)
)
}
@Test
fun testGetFirstWeekdayNumberAccordingToLocale_us() {
val saved = Locale.getDefault()
try {
Locale.setDefault(Locale.US)
assertEquals(1, getFirstWeekdayNumberAccordingToLocale())
} finally {
Locale.setDefault(saved)
}
}
@Test
fun testGetFirstWeekdayNumberAccordingToLocale_germany() {
val saved = Locale.getDefault()
try {
Locale.setDefault(Locale.GERMANY)
assertEquals(2, getFirstWeekdayNumberAccordingToLocale())
} finally {
Locale.setDefault(saved)
}
}
}

View File

@ -1,76 +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.io
import kotlinx.coroutines.runBlocking
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class JavaFilesTest {
@Test
fun testWriteStringAndLines() = runBlocking {
val file = JavaUserFile(kotlin.io.path.createTempFile("test", ".txt"))
file.writeString("hello\nworld\n")
val lines = file.lines()
assertEquals(listOf("hello", "world"), lines)
file.delete()
}
@Test
fun testWriteBytesAndReadBytes() = runBlocking {
val file = JavaUserFile(kotlin.io.path.createTempFile("test", ".bin"))
val data = byteArrayOf(0x53, 0x51, 0x4C, 0x69, 0x74, 0x65)
file.writeBytes(data)
val read = file.readBytes(6)
assertTrue(data.contentEquals(read))
file.delete()
}
@Test
fun testReadBytesWithLimit() = runBlocking {
val file = JavaUserFile(kotlin.io.path.createTempFile("test", ".bin"))
file.writeBytes(byteArrayOf(1, 2, 3, 4, 5, 6, 7, 8))
val read = file.readBytes(3)
assertEquals(3, read.size)
assertEquals(1, read[0])
assertEquals(2, read[1])
assertEquals(3, read[2])
file.delete()
}
@Test
fun testPath() = runBlocking {
val tmpPath = kotlin.io.path.createTempFile("test", ".txt")
val file = JavaUserFile(tmpPath)
assertEquals(tmpPath.toString(), file.pathString)
file.delete()
}
@Test
fun testExists() = runBlocking {
val file = JavaUserFile(kotlin.io.path.createTempFile("test", ".txt"))
assertTrue(file.exists())
file.delete()
assertFalse(file.exists())
}
}