Implement uhabits-core/jsMain (WIP)
This commit is contained in:
parent
36fad2491e
commit
499146d102
1
.gitignore
vendored
1
.gitignore
vendored
@ -19,3 +19,4 @@ node_modules
|
||||
crowdin.yml
|
||||
kotlin-js-store
|
||||
*.md
|
||||
.kotlin
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
38
uhabits-core/karma.config.d/karma.conf.js
Normal file
38
uhabits-core/karma.config.d/karma.conf.js
Normal 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") }
|
||||
);
|
||||
@ -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()
|
||||
@ -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 }
|
||||
|
||||
@ -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")
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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')")
|
||||
|
||||
@ -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')")
|
||||
|
||||
@ -6,7 +6,7 @@ import kotlin.test.assertEquals
|
||||
|
||||
class MigrationTest {
|
||||
@Test
|
||||
fun testMigrateFromScratch() {
|
||||
fun testMigrateFromScratch() = runTest {
|
||||
val db = TestDatabaseHelper.createEmptyDatabase()
|
||||
assertEquals(25, db.getVersion())
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -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 }
|
||||
}
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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"
|
||||
)
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
184
uhabits-core/src/jsMain/kotlin/org/isoron/platform/io/JsFiles.kt
Normal file
184
uhabits-core/src/jsMain/kotlin/org/isoron/platform/io/JsFiles.kt
Normal 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)
|
||||
}
|
||||
@ -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)
|
||||
@ -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)
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
@ -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")) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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) }
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,4 @@
|
||||
package org.isoron.platform
|
||||
|
||||
actual typealias Synchronized = kotlin.jvm.Synchronized
|
||||
actual typealias JvmStatic = kotlin.jvm.JvmStatic
|
||||
@ -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)
|
||||
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@ -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() {}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user