diff --git a/.gitignore b/.gitignore index 0d0456ce..a69cccd6 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,4 @@ node_modules crowdin.yml kotlin-js-store *.md +.kotlin diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 641f0bc0..80ca15f9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -16,14 +16,14 @@ junit = "1.2.1" junitJupiter = "5.10.1" junitVersion = "4.13.2" konfetti-xml = "2.0.2" -kotlin = "2.1.10" -ksp = "2.1.10-1.0.30" +kotlin = "2.3.20" +ksp = "2.3.6" ktlint-plugin = "11.6.1" ktor = "1.6.8" ktxCoroutine = "1.10.1" legacy-support = "1.0.0" material = "1.12.0" -mokkery = "2.7.1" +mokkery = "3.3.0" opencsv = "5.9" rules = "1.6.1" shadow = "8.1.1" diff --git a/uhabits-android/build.gradle.kts b/uhabits-android/build.gradle.kts index 3b868d74..201ddf7f 100644 --- a/uhabits-android/build.gradle.kts +++ b/uhabits-android/build.gradle.kts @@ -84,7 +84,11 @@ android { sourceCompatibility(JavaVersion.VERSION_17) } - kotlinOptions.jvmTarget = JavaVersion.VERSION_17.toString() + kotlin { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) + } + } buildFeatures.viewBinding = true lint.abortOnError = false } diff --git a/uhabits-core/build.gradle.kts b/uhabits-core/build.gradle.kts index 3f05a9ee..2d558e93 100644 --- a/uhabits-core/build.gradle.kts +++ b/uhabits-core/build.gradle.kts @@ -27,6 +27,16 @@ kotlin { jvm().withJava() jvmToolchain(17) + js(IR) { + browser { + testTask { + useKarma { + useChromeHeadless() + } + } + } + } + sourceSets { val commonMain by getting { dependencies { @@ -66,16 +76,30 @@ kotlin { implementation(libs.junit.jupiter) } } + + val jsMain by getting { + dependencies { + implementation(npm("sql.js", "1.11.0")) + implementation(npm("sprintf-js", "1.1.3")) + } + } + + val jsTest by getting { + dependencies { + implementation(kotlin("test-js")) + } + } } } mokkery { defaultMockMode.set(dev.mokkery.MockMode.autofill) + stubs.allowConcreteClassInstantiation.set(true) } -tasks.withType { +tasks.withType { compilerOptions { - freeCompilerArgs.add("-Xjvm-default=all") + freeCompilerArgs.add("-jvm-default=enable") } } diff --git a/uhabits-core/karma.config.d/karma.conf.js b/uhabits-core/karma.config.d/karma.conf.js new file mode 100644 index 00000000..7b5e0d2b --- /dev/null +++ b/uhabits-core/karma.config.d/karma.conf.js @@ -0,0 +1,38 @@ +const path = require("path"); +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"); + +config.set({ + browserNoActivityTimeout: 120000, + browserDisconnectTimeout: 30000, + browserDisconnectTolerance: 3, + client: { + mocha: { + timeout: 30000 + } + }, + proxies: { + '/migrations/': '/absolute' + migrationsDir + '/', + '/test-assets/': '/absolute' + testAssetsDir + '/', + '/fonts/': '/absolute' + fontsDir + '/' + } +}); + +config.files.push( + { pattern: migrationsDir + '/*.sql', included: false, served: true, watched: false }, + { pattern: testAssetsDir + '/**/*', included: false, served: true, watched: false }, + { pattern: fontsDir + '/*.ttf', included: false, served: true, watched: false } +); + +// Use sql-asm.js (pure JS, no WASM binary loading) to avoid karma-webpack +// issue #498 where WASM assets in webpack's temp output dir are not served. +config.webpack.resolve = config.webpack.resolve || {}; +config.webpack.resolve.fallback = Object.assign( + config.webpack.resolve.fallback || {}, + { fs: false, path: false, crypto: false } +); +config.webpack.resolve.alias = Object.assign( + config.webpack.resolve.alias || {}, + { "sql.js": path.resolve(__dirname, "../../node_modules/sql.js/dist/sql-asm.js") } +); diff --git a/uhabits-core/src/commonMain/kotlin/org/isoron/platform/JvmAnnotations.kt b/uhabits-core/src/commonMain/kotlin/org/isoron/platform/JvmAnnotations.kt new file mode 100644 index 00000000..abd3c6b0 --- /dev/null +++ b/uhabits-core/src/commonMain/kotlin/org/isoron/platform/JvmAnnotations.kt @@ -0,0 +1,22 @@ +package org.isoron.platform + +@OptIn(ExperimentalMultiplatform::class) +@OptionalExpectation +@Target( + AnnotationTarget.FUNCTION, + AnnotationTarget.PROPERTY_GETTER, + AnnotationTarget.PROPERTY_SETTER +) +@Retention(AnnotationRetention.SOURCE) +expect annotation class Synchronized() + +@OptIn(ExperimentalMultiplatform::class) +@OptionalExpectation +@Target( + AnnotationTarget.FUNCTION, + AnnotationTarget.PROPERTY, + AnnotationTarget.PROPERTY_GETTER, + AnnotationTarget.PROPERTY_SETTER +) +@Retention(AnnotationRetention.SOURCE) +expect annotation class JvmStatic() diff --git a/uhabits-core/src/commonMain/kotlin/org/isoron/platform/time/Dates.kt b/uhabits-core/src/commonMain/kotlin/org/isoron/platform/time/Dates.kt index c39cb3ae..a77213f9 100644 --- a/uhabits-core/src/commonMain/kotlin/org/isoron/platform/time/Dates.kt +++ b/uhabits-core/src/commonMain/kotlin/org/isoron/platform/time/Dates.kt @@ -238,6 +238,8 @@ fun getWeekdaySequence(firstWeekday: DayOfWeek): List { expect fun getFirstWeekdayNumberAccordingToLocale(): Int +expect fun computeToday(hourOffset: Int = 0, minuteOffset: Int = 0): LocalDate + fun countWeekdayOccurrencesInMonth(startOfMonth: LocalDate): Array { val weekday = (startOfMonth.dayOfWeek.daysSinceSunday + 1) % 7 val freq = Array(7) { 0 } diff --git a/uhabits-core/src/commonMain/kotlin/org/isoron/uhabits/core/io/LoopDBImporter.kt b/uhabits-core/src/commonMain/kotlin/org/isoron/uhabits/core/io/LoopDBImporter.kt index ff7670ef..529624f8 100644 --- a/uhabits-core/src/commonMain/kotlin/org/isoron/uhabits/core/io/LoopDBImporter.kt +++ b/uhabits-core/src/commonMain/kotlin/org/isoron/uhabits/core/io/LoopDBImporter.kt @@ -77,7 +77,7 @@ class LoopDBImporter( override suspend fun importHabitsFromFile(file: UserFile) { val db = opener.open(file.pathString) db.migrateTo(DATABASE_VERSION) { version -> - val filename = "%02d.sql".format(version) + val filename = org.isoron.platform.io.format("%02d.sql", version) fileOpener.openResourceFile("migrations/$filename").lines().joinToString("\n") } diff --git a/uhabits-core/src/commonMain/kotlin/org/isoron/uhabits/core/models/EntryList.kt b/uhabits-core/src/commonMain/kotlin/org/isoron/uhabits/core/models/EntryList.kt index ee3eb48b..746474c5 100644 --- a/uhabits-core/src/commonMain/kotlin/org/isoron/uhabits/core/models/EntryList.kt +++ b/uhabits-core/src/commonMain/kotlin/org/isoron/uhabits/core/models/EntryList.kt @@ -19,6 +19,7 @@ package org.isoron.uhabits.core.models +import org.isoron.platform.Synchronized import org.isoron.platform.time.DayOfWeek import org.isoron.platform.time.LocalDate import org.isoron.platform.time.TruncateField diff --git a/uhabits-core/src/commonMain/kotlin/org/isoron/uhabits/core/models/HabitList.kt b/uhabits-core/src/commonMain/kotlin/org/isoron/uhabits/core/models/HabitList.kt index 228c46a4..e56dbf87 100644 --- a/uhabits-core/src/commonMain/kotlin/org/isoron/uhabits/core/models/HabitList.kt +++ b/uhabits-core/src/commonMain/kotlin/org/isoron/uhabits/core/models/HabitList.kt @@ -194,7 +194,10 @@ abstract class HabitList : Iterable { habit.color.toCsvColor(), if (habit.isNumerical) habit.unit else "", if (habit.isNumerical) habit.targetType.name else "", - if (habit.isNumerical) habit.targetValue.toString() else "", + if (habit.isNumerical) { + val s = habit.targetValue.toString() + if ('.' !in s) "$s.0" else s + } else "", habit.isArchived.toString() ) sb.append(csvLine(cols)) diff --git a/uhabits-core/src/commonMain/kotlin/org/isoron/uhabits/core/models/ModelObservable.kt b/uhabits-core/src/commonMain/kotlin/org/isoron/uhabits/core/models/ModelObservable.kt index 3f846b8d..2b6da8a0 100644 --- a/uhabits-core/src/commonMain/kotlin/org/isoron/uhabits/core/models/ModelObservable.kt +++ b/uhabits-core/src/commonMain/kotlin/org/isoron/uhabits/core/models/ModelObservable.kt @@ -18,6 +18,8 @@ */ package org.isoron.uhabits.core.models +import org.isoron.platform.Synchronized + /** * A ModelObservable allows objects to subscribe themselves to it and receive * notifications whenever the model is changed. diff --git a/uhabits-core/src/commonMain/kotlin/org/isoron/uhabits/core/models/Score.kt b/uhabits-core/src/commonMain/kotlin/org/isoron/uhabits/core/models/Score.kt index c8053d46..9b26f348 100644 --- a/uhabits-core/src/commonMain/kotlin/org/isoron/uhabits/core/models/Score.kt +++ b/uhabits-core/src/commonMain/kotlin/org/isoron/uhabits/core/models/Score.kt @@ -18,6 +18,7 @@ */ package org.isoron.uhabits.core.models +import org.isoron.platform.JvmStatic import org.isoron.platform.time.LocalDate import kotlin.math.pow import kotlin.math.sqrt diff --git a/uhabits-core/src/commonMain/kotlin/org/isoron/uhabits/core/models/ScoreList.kt b/uhabits-core/src/commonMain/kotlin/org/isoron/uhabits/core/models/ScoreList.kt index 01949896..fe30e746 100644 --- a/uhabits-core/src/commonMain/kotlin/org/isoron/uhabits/core/models/ScoreList.kt +++ b/uhabits-core/src/commonMain/kotlin/org/isoron/uhabits/core/models/ScoreList.kt @@ -18,6 +18,7 @@ */ package org.isoron.uhabits.core.models +import org.isoron.platform.Synchronized import org.isoron.platform.time.LocalDate import org.isoron.uhabits.core.models.Score.Companion.compute import kotlin.math.max diff --git a/uhabits-core/src/commonMain/kotlin/org/isoron/uhabits/core/models/StreakList.kt b/uhabits-core/src/commonMain/kotlin/org/isoron/uhabits/core/models/StreakList.kt index 03cd23bf..b08e4129 100644 --- a/uhabits-core/src/commonMain/kotlin/org/isoron/uhabits/core/models/StreakList.kt +++ b/uhabits-core/src/commonMain/kotlin/org/isoron/uhabits/core/models/StreakList.kt @@ -18,6 +18,7 @@ */ package org.isoron.uhabits.core.models +import org.isoron.platform.Synchronized import org.isoron.platform.time.LocalDate import kotlin.math.min diff --git a/uhabits-core/src/commonMain/kotlin/org/isoron/uhabits/core/models/memory/MemoryHabitList.kt b/uhabits-core/src/commonMain/kotlin/org/isoron/uhabits/core/models/memory/MemoryHabitList.kt index 180a2d4d..c12e5710 100644 --- a/uhabits-core/src/commonMain/kotlin/org/isoron/uhabits/core/models/memory/MemoryHabitList.kt +++ b/uhabits-core/src/commonMain/kotlin/org/isoron/uhabits/core/models/memory/MemoryHabitList.kt @@ -18,6 +18,7 @@ */ package org.isoron.uhabits.core.models.memory +import org.isoron.platform.Synchronized import org.isoron.uhabits.core.models.Habit import org.isoron.uhabits.core.models.HabitList import org.isoron.uhabits.core.models.HabitMatcher diff --git a/uhabits-core/src/commonMain/kotlin/org/isoron/uhabits/core/models/sqlite/SQLiteHabitList.kt b/uhabits-core/src/commonMain/kotlin/org/isoron/uhabits/core/models/sqlite/SQLiteHabitList.kt index 6c034b3b..2342634f 100644 --- a/uhabits-core/src/commonMain/kotlin/org/isoron/uhabits/core/models/sqlite/SQLiteHabitList.kt +++ b/uhabits-core/src/commonMain/kotlin/org/isoron/uhabits/core/models/sqlite/SQLiteHabitList.kt @@ -19,6 +19,7 @@ package org.isoron.uhabits.core.models.sqlite import me.tatarka.inject.annotations.Inject +import org.isoron.platform.Synchronized import org.isoron.uhabits.core.database.HabitData import org.isoron.uhabits.core.database.HabitRepository import org.isoron.uhabits.core.models.Frequency diff --git a/uhabits-core/src/commonMain/kotlin/org/isoron/uhabits/core/ui/screens/habits/list/HabitCardListCache.kt b/uhabits-core/src/commonMain/kotlin/org/isoron/uhabits/core/ui/screens/habits/list/HabitCardListCache.kt index d627dc3f..b7ef20a7 100644 --- a/uhabits-core/src/commonMain/kotlin/org/isoron/uhabits/core/ui/screens/habits/list/HabitCardListCache.kt +++ b/uhabits-core/src/commonMain/kotlin/org/isoron/uhabits/core/ui/screens/habits/list/HabitCardListCache.kt @@ -19,6 +19,7 @@ package org.isoron.uhabits.core.ui.screens.habits.list import me.tatarka.inject.annotations.Inject +import org.isoron.platform.Synchronized import org.isoron.platform.time.getToday import org.isoron.uhabits.core.AppScope import org.isoron.uhabits.core.commands.Command diff --git a/uhabits-core/src/commonMain/kotlin/org/isoron/uhabits/core/ui/views/NumberButton.kt b/uhabits-core/src/commonMain/kotlin/org/isoron/uhabits/core/ui/views/NumberButton.kt index 122fba73..5e181cf5 100644 --- a/uhabits-core/src/commonMain/kotlin/org/isoron/uhabits/core/ui/views/NumberButton.kt +++ b/uhabits-core/src/commonMain/kotlin/org/isoron/uhabits/core/ui/views/NumberButton.kt @@ -23,25 +23,26 @@ import org.isoron.platform.gui.Canvas import org.isoron.platform.gui.Color import org.isoron.platform.gui.Font import org.isoron.platform.gui.View +import org.isoron.platform.io.format import kotlin.math.round fun Double.toShortString(): String = when { - this >= 1e9 -> "%.1fG".format(this / 1e9) - this >= 1e8 -> "%.0fM".format(this / 1e6) - this >= 1e7 -> "%.1fM".format(this / 1e6) - this >= 1e6 -> "%.1fM".format(this / 1e6) - this >= 1e5 -> "%.0fk".format(this / 1e3) - this >= 1e4 -> "%.1fk".format(this / 1e3) - this >= 1e3 -> "%.1fk".format(this / 1e3) - this >= 1e2 -> "%.0f".format(this) + this >= 1e9 -> format("%.1fG", this / 1e9) + this >= 1e8 -> format("%.0fM", this / 1e6) + this >= 1e7 -> format("%.1fM", this / 1e6) + this >= 1e6 -> format("%.1fM", this / 1e6) + this >= 1e5 -> format("%.0fk", this / 1e3) + this >= 1e4 -> format("%.1fk", this / 1e3) + this >= 1e3 -> format("%.1fk", this / 1e3) + this >= 1e2 -> format("%.0f", this) this >= 1e1 -> when { - round(this) == this -> "%.0f".format(this) - else -> "%.1f".format(this) + round(this) == this -> format("%.0f", this) + else -> format("%.1f", this) } else -> when { - round(this) == this -> "%.0f".format(this) - round(this * 10) == this * 10 -> "%.1f".format(this) - else -> "%.2f".format(this) + round(this) == this -> format("%.0f", this) + round(this * 10) == this * 10 -> format("%.1f", this) + else -> format("%.2f", this) } } diff --git a/uhabits-core/src/commonMain/kotlin/org/isoron/uhabits/core/ui/views/Ring.kt b/uhabits-core/src/commonMain/kotlin/org/isoron/uhabits/core/ui/views/Ring.kt index 6c1d6a51..f9756026 100644 --- a/uhabits-core/src/commonMain/kotlin/org/isoron/uhabits/core/ui/views/Ring.kt +++ b/uhabits-core/src/commonMain/kotlin/org/isoron/uhabits/core/ui/views/Ring.kt @@ -22,6 +22,7 @@ package org.isoron.uhabits.core.ui.views import org.isoron.platform.gui.Canvas import org.isoron.platform.gui.Color import org.isoron.platform.gui.View +import org.isoron.platform.io.format import kotlin.math.max import kotlin.math.min @@ -51,7 +52,7 @@ class Ring( if (label) { canvas.setColor(color) canvas.setFontSize(radius * 0.4) - canvas.drawText("%.0f%%".format(percentage * 100), width / 2, height / 2) + canvas.drawText(format("%.0f%%", percentage * 100), width / 2, height / 2) } } } diff --git a/uhabits-core/src/commonTest/kotlin/org/isoron/platform/gui/DatesTest.kt b/uhabits-core/src/commonTest/kotlin/org/isoron/platform/gui/DatesTest.kt index d93f059f..dbdc7985 100644 --- a/uhabits-core/src/commonTest/kotlin/org/isoron/platform/gui/DatesTest.kt +++ b/uhabits-core/src/commonTest/kotlin/org/isoron/platform/gui/DatesTest.kt @@ -177,11 +177,11 @@ class DatesTest { val jan2 = LocalDate(2015, 1, 2) val jan1b = LocalDate(2015, 1, 1) - assert(jan1 < jan2) - assert(jan2 > jan1) - assert(jan1 <= jan1b) - assert(jan1 >= jan1b) - assert(jan1 == jan1b) + assertTrue(jan1 < jan2) + assertTrue(jan2 > jan1) + assertTrue(jan1 <= jan1b) + assertTrue(jan1 >= jan1b) + assertEquals(jan1, jan1b) } @Test diff --git a/uhabits-core/src/commonTest/kotlin/org/isoron/platform/gui/ViewTestHelper.kt b/uhabits-core/src/commonTest/kotlin/org/isoron/platform/gui/ViewTestHelper.kt index 4b6433b8..5cd8d3d9 100644 --- a/uhabits-core/src/commonTest/kotlin/org/isoron/platform/gui/ViewTestHelper.kt +++ b/uhabits-core/src/commonTest/kotlin/org/isoron/platform/gui/ViewTestHelper.kt @@ -2,6 +2,7 @@ package org.isoron.platform.gui import org.isoron.platform.io.createTestCanvas import org.isoron.platform.io.createTestFileOpener +import org.isoron.platform.io.ensureFontsLoaded import kotlin.test.fail suspend fun assertRenders( @@ -37,6 +38,7 @@ suspend fun assertRenders( expectedPath: String, view: View ) { + ensureFontsLoaded() val canvas = createTestCanvas(width, height) view.draw(canvas) assertRenders(expectedPath, canvas) diff --git a/uhabits-core/src/commonTest/kotlin/org/isoron/platform/io/DatabaseQueryHelpersTest.kt b/uhabits-core/src/commonTest/kotlin/org/isoron/platform/io/DatabaseQueryHelpersTest.kt index e279a958..9fbf20dc 100644 --- a/uhabits-core/src/commonTest/kotlin/org/isoron/platform/io/DatabaseQueryHelpersTest.kt +++ b/uhabits-core/src/commonTest/kotlin/org/isoron/platform/io/DatabaseQueryHelpersTest.kt @@ -1,5 +1,6 @@ package org.isoron.platform.io +import kotlinx.coroutines.test.runTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -8,7 +9,7 @@ import kotlin.test.assertTrue class DatabaseQueryHelpersTest { @Test - fun testQueryIteratesAllRows() { + fun testQueryIteratesAllRows() = runTest { val db = TestDatabaseHelper.createEmptyDatabase() db.run("create table t(v int)") db.run("insert into t(v) values (10)") @@ -24,7 +25,7 @@ class DatabaseQueryHelpersTest { } @Test - fun testQueryWithParameters() { + fun testQueryWithParameters() = runTest { val db = TestDatabaseHelper.createEmptyDatabase() db.run("create table t(name text, age int)") db.run("insert into t(name, age) values ('Alice', 30)") @@ -42,7 +43,7 @@ class DatabaseQueryHelpersTest { } @Test - fun testQueryWithNoResults() { + fun testQueryWithNoResults() = runTest { val db = TestDatabaseHelper.createEmptyDatabase() db.run("create table t(v int)") @@ -53,7 +54,7 @@ class DatabaseQueryHelpersTest { } @Test - fun testQuerySingleReturnsFirstRow() { + fun testQuerySingleReturnsFirstRow() = runTest { val db = TestDatabaseHelper.createEmptyDatabase() db.run("create table t(v int)") db.run("insert into t(v) values (42)") @@ -65,7 +66,7 @@ class DatabaseQueryHelpersTest { } @Test - fun testQuerySingleReturnsNullForEmptyResult() { + fun testQuerySingleReturnsNullForEmptyResult() = runTest { val db = TestDatabaseHelper.createEmptyDatabase() db.run("create table t(v int)") @@ -75,7 +76,7 @@ class DatabaseQueryHelpersTest { } @Test - fun testQuerySingleWithParameters() { + fun testQuerySingleWithParameters() = runTest { val db = TestDatabaseHelper.createEmptyDatabase() db.run("create table t(name text, score int)") db.run("insert into t(name, score) values ('Alice', 90)") @@ -96,7 +97,7 @@ class DatabaseQueryHelpersTest { } @Test - fun testNestedQueries() { + fun testNestedQueries() = runTest { val db = TestDatabaseHelper.createEmptyDatabase() db.run("create table parents(id int, name text)") db.run("create table children(parent_id int, name text)") @@ -126,7 +127,7 @@ class DatabaseQueryHelpersTest { } @Test - fun testQueryHandlesNullableColumns() { + fun testQueryHandlesNullableColumns() = runTest { val db = TestDatabaseHelper.createEmptyDatabase() db.run("create table t(a int, b text)") db.run("insert into t(a, b) values (1, 'hello')") diff --git a/uhabits-core/src/commonTest/kotlin/org/isoron/platform/io/DatabaseTest.kt b/uhabits-core/src/commonTest/kotlin/org/isoron/platform/io/DatabaseTest.kt index 465ae62b..f6b60732 100644 --- a/uhabits-core/src/commonTest/kotlin/org/isoron/platform/io/DatabaseTest.kt +++ b/uhabits-core/src/commonTest/kotlin/org/isoron/platform/io/DatabaseTest.kt @@ -1,5 +1,6 @@ package org.isoron.platform.io +import kotlinx.coroutines.test.runTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNull @@ -7,7 +8,7 @@ import kotlin.test.assertTrue class DatabaseTest { @Test - fun testVersionReadWrite() { + fun testVersionReadWrite() = runTest { val db = TestDatabaseHelper.createEmptyDatabase() db.setVersion(0) assertEquals(0, db.getVersion()) @@ -17,7 +18,7 @@ class DatabaseTest { } @Test - fun testCreateInsertQuery() { + fun testCreateInsertQuery() = runTest { val db = TestDatabaseHelper.createEmptyDatabase() db.run("drop table if exists demo") @@ -41,7 +42,7 @@ class DatabaseTest { } @Test - fun testNullHandling() { + fun testNullHandling() = runTest { val db = TestDatabaseHelper.createEmptyDatabase() db.run("create table nullable_demo(a int, b text, c real)") @@ -63,7 +64,7 @@ class DatabaseTest { } @Test - fun testStatementReset() { + fun testStatementReset() = runTest { val db = TestDatabaseHelper.createEmptyDatabase() db.run("create table reset_demo(v int)") @@ -95,7 +96,7 @@ class DatabaseTest { } @Test - fun testRunExtensionFunction() { + fun testRunExtensionFunction() = runTest { val db = TestDatabaseHelper.createEmptyDatabase() db.run("create table ext_demo(v int)") db.run("insert into ext_demo(v) values (?)") { bindInt(1, 99) } @@ -104,7 +105,7 @@ class DatabaseTest { } @Test - fun testLastInsertRowId() { + fun testLastInsertRowId() = runTest { val db = TestDatabaseHelper.createEmptyDatabase() db.run("create table rowid_demo(id integer primary key autoincrement, v text)") db.run("insert into rowid_demo(v) values ('first')") diff --git a/uhabits-core/src/commonTest/kotlin/org/isoron/platform/io/MigrationTest.kt b/uhabits-core/src/commonTest/kotlin/org/isoron/platform/io/MigrationTest.kt index d5395188..97f03277 100644 --- a/uhabits-core/src/commonTest/kotlin/org/isoron/platform/io/MigrationTest.kt +++ b/uhabits-core/src/commonTest/kotlin/org/isoron/platform/io/MigrationTest.kt @@ -6,7 +6,7 @@ import kotlin.test.assertEquals class MigrationTest { @Test - fun testMigrateFromScratch() { + fun testMigrateFromScratch() = runTest { val db = TestDatabaseHelper.createEmptyDatabase() assertEquals(25, db.getVersion()) diff --git a/uhabits-core/src/commonTest/kotlin/org/isoron/platform/io/TestDatabaseHelper.kt b/uhabits-core/src/commonTest/kotlin/org/isoron/platform/io/TestDatabaseHelper.kt index 73d0368d..f0d6c853 100644 --- a/uhabits-core/src/commonTest/kotlin/org/isoron/platform/io/TestDatabaseHelper.kt +++ b/uhabits-core/src/commonTest/kotlin/org/isoron/platform/io/TestDatabaseHelper.kt @@ -1,6 +1,6 @@ package org.isoron.platform.io expect object TestDatabaseHelper { - fun createEmptyDatabase(): Database - fun loadMigrationSQL(version: Int): String + suspend fun createEmptyDatabase(): Database + suspend fun loadMigrationSQL(version: Int): String } diff --git a/uhabits-core/src/commonTest/kotlin/org/isoron/platform/io/TestPlatformHelper.kt b/uhabits-core/src/commonTest/kotlin/org/isoron/platform/io/TestPlatformHelper.kt index 36f1ffde..2e5c436b 100644 --- a/uhabits-core/src/commonTest/kotlin/org/isoron/platform/io/TestPlatformHelper.kt +++ b/uhabits-core/src/commonTest/kotlin/org/isoron/platform/io/TestPlatformHelper.kt @@ -5,5 +5,7 @@ import org.isoron.platform.time.LocalDateFormatter expect fun createTestFileOpener(): FileOpener expect fun createTestDatabaseOpener(): DatabaseOpener +expect suspend fun createTestDatabaseOpenerSuspend(): DatabaseOpener expect fun createTestCanvas(width: Int, height: Int): Canvas expect fun createTestDateFormatter(): LocalDateFormatter +expect suspend fun ensureFontsLoaded() diff --git a/uhabits-core/src/commonTest/kotlin/org/isoron/uhabits/core/BaseUnitTest.kt b/uhabits-core/src/commonTest/kotlin/org/isoron/uhabits/core/BaseUnitTest.kt index e14e224e..8951ada1 100644 --- a/uhabits-core/src/commonTest/kotlin/org/isoron/uhabits/core/BaseUnitTest.kt +++ b/uhabits-core/src/commonTest/kotlin/org/isoron/uhabits/core/BaseUnitTest.kt @@ -23,9 +23,8 @@ import org.isoron.platform.io.DatabaseOpener import org.isoron.platform.io.FileOpener import org.isoron.platform.io.TestDatabaseHelper import org.isoron.platform.io.UserFile -import org.isoron.platform.io.createTestDatabaseOpener +import org.isoron.platform.io.createTestDatabaseOpenerSuspend import org.isoron.platform.io.createTestFileOpener -import org.isoron.platform.runSuspend import org.isoron.platform.time.LocalDate import org.isoron.platform.time.setToday import org.isoron.uhabits.core.commands.CommandRunner @@ -43,7 +42,13 @@ open class BaseUnitTest { protected lateinit var taskRunner: SingleThreadTaskRunner protected open lateinit var commandRunner: CommandRunner protected val fileOpener: FileOpener = createTestFileOpener() - protected val databaseOpener: DatabaseOpener = createTestDatabaseOpener() + private var _databaseOpener: DatabaseOpener? = null + protected suspend fun databaseOpener(): DatabaseOpener { + if (_databaseOpener == null) { + _databaseOpener = createTestDatabaseOpenerSuspend() + } + return _databaseOpener!! + } @BeforeTest open fun setUp() { @@ -56,28 +61,28 @@ open class BaseUnitTest { commandRunner = CommandRunner(taskRunner) } - protected fun createTempDir(): UserFile = runSuspend { + protected suspend fun createTempDir(): UserFile { val dir = fileOpener.openUserFile("test-temp-dir-${tempFileCounter++}") dir.mkdirs() - dir + return dir } - protected fun copyResourceToTempFile(resourcePath: String): UserFile = runSuspend { + protected suspend fun copyResourceToTempFile(resourcePath: String): UserFile { val cleanPath = resourcePath.removePrefix("/") val tempFile = fileOpener.openUserFile("test-temp-${tempFileCounter++}") fileOpener.openResourceFile(cleanPath).copyTo(tempFile) - tempFile + return tempFile } - protected fun openDatabaseResource(resourcePath: String): Database = runSuspend { + protected suspend fun openDatabaseResource(resourcePath: String): Database { val tempFile = copyResourceToTempFile(resourcePath) - databaseOpener.open(tempFile.pathString) + return databaseOpener().open(tempFile.pathString) } companion object { private var tempFileCounter = 0 - fun buildMemoryDatabase(): Database { + suspend fun buildMemoryDatabase(): Database { return TestDatabaseHelper.createEmptyDatabase() } } diff --git a/uhabits-core/src/commonTest/kotlin/org/isoron/uhabits/core/database/EntryRepositoryTest.kt b/uhabits-core/src/commonTest/kotlin/org/isoron/uhabits/core/database/EntryRepositoryTest.kt index 47e3ac4d..8043b863 100644 --- a/uhabits-core/src/commonTest/kotlin/org/isoron/uhabits/core/database/EntryRepositoryTest.kt +++ b/uhabits-core/src/commonTest/kotlin/org/isoron/uhabits/core/database/EntryRepositoryTest.kt @@ -1,5 +1,6 @@ package org.isoron.uhabits.core.database +import kotlinx.coroutines.test.runTest import org.isoron.platform.io.TestDatabaseHelper import org.isoron.platform.io.queryLong import org.isoron.platform.io.run @@ -19,7 +20,7 @@ class EntryRepositoryTest { } @Test - fun testInsertAndFindAll() { + fun testInsertAndFindAll() = runTest { val db = TestDatabaseHelper.createEmptyDatabase() val repo = EntryRepository(db) val habitId = insertTestHabit(db) @@ -44,7 +45,7 @@ class EntryRepositoryTest { } @Test - fun testDeleteByHabitIdAndTimestamp() { + fun testDeleteByHabitIdAndTimestamp() = runTest { val db = TestDatabaseHelper.createEmptyDatabase() val repo = EntryRepository(db) val habitId = insertTestHabit(db) @@ -62,7 +63,7 @@ class EntryRepositoryTest { } @Test - fun testDeleteByHabitId() { + fun testDeleteByHabitId() = runTest { val db = TestDatabaseHelper.createEmptyDatabase() val repo = EntryRepository(db) val habitA = insertTestHabit(db) @@ -80,7 +81,7 @@ class EntryRepositoryTest { } @Test - fun testIsolationBetweenHabits() { + fun testIsolationBetweenHabits() = runTest { val db = TestDatabaseHelper.createEmptyDatabase() val repo = EntryRepository(db) val habitA = insertTestHabit(db) @@ -97,7 +98,7 @@ class EntryRepositoryTest { } @Test - fun testAllFieldsSurviveRoundTrip() { + fun testAllFieldsSurviveRoundTrip() = runTest { val db = TestDatabaseHelper.createEmptyDatabase() val repo = EntryRepository(db) diff --git a/uhabits-core/src/commonTest/kotlin/org/isoron/uhabits/core/database/HabitRepositoryTest.kt b/uhabits-core/src/commonTest/kotlin/org/isoron/uhabits/core/database/HabitRepositoryTest.kt index 7136c4d8..b36ef890 100644 --- a/uhabits-core/src/commonTest/kotlin/org/isoron/uhabits/core/database/HabitRepositoryTest.kt +++ b/uhabits-core/src/commonTest/kotlin/org/isoron/uhabits/core/database/HabitRepositoryTest.kt @@ -1,5 +1,6 @@ package org.isoron.uhabits.core.database +import kotlinx.coroutines.test.runTest import org.isoron.platform.io.TestDatabaseHelper import kotlin.test.Test import kotlin.test.assertEquals @@ -9,7 +10,7 @@ import kotlin.test.assertTrue class HabitRepositoryTest { @Test - fun testInsertAndFindAll() { + fun testInsertAndFindAll() = runTest { val db = TestDatabaseHelper.createEmptyDatabase() val repo = HabitRepository(db) @@ -44,7 +45,7 @@ class HabitRepositoryTest { } @Test - fun testUpdate() { + fun testUpdate() = runTest { val db = TestDatabaseHelper.createEmptyDatabase() val repo = HabitRepository(db) @@ -65,7 +66,7 @@ class HabitRepositoryTest { } @Test - fun testDelete() { + fun testDelete() = runTest { val db = TestDatabaseHelper.createEmptyDatabase() val repo = HabitRepository(db) @@ -84,7 +85,7 @@ class HabitRepositoryTest { } @Test - fun testNullableFields() { + fun testNullableFields() = runTest { val db = TestDatabaseHelper.createEmptyDatabase() val repo = HabitRepository(db) @@ -117,7 +118,7 @@ class HabitRepositoryTest { } @Test - fun testFindAllOrderedByPosition() { + fun testFindAllOrderedByPosition() = runTest { val db = TestDatabaseHelper.createEmptyDatabase() val repo = HabitRepository(db) @@ -134,7 +135,7 @@ class HabitRepositoryTest { } @Test - fun testExecSQL() { + fun testExecSQL() = runTest { val db = TestDatabaseHelper.createEmptyDatabase() val repo = HabitRepository(db) @@ -153,7 +154,7 @@ class HabitRepositoryTest { } @Test - fun testAllFieldsSurviveRoundTrip() { + fun testAllFieldsSurviveRoundTrip() = runTest { val db = TestDatabaseHelper.createEmptyDatabase() val repo = HabitRepository(db) diff --git a/uhabits-core/src/commonTest/kotlin/org/isoron/uhabits/core/database/migrations/Version22Test.kt b/uhabits-core/src/commonTest/kotlin/org/isoron/uhabits/core/database/migrations/Version22Test.kt index 9cff1ecf..8a428553 100644 --- a/uhabits-core/src/commonTest/kotlin/org/isoron/uhabits/core/database/migrations/Version22Test.kt +++ b/uhabits-core/src/commonTest/kotlin/org/isoron/uhabits/core/database/migrations/Version22Test.kt @@ -18,11 +18,12 @@ */ package org.isoron.uhabits.core.database.migrations +import kotlinx.coroutines.test.runTest import org.isoron.platform.io.Database +import org.isoron.platform.io.format import org.isoron.platform.io.migrateTo import org.isoron.platform.io.querySingle import org.isoron.platform.io.run -import org.isoron.platform.runSuspend import org.isoron.uhabits.core.BaseUnitTest import kotlin.test.Test import kotlin.test.assertContains @@ -32,20 +33,24 @@ import kotlin.test.assertFailsWith class Version22Test : BaseUnitTest() { private lateinit var db: Database - override fun setUp() { - super.setUp() + private suspend fun initDb() { db = openDatabaseResource("/databases/021.db") } - private fun migrateTo(version: Int) = runSuspend { + private suspend fun migrateTo(version: Int) { db.migrateTo(version) { v -> - val path = "migrations/%02d.sql".format(v) + val path = "migrations/${format("%02d.sql", v)}" fileOpener.openResourceFile(path).lines().joinToString("\n") } } + private fun dbTest(block: suspend () -> Unit) = runTest { + initDb() + block() + } + @Test - fun testKeepValidReps() { + fun testKeepValidReps() = dbTest { val before = db.querySingle("select count(*) from repetitions") { it.getInt(0) } assertEquals(3, before) migrateTo(22) @@ -54,7 +59,7 @@ class Version22Test : BaseUnitTest() { } @Test - fun testRemoveRepsWithInvalidId() { + fun testRemoveRepsWithInvalidId() = dbTest { db.run("insert into Repetitions(habit, timestamp, value) values (99999, 100, 2)") val before = db.querySingle( "select count(*) from repetitions where habit = 99999" @@ -68,16 +73,16 @@ class Version22Test : BaseUnitTest() { } @Test - fun testDisallowNewRepsWithInvalidRef() { + fun testDisallowNewRepsWithInvalidRef() = dbTest { migrateTo(22) - val exception = assertFailsWith { + val exception = assertFailsWith { db.run("insert into Repetitions(habit, timestamp, value) values (99999, 100, 2)") } assertContains(exception.message!!, "constraint") } @Test - fun testRemoveRepetitionsWithNullTimestamp() { + fun testRemoveRepetitionsWithNullTimestamp() = dbTest { db.run("insert into repetitions(habit, value) values (0, 2)") val before = db.querySingle( "select count(*) from repetitions where timestamp is null" @@ -91,16 +96,16 @@ class Version22Test : BaseUnitTest() { } @Test - fun testDisallowNullTimestamp() { + fun testDisallowNullTimestamp() = dbTest { migrateTo(22) - val exception = assertFailsWith { + val exception = assertFailsWith { db.run("insert into Repetitions(habit, value) values (0, 2)") } assertContains(exception.message!!, "constraint") } @Test - fun testRemoveRepetitionsWithNullHabit() { + fun testRemoveRepetitionsWithNullHabit() = dbTest { db.run("insert into repetitions(timestamp, value) values (0, 2)") val before = db.querySingle( "select count(*) from repetitions where habit is null" @@ -114,16 +119,16 @@ class Version22Test : BaseUnitTest() { } @Test - fun testDisallowNullHabit() { + fun testDisallowNullHabit() = dbTest { migrateTo(22) - val exception = assertFailsWith { + val exception = assertFailsWith { db.run("insert into Repetitions(timestamp, value) values (5, 2)") } assertContains(exception.message!!, "constraint") } @Test - fun testRemoveDuplicateRepetitions() { + fun testRemoveDuplicateRepetitions() = dbTest { db.run("insert into repetitions(habit, timestamp, value)values (0, 100, 2)") db.run("insert into repetitions(habit, timestamp, value)values (0, 100, 5)") db.run("insert into repetitions(habit, timestamp, value)values (0, 100, 10)") @@ -139,10 +144,10 @@ class Version22Test : BaseUnitTest() { } @Test - fun testDisallowNewDuplicateTimestamps() { + fun testDisallowNewDuplicateTimestamps() = dbTest { migrateTo(22) db.run("insert into repetitions(habit, timestamp, value)values (0, 100, 2)") - val exception = assertFailsWith { + val exception = assertFailsWith { db.run("insert into repetitions(habit, timestamp, value)values (0, 100, 5)") } assertContains(exception.message!!, "constraint") diff --git a/uhabits-core/src/commonTest/kotlin/org/isoron/uhabits/core/database/migrations/Version23Test.kt b/uhabits-core/src/commonTest/kotlin/org/isoron/uhabits/core/database/migrations/Version23Test.kt index 24fa0ffb..c0a18dfc 100644 --- a/uhabits-core/src/commonTest/kotlin/org/isoron/uhabits/core/database/migrations/Version23Test.kt +++ b/uhabits-core/src/commonTest/kotlin/org/isoron/uhabits/core/database/migrations/Version23Test.kt @@ -19,10 +19,11 @@ package org.isoron.uhabits.core.database.migrations +import kotlinx.coroutines.test.runTest import org.isoron.platform.io.Database +import org.isoron.platform.io.format import org.isoron.platform.io.migrateTo import org.isoron.platform.io.query -import org.isoron.platform.runSuspend import org.isoron.uhabits.core.BaseUnitTest import kotlin.test.Test import kotlin.test.assertEquals @@ -31,26 +32,30 @@ class Version23Test : BaseUnitTest() { private lateinit var db: Database - override fun setUp() { - super.setUp() + private suspend fun initDb() { db = openDatabaseResource("/databases/022.db") } - private fun migrateTo(version: Int) = runSuspend { + private suspend fun migrateTo(version: Int) { db.migrateTo(version) { v -> - val path = "migrations/%02d.sql".format(v) + val path = "migrations/${format("%02d.sql", v)}" fileOpener.openResourceFile(path).lines().joinToString("\n") } } + private fun dbTest(block: suspend () -> Unit) = runTest { + initDb() + block() + } + @Test - fun testMigrateTo23CreatesQuestionColumn() { + fun testMigrateTo23CreatesQuestionColumn() = dbTest { migrateTo(23) db.query("select question from Habits") {} } @Test - fun testMigrateTo23MovesDescriptionToQuestionColumn() { + fun testMigrateTo23MovesDescriptionToQuestionColumn() = dbTest { val descriptions = mutableListOf() db.query("select description from Habits") { stmt -> descriptions.add(stmt.getTextOrNull(0)) @@ -69,7 +74,7 @@ class Version23Test : BaseUnitTest() { } @Test - fun testMigrateTo23SetsDescriptionToNull() { + fun testMigrateTo23SetsDescriptionToNull() = dbTest { migrateTo(23) db.query("select description from Habits") { stmt -> assertEquals("", stmt.getTextOrNull(0)) diff --git a/uhabits-core/src/commonTest/kotlin/org/isoron/uhabits/core/io/HabitsCSVExporterTest.kt b/uhabits-core/src/commonTest/kotlin/org/isoron/uhabits/core/io/HabitsCSVExporterTest.kt index ac899873..a9b5ed1d 100644 --- a/uhabits-core/src/commonTest/kotlin/org/isoron/uhabits/core/io/HabitsCSVExporterTest.kt +++ b/uhabits-core/src/commonTest/kotlin/org/isoron/uhabits/core/io/HabitsCSVExporterTest.kt @@ -22,6 +22,7 @@ import kotlinx.coroutines.test.runTest import org.isoron.platform.io.ZipReader import org.isoron.uhabits.core.BaseUnitTest import org.isoron.uhabits.core.models.Habit +import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull @@ -29,6 +30,7 @@ import kotlin.test.assertTrue class HabitsCSVExporterTest : BaseUnitTest() { + @BeforeTest override fun setUp() { super.setUp() habitList.add(fixtures.createShortHabit()) diff --git a/uhabits-core/src/commonTest/kotlin/org/isoron/uhabits/core/io/ImportTest.kt b/uhabits-core/src/commonTest/kotlin/org/isoron/uhabits/core/io/ImportTest.kt index b2b69b68..76d3b38d 100644 --- a/uhabits-core/src/commonTest/kotlin/org/isoron/uhabits/core/io/ImportTest.kt +++ b/uhabits-core/src/commonTest/kotlin/org/isoron/uhabits/core/io/ImportTest.kt @@ -18,7 +18,7 @@ */ package org.isoron.uhabits.core.io -import org.isoron.platform.runSuspend +import kotlinx.coroutines.test.runTest import org.isoron.platform.time.LocalDate import org.isoron.uhabits.core.BaseUnitTest import org.isoron.uhabits.core.models.Entry @@ -33,7 +33,7 @@ import kotlin.test.assertTrue class ImportTest : BaseUnitTest() { @Test - fun testHabitBullCSV() { + fun testHabitBullCSV() = runTest { importFromFile("habitbull.csv") assertEquals(4, habitList.size()) val habit = habitList.getByPosition(0) @@ -47,7 +47,7 @@ class ImportTest : BaseUnitTest() { } @Test - fun testHabitBullCSV2() { + fun testHabitBullCSV2() = runTest { importFromFile("habitbull2.csv") assertEquals(6, habitList.size()) val habit = habitList.getByPosition(2) @@ -62,7 +62,7 @@ class ImportTest : BaseUnitTest() { } @Test - fun testHabitBullCSV3() { + fun testHabitBullCSV3() = runTest { importFromFile("habitbull3.csv") assertEquals(2, habitList.size()) @@ -85,7 +85,7 @@ class ImportTest : BaseUnitTest() { } @Test - fun testHabitBullCSV4() { + fun testHabitBullCSV4() = runTest { importFromFile("habitbull4.csv") assertEquals(1, habitList.size()) @@ -99,7 +99,7 @@ class ImportTest : BaseUnitTest() { } @Test - fun testLoopDB() { + fun testLoopDB() = runTest { importFromFile("loop.db") assertEquals(9, habitList.size()) val habit = habitList.getByPosition(0) @@ -111,7 +111,7 @@ class ImportTest : BaseUnitTest() { } @Test - fun testRewireDB() { + fun testRewireDB() = runTest { importFromFile("rewire.db") assertEquals(3, habitList.size()) var habit = habitList.getByPosition(1) @@ -134,7 +134,7 @@ class ImportTest : BaseUnitTest() { } @Test - fun testTickmateDB() { + fun testTickmateDB() = runTest { importFromFile("tickmate.db") assertEquals(3, habitList.size()) val h = habitList.getByPosition(2) @@ -157,20 +157,21 @@ class ImportTest : BaseUnitTest() { return h.originalEntries.get(LocalDate(year, month, day)).notes == notes } - private fun importFromFile(assetFilename: String) = runSuspend { + private suspend fun importFromFile(assetFilename: String) { val userFile = copyResourceToTempFile(assetFilename) assertTrue(userFile.exists()) + val dbOpener = databaseOpener() val importer = GenericImporter( LoopDBImporter( habitList, modelFactory, - databaseOpener, + dbOpener, commandRunner, StandardLogging(), fileOpener ), - RewireDBImporter(habitList, modelFactory, databaseOpener), - TickmateDBImporter(habitList, modelFactory, databaseOpener), + RewireDBImporter(habitList, modelFactory, dbOpener), + TickmateDBImporter(habitList, modelFactory, dbOpener), HabitBullCSVImporter(habitList, modelFactory, StandardLogging()) ) assertTrue(importer.canHandle(userFile)) diff --git a/uhabits-core/src/commonTest/kotlin/org/isoron/uhabits/core/models/HabitListTest.kt b/uhabits-core/src/commonTest/kotlin/org/isoron/uhabits/core/models/HabitListTest.kt index 63feb6d6..b7d6a887 100644 --- a/uhabits-core/src/commonTest/kotlin/org/isoron/uhabits/core/models/HabitListTest.kt +++ b/uhabits-core/src/commonTest/kotlin/org/isoron/uhabits/core/models/HabitListTest.kt @@ -19,6 +19,7 @@ package org.isoron.uhabits.core.models import org.isoron.uhabits.core.BaseUnitTest +import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertContentEquals import kotlin.test.assertEquals @@ -32,6 +33,7 @@ class HabitListTest : BaseUnitTest() { private lateinit var activeHabits: HabitList private lateinit var reminderHabits: HabitList + @BeforeTest override fun setUp() { super.setUp() habitsArray = ArrayList() diff --git a/uhabits-core/src/commonTest/kotlin/org/isoron/uhabits/core/models/HabitTest.kt b/uhabits-core/src/commonTest/kotlin/org/isoron/uhabits/core/models/HabitTest.kt index 754c044c..7cc8b422 100644 --- a/uhabits-core/src/commonTest/kotlin/org/isoron/uhabits/core/models/HabitTest.kt +++ b/uhabits-core/src/commonTest/kotlin/org/isoron/uhabits/core/models/HabitTest.kt @@ -20,6 +20,7 @@ package org.isoron.uhabits.core.models import org.isoron.platform.time.getToday import org.isoron.uhabits.core.BaseUnitTest +import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -28,6 +29,7 @@ import kotlin.test.assertTrue class HabitTest : BaseUnitTest() { + @BeforeTest override fun setUp() { super.setUp() } diff --git a/uhabits-core/src/commonTest/kotlin/org/isoron/uhabits/core/models/StreakListTest.kt b/uhabits-core/src/commonTest/kotlin/org/isoron/uhabits/core/models/StreakListTest.kt index b2006929..8953c7da 100644 --- a/uhabits-core/src/commonTest/kotlin/org/isoron/uhabits/core/models/StreakListTest.kt +++ b/uhabits-core/src/commonTest/kotlin/org/isoron/uhabits/core/models/StreakListTest.kt @@ -21,6 +21,7 @@ package org.isoron.uhabits.core.models import org.isoron.platform.time.LocalDate import org.isoron.platform.time.getToday import org.isoron.uhabits.core.BaseUnitTest +import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals @@ -29,6 +30,7 @@ class StreakListTest : BaseUnitTest() { private lateinit var streaks: StreakList private lateinit var today: LocalDate + @BeforeTest override fun setUp() { super.setUp() habit = fixtures.createLongHabit() diff --git a/uhabits-core/src/commonTest/kotlin/org/isoron/uhabits/core/models/sqlite/SQLiteEntryListTest.kt b/uhabits-core/src/commonTest/kotlin/org/isoron/uhabits/core/models/sqlite/SQLiteEntryListTest.kt index 2b7e37c6..7ecc663d 100644 --- a/uhabits-core/src/commonTest/kotlin/org/isoron/uhabits/core/models/sqlite/SQLiteEntryListTest.kt +++ b/uhabits-core/src/commonTest/kotlin/org/isoron/uhabits/core/models/sqlite/SQLiteEntryListTest.kt @@ -19,25 +19,24 @@ package org.isoron.uhabits.core.models.sqlite +import kotlinx.coroutines.test.runTest import org.isoron.platform.time.LocalDate import org.isoron.uhabits.core.BaseUnitTest.Companion.buildMemoryDatabase import org.isoron.uhabits.core.database.EntryData import org.isoron.uhabits.core.models.Entry import org.isoron.uhabits.core.models.Entry.Companion.UNKNOWN -import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals class SQLiteEntryListTest { - private val database = buildMemoryDatabase() - private val factory = SQLModelFactory(database) - private val entryRepository = factory.entryRepository + private lateinit var factory: SQLModelFactory private lateinit var entries: SQLiteEntryList private val today = LocalDate(2015, 1, 25) - @BeforeTest - fun setUp() { + private suspend fun initTest() { + val database = buildMemoryDatabase() + factory = SQLModelFactory(database) val habitList = factory.buildHabitList() val habit = factory.buildHabit() habitList.add(habit) @@ -45,16 +44,17 @@ class SQLiteEntryListTest { } @Test - fun testLoad() { + fun testLoad() = runTest { + initTest() val today = LocalDate(2015, 1, 25) - entryRepository.insert( + factory.entryRepository.insert( EntryData( habitId = entries.habitId, timestamp = today.unixTime, value = 500 ) ) - entryRepository.insert( + factory.entryRepository.insert( EntryData( habitId = entries.habitId, timestamp = today.minus(5).unixTime, @@ -76,15 +76,16 @@ class SQLiteEntryListTest { } @Test - fun testAdd() { + fun testAdd() = runTest { + initTest() val habitId = entries.habitId!! - assertEquals(0, entryRepository.findAllByHabitId(habitId).size) + assertEquals(0, factory.entryRepository.findAllByHabitId(habitId).size) val original = Entry(today, 150) entries.add(original) - val all = entryRepository.findAllByHabitId(habitId) + val all = factory.entryRepository.findAllByHabitId(habitId) assertEquals(1, all.size) assertEquals(150, all[0].value) assertEquals(today.unixTime, all[0].timestamp) @@ -92,7 +93,7 @@ class SQLiteEntryListTest { val replacement = Entry(today, 90) entries.add(replacement) - val all2 = entryRepository.findAllByHabitId(habitId) + val all2 = factory.entryRepository.findAllByHabitId(habitId) assertEquals(1, all2.size) assertEquals(90, all2[0].value) } diff --git a/uhabits-core/src/commonTest/kotlin/org/isoron/uhabits/core/models/sqlite/SQLiteHabitListTest.kt b/uhabits-core/src/commonTest/kotlin/org/isoron/uhabits/core/models/sqlite/SQLiteHabitListTest.kt index 3e882c46..67380a41 100644 --- a/uhabits-core/src/commonTest/kotlin/org/isoron/uhabits/core/models/sqlite/SQLiteHabitListTest.kt +++ b/uhabits-core/src/commonTest/kotlin/org/isoron/uhabits/core/models/sqlite/SQLiteHabitListTest.kt @@ -20,6 +20,7 @@ package org.isoron.uhabits.core.models.sqlite import dev.mokkery.mock import dev.mokkery.verify +import kotlinx.coroutines.test.runTest import org.isoron.uhabits.core.BaseUnitTest import org.isoron.uhabits.core.database.HabitRepository import org.isoron.uhabits.core.models.Habit @@ -29,7 +30,6 @@ import org.isoron.uhabits.core.models.ModelObservable import org.isoron.uhabits.core.models.Reminder import org.isoron.uhabits.core.models.WeekdayList import org.isoron.uhabits.core.test.HabitFixtures -import kotlin.test.AfterTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith @@ -42,8 +42,7 @@ class SQLiteHabitListTest : BaseUnitTest() { private lateinit var activeHabits: HabitList private lateinit var reminderHabits: HabitList - override fun setUp() { - super.setUp() + private suspend fun initDb() { val db = buildMemoryDatabase() modelFactory = SQLModelFactory(db) habitList = SQLiteHabitList(modelFactory) @@ -72,13 +71,17 @@ class SQLiteHabitListTest : BaseUnitTest() { habitList.observable.addListener(listener) } - @AfterTest - fun tearDown() { - habitList.observable.removeListener(listener) + private fun dbTest(block: suspend () -> Unit) = runTest { + initDb() + try { + block() + } finally { + habitList.observable.removeListener(listener) + } } @Test - fun testAdd_withDuplicate() { + fun testAdd_withDuplicate() = dbTest { val habit = modelFactory.buildHabit() habitList.add(habit) verify { listener.onModelChange() } @@ -88,7 +91,7 @@ class SQLiteHabitListTest : BaseUnitTest() { } @Test - fun testAdd_withId() { + fun testAdd_withId() = dbTest { val habit = modelFactory.buildHabit() habit.name = "Hello world with id" habit.id = 12300L @@ -100,7 +103,7 @@ class SQLiteHabitListTest : BaseUnitTest() { } @Test - fun testAdd_withoutId() { + fun testAdd_withoutId() = dbTest { val habit = modelFactory.buildHabit() habit.name = "Hello world" assertNull(habit.id) @@ -111,12 +114,12 @@ class SQLiteHabitListTest : BaseUnitTest() { } @Test - fun testSize() { + fun testSize() = dbTest { assertEquals(10, habitList.size()) } @Test - fun testGetById() { + fun testGetById() = dbTest { val h1 = habitList.getById(1)!! assertEquals("habit 1", h1.name) val h2 = habitList.getById(2)!! @@ -124,20 +127,20 @@ class SQLiteHabitListTest : BaseUnitTest() { } @Test - fun testGetById_withInvalid() { + fun testGetById_withInvalid() = dbTest { val invalidId = 9183792001L val h1 = habitList.getById(invalidId) assertNull(h1) } @Test - fun testGetByPosition() { + fun testGetByPosition() = dbTest { val h = habitList.getByPosition(4) assertEquals("habit 5", h.name) } @Test - fun testIndexOf() { + fun testIndexOf() = dbTest { val h1 = habitList.getByPosition(5) assertEquals(5, habitList.indexOf(h1)) val h2 = modelFactory.buildHabit() @@ -147,7 +150,7 @@ class SQLiteHabitListTest : BaseUnitTest() { } @Test - fun testRemove() { + fun testRemove() = dbTest { val h = habitList.getById(2) habitList.remove(h!!) assertEquals(-1, habitList.indexOf(h)) @@ -160,7 +163,7 @@ class SQLiteHabitListTest : BaseUnitTest() { } @Test - fun testRemove_orderByName() { + fun testRemove_orderByName() = dbTest { habitList.primaryOrder = HabitList.Order.BY_NAME_DESC val h = habitList.getById(2) habitList.remove(h!!) @@ -174,7 +177,7 @@ class SQLiteHabitListTest : BaseUnitTest() { } @Test - fun testReorder() { + fun testReorder() = dbTest { val habit3 = habitList.getById(3)!! val habit4 = habitList.getById(4)!! habitList.reorder(habit4, habit3) diff --git a/uhabits-core/src/commonTest/kotlin/org/isoron/uhabits/core/tasks/SingleThreadTaskRunnerTest.kt b/uhabits-core/src/commonTest/kotlin/org/isoron/uhabits/core/tasks/SingleThreadTaskRunnerTest.kt index ee546892..97cb93b3 100644 --- a/uhabits-core/src/commonTest/kotlin/org/isoron/uhabits/core/tasks/SingleThreadTaskRunnerTest.kt +++ b/uhabits-core/src/commonTest/kotlin/org/isoron/uhabits/core/tasks/SingleThreadTaskRunnerTest.kt @@ -22,13 +22,14 @@ import dev.mokkery.mock import dev.mokkery.verify import dev.mokkery.verify.VerifyMode.Companion.order import org.isoron.uhabits.core.BaseUnitTest +import kotlin.test.BeforeTest import kotlin.test.Test class SingleThreadTaskRunnerTest : BaseUnitTest() { private lateinit var runner: SingleThreadTaskRunner private var task: Task = mock() - @Throws(Exception::class) + @BeforeTest override fun setUp() { super.setUp() runner = SingleThreadTaskRunner() diff --git a/uhabits-core/src/commonTest/kotlin/org/isoron/uhabits/core/ui/screens/habits/list/HabitCardListCacheTest.kt b/uhabits-core/src/commonTest/kotlin/org/isoron/uhabits/core/ui/screens/habits/list/HabitCardListCacheTest.kt index 83c64d4f..216dbe50 100644 --- a/uhabits-core/src/commonTest/kotlin/org/isoron/uhabits/core/ui/screens/habits/list/HabitCardListCacheTest.kt +++ b/uhabits-core/src/commonTest/kotlin/org/isoron/uhabits/core/ui/screens/habits/list/HabitCardListCacheTest.kt @@ -27,6 +27,7 @@ import org.isoron.uhabits.core.BaseUnitTest import org.isoron.uhabits.core.commands.CreateRepetitionCommand import org.isoron.uhabits.core.commands.DeleteHabitsCommand import org.isoron.uhabits.core.models.Entry +import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertContentEquals import kotlin.test.assertEquals @@ -36,7 +37,7 @@ class HabitCardListCacheTest : BaseUnitTest() { private lateinit var listener: HabitCardListCache.Listener var today = LocalDate(2015, 1, 25) - @Throws(Exception::class) + @BeforeTest override fun setUp() { super.setUp() habitList.removeAll() diff --git a/uhabits-core/src/commonTest/kotlin/org/isoron/uhabits/core/ui/screens/habits/list/HintListTest.kt b/uhabits-core/src/commonTest/kotlin/org/isoron/uhabits/core/ui/screens/habits/list/HintListTest.kt index bd4ced89..1688ee1f 100644 --- a/uhabits-core/src/commonTest/kotlin/org/isoron/uhabits/core/ui/screens/habits/list/HintListTest.kt +++ b/uhabits-core/src/commonTest/kotlin/org/isoron/uhabits/core/ui/screens/habits/list/HintListTest.kt @@ -26,6 +26,7 @@ import org.isoron.platform.time.LocalDate import org.isoron.platform.time.getToday import org.isoron.uhabits.core.BaseUnitTest import org.isoron.uhabits.core.preferences.Preferences +import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -40,7 +41,7 @@ class HintListTest : BaseUnitTest() { private lateinit var today: LocalDate private lateinit var yesterday: LocalDate - @Throws(Exception::class) + @BeforeTest override fun setUp() { super.setUp() today = getToday() diff --git a/uhabits-core/src/commonTest/kotlin/org/isoron/uhabits/core/ui/screens/habits/list/ListHabitsBehaviorTest.kt b/uhabits-core/src/commonTest/kotlin/org/isoron/uhabits/core/ui/screens/habits/list/ListHabitsBehaviorTest.kt index 5dea98b2..f51803d3 100644 --- a/uhabits-core/src/commonTest/kotlin/org/isoron/uhabits/core/ui/screens/habits/list/ListHabitsBehaviorTest.kt +++ b/uhabits-core/src/commonTest/kotlin/org/isoron/uhabits/core/ui/screens/habits/list/ListHabitsBehaviorTest.kt @@ -26,14 +26,15 @@ import dev.mokkery.matcher.any import dev.mokkery.mock import dev.mokkery.spy import dev.mokkery.verify +import kotlinx.coroutines.test.runTest import org.isoron.platform.io.UserFile -import org.isoron.platform.runSuspend import org.isoron.platform.time.getToday import org.isoron.uhabits.core.BaseUnitTest import org.isoron.uhabits.core.models.Entry import org.isoron.uhabits.core.models.Habit import org.isoron.uhabits.core.preferences.Preferences import org.isoron.uhabits.core.ui.callbacks.NumberPickerCallback +import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -53,6 +54,7 @@ class ListHabitsBehaviorTest : BaseUnitTest() { private val bugReporter: ListHabitsBehavior.BugReporter = mock() + @BeforeTest override fun setUp() { super.setUp() habit1 = fixtures.createShortHabit() @@ -89,12 +91,12 @@ class ListHabitsBehaviorTest : BaseUnitTest() { } @Test - fun testOnExportCSV() { + fun testOnExportCSV() = runTest { val outputDir = createTempDir() every { dirFinder.getCSVOutputDir() } returns outputDir behavior.onExportCSV() verify { screen.showSendFileScreen(any()) } - val files = runSuspend { outputDir.listFiles() } + val files = outputDir.listFiles() assertEquals(1, files!!.size) } diff --git a/uhabits-core/src/commonTest/kotlin/org/isoron/uhabits/core/ui/screens/habits/list/ListHabitsMenuBehaviorTest.kt b/uhabits-core/src/commonTest/kotlin/org/isoron/uhabits/core/ui/screens/habits/list/ListHabitsMenuBehaviorTest.kt index 32cac200..92e77f6c 100644 --- a/uhabits-core/src/commonTest/kotlin/org/isoron/uhabits/core/ui/screens/habits/list/ListHabitsMenuBehaviorTest.kt +++ b/uhabits-core/src/commonTest/kotlin/org/isoron/uhabits/core/ui/screens/habits/list/ListHabitsMenuBehaviorTest.kt @@ -30,6 +30,7 @@ import org.isoron.uhabits.core.models.HabitList import org.isoron.uhabits.core.models.HabitMatcher import org.isoron.uhabits.core.preferences.Preferences import org.isoron.uhabits.core.ui.ThemeSwitcher +import kotlin.test.BeforeTest import kotlin.test.Test import dev.mokkery.verify.VerifyMode.Companion.not as notCalled @@ -48,7 +49,7 @@ class ListHabitsMenuBehaviorTest : BaseUnitTest() { private var capturedOrder: HabitList.Order? = null private var capturedSecondaryOrder: HabitList.Order? = null - @Throws(Exception::class) + @BeforeTest override fun setUp() { super.setUp() every { adapter.setFilter(any()) } returns Unit @@ -80,6 +81,7 @@ class ListHabitsMenuBehaviorTest : BaseUnitTest() { @Test fun testOnSortByColor() { + every { adapter.primaryOrder } returns HabitList.Order.BY_POSITION behavior.onSortByColor() verify { adapter.primaryOrder = HabitList.Order.BY_COLOR_ASC } } @@ -92,12 +94,14 @@ class ListHabitsMenuBehaviorTest : BaseUnitTest() { @Test fun testOnSortScore() { + every { adapter.primaryOrder } returns HabitList.Order.BY_POSITION behavior.onSortByScore() verify { adapter.primaryOrder = HabitList.Order.BY_SCORE_DESC } } @Test fun testOnSortName() { + every { adapter.primaryOrder } returns HabitList.Order.BY_POSITION behavior.onSortByName() verify { adapter.primaryOrder = HabitList.Order.BY_NAME_ASC } } diff --git a/uhabits-core/src/commonTest/kotlin/org/isoron/uhabits/core/ui/screens/habits/list/ListHabitsSelectionMenuBehaviorTest.kt b/uhabits-core/src/commonTest/kotlin/org/isoron/uhabits/core/ui/screens/habits/list/ListHabitsSelectionMenuBehaviorTest.kt index 9582a581..a16fc5ba 100644 --- a/uhabits-core/src/commonTest/kotlin/org/isoron/uhabits/core/ui/screens/habits/list/ListHabitsSelectionMenuBehaviorTest.kt +++ b/uhabits-core/src/commonTest/kotlin/org/isoron/uhabits/core/ui/screens/habits/list/ListHabitsSelectionMenuBehaviorTest.kt @@ -29,6 +29,7 @@ import org.isoron.uhabits.core.models.Habit import org.isoron.uhabits.core.models.PaletteColor import org.isoron.uhabits.core.ui.callbacks.OnColorPickedCallback import org.isoron.uhabits.core.ui.callbacks.OnConfirmedCallback +import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -130,7 +131,7 @@ class ListHabitsSelectionMenuBehaviorTest : BaseUnitTest() { assertFalse(habit1.isArchived) } - @Throws(Exception::class) + @BeforeTest override fun setUp() { super.setUp() habit1 = fixtures.createShortHabit() diff --git a/uhabits-core/src/commonTest/kotlin/org/isoron/uhabits/core/ui/screens/habits/show/ShowHabitMenuPresenterTest.kt b/uhabits-core/src/commonTest/kotlin/org/isoron/uhabits/core/ui/screens/habits/show/ShowHabitMenuPresenterTest.kt index 7fe136fe..5428001e 100644 --- a/uhabits-core/src/commonTest/kotlin/org/isoron/uhabits/core/ui/screens/habits/show/ShowHabitMenuPresenterTest.kt +++ b/uhabits-core/src/commonTest/kotlin/org/isoron/uhabits/core/ui/screens/habits/show/ShowHabitMenuPresenterTest.kt @@ -22,9 +22,10 @@ import dev.mokkery.answering.returns import dev.mokkery.every import dev.mokkery.mock import dev.mokkery.verify -import org.isoron.platform.runSuspend +import kotlinx.coroutines.test.runTest import org.isoron.uhabits.core.BaseUnitTest import org.isoron.uhabits.core.models.Habit +import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals @@ -34,6 +35,7 @@ class ShowHabitMenuPresenterTest : BaseUnitTest() { private lateinit var habit: Habit private lateinit var menu: ShowHabitMenuPresenter + @BeforeTest override fun setUp() { super.setUp() system = mock() @@ -56,11 +58,11 @@ class ShowHabitMenuPresenterTest : BaseUnitTest() { } @Test - fun testOnExport() { + fun testOnExport() = runTest { val outputDir = createTempDir() every { system.getCSVOutputDir() } returns outputDir menu.onExportCSV() - val files = runSuspend { outputDir.listFiles() } + val files = outputDir.listFiles() assertEquals(1, files!!.size) } } diff --git a/uhabits-core/src/jsMain/kotlin/org/isoron/platform/RunSuspend.kt b/uhabits-core/src/jsMain/kotlin/org/isoron/platform/RunSuspend.kt new file mode 100644 index 00000000..abcfefbc --- /dev/null +++ b/uhabits-core/src/jsMain/kotlin/org/isoron/platform/RunSuspend.kt @@ -0,0 +1,21 @@ +package org.isoron.platform + +import kotlin.coroutines.Continuation +import kotlin.coroutines.EmptyCoroutineContext +import kotlin.coroutines.startCoroutine + +actual fun runSuspend(block: suspend () -> T): T { + var result: Result? = null + block.startCoroutine( + object : Continuation { + override val context = EmptyCoroutineContext + override fun resumeWith(r: Result) { + result = r + } + } + ) + return result?.getOrThrow() + ?: throw IllegalStateException( + "runSuspend: coroutine did not complete synchronously" + ) +} 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 new file mode 100644 index 00000000..b569d23d --- /dev/null +++ b/uhabits-core/src/jsMain/kotlin/org/isoron/platform/gui/JsCanvas.kt @@ -0,0 +1,162 @@ +package org.isoron.platform.gui + +import kotlinx.browser.document +import org.w3c.dom.CanvasRenderingContext2D +import org.w3c.dom.HTMLCanvasElement +import kotlin.math.PI +import kotlin.math.roundToInt + +class JsCanvas( + private val canvas: HTMLCanvasElement, + private val pixelScale: Double = 2.0 +) : Canvas { + private val ctx = canvas.getContext("2d") as CanvasRenderingContext2D + private var fontSize = 12.0 + private var font = Font.REGULAR + private var textAlign = TextAlign.CENTER + + init { + updateFont() + } + + private fun toPixel(x: Double): Double = pixelScale * x + private fun toDp(x: Double): Double = x / pixelScale + + override fun setColor(color: Color) { + val r = (color.red * 255).roundToInt() + val g = (color.green * 255).roundToInt() + val b = (color.blue * 255).roundToInt() + val a = color.alpha + val css = "rgba($r,$g,$b,$a)" + ctx.fillStyle = css + ctx.strokeStyle = css + } + + override fun drawLine(x1: Double, y1: Double, x2: Double, y2: Double) { + ctx.beginPath() + ctx.moveTo(toPixel(x1), toPixel(y1)) + ctx.lineTo(toPixel(x2), toPixel(y2)) + ctx.stroke() + } + + override fun drawText(text: String, x: Double, y: Double) { + updateFont() + val px = toPixel(x) + val py = toPixel(y) + val metrics = ctx.measureText(text) + val textWidth = metrics.width + val xPos = when (textAlign) { + TextAlign.CENTER -> px - textWidth / 2 + TextAlign.RIGHT -> px - textWidth + TextAlign.LEFT -> px + } + ctx.fillText(text, xPos, py) + } + + override fun fillRect(x: Double, y: Double, width: Double, height: Double) { + ctx.fillRect(toPixel(x), toPixel(y), toPixel(width), toPixel(height)) + } + + override fun fillRoundRect( + x: Double, + y: Double, + width: Double, + height: Double, + cornerRadius: Double + ) { + val px = toPixel(x) + val py = toPixel(y) + val pw = toPixel(width) + val ph = toPixel(height) + val pr = toPixel(cornerRadius) + ctx.beginPath() + ctx.moveTo(px + pr, py) + ctx.arcTo(px + pw, py, px + pw, py + ph, pr) + ctx.arcTo(px + pw, py + ph, px, py + ph, pr) + ctx.arcTo(px, py + ph, px, py, pr) + ctx.arcTo(px, py, px + pw, py, pr) + ctx.closePath() + ctx.fill() + } + + override fun drawRect(x: Double, y: Double, width: Double, height: Double) { + ctx.strokeRect(toPixel(x), toPixel(y), toPixel(width), toPixel(height)) + } + + override fun getHeight(): Double = toDp(canvas.height.toDouble()) + override fun getWidth(): Double = toDp(canvas.width.toDouble()) + + override fun setFont(font: Font) { + this.font = font + updateFont() + } + + override fun setFontSize(size: Double) { + this.fontSize = size + updateFont() + } + + override fun setStrokeWidth(size: Double) { + ctx.lineWidth = size * pixelScale + } + + override fun fillArc( + centerX: Double, + centerY: Double, + radius: Double, + startAngle: Double, + swipeAngle: Double + ) { + val cx = toPixel(centerX) + val cy = toPixel(centerY) + val r = toPixel(radius) + val start = -startAngle * PI / 180.0 + val end = -(startAngle + swipeAngle) * PI / 180.0 + ctx.beginPath() + ctx.moveTo(cx, cy) + ctx.arc(cx, cy, r, start, end, swipeAngle > 0) + ctx.closePath() + ctx.fill() + } + + override fun fillCircle(centerX: Double, centerY: Double, radius: Double) { + val cx = toPixel(centerX) + val cy = toPixel(centerY) + val r = toPixel(radius) + ctx.beginPath() + ctx.arc(cx, cy, r, 0.0, 2 * PI) + ctx.fill() + } + + override fun setTextAlign(align: TextAlign) { + this.textAlign = align + } + + override fun toImage(): Image = JsImage(canvas) + + override fun measureText(text: String): Double { + updateFont() + return toDp(ctx.measureText(text).width) + } + + private fun updateFont() { + val sizePx = (fontSize * pixelScale).roundToInt() + val family = when (font) { + Font.REGULAR -> "NotoSans" + Font.BOLD -> "NotoSansBold" + Font.FONT_AWESOME -> "FontAwesome" + } + val weight = if (font == Font.BOLD) "bold" else "normal" + ctx.font = "$weight ${sizePx}px $family" + ctx.asDynamic().textBaseline = "middle" + } + + companion object { + fun create(width: Int, height: Int, pixelScale: Double = 2.0): JsCanvas { + val canvas = document.createElement("canvas") as HTMLCanvasElement + canvas.width = (width * pixelScale).roundToInt() + canvas.height = (height * pixelScale).roundToInt() + return JsCanvas(canvas, pixelScale) + } + } +} 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 new file mode 100644 index 00000000..d0a132e2 --- /dev/null +++ b/uhabits-core/src/jsMain/kotlin/org/isoron/platform/gui/JsImage.kt @@ -0,0 +1,45 @@ +package org.isoron.platform.gui + +import kotlinx.browser.document +import org.w3c.dom.CanvasRenderingContext2D +import org.w3c.dom.HTMLCanvasElement + +class JsImage(private val canvas: HTMLCanvasElement) : Image { + private val ctx = canvas.getContext("2d") as CanvasRenderingContext2D + + override val width: Int get() = canvas.width + override val height: Int get() = canvas.height + + override fun getPixel(x: Int, y: Int): Color { + val data = ctx.getImageData(x.toDouble(), y.toDouble(), 1.0, 1.0) + val d = data.data.asDynamic() + val r = (d[0] as Number).toDouble() / 255.0 + val g = (d[1] as Number).toDouble() / 255.0 + val b = (d[2] as Number).toDouble() / 255.0 + val a = (d[3] as Number).toDouble() / 255.0 + return Color(r, g, b, a) + } + + override fun setPixel(x: Int, y: Int, color: Color) { + val data = ctx.createImageData(1.0, 1.0) + val d = data.data.asDynamic() + d[0] = (color.red * 255).toInt() + d[1] = (color.green * 255).toInt() + d[2] = (color.blue * 255).toInt() + d[3] = (color.alpha * 255).toInt() + ctx.putImageData(data, x.toDouble(), y.toDouble()) + } + + override suspend fun export(path: String) { + // No-op on JS browser; images can't be exported to filesystem + } + + companion object { + fun create(width: Int, height: Int): JsImage { + val canvas = document.createElement("canvas") as HTMLCanvasElement + canvas.width = width + canvas.height = height + return JsImage(canvas) + } + } +} diff --git a/uhabits-core/src/jsMain/kotlin/org/isoron/platform/io/JsDatabase.kt b/uhabits-core/src/jsMain/kotlin/org/isoron/platform/io/JsDatabase.kt new file mode 100644 index 00000000..b6552234 --- /dev/null +++ b/uhabits-core/src/jsMain/kotlin/org/isoron/platform/io/JsDatabase.kt @@ -0,0 +1,126 @@ +package org.isoron.platform.io + +import org.khronos.webgl.Uint8Array +import org.khronos.webgl.set +import kotlin.js.Promise + +@JsModule("sql.js") +@JsNonModule +external fun initSqlJs(config: dynamic = definedExternally): Promise + +private fun sqlJsNewDatabase(sqlJs: dynamic): dynamic { + val ctor = sqlJs.Database + return js("new ctor()") +} + +class JsPreparedStatement( + private val db: dynamic, + sql: String +) : PreparedStatement { + private val stmt: dynamic = db.prepare(sql) + private var currentRow: dynamic = null + private var bindings: dynamic = js("[]") + private var needsBind: Boolean = false + + override fun step(): StepResult { + if (needsBind) { + stmt.bind(bindings) + needsBind = false + } + val hasRow = stmt.step() as Boolean + currentRow = if (hasRow) stmt.get() else null + return if (hasRow) StepResult.ROW else StepResult.DONE + } + + override fun getInt(index: Int): Int = (currentRow[index] as Number).toInt() + override fun getLong(index: Int): Long = (currentRow[index] as Number).toLong() + override fun getReal(index: Int): Double = (currentRow[index] as Number).toDouble() + override fun getText(index: Int): String = currentRow[index] as String + + override fun getIntOrNull(index: Int): Int? { + val v = currentRow[index] ?: return null + return (v as Number).toInt() + } + + override fun getLongOrNull(index: Int): Long? { + val v = currentRow[index] ?: return null + return (v as Number).toLong() + } + + override fun getRealOrNull(index: Int): Double? { + val v = currentRow[index] ?: return null + return (v as Number).toDouble() + } + + override fun getTextOrNull(index: Int): String? { + val v = currentRow[index] ?: return null + return v as String + } + + override fun bindInt(index: Int, value: Int) { + bindings[index - 1] = value + needsBind = true + } + + override fun bindLong(index: Int, value: Long) { + bindings[index - 1] = value.toDouble() + needsBind = true + } + + override fun bindReal(index: Int, value: Double) { + bindings[index - 1] = value + needsBind = true + } + + override fun bindText(index: Int, value: String) { + bindings[index - 1] = value + needsBind = true + } + + override fun bindNull(index: Int) { + bindings[index - 1] = null + needsBind = true + } + + override fun reset() { + stmt.reset() + currentRow = null + bindings = js("[]") + needsBind = false + } + + override fun finalize() { + stmt.free() + } +} + +class JsDatabase(val db: dynamic) : Database { + override fun prepareStatement(sql: String): PreparedStatement { + return JsPreparedStatement(db, sql) + } + + override fun close() { + db.close() + } +} + +class JsDatabaseOpener( + private val sqlJs: dynamic, + private val storage: JsFileStorage? = null +) : DatabaseOpener { + override fun open(path: String): Database { + if (path == ":memory:" || storage == null) { + val db = sqlJsNewDatabase(sqlJs) + return JsDatabase(db) + } + val bytes = storage.read(path) + ?: error("File not found in storage: $path") + val data = Uint8Array(bytes.size) + for (i in bytes.indices) { + data[i] = bytes[i] + } + val ctor = sqlJs.Database + val db = js("new ctor(data)") + return JsDatabase(db) + } +} diff --git a/uhabits-core/src/jsMain/kotlin/org/isoron/platform/io/JsFiles.kt b/uhabits-core/src/jsMain/kotlin/org/isoron/platform/io/JsFiles.kt new file mode 100644 index 00000000..489baed6 --- /dev/null +++ b/uhabits-core/src/jsMain/kotlin/org/isoron/platform/io/JsFiles.kt @@ -0,0 +1,184 @@ +package org.isoron.platform.io + +import kotlinx.browser.window +import org.isoron.platform.gui.Image +import org.isoron.platform.gui.JsImage +import org.w3c.dom.CanvasRenderingContext2D +import org.w3c.dom.HTMLCanvasElement +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlin.coroutines.suspendCoroutine +import kotlin.js.Promise + +private fun Promise.await(): suspend () -> T = { + suspendCoroutine { cont -> + then({ cont.resume(it) }, { cont.resumeWithException(it) }) + } +} + +class JsFileStorage { + private val store = mutableMapOf() + + fun write(path: String, bytes: ByteArray) { + store[path] = bytes + } + + fun read(path: String): ByteArray? = store[path] + + fun exists(path: String): Boolean = path in store + + fun delete(path: String) { + store.remove(path) + } + + fun listKeys(prefix: String): List = + store.keys.filter { it.startsWith(prefix) } +} + +class JsUserFile( + private val storage: JsFileStorage, + override val pathString: String +) : UserFile { + override suspend fun delete() { + storage.delete(pathString) + } + + override suspend fun exists(): Boolean = storage.exists(pathString) + + override suspend fun lines(): List { + val bytes = storage.read(pathString) + ?: error("File not found: $pathString") + return bytes.decodeToString().lines() + } + + override suspend fun writeString(content: String) { + storage.write(pathString, content.encodeToByteArray()) + } + + override suspend fun writeBytes(bytes: ByteArray) { + storage.write(pathString, bytes) + } + + override suspend fun readBytes(limit: Int): ByteArray { + val bytes = storage.read(pathString) ?: return ByteArray(0) + return if (bytes.size <= limit) bytes else bytes.copyOf(limit) + } + + override fun resolve(child: String): UserFile { + val resolved = if (pathString.endsWith("/")) { + "$pathString$child" + } else { + "$pathString/$child" + } + return JsUserFile(storage, resolved) + } + + override suspend fun listFiles(): List? { + val prefix = if (pathString.endsWith("/")) pathString else "$pathString/" + val keys = storage.listKeys(prefix) + if (keys.isEmpty()) return null + val children = mutableSetOf() + for (key in keys) { + val rest = key.removePrefix(prefix) + val child = rest.split("/").first() + children.add(child) + } + return children.map { JsUserFile(storage, "$prefix$it") } + } + + override suspend fun mkdirs() { + // No-op for in-memory storage + } +} + +class JsResourceFile(private val path: String) : ResourceFile { + override suspend fun copyTo(dest: UserFile) { + val bytes = fetchBytes(path) + dest.writeBytes(bytes) + } + + override suspend fun lines(): List { + val text = fetchText(path) + val result = text.lines() + return if (result.lastOrNull() == "") result.dropLast(1) else result + } + + override suspend fun exists(): Boolean { + return try { + fetchText(path) + true + } catch (e: Throwable) { + false + } + } + + override suspend fun toImage(): Image { + return suspendCoroutine { cont -> + val img = org.w3c.dom.Image() + img.onload = { + val canvas = ( + kotlinx.browser.document.createElement("canvas") + as HTMLCanvasElement + ) + canvas.width = img.width + canvas.height = img.height + val ctx = canvas.getContext("2d") as CanvasRenderingContext2D + ctx.drawImage(img, 0.0, 0.0) + cont.resume(JsImage(canvas)) + } + img.onerror = { _, _, _, _, _ -> + cont.resumeWithException(RuntimeException("Failed to load image: $path")) + } + img.src = path + } + } +} + +private suspend fun fetchText(path: String): String = suspendCoroutine { cont -> + window.fetch(path).then( + { response -> + if (!response.ok) { + cont.resumeWithException(RuntimeException("Failed to fetch $path: ${response.status}")) + } else { + response.text().then( + { text -> cont.resume(text) }, + { err -> cont.resumeWithException(RuntimeException("Failed to read $path: $err")) } + ) + } + }, + { err -> cont.resumeWithException(RuntimeException("Failed to fetch $path: $err")) } + ) +} + +private suspend fun fetchBytes(path: String): ByteArray = suspendCoroutine { cont -> + window.fetch(path).then( + { response -> + if (!response.ok) { + cont.resumeWithException(RuntimeException("Failed to fetch $path: ${response.status}")) + } else { + response.arrayBuffer().then( + { buffer -> + val uint8Array = js("new Uint8Array(buffer)") + val length = uint8Array.length as Int + val bytes = ByteArray(length) + for (i in 0 until length) { + bytes[i] = (uint8Array[i] as Number).toByte() + } + cont.resume(bytes) + }, + { err -> cont.resumeWithException(RuntimeException("Failed to read $path: $err")) } + ) + } + }, + { err -> cont.resumeWithException(RuntimeException("Failed to fetch $path: $err")) } + ) +} + +class JsFileOpener(private val storage: JsFileStorage = JsFileStorage()) : FileOpener { + override fun openResourceFile(path: String): ResourceFile { + val url = if (path.startsWith("migrations/")) path else "test-assets/$path" + return JsResourceFile(url) + } + + override fun openUserFile(path: String): UserFile = JsUserFile(storage, path) +} diff --git a/uhabits-core/src/jsMain/kotlin/org/isoron/platform/io/JsStrings.kt b/uhabits-core/src/jsMain/kotlin/org/isoron/platform/io/JsStrings.kt new file mode 100644 index 00000000..e1ae8dbe --- /dev/null +++ b/uhabits-core/src/jsMain/kotlin/org/isoron/platform/io/JsStrings.kt @@ -0,0 +1,16 @@ +package org.isoron.platform.io + +@JsModule("sprintf-js") +@JsNonModule +external object SprintfJs { + fun sprintf(format: String, vararg args: Any?): String +} + +actual fun format(format: String, arg: String): String = + SprintfJs.sprintf(format, arg) + +actual fun format(format: String, arg: Int): String = + SprintfJs.sprintf(format, arg) + +actual fun format(format: String, arg: Double): String = + SprintfJs.sprintf(format, arg) diff --git a/uhabits-core/src/jsMain/kotlin/org/isoron/platform/io/JsZipReader.kt b/uhabits-core/src/jsMain/kotlin/org/isoron/platform/io/JsZipReader.kt new file mode 100644 index 00000000..738ccb6d --- /dev/null +++ b/uhabits-core/src/jsMain/kotlin/org/isoron/platform/io/JsZipReader.kt @@ -0,0 +1,40 @@ +package org.isoron.platform.io + +actual class ZipReader actual constructor(bytes: ByteArray) { + private val data = bytes + + actual fun entries(): List { + val result = mutableListOf() + var pos = 0 + while (pos + 30 <= data.size) { + val sig = readInt(pos) + if (sig != 0x04034b50) break + val compressionMethod = readShort(pos + 8) + val compressedSize = readInt(pos + 18) + val nameLen = readShort(pos + 26) + val extraLen = readShort(pos + 28) + val nameStart = pos + 30 + val name = data.copyOfRange(nameStart, nameStart + nameLen).decodeToString() + val dataStart = nameStart + nameLen + extraLen + if (compressionMethod != 0) { + throw UnsupportedOperationException( + "Only STORED ZIP entries are supported, got method $compressionMethod" + ) + } + val content = data.copyOfRange(dataStart, dataStart + compressedSize).decodeToString() + result.add(ZipEntry(name, content)) + pos = dataStart + compressedSize + } + return result + } + + private fun readInt(offset: Int): Int = + (data[offset].toInt() and 0xFF) or + ((data[offset + 1].toInt() and 0xFF) shl 8) or + ((data[offset + 2].toInt() and 0xFF) shl 16) or + ((data[offset + 3].toInt() and 0xFF) shl 24) + + private fun readShort(offset: Int): Int = + (data[offset].toInt() and 0xFF) or + ((data[offset + 1].toInt() and 0xFF) shl 8) +} diff --git a/uhabits-core/src/jsMain/kotlin/org/isoron/platform/io/JsZipWriter.kt b/uhabits-core/src/jsMain/kotlin/org/isoron/platform/io/JsZipWriter.kt new file mode 100644 index 00000000..a438a79a --- /dev/null +++ b/uhabits-core/src/jsMain/kotlin/org/isoron/platform/io/JsZipWriter.kt @@ -0,0 +1,122 @@ +package org.isoron.platform.io + +actual class ZipWriter { + private val entries = mutableListOf>() + + actual fun addEntry(name: String, content: String) { + entries.add(name to content.encodeToByteArray()) + } + + actual suspend fun toBytes(): ByteArray { + val buf = ZipBuffer() + val offsets = mutableListOf() + + for ((name, data) in entries) { + offsets.add(buf.size) + val nameBytes = name.encodeToByteArray() + val crc = crc32(data) + buf.writeInt(0x04034b50) + buf.writeShort(20) + buf.writeShort(0) + buf.writeShort(0) + buf.writeShort(0) + buf.writeShort(0) + buf.writeInt(crc) + buf.writeInt(data.size) + buf.writeInt(data.size) + buf.writeShort(nameBytes.size) + buf.writeShort(0) + buf.writeBytes(nameBytes) + buf.writeBytes(data) + } + + val centralDirOffset = buf.size + for (i in entries.indices) { + val (name, data) = entries[i] + val nameBytes = name.encodeToByteArray() + val crc = crc32(data) + buf.writeInt(0x02014b50) + buf.writeShort(20) + buf.writeShort(20) + buf.writeShort(0) + buf.writeShort(0) + buf.writeShort(0) + buf.writeShort(0) + buf.writeInt(crc) + buf.writeInt(data.size) + buf.writeInt(data.size) + buf.writeShort(nameBytes.size) + buf.writeShort(0) + buf.writeShort(0) + buf.writeShort(0) + buf.writeShort(0) + buf.writeInt(0) + buf.writeInt(offsets[i]) + buf.writeBytes(nameBytes) + } + val centralDirSize = buf.size - centralDirOffset + + buf.writeInt(0x06054b50) + buf.writeShort(0) + buf.writeShort(0) + buf.writeShort(entries.size) + buf.writeShort(entries.size) + buf.writeInt(centralDirSize) + buf.writeInt(centralDirOffset) + buf.writeShort(0) + + return buf.toByteArray() + } +} + +private class ZipBuffer { + private var data = ByteArray(4096) + var size = 0 + private set + + fun writeShort(v: Int) { + ensureCapacity(2) + data[size++] = (v and 0xFF).toByte() + data[size++] = ((v shr 8) and 0xFF).toByte() + } + + fun writeInt(v: Int) { + ensureCapacity(4) + data[size++] = (v and 0xFF).toByte() + data[size++] = ((v shr 8) and 0xFF).toByte() + data[size++] = ((v shr 16) and 0xFF).toByte() + data[size++] = ((v shr 24) and 0xFF).toByte() + } + + fun writeBytes(bytes: ByteArray) { + ensureCapacity(bytes.size) + bytes.copyInto(data, size) + size += bytes.size + } + + fun toByteArray(): ByteArray = data.copyOf(size) + + private fun ensureCapacity(needed: Int) { + if (size + needed > data.size) { + data = data.copyOf(maxOf(data.size * 2, size + needed)) + } + } +} + +private val crcTable = IntArray(256).also { table -> + for (n in 0..255) { + var c = n + repeat(8) { + c = if (c and 1 != 0) (c ushr 1) xor 0xEDB88320.toInt() else c ushr 1 + } + table[n] = c + } +} + +private fun crc32(data: ByteArray): Int { + var crc = -1 + for (b in data) { + crc = (crc ushr 8) xor crcTable[(crc xor b.toInt()) and 0xFF] + } + return crc xor -1 +} diff --git a/uhabits-core/src/jsMain/kotlin/org/isoron/platform/time/JsDates.kt b/uhabits-core/src/jsMain/kotlin/org/isoron/platform/time/JsDates.kt new file mode 100644 index 00000000..758121c9 --- /dev/null +++ b/uhabits-core/src/jsMain/kotlin/org/isoron/platform/time/JsDates.kt @@ -0,0 +1,67 @@ +package org.isoron.platform.time + +import kotlin.js.Date + +actual fun computeToday(hourOffset: Int, minuteOffset: Int): LocalDate { + val now = Date() + val localMillis = now.getTime() + now.getTimezoneOffset() * 60000.0 * -1 + val offsetMillis = hourOffset * 3600000.0 + minuteOffset * 60000.0 + val adjustedMillis = localMillis - offsetMillis + val daysSinceEpoch = kotlin.math.floor(adjustedMillis / 86400000.0).toLong() + val daysSince2000 = (daysSinceEpoch - 10957).toInt() + return LocalDate(daysSince2000) +} + +actual fun getFirstWeekdayNumberAccordingToLocale(): Int { + // JS Intl does not expose first day of week. Default to Sunday (1) + // matching java.util.Calendar convention where Sunday=1. + return 1 +} + +private fun toLocaleDateString(date: Date, locale: String, options: dynamic): String { + return js("date.toLocaleDateString(locale, options)").unsafeCast() +} + +class JsLocalDateFormatter(private val locale: String = "en-US") : LocalDateFormatter { + override fun shortWeekdayName(weekday: DayOfWeek): String { + val date = weekdayToJsDate(weekday) + return formatWeekday(date, "short") + } + + override fun shortWeekdayName(date: LocalDate): String { + return formatWeekday(localDateToJsDate(date), "short") + } + + override fun shortMonthName(date: LocalDate): String { + val jsDate = localDateToJsDate(date) + val options = js("{month: 'short', timeZone: 'UTC'}") + return toLocaleDateString(jsDate, locale, options) + } + + override fun longWeekdayName(weekday: DayOfWeek): String { + val date = weekdayToJsDate(weekday) + return formatWeekday(date, "long") + } + + override fun longMonthName(date: LocalDate): String { + val jsDate = localDateToJsDate(date) + val options = js("{month: 'long', timeZone: 'UTC'}") + return toLocaleDateString(jsDate, locale, options) + } + + private fun formatWeekday(jsDate: Date, style: String): String { + val options = js("{timeZone: 'UTC'}") + options["weekday"] = style + return toLocaleDateString(jsDate, locale, options) + } + + private fun localDateToJsDate(date: LocalDate): Date { + return Date(Date.UTC(date.year, date.month - 1, date.day)) + } + + private fun weekdayToJsDate(weekday: DayOfWeek): Date { + // Jan 2, 2000 is a Sunday + val dayOffset = weekday.daysSinceSunday + return Date(Date.UTC(2000, 0, 2 + dayOffset)) + } +} diff --git a/uhabits-core/src/jsTest/kotlin/org/isoron/platform/io/TestDatabaseHelper.kt b/uhabits-core/src/jsTest/kotlin/org/isoron/platform/io/TestDatabaseHelper.kt new file mode 100644 index 00000000..9b0917fc --- /dev/null +++ b/uhabits-core/src/jsTest/kotlin/org/isoron/platform/io/TestDatabaseHelper.kt @@ -0,0 +1,68 @@ +package org.isoron.platform.io + +import org.isoron.uhabits.core.DATABASE_VERSION +import org.isoron.uhabits.core.database.SQLParser +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlin.coroutines.suspendCoroutine + +actual object TestDatabaseHelper { + private var sqlJs: dynamic = null + private val migrationCache = mutableMapOf() + + private suspend fun ensureInitialized() { + if (sqlJs == null) { + sqlJs = suspendCoroutine { cont -> + initSqlJs().then( + { result: dynamic -> cont.resume(result) }, + { err: dynamic -> cont.resumeWithException(RuntimeException("$err")) } + ) + } + for (v in 9..DATABASE_VERSION) { + migrationCache[v] = fetchMigrationSQL(v) + } + } + } + + actual suspend fun createEmptyDatabase(): Database { + ensureInitialized() + val db = JsDatabaseOpener(sqlJs).open(":memory:") + db.setVersion(8) + for (v in 9..DATABASE_VERSION) { + val commands = SQLParser.parse(migrationCache[v]!!) + for (cmd in commands) db.run(cmd) + db.setVersion(v) + } + return db + } + + suspend fun getInitializedSqlJs(): dynamic { + ensureInitialized() + return sqlJs + } + + actual suspend fun loadMigrationSQL(version: Int): String { + ensureInitialized() + return migrationCache[version] + ?: error("Migration SQL for version $version not found") + } + + private suspend fun fetchMigrationSQL(version: Int): String { + val path = "migrations/${version.toString().padStart(2, '0')}.sql" + return suspendCoroutine { cont -> + kotlinx.browser.window.fetch(path).then( + { response -> + if (!response.ok) { + cont.resumeWithException(RuntimeException("Failed to fetch $path: ${response.status}")) + } else { + response.text().then( + { text: String -> cont.resume(text) }, + { err: dynamic -> cont.resumeWithException(RuntimeException("$err")) } + ) + } + }, + { err: dynamic -> cont.resumeWithException(RuntimeException("$err")) } + ) + } + } +} 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 new file mode 100644 index 00000000..d04d1489 --- /dev/null +++ b/uhabits-core/src/jsTest/kotlin/org/isoron/platform/io/TestPlatformHelper.kt @@ -0,0 +1,57 @@ +package org.isoron.platform.io + +import kotlinx.browser.document +import org.isoron.platform.gui.Canvas +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.suspendCoroutine + +val sharedTestStorage = JsFileStorage() + +actual fun createTestFileOpener(): FileOpener = JsFileOpener(sharedTestStorage) +actual fun createTestDatabaseOpener(): DatabaseOpener { + throw UnsupportedOperationException( + "createTestDatabaseOpener() requires async sql.js init. " + + "Use createTestDatabaseOpenerSuspend() instead." + ) +} + +actual suspend fun createTestDatabaseOpenerSuspend(): DatabaseOpener { + val sqlJs = TestDatabaseHelper.getInitializedSqlJs() + return JsDatabaseOpener(sqlJs, sharedTestStorage) +} + +actual fun createTestCanvas(width: Int, height: Int): Canvas { + return JsCanvas.create(width, height, 2.0) +} + +actual fun createTestDateFormatter(): LocalDateFormatter { + return JsLocalDateFormatter("en-US") +} + +private var fontsLoaded = false + +actual suspend fun ensureFontsLoaded() { + if (fontsLoaded) return + val style = document.createElement("style") + style.textContent = buildString { + appendLine("@font-face { font-family: 'NotoSans'; src: url('/fonts/NotoSans-Regular.ttf') format('truetype'); }") + appendLine("@font-face { font-family: 'NotoSansBold'; src: url('/fonts/NotoSans-Bold.ttf') format('truetype'); }") + appendLine("@font-face { font-family: 'FontAwesome'; src: url('/fonts/FontAwesome.ttf') format('truetype'); }") + } + document.head?.appendChild(style) + loadFontAsync("12px NotoSans") + loadFontAsync("bold 12px NotoSansBold") + loadFontAsync("12px FontAwesome") + 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) } + ) +} diff --git a/uhabits-core/src/jvmMain/java/org/isoron/platform/JvmAnnotations.kt b/uhabits-core/src/jvmMain/java/org/isoron/platform/JvmAnnotations.kt new file mode 100644 index 00000000..9cdfc954 --- /dev/null +++ b/uhabits-core/src/jvmMain/java/org/isoron/platform/JvmAnnotations.kt @@ -0,0 +1,4 @@ +package org.isoron.platform + +actual typealias Synchronized = kotlin.jvm.Synchronized +actual typealias JvmStatic = kotlin.jvm.JvmStatic diff --git a/uhabits-core/src/jvmMain/java/org/isoron/platform/time/JvmDates.kt b/uhabits-core/src/jvmMain/java/org/isoron/platform/time/JvmDates.kt index a8bb2ecc..3de3adb8 100644 --- a/uhabits-core/src/jvmMain/java/org/isoron/platform/time/JvmDates.kt +++ b/uhabits-core/src/jvmMain/java/org/isoron/platform/time/JvmDates.kt @@ -2,7 +2,7 @@ package org.isoron.platform.time import java.util.TimeZone -fun computeToday(hourOffset: Int = 0, minuteOffset: Int = 0): LocalDate { +actual fun computeToday(hourOffset: Int, minuteOffset: Int): LocalDate { val nowMillis = System.currentTimeMillis() val tz = TimeZone.getDefault() val localMillis = nowMillis + tz.getOffset(nowMillis) diff --git a/uhabits-core/src/jvmTest/java/org/isoron/platform/io/TestDatabaseHelper.kt b/uhabits-core/src/jvmTest/java/org/isoron/platform/io/TestDatabaseHelper.kt index 95ddf251..4d269550 100644 --- a/uhabits-core/src/jvmTest/java/org/isoron/platform/io/TestDatabaseHelper.kt +++ b/uhabits-core/src/jvmTest/java/org/isoron/platform/io/TestDatabaseHelper.kt @@ -1,24 +1,19 @@ package org.isoron.platform.io -import kotlinx.coroutines.runBlocking import org.isoron.uhabits.core.DATABASE_VERSION actual object TestDatabaseHelper { private val fileOpener = JavaFileOpener() - actual fun createEmptyDatabase(): Database { + actual suspend fun createEmptyDatabase(): Database { val db = JavaDatabaseOpener().open(":memory:") db.setVersion(8) - runBlocking { - db.migrateTo(DATABASE_VERSION) { v -> loadMigrationSQL(v) } - } + db.migrateTo(DATABASE_VERSION) { v -> loadMigrationSQL(v) } return db } - actual fun loadMigrationSQL(version: Int): String { + actual suspend fun loadMigrationSQL(version: Int): String { val path = "migrations/%02d.sql".format(version) - return runBlocking { - fileOpener.openResourceFile(path).lines().joinToString("\n") - } + return fileOpener.openResourceFile(path).lines().joinToString("\n") } } diff --git a/uhabits-core/src/jvmTest/java/org/isoron/platform/io/TestPlatformHelper.kt b/uhabits-core/src/jvmTest/java/org/isoron/platform/io/TestPlatformHelper.kt index 08b71ecf..7550b2b1 100644 --- a/uhabits-core/src/jvmTest/java/org/isoron/platform/io/TestPlatformHelper.kt +++ b/uhabits-core/src/jvmTest/java/org/isoron/platform/io/TestPlatformHelper.kt @@ -10,6 +10,7 @@ import java.util.Locale actual fun createTestFileOpener(): FileOpener = JavaFileOpener() actual fun createTestDatabaseOpener(): DatabaseOpener = JavaDatabaseOpener() +actual suspend fun createTestDatabaseOpenerSuspend(): DatabaseOpener = JavaDatabaseOpener() actual fun createTestCanvas(width: Int, height: Int): Canvas { return JavaCanvas(BufferedImage(2 * width, 2 * height, TYPE_INT_ARGB), 2.0) @@ -18,3 +19,5 @@ actual fun createTestCanvas(width: Int, height: Int): Canvas { actual fun createTestDateFormatter(): LocalDateFormatter { return JavaLocalDateFormatter(Locale.US) } + +actual suspend fun ensureFontsLoaded() {}