Implement uhabits-core/jsMain (WIP)

This commit is contained in:
Alinson S. Xavier 2026-04-09 19:11:06 -05:00
parent 36fad2491e
commit 499146d102
60 changed files with 1218 additions and 155 deletions

1
.gitignore vendored
View File

@ -19,3 +19,4 @@ node_modules
crowdin.yml
kotlin-js-store
*.md
.kotlin

View File

@ -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"

View File

@ -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
}

View File

@ -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<org.jetbrains.kotlin.gradle.tasks.KotlinCompile> {
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile> {
compilerOptions {
freeCompilerArgs.add("-Xjvm-default=all")
freeCompilerArgs.add("-jvm-default=enable")
}
}

View File

@ -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") }
);

View File

@ -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()

View File

@ -238,6 +238,8 @@ fun getWeekdaySequence(firstWeekday: DayOfWeek): List<DayOfWeek> {
expect fun getFirstWeekdayNumberAccordingToLocale(): Int
expect fun computeToday(hourOffset: Int = 0, minuteOffset: Int = 0): LocalDate
fun countWeekdayOccurrencesInMonth(startOfMonth: LocalDate): Array<Int> {
val weekday = (startOfMonth.dayOfWeek.daysSinceSunday + 1) % 7
val freq = Array(7) { 0 }

View File

@ -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")
}

View File

@ -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

View File

@ -194,7 +194,10 @@ abstract class HabitList : Iterable<Habit> {
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))

View File

@ -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.

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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)
}
}

View File

@ -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)
}
}
}

View File

@ -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

View File

@ -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)

View File

@ -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')")

View File

@ -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')")

View File

@ -6,7 +6,7 @@ import kotlin.test.assertEquals
class MigrationTest {
@Test
fun testMigrateFromScratch() {
fun testMigrateFromScratch() = runTest {
val db = TestDatabaseHelper.createEmptyDatabase()
assertEquals(25, db.getVersion())

View File

@ -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
}

View File

@ -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()

View File

@ -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()
}
}

View File

@ -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)

View File

@ -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)

View File

@ -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<Exception> {
val exception = assertFailsWith<Throwable> {
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<Exception> {
val exception = assertFailsWith<Throwable> {
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<Exception> {
val exception = assertFailsWith<Throwable> {
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<Exception> {
val exception = assertFailsWith<Throwable> {
db.run("insert into repetitions(habit, timestamp, value)values (0, 100, 5)")
}
assertContains(exception.message!!, "constraint")

View File

@ -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<String?>()
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))

View File

@ -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())

View File

@ -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))

View File

@ -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()

View File

@ -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()
}

View File

@ -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()

View File

@ -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)
}

View File

@ -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)

View File

@ -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()

View File

@ -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()

View File

@ -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()

View File

@ -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)
}

View File

@ -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 }
}

View File

@ -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()

View File

@ -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)
}
}

View File

@ -0,0 +1,21 @@
package org.isoron.platform
import kotlin.coroutines.Continuation
import kotlin.coroutines.EmptyCoroutineContext
import kotlin.coroutines.startCoroutine
actual fun <T> runSuspend(block: suspend () -> T): T {
var result: Result<T>? = null
block.startCoroutine(
object : Continuation<T> {
override val context = EmptyCoroutineContext
override fun resumeWith(r: Result<T>) {
result = r
}
}
)
return result?.getOrThrow()
?: throw IllegalStateException(
"runSuspend: coroutine did not complete synchronously"
)
}

View File

@ -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)
}
}
}

View File

@ -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)
}
}
}

View File

@ -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<dynamic>
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)
}
}

View File

@ -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 <T> Promise<T>.await(): suspend () -> T = {
suspendCoroutine { cont ->
then({ cont.resume(it) }, { cont.resumeWithException(it) })
}
}
class JsFileStorage {
private val store = mutableMapOf<String, ByteArray>()
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<String> =
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<String> {
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<UserFile>? {
val prefix = if (pathString.endsWith("/")) pathString else "$pathString/"
val keys = storage.listKeys(prefix)
if (keys.isEmpty()) return null
val children = mutableSetOf<String>()
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<String> {
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)
}

View File

@ -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)

View File

@ -0,0 +1,40 @@
package org.isoron.platform.io
actual class ZipReader actual constructor(bytes: ByteArray) {
private val data = bytes
actual fun entries(): List<ZipEntry> {
val result = mutableListOf<ZipEntry>()
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)
}

View File

@ -0,0 +1,122 @@
package org.isoron.platform.io
actual class ZipWriter {
private val entries = mutableListOf<Pair<String, ByteArray>>()
actual fun addEntry(name: String, content: String) {
entries.add(name to content.encodeToByteArray())
}
actual suspend fun toBytes(): ByteArray {
val buf = ZipBuffer()
val offsets = mutableListOf<Int>()
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
}

View File

@ -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<String>()
}
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))
}
}

View File

@ -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<Int, String>()
private suspend fun ensureInitialized() {
if (sqlJs == null) {
sqlJs = suspendCoroutine<dynamic> { 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")) }
)
}
}
}

View File

@ -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<Unit> { cont ->
val promise = document.asDynamic().fonts.load(fontSpec)
promise.then(
{ _: dynamic -> cont.resume(Unit) },
{ _: dynamic -> cont.resume(Unit) }
)
}

View File

@ -0,0 +1,4 @@
package org.isoron.platform
actual typealias Synchronized = kotlin.jvm.Synchronized
actual typealias JvmStatic = kotlin.jvm.JvmStatic

View File

@ -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)

View File

@ -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")
}
}

View File

@ -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() {}