From 7d22909c1b54b7fa2a78a99599601bf19aea7c26 Mon Sep 17 00:00:00 2001 From: Messias Junior Date: Tue, 21 Jul 2026 10:57:53 +0100 Subject: [PATCH 1/9] task data layer --- .../1.json | 69 ++++++++++++++++++- .../foliary/core/database/FoliaryDatabase.kt | 9 ++- .../typeconverter/InstantTypeConverter.kt | 12 ++++ .../typeconverter/UuidTypeConverter.kt | 12 ++++ .../foliary/data/task/TaskRepository.kt | 5 +- .../foliary/data/task/TaskRepositoryImpl.kt | 21 ++++-- .../foliary/data/task/database/TaskDao.kt | 18 +++++ .../data/task/database/entity/Location.kt | 6 ++ .../data/task/database/entity/Priority.kt | 11 +++ .../foliary/data/task/database/entity/Task.kt | 20 ++++++ .../dev/appoutlet/foliary/domain/Task.kt | 3 - 11 files changed, 174 insertions(+), 12 deletions(-) create mode 100644 foliary/src/commonMain/kotlin/dev/appoutlet/foliary/core/database/typeconverter/InstantTypeConverter.kt create mode 100644 foliary/src/commonMain/kotlin/dev/appoutlet/foliary/core/database/typeconverter/UuidTypeConverter.kt create mode 100644 foliary/src/commonMain/kotlin/dev/appoutlet/foliary/data/task/database/TaskDao.kt create mode 100644 foliary/src/commonMain/kotlin/dev/appoutlet/foliary/data/task/database/entity/Location.kt create mode 100644 foliary/src/commonMain/kotlin/dev/appoutlet/foliary/data/task/database/entity/Priority.kt create mode 100644 foliary/src/commonMain/kotlin/dev/appoutlet/foliary/data/task/database/entity/Task.kt delete mode 100644 foliary/src/commonMain/kotlin/dev/appoutlet/foliary/domain/Task.kt diff --git a/foliary/schemas/dev.appoutlet.foliary.core.database.FoliaryDatabase/1.json b/foliary/schemas/dev.appoutlet.foliary.core.database.FoliaryDatabase/1.json index a1936f1..2e3e0f6 100644 --- a/foliary/schemas/dev.appoutlet.foliary.core.database.FoliaryDatabase/1.json +++ b/foliary/schemas/dev.appoutlet.foliary.core.database.FoliaryDatabase/1.json @@ -2,7 +2,7 @@ "formatVersion": 1, "database": { "version": 1, - "identityHash": "588df0fceb5b7c0bd658e710a4ae336f", + "identityHash": "218189b9f12041c6ed819e387c07388a", "entities": [ { "tableName": "ApplicationVersion", @@ -85,11 +85,76 @@ "accessToken" ] } + }, + { + "tableName": "Task", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `description` TEXT, `creationDate` INTEGER NOT NULL, `dueDate` INTEGER, `completionDate` INTEGER, `priority` TEXT, `url` TEXT, `latitude` INTEGER, `longitude` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT" + }, + { + "fieldPath": "creationDate", + "columnName": "creationDate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "dueDate", + "columnName": "dueDate", + "affinity": "INTEGER" + }, + { + "fieldPath": "completionDate", + "columnName": "completionDate", + "affinity": "INTEGER" + }, + { + "fieldPath": "priority", + "columnName": "priority", + "affinity": "TEXT" + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT" + }, + { + "fieldPath": "location.latitude", + "columnName": "latitude", + "affinity": "INTEGER" + }, + { + "fieldPath": "location.longitude", + "columnName": "longitude", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } } ], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '588df0fceb5b7c0bd658e710a4ae336f')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '218189b9f12041c6ed819e387c07388a')" ] } } \ No newline at end of file diff --git a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/core/database/FoliaryDatabase.kt b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/core/database/FoliaryDatabase.kt index 4e0fdf4..7c5e790 100644 --- a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/core/database/FoliaryDatabase.kt +++ b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/core/database/FoliaryDatabase.kt @@ -3,14 +3,21 @@ package dev.appoutlet.foliary.core.database import androidx.room3.ConstructedBy import androidx.room3.Database import androidx.room3.RoomDatabase +import androidx.room3.TypeConverters +import dev.appoutlet.foliary.core.database.typeconverter.InstantTypeConverter +import dev.appoutlet.foliary.core.database.typeconverter.UuidTypeConverter import dev.appoutlet.foliary.data.applicationversion.ApplicationVersionDao import dev.appoutlet.foliary.data.applicationversion.model.ApplicationVersion import dev.appoutlet.foliary.data.authentication.database.SessionDao import dev.appoutlet.foliary.data.authentication.model.Session +import dev.appoutlet.foliary.data.task.database.TaskDao +import dev.appoutlet.foliary.data.task.database.entity.Task -@Database(entities = [ApplicationVersion::class, Session::class], version = 1) +@Database(entities = [ApplicationVersion::class, Session::class, Task::class], version = 1) @ConstructedBy(FoliaryDatabaseConstructor::class) +@TypeConverters(UuidTypeConverter::class, InstantTypeConverter::class) abstract class FoliaryDatabase : RoomDatabase() { abstract fun applicationVersionDao(): ApplicationVersionDao abstract fun sessionDao(): SessionDao + abstract fun taskDao(): TaskDao } diff --git a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/core/database/typeconverter/InstantTypeConverter.kt b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/core/database/typeconverter/InstantTypeConverter.kt new file mode 100644 index 0000000..7c4b766 --- /dev/null +++ b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/core/database/typeconverter/InstantTypeConverter.kt @@ -0,0 +1,12 @@ +package dev.appoutlet.foliary.core.database.typeconverter + +import androidx.room3.TypeConverter +import kotlin.time.Instant + +class InstantTypeConverter { + @TypeConverter + fun toInstant(epochMilliseconds: Long) = Instant.fromEpochMilliseconds(epochMilliseconds) + + @TypeConverter + fun fromInstant(instant: Instant) = instant.toEpochMilliseconds() +} diff --git a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/core/database/typeconverter/UuidTypeConverter.kt b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/core/database/typeconverter/UuidTypeConverter.kt new file mode 100644 index 0000000..0c6bccb --- /dev/null +++ b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/core/database/typeconverter/UuidTypeConverter.kt @@ -0,0 +1,12 @@ +package dev.appoutlet.foliary.core.database.typeconverter + +import androidx.room3.TypeConverter +import kotlin.uuid.Uuid + +class UuidTypeConverter { + @TypeConverter + fun fromUuid(uuid: Uuid): String = uuid.toString() + + @TypeConverter + fun toUuid(string: String) = Uuid.parse(string) +} diff --git a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/data/task/TaskRepository.kt b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/data/task/TaskRepository.kt index ab0d7ec..536f162 100644 --- a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/data/task/TaskRepository.kt +++ b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/data/task/TaskRepository.kt @@ -1,7 +1,8 @@ package dev.appoutlet.foliary.data.task -import dev.appoutlet.foliary.domain.Task +import dev.appoutlet.foliary.data.task.database.entity.Task +import kotlinx.coroutines.flow.Flow interface TaskRepository { - suspend fun findTodaysTasks(): List + fun findTodayTasks(): Flow> } diff --git a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/data/task/TaskRepositoryImpl.kt b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/data/task/TaskRepositoryImpl.kt index 5f19d92..9aeb2b1 100644 --- a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/data/task/TaskRepositoryImpl.kt +++ b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/data/task/TaskRepositoryImpl.kt @@ -1,9 +1,22 @@ package dev.appoutlet.foliary.data.task -import dev.appoutlet.foliary.domain.Task +import dev.appoutlet.foliary.data.task.database.TaskDao +import dev.appoutlet.foliary.data.task.database.entity.Task +import kotlinx.coroutines.flow.Flow +import kotlinx.datetime.TimeZone +import kotlinx.datetime.atStartOfDayIn +import kotlinx.datetime.toLocalDateTime +import kotlin.time.Clock -class TaskRepositoryImpl : TaskRepository { - override suspend fun findTodaysTasks(): List { - return emptyList() +class TaskRepositoryImpl(private val taskDao: TaskDao) : TaskRepository { + override fun findTodayTasks(): Flow> { + val timeZone = TimeZone.currentSystemDefault() + + val today = Clock.System.now() + .toLocalDateTime(timeZone) + .date + .atStartOfDayIn(timeZone) + + return taskDao.findTodayTasks(today) } } diff --git a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/data/task/database/TaskDao.kt b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/data/task/database/TaskDao.kt new file mode 100644 index 0000000..79c07fa --- /dev/null +++ b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/data/task/database/TaskDao.kt @@ -0,0 +1,18 @@ +package dev.appoutlet.foliary.data.task.database + +import androidx.room3.Dao +import androidx.room3.Insert +import androidx.room3.OnConflictStrategy +import androidx.room3.Query +import dev.appoutlet.foliary.data.task.database.entity.Task +import kotlinx.coroutines.flow.Flow +import kotlin.time.Instant + +@Dao +interface TaskDao { + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun save(vararg task: Task) + + @Query("SELECT * FROM Task WHERE dueDate > :today ORDER BY dueDate DESC") + fun findTodayTasks(today: Instant): Flow> +} diff --git a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/data/task/database/entity/Location.kt b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/data/task/database/entity/Location.kt new file mode 100644 index 0000000..58b3b84 --- /dev/null +++ b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/data/task/database/entity/Location.kt @@ -0,0 +1,6 @@ +package dev.appoutlet.foliary.data.task.database.entity + +data class Location( + val latitude: Long, + val longitude: Long, +) diff --git a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/data/task/database/entity/Priority.kt b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/data/task/database/entity/Priority.kt new file mode 100644 index 0000000..d3884e5 --- /dev/null +++ b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/data/task/database/entity/Priority.kt @@ -0,0 +1,11 @@ +package dev.appoutlet.foliary.data.task.database.entity + +enum class Priority { + LOWEST, LOW, MEDIUM, HIGH, HIGHEST, BLOCKER; + companion object { + fun parse(priorityString: String): Priority { + return entries.find { it.name.equals(priorityString, ignoreCase = true) } + ?: error("Priority $priorityString not found") + } + } +} diff --git a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/data/task/database/entity/Task.kt b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/data/task/database/entity/Task.kt new file mode 100644 index 0000000..e77234a --- /dev/null +++ b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/data/task/database/entity/Task.kt @@ -0,0 +1,20 @@ +package dev.appoutlet.foliary.data.task.database.entity + +import androidx.room3.Embedded +import androidx.room3.Entity +import androidx.room3.PrimaryKey +import kotlin.time.Instant +import kotlin.uuid.Uuid + +@Entity +data class Task( + @PrimaryKey val id: Uuid, + val title: String, + val description: String?, + val creationDate: Instant, + val dueDate: Instant?, + val completionDate: Instant?, + val priority: Priority?, + val url: String?, + @Embedded val location: Location? +) diff --git a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/domain/Task.kt b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/domain/Task.kt deleted file mode 100644 index c59827b..0000000 --- a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/domain/Task.kt +++ /dev/null @@ -1,3 +0,0 @@ -package dev.appoutlet.foliary.domain - -data class Task(val id: String) From 577edce3711eb5135a64dbe58c5fcfac9442f8a8 Mon Sep 17 00:00:00 2001 From: Messias Junior Date: Tue, 21 Jul 2026 11:11:11 +0100 Subject: [PATCH 2/9] type converters test --- .../typeconverter/InstantTypeConverterTest.kt | 27 +++++++++++++++++++ .../typeconverter/UuidTypeConverterTest.kt | 27 +++++++++++++++++++ .../data/task/TaskRepositoryImplTest.kt | 16 ----------- 3 files changed, 54 insertions(+), 16 deletions(-) create mode 100644 foliary/src/commonTest/kotlin/dev/appoutlet/foliary/core/database/typeconverter/InstantTypeConverterTest.kt create mode 100644 foliary/src/commonTest/kotlin/dev/appoutlet/foliary/core/database/typeconverter/UuidTypeConverterTest.kt delete mode 100644 foliary/src/commonTest/kotlin/dev/appoutlet/foliary/data/task/TaskRepositoryImplTest.kt diff --git a/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/core/database/typeconverter/InstantTypeConverterTest.kt b/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/core/database/typeconverter/InstantTypeConverterTest.kt new file mode 100644 index 0000000..c6792b3 --- /dev/null +++ b/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/core/database/typeconverter/InstantTypeConverterTest.kt @@ -0,0 +1,27 @@ +package dev.appoutlet.foliary.core.database.typeconverter + +import io.kotest.matchers.shouldBe +import kotlin.test.Test +import kotlin.time.Instant + +class InstantTypeConverterTest { + private val converter = InstantTypeConverter() + + @Test + fun `should convert epoch milliseconds to instant`() { + val epochMilliseconds = 1_726_131_200_000L + + val result = converter.toInstant(epochMilliseconds) + + result shouldBe Instant.fromEpochMilliseconds(epochMilliseconds) + } + + @Test + fun `should convert instant to epoch milliseconds`() { + val instant = Instant.fromEpochMilliseconds(1_726_131_200_000L) + + val result = converter.fromInstant(instant) + + result shouldBe 1_726_131_200_000L + } +} diff --git a/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/core/database/typeconverter/UuidTypeConverterTest.kt b/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/core/database/typeconverter/UuidTypeConverterTest.kt new file mode 100644 index 0000000..393a17c --- /dev/null +++ b/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/core/database/typeconverter/UuidTypeConverterTest.kt @@ -0,0 +1,27 @@ +package dev.appoutlet.foliary.core.database.typeconverter + +import io.kotest.matchers.shouldBe +import kotlin.test.Test +import kotlin.uuid.Uuid + +class UuidTypeConverterTest { + private val converter = UuidTypeConverter() + + @Test + fun `should convert uuid to string`() { + val uuid = Uuid.parse("123e4567-e89b-12d3-a456-426614174000") + + val result = converter.fromUuid(uuid) + + result shouldBe "123e4567-e89b-12d3-a456-426614174000" + } + + @Test + fun `should convert string to uuid`() { + val string = "123e4567-e89b-12d3-a456-426614174000" + + val result = converter.toUuid(string) + + result shouldBe Uuid.parse(string) + } +} diff --git a/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/data/task/TaskRepositoryImplTest.kt b/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/data/task/TaskRepositoryImplTest.kt deleted file mode 100644 index 058e6a0..0000000 --- a/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/data/task/TaskRepositoryImplTest.kt +++ /dev/null @@ -1,16 +0,0 @@ -package dev.appoutlet.foliary.data.task - -import io.kotest.matchers.collections.shouldHaveSize -import kotlinx.coroutines.test.runTest -import kotlin.test.Test - -class TaskRepositoryImplTest { - private val subject = TaskRepositoryImpl() - - @Test - fun `should return todays tasks`() = runTest { - val result = subject.findTodaysTasks() - - result shouldHaveSize 0 - } -} From 44cb7e5a135269778291d8fb7e43c8946c46bc1a Mon Sep 17 00:00:00 2001 From: Messias Junior Date: Tue, 21 Jul 2026 11:31:45 +0100 Subject: [PATCH 3/9] fix today's tasks logic --- .../foliary/core/database/DatabaseModule.kt | 3 + .../foliary/data/task/TaskRepositoryImpl.kt | 16 ++-- .../foliary/data/task/database/TaskDao.kt | 2 +- .../data/task/TaskRepositoryImplTest.kt | 76 +++++++++++++++++++ 4 files changed, 90 insertions(+), 7 deletions(-) create mode 100644 foliary/src/commonTest/kotlin/dev/appoutlet/foliary/data/task/TaskRepositoryImplTest.kt diff --git a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/core/database/DatabaseModule.kt b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/core/database/DatabaseModule.kt index 2630a14..d590812 100644 --- a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/core/database/DatabaseModule.kt +++ b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/core/database/DatabaseModule.kt @@ -22,6 +22,9 @@ class DatabaseModule { @Single fun provideApplicationVersionDao(database: FoliaryDatabase) = database.applicationVersionDao() + + @Single + fun provideTaskDao(database: FoliaryDatabase) = database.taskDao() } @Module diff --git a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/data/task/TaskRepositoryImpl.kt b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/data/task/TaskRepositoryImpl.kt index 9aeb2b1..7cc7746 100644 --- a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/data/task/TaskRepositoryImpl.kt +++ b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/data/task/TaskRepositoryImpl.kt @@ -4,19 +4,23 @@ import dev.appoutlet.foliary.data.task.database.TaskDao import dev.appoutlet.foliary.data.task.database.entity.Task import kotlinx.coroutines.flow.Flow import kotlinx.datetime.TimeZone -import kotlinx.datetime.atStartOfDayIn +import kotlinx.datetime.atTime +import kotlinx.datetime.toInstant import kotlinx.datetime.toLocalDateTime +import org.koin.core.annotation.Single import kotlin.time.Clock +import kotlin.time.Duration.Companion.days +@Single class TaskRepositoryImpl(private val taskDao: TaskDao) : TaskRepository { override fun findTodayTasks(): Flow> { val timeZone = TimeZone.currentSystemDefault() - val today = Clock.System.now() - .toLocalDateTime(timeZone) - .date - .atStartOfDayIn(timeZone) + val now = Clock.System.now() + val cutoff = now.toLocalDateTime(timeZone).date + .atTime(0, 0) + .toInstant(timeZone) + 1.days - return taskDao.findTodayTasks(today) + return taskDao.findTodayTasks(cutoff) } } diff --git a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/data/task/database/TaskDao.kt b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/data/task/database/TaskDao.kt index 79c07fa..b2f1b0e 100644 --- a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/data/task/database/TaskDao.kt +++ b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/data/task/database/TaskDao.kt @@ -13,6 +13,6 @@ interface TaskDao { @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun save(vararg task: Task) - @Query("SELECT * FROM Task WHERE dueDate > :today ORDER BY dueDate DESC") + @Query("SELECT * FROM Task WHERE dueDate < :today AND completionDate IS NULL ORDER BY dueDate DESC") fun findTodayTasks(today: Instant): Flow> } diff --git a/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/data/task/TaskRepositoryImplTest.kt b/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/data/task/TaskRepositoryImplTest.kt new file mode 100644 index 0000000..75d9f2c --- /dev/null +++ b/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/data/task/TaskRepositoryImplTest.kt @@ -0,0 +1,76 @@ +package dev.appoutlet.foliary.data.task + +import dev.appoutlet.foliary.core.testing.DaoTest +import dev.appoutlet.foliary.data.task.database.entity.Task +import io.kotest.matchers.shouldBe +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import kotlinx.datetime.TimeZone +import kotlinx.datetime.atTime +import kotlinx.datetime.toInstant +import kotlinx.datetime.toLocalDateTime +import kotlin.test.Test +import kotlin.time.Clock +import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.milliseconds +import kotlin.uuid.Uuid + +class TaskRepositoryImplTest : DaoTest() { + private val taskDao by lazy { database.taskDao() } + private val subject by lazy { TaskRepositoryImpl(taskDao) } + + @Test + fun `should return tasks due today and overdue tasks`() = runTest { + val timeZone = TimeZone.currentSystemDefault() + val now = Clock.System.now() + val todayStart = now.toLocalDateTime(timeZone).date + .atTime(0, 0) + .toInstant(timeZone) + val tomorrowStart = todayStart + 1.days + + val overdueTask = task( + title = "Overdue task", + dueDate = todayStart - 1.hours, + ) + val dueTodayTask = task( + title = "Due today task", + dueDate = todayStart + 12.hours, + ) + val dueAtEndOfDayTask = task( + title = "Due at end of day task", + dueDate = tomorrowStart - 1.milliseconds, + ) + val futureTask = task( + title = "Future task", + dueDate = tomorrowStart, + ) + val completedTask = task( + title = "Completed task", + dueDate = todayStart - 2.hours, + completionDate = now, + ) + + taskDao.save(overdueTask, dueTodayTask, dueAtEndOfDayTask, futureTask, completedTask) + + val result = subject.findTodayTasks().first() + + result shouldBe listOf(dueAtEndOfDayTask, dueTodayTask, overdueTask) + } + + private fun task( + title: String, + dueDate: kotlin.time.Instant, + completionDate: kotlin.time.Instant? = null, + ) = Task( + id = Uuid.random(), + title = title, + description = null, + creationDate = dueDate, + dueDate = dueDate, + completionDate = completionDate, + priority = null, + url = null, + location = null, + ) +} From f5a6c7a6b932d619031c0a3a81dfd98d547087e7 Mon Sep 17 00:00:00 2001 From: Messias Junior Date: Tue, 21 Jul 2026 11:35:39 +0100 Subject: [PATCH 4/9] delete priority parsing method --- .../appoutlet/foliary/data/task/database/entity/Priority.kt | 6 ------ 1 file changed, 6 deletions(-) diff --git a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/data/task/database/entity/Priority.kt b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/data/task/database/entity/Priority.kt index d3884e5..921249c 100644 --- a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/data/task/database/entity/Priority.kt +++ b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/data/task/database/entity/Priority.kt @@ -2,10 +2,4 @@ package dev.appoutlet.foliary.data.task.database.entity enum class Priority { LOWEST, LOW, MEDIUM, HIGH, HIGHEST, BLOCKER; - companion object { - fun parse(priorityString: String): Priority { - return entries.find { it.name.equals(priorityString, ignoreCase = true) } - ?: error("Priority $priorityString not found") - } - } } From f2c48f943c8bd704c611d7ada920c8ebbb8369f7 Mon Sep 17 00:00:00 2001 From: Messias Junior Date: Tue, 21 Jul 2026 12:40:08 +0100 Subject: [PATCH 5/9] Unit tests --- .../1.json | 10 +-- .../foliary/data/task/TaskRepositoryImpl.kt | 21 ++---- .../foliary/data/task/database/TaskDao.kt | 7 +- .../data/task/database/entity/Location.kt | 8 +- .../foliary/data/task/database/entity/Task.kt | 7 +- .../foliary/data/time/TimeProvider.kt | 22 ++++++ .../data/task/TaskRepositoryImplTest.kt | 75 +++++-------------- .../foliary/data/task/database/TaskDaoTest.kt | 60 +++++++++++++++ .../task/database/entity/LocationFixture.kt | 11 +++ .../data/task/database/entity/TaskFixture.kt | 28 +++++++ .../foliary/data/time/TimeProviderTest.kt | 31 ++++++++ 11 files changed, 197 insertions(+), 83 deletions(-) create mode 100644 foliary/src/commonMain/kotlin/dev/appoutlet/foliary/data/time/TimeProvider.kt create mode 100644 foliary/src/commonTest/kotlin/dev/appoutlet/foliary/data/task/database/TaskDaoTest.kt create mode 100644 foliary/src/commonTest/kotlin/dev/appoutlet/foliary/data/task/database/entity/LocationFixture.kt create mode 100644 foliary/src/commonTest/kotlin/dev/appoutlet/foliary/data/task/database/entity/TaskFixture.kt create mode 100644 foliary/src/commonTest/kotlin/dev/appoutlet/foliary/data/time/TimeProviderTest.kt diff --git a/foliary/schemas/dev.appoutlet.foliary.core.database.FoliaryDatabase/1.json b/foliary/schemas/dev.appoutlet.foliary.core.database.FoliaryDatabase/1.json index 2e3e0f6..fe3f87c 100644 --- a/foliary/schemas/dev.appoutlet.foliary.core.database.FoliaryDatabase/1.json +++ b/foliary/schemas/dev.appoutlet.foliary.core.database.FoliaryDatabase/1.json @@ -2,7 +2,7 @@ "formatVersion": 1, "database": { "version": 1, - "identityHash": "218189b9f12041c6ed819e387c07388a", + "identityHash": "5b116516a481542d4cc6d3c7547ced00", "entities": [ { "tableName": "ApplicationVersion", @@ -88,7 +88,7 @@ }, { "tableName": "Task", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `description` TEXT, `creationDate` INTEGER NOT NULL, `dueDate` INTEGER, `completionDate` INTEGER, `priority` TEXT, `url` TEXT, `latitude` INTEGER, `longitude` INTEGER, PRIMARY KEY(`id`))", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `description` TEXT, `creationDate` INTEGER NOT NULL, `dueDate` INTEGER, `completionDate` INTEGER, `priority` TEXT, `url` TEXT, `latitude` REAL, `longitude` REAL, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", @@ -136,12 +136,12 @@ { "fieldPath": "location.latitude", "columnName": "latitude", - "affinity": "INTEGER" + "affinity": "REAL" }, { "fieldPath": "location.longitude", "columnName": "longitude", - "affinity": "INTEGER" + "affinity": "REAL" } ], "primaryKey": { @@ -154,7 +154,7 @@ ], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '218189b9f12041c6ed819e387c07388a')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '5b116516a481542d4cc6d3c7547ced00')" ] } } \ No newline at end of file diff --git a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/data/task/TaskRepositoryImpl.kt b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/data/task/TaskRepositoryImpl.kt index 7cc7746..33446d0 100644 --- a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/data/task/TaskRepositoryImpl.kt +++ b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/data/task/TaskRepositoryImpl.kt @@ -1,26 +1,17 @@ package dev.appoutlet.foliary.data.task +import dev.appoutlet.foliary.data.time.TimeProvider import dev.appoutlet.foliary.data.task.database.TaskDao import dev.appoutlet.foliary.data.task.database.entity.Task import kotlinx.coroutines.flow.Flow -import kotlinx.datetime.TimeZone -import kotlinx.datetime.atTime -import kotlinx.datetime.toInstant -import kotlinx.datetime.toLocalDateTime import org.koin.core.annotation.Single -import kotlin.time.Clock -import kotlin.time.Duration.Companion.days @Single -class TaskRepositoryImpl(private val taskDao: TaskDao) : TaskRepository { +class TaskRepositoryImpl( + private val taskDao: TaskDao, + private val timeProvider: TimeProvider, +) : TaskRepository { override fun findTodayTasks(): Flow> { - val timeZone = TimeZone.currentSystemDefault() - - val now = Clock.System.now() - val cutoff = now.toLocalDateTime(timeZone).date - .atTime(0, 0) - .toInstant(timeZone) + 1.days - - return taskDao.findTodayTasks(cutoff) + return taskDao.findTodayTasks(timeProvider.endOfToday()) } } diff --git a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/data/task/database/TaskDao.kt b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/data/task/database/TaskDao.kt index b2f1b0e..bae8f08 100644 --- a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/data/task/database/TaskDao.kt +++ b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/data/task/database/TaskDao.kt @@ -13,6 +13,9 @@ interface TaskDao { @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun save(vararg task: Task) - @Query("SELECT * FROM Task WHERE dueDate < :today AND completionDate IS NULL ORDER BY dueDate DESC") - fun findTodayTasks(today: Instant): Flow> + @Query("SELECT * FROM Task") + suspend fun findAll() : List + + @Query("SELECT * FROM Task WHERE dueDate <= :endOfToday AND completionDate IS NULL ORDER BY dueDate DESC") + fun findTodayTasks(endOfToday: Instant): Flow> } diff --git a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/data/task/database/entity/Location.kt b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/data/task/database/entity/Location.kt index 58b3b84..193fa72 100644 --- a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/data/task/database/entity/Location.kt +++ b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/data/task/database/entity/Location.kt @@ -1,6 +1,8 @@ package dev.appoutlet.foliary.data.task.database.entity data class Location( - val latitude: Long, - val longitude: Long, -) + val latitude: Double, + val longitude: Double, +) { + companion object +} diff --git a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/data/task/database/entity/Task.kt b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/data/task/database/entity/Task.kt index e77234a..8b0f3a4 100644 --- a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/data/task/database/entity/Task.kt +++ b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/data/task/database/entity/Task.kt @@ -3,6 +3,7 @@ package dev.appoutlet.foliary.data.task.database.entity import androidx.room3.Embedded import androidx.room3.Entity import androidx.room3.PrimaryKey +import kotlinx.serialization.internal.NamedCompanion import kotlin.time.Instant import kotlin.uuid.Uuid @@ -16,5 +17,7 @@ data class Task( val completionDate: Instant?, val priority: Priority?, val url: String?, - @Embedded val location: Location? -) + @Embedded val location: Location?, +) { + companion object +} diff --git a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/data/time/TimeProvider.kt b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/data/time/TimeProvider.kt new file mode 100644 index 0000000..20ac6f7 --- /dev/null +++ b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/data/time/TimeProvider.kt @@ -0,0 +1,22 @@ +package dev.appoutlet.foliary.data.time + +import dev.appoutlet.foliary.core.allopen.Open +import kotlinx.datetime.TimeZone +import kotlinx.datetime.atTime +import kotlinx.datetime.toInstant +import kotlinx.datetime.toLocalDateTime +import org.koin.core.annotation.Single +import kotlin.time.Clock +import kotlin.time.Instant + +@Single +@Open +class TimeProvider(private val clock: Clock = Clock.System) { + fun now(): Instant = clock.now() + + fun endOfToday(timeZone: TimeZone = TimeZone.currentSystemDefault()): Instant { + return now().toLocalDateTime(timeZone).date + .atTime(23, 59, 59, 999999999) + .toInstant(timeZone) + } +} diff --git a/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/data/task/TaskRepositoryImplTest.kt b/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/data/task/TaskRepositoryImplTest.kt index 75d9f2c..3f64a04 100644 --- a/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/data/task/TaskRepositoryImplTest.kt +++ b/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/data/task/TaskRepositoryImplTest.kt @@ -1,76 +1,39 @@ package dev.appoutlet.foliary.data.task -import dev.appoutlet.foliary.core.testing.DaoTest +import dev.appoutlet.foliary.data.task.database.TaskDao +import dev.appoutlet.foliary.data.task.database.TaskDaoTest import dev.appoutlet.foliary.data.task.database.entity.Task +import dev.appoutlet.foliary.data.task.database.entity.fixture +import dev.appoutlet.foliary.data.time.TimeProvider +import dev.mokkery.answering.returns +import dev.mokkery.every +import dev.mokkery.mock import io.kotest.matchers.shouldBe import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest -import kotlinx.datetime.TimeZone -import kotlinx.datetime.atTime -import kotlinx.datetime.toInstant -import kotlinx.datetime.toLocalDateTime +import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.time.Clock -import kotlin.time.Duration.Companion.days -import kotlin.time.Duration.Companion.hours import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Instant import kotlin.uuid.Uuid -class TaskRepositoryImplTest : DaoTest() { - private val taskDao by lazy { database.taskDao() } - private val subject by lazy { TaskRepositoryImpl(taskDao) } +class TaskRepositoryImplTest { + private val mockTaskDao = mock() + private val mockTimeProvider = mock() + private val subject = TaskRepositoryImpl(mockTaskDao, mockTimeProvider) @Test fun `should return tasks due today and overdue tasks`() = runTest { - val timeZone = TimeZone.currentSystemDefault() - val now = Clock.System.now() - val todayStart = now.toLocalDateTime(timeZone).date - .atTime(0, 0) - .toInstant(timeZone) - val tomorrowStart = todayStart + 1.days + val endOfToday = Instant.parse("2026-07-22T23:59:59.999999999Z") + val fixtureTasks = listOf(Task.fixture()) - val overdueTask = task( - title = "Overdue task", - dueDate = todayStart - 1.hours, - ) - val dueTodayTask = task( - title = "Due today task", - dueDate = todayStart + 12.hours, - ) - val dueAtEndOfDayTask = task( - title = "Due at end of day task", - dueDate = tomorrowStart - 1.milliseconds, - ) - val futureTask = task( - title = "Future task", - dueDate = tomorrowStart, - ) - val completedTask = task( - title = "Completed task", - dueDate = todayStart - 2.hours, - completionDate = now, - ) - - taskDao.save(overdueTask, dueTodayTask, dueAtEndOfDayTask, futureTask, completedTask) + every { mockTimeProvider.endOfToday() } returns endOfToday + every { mockTaskDao.findTodayTasks(endOfToday) } returns flowOf(fixtureTasks) val result = subject.findTodayTasks().first() - result shouldBe listOf(dueAtEndOfDayTask, dueTodayTask, overdueTask) + result shouldBe fixtureTasks } - - private fun task( - title: String, - dueDate: kotlin.time.Instant, - completionDate: kotlin.time.Instant? = null, - ) = Task( - id = Uuid.random(), - title = title, - description = null, - creationDate = dueDate, - dueDate = dueDate, - completionDate = completionDate, - priority = null, - url = null, - location = null, - ) } diff --git a/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/data/task/database/TaskDaoTest.kt b/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/data/task/database/TaskDaoTest.kt new file mode 100644 index 0000000..ef2ba73 --- /dev/null +++ b/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/data/task/database/TaskDaoTest.kt @@ -0,0 +1,60 @@ +package dev.appoutlet.foliary.data.task.database + +import dev.appoutlet.foliary.core.testing.DaoTest +import dev.appoutlet.foliary.data.task.database.entity.Task +import dev.appoutlet.foliary.data.task.database.entity.fixture +import io.kotest.matchers.shouldBe +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.time.Clock +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Instant +import kotlin.uuid.Uuid + +class TaskDaoTest : DaoTest() { + private val dao by lazy { database.taskDao() } + + @Test + fun `should return overdue tasks and tasks due today`() = runTest { + val endOfToday = Instant.parse("2026-07-21T23:59:59.999999999Z") + + val overdueTask = Task.fixture( + title = "Overdue task", + dueDate = Instant.parse("2026-07-20T22:00:00Z"), + completionDate = null, + ) + + val dueTodayTask = Task.fixture( + title = "Due today task", + dueDate = Instant.parse("2026-07-21T11:00:00Z"), + completionDate = null, + ) + + val dueAtEndOfDayTask = Task.fixture( + title = "Due at end of day task", + dueDate = Instant.parse("2026-07-21T23:59:59.999Z"), + completionDate = null, + ) + + val futureTask = Task.fixture( + title = "Future task", + dueDate = endOfToday + 1.milliseconds, + completionDate = null, + ) + + val completedTask = Task.fixture( + title = "Completed task", + dueDate = Instant.parse("2026-07-20T21:00:00Z"), + completionDate = Instant.parse("2026-07-21T10:00:00Z"), + ) + + dao.save(overdueTask, dueTodayTask, dueAtEndOfDayTask, futureTask, completedTask) + + val result = dao.findTodayTasks(endOfToday).first() + val resultIds = result.map { it.id } + + resultIds shouldBe listOf(dueAtEndOfDayTask.id, dueTodayTask.id, overdueTask.id) + } +} diff --git a/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/data/task/database/entity/LocationFixture.kt b/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/data/task/database/entity/LocationFixture.kt new file mode 100644 index 0000000..93adba3 --- /dev/null +++ b/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/data/task/database/entity/LocationFixture.kt @@ -0,0 +1,11 @@ +package dev.appoutlet.foliary.data.task.database.entity + +import kotlin.random.Random + +fun Location.Companion.fixture( + latitude: Double = Random.nextDouble(-90.0, 90.0), + longitude: Double = Random.nextDouble(-180.0, 180.0), +) = Location( + latitude = latitude, + longitude = longitude +) diff --git a/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/data/task/database/entity/TaskFixture.kt b/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/data/task/database/entity/TaskFixture.kt new file mode 100644 index 0000000..dc907e3 --- /dev/null +++ b/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/data/task/database/entity/TaskFixture.kt @@ -0,0 +1,28 @@ +package dev.appoutlet.foliary.data.task.database.entity + +import kotlin.time.Clock +import kotlin.time.Duration.Companion.days +import kotlin.time.Instant +import kotlin.uuid.Uuid + +fun Task.Companion.fixture( + id: Uuid = Uuid.random(), + title: String = "Task fixture", + description: String? = "Task description", + creationDate: Instant = Clock.System.now(), + dueDate: Instant? = creationDate.plus(30.days), + completionDate: Instant? = creationDate.plus(15.days), + priority: Priority? = Priority.MEDIUM, + url: String? = "https://foliary.appoutlet.dev/", + location: Location? = Location.fixture() +) = Task( + id = id, + title = title, + description = description, + creationDate = creationDate, + dueDate = dueDate, + completionDate = completionDate, + priority = priority, + url = url, + location = location +) diff --git a/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/data/time/TimeProviderTest.kt b/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/data/time/TimeProviderTest.kt new file mode 100644 index 0000000..47941e7 --- /dev/null +++ b/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/data/time/TimeProviderTest.kt @@ -0,0 +1,31 @@ +package dev.appoutlet.foliary.data.time + +import io.kotest.matchers.longs.shouldBeAtLeast +import io.kotest.matchers.shouldBe +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.time.Clock +import kotlin.time.Instant + +class TimeProviderTest { + @Test + fun `should return current instant`() { + val now = Clock.System.now() + val subject = TimeProvider() + subject.now().toEpochMilliseconds() shouldBeAtLeast now.toEpochMilliseconds() + } + + @Test + fun `should return the end of today for the configured time zone`() { + val timezone = TimeZone.UTC + val today = Instant.parse("2026-07-22T02:10:02.999Z") + val subject = TimeProvider(clock = FixedClock(today)) + subject.endOfToday(timezone) shouldBe Instant.parse("2026-07-22T23:59:59.999999999Z") + } + + private class FixedClock(private val instant: Instant) : Clock { + override fun now(): Instant = instant + } +} From 754ebf2ba9c5fc77f1e270246f3013a901de99c3 Mon Sep 17 00:00:00 2001 From: Messias Junior Date: Tue, 21 Jul 2026 13:31:48 +0100 Subject: [PATCH 6/9] testing skill --- .agents/skills/testing/SKILL.md | 49 +++++++++++++++++++ .agents/skills/testing/references/fixture.md | 34 +++++++++++++ .../testing/templates/base-unit-test.md | 27 ++++++++++ .../skills/testing/templates/dao-unit-test.md | 30 ++++++++++++ .../testing/templates/view-model-unit-test.md | 45 +++++++++++++++++ 5 files changed, 185 insertions(+) create mode 100644 .agents/skills/testing/SKILL.md create mode 100644 .agents/skills/testing/references/fixture.md create mode 100644 .agents/skills/testing/templates/base-unit-test.md create mode 100644 .agents/skills/testing/templates/dao-unit-test.md create mode 100644 .agents/skills/testing/templates/view-model-unit-test.md diff --git a/.agents/skills/testing/SKILL.md b/.agents/skills/testing/SKILL.md new file mode 100644 index 0000000..9166ee2 --- /dev/null +++ b/.agents/skills/testing/SKILL.md @@ -0,0 +1,49 @@ +--- +name: testing +description: How to write tests in Foliary. Make sure to use this skill whenever the user asks to add, update, fix, refactor, or review unit tests, DAO tests, repository tests, ViewModel tests, or test fixtures. Use it even when the request only mentions a production file if the work clearly requires test coverage. +--- +# Writing Tests in Foliary + +Follow the existing `commonTest` conventions already used in Foliary. Keep tests small, deterministic, and aligned with the production abstraction being exercised. + +## General rules + +### Fixtures First + +Never manually build domain or entity objects inside tests when the object can be represented by a fixture. + +1. Reuse an existing `fixture` helper first. +2. If no fixture exists yet, create one in `commonTest` near the tested type. Before creating or updating fixture functions you MUST read [`references/fixture.md`](./references/fixture.md). + +### General testing rules + +1. Put tests in the `commonTest` source set unless there is a clear reason to use another source set. +2. Use Kotest assertions such as `shouldBe`. Do not use plain `assert()`. +3. Use `runTest` for suspend and flow-based tests. +4. Name test methods with backticks and sentence-style names. +5. Before writing or updating this kind of test, read `templates/base-unit-test.md`. +6. Mock collaborators instead of standing up integration infrastructure unless the test is explicitly integration-oriented. +7. Use Mokkery with patterns like `mock()`, `every { ... } returns ...`, and `verifySuspend { ... }` when verification is needed. +8. Mokkery cannot mock final classes, and Kotlin classes are final by default, so regular classes that need to be mocked in tests must be annotated with `@Open`. + +## DAO Tests + +1. DAO tests must extend the shared `DaoTest` base class. +2. Use the real in-memory database and obtain the DAO from `database`. +3. Before writing or updating this kind of test, read `templates/dao-unit-test.md`. + +## ViewModel Tests + +1. ViewModel tests must extend the shared `ViewModelTest` base class. +2. Implement `createViewModel()` and build the subject there. +3. Use the inherited `test { ... }` helper rather than calling Orbit test APIs directly. +4. Assert states with `expectState(...)` and side effects with `expectSideEffect(...)`. +5. Use `advanceTimeBy(...)` or `advanceUntilIdle()` when the ViewModel relies on delays or queued coroutines. +6. Before writing or updating this kind of test, read `templates/view-model-unit-test.md`. + +## Implementation Checklist + +1. Check whether a shared base class already exists for this test type. +2. Check whether a fixture already exists for every object you need. +3. Create or extend fixtures before manually constructing objects. +4. Keep assertions focused on the behavior under test. diff --git a/.agents/skills/testing/references/fixture.md b/.agents/skills/testing/references/fixture.md new file mode 100644 index 0000000..c894fcb --- /dev/null +++ b/.agents/skills/testing/references/fixture.md @@ -0,0 +1,34 @@ +# Fixture Creation In Foliary + +Read this file before creating or updating any fixture function. + +## Workflow + +1. Read the entity primary constructor in `commonMain`. +2. Create the fixture file in `commonTest` under the exact same package as the entity. +3. Name the file `[EntityName]Fixture.kt`. +4. Implement the fixture as a companion extension function: `fun Entity.Companion.fixture(...)`. +5. Copy the fixture function arguments from the entity primary constructor exactly. +6. Keep the same argument names, types, nullability, and order as the entity constructor. +7. Return the entity by passing those arguments through directly. +8. If you need a concrete shape reference, read the relevant example in `templates/` before writing the fixture. + +## Strict Rules + +1. The fixture file must live in the same package as the entity, but under the `commonTest` source set. +2. The fixture function arguments must match the entity primary constructor exactly. +3. The fixture default values must be sensible and represent a realistic object. +4. Default values must never be `null`, even when the parameter type is nullable. +5. When a parameter is another entity, use that entity's fixture as the default value. +6. When a parameter type is a collection, the default value must contain at least one element. +7. Do not invent fixture-only parameters. The fixture signature must mirror the entity constructor. +8. Do not reorder parameters in the fixture. + +## Default Value Guidance + +1. For IDs, use a generated but valid ID such as `Uuid.random()`. +2. For names, titles, descriptions, and URLs, use readable real-looking values. +3. For dates, choose values that make sense together as one coherent object. +4. For nullable timestamps, enums, nested objects, and strings, still provide a non-null default. +5. For nested entities, call that nested entity's `fixture()`. +6. For collections, create at least one realistic child element using fixtures where appropriate. diff --git a/.agents/skills/testing/templates/base-unit-test.md b/.agents/skills/testing/templates/base-unit-test.md new file mode 100644 index 0000000..47d8fe2 --- /dev/null +++ b/.agents/skills/testing/templates/base-unit-test.md @@ -0,0 +1,27 @@ +# Base Unit Test Pattern + +Use this shape for repository tests and other unit tests that mock collaborators. + +```kotlin +private val mockTaskDao = mock() +private val mockTimeProvider = mock() +private val subject = TaskRepositoryImpl(mockTaskDao, mockTimeProvider) + +@Test +fun `should return tasks due today and overdue tasks`() = runTest { + val endOfToday = Instant.parse("2026-07-22T23:59:59.999999999Z") + val fixtureTasks = listOf(Task.fixture()) + + every { mockTimeProvider.endOfToday() } returns endOfToday + every { mockTaskDao.findTodayTasks(endOfToday) } returns flowOf(fixtureTasks) + + subject.findTodayTasks().first() shouldBe fixtureTasks +} +``` + +## Notes + +1. Use Mokkery for collaborators. +2. Prefer fixtures for returned objects. +3. For `Flow`-based APIs, stub with `flowOf(...)` and assert with `.first()`. +4. Keep the test focused on the unit boundary instead of persistence details. diff --git a/.agents/skills/testing/templates/dao-unit-test.md b/.agents/skills/testing/templates/dao-unit-test.md new file mode 100644 index 0000000..0858afc --- /dev/null +++ b/.agents/skills/testing/templates/dao-unit-test.md @@ -0,0 +1,30 @@ +# DAO Unit Test Pattern + +Use this shape for DAO tests against the real in-memory Room database. + +```kotlin +class TaskDaoTest : DaoTest() { + private val dao by lazy { database.taskDao() } + + @Test + fun `should return overdue tasks and tasks due today`() = runTest { + val endOfToday = Instant.parse("2026-07-21T23:59:59.999999999Z") + val overdueTask = Task.fixture( + dueDate = Instant.parse("2026-07-20T22:00:00Z"), + completionDate = null, + ) + + dao.save(overdueTask) + + val result = dao.findTodayTasks(endOfToday).first() + result.map { it.id } shouldBe listOf(overdueTask.id) + } +} +``` + +## Notes + +1. Extend `DaoTest`. +2. Use fixtures for inserted entities. +3. Read flow results with `.first()`. +4. When full entity equality is noisy because of persistence normalization, compare stable fields such as IDs. diff --git a/.agents/skills/testing/templates/view-model-unit-test.md b/.agents/skills/testing/templates/view-model-unit-test.md new file mode 100644 index 0000000..74a17e8 --- /dev/null +++ b/.agents/skills/testing/templates/view-model-unit-test.md @@ -0,0 +1,45 @@ +# ViewModel Unit Test Pattern + +Use this shape for Orbit-based ViewModel tests. + +```kotlin +class SignInViewModelTest : ViewModelTest() { + private val sessionStatusFlow = MutableStateFlow(SessionStatus.Initializing) + private val mockAuthenticationRepository = mock(MockMode.autoUnit) + .apply { every { sessionStatus() } returns sessionStatusFlow } + + override fun createViewModel() = SignInViewModel( + authenticationRepository = mockAuthenticationRepository, + deeplinkDispatcher = DeepLinkDispatcher, + ) + + @Test + fun `should show loading when session is initializing`() = test { + expectState(SignInViewData.Loading) + } + + @Test + fun `should request magic link`() { + val email = "user@example.com" + sessionStatusFlow.value = SessionStatus.NotAuthenticated() + + test { + expectState(SignInViewData.NotAuthenticated()) + + viewModel.onEvent(SignInEvent.OnSendMagicLink(email)) + + expectState(SignInViewData.NotAuthenticated(requestingMagicLink = true)) + verifySuspend { mockAuthenticationRepository.requestMagicLink(email) } + expectState(SignInViewData.MagicLinkSent(email)) + } + } +} +``` + +## Notes + +1. Extend `ViewModelTest`. +2. Build the subject in `createViewModel()`. +3. Use the inherited `test { ... }` helper. +4. Assert states and side effects through the provided Orbit helpers. +5. Set initial collaborator state before entering the `test { ... }` block when startup behavior depends on it. From 9f1a6802bb1da59c94099d45e5c83711355c72cf Mon Sep 17 00:00:00 2001 From: Messias Junior Date: Tue, 21 Jul 2026 13:32:50 +0100 Subject: [PATCH 7/9] improve AGENTS.md --- AGENTS.md | 17 +++-------------- 1 file changed, 3 insertions(+), 14 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b7fa43d..d51c39c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,7 +39,7 @@ Additionally, the project contains email templates for authentication (e.g., sig | Goal | Command | |------|---------| | Run single test class | `./gradlew foliary:jvmTest --tests "dev.appoutlet.foliary.data.task.TaskRepositoryImplTest"` | -| Run single test method | `./gradlew foliary:jvmTest --tests "dev.appoutlet.foliary.data.task.TaskRepositoryImplTest.should return todays tasks"` | +| Run single test method | `./gradlew foliary:jvmTest --tests "dev.appoutlet.foliary.data.task.TaskRepositoryImplTest.should return tasks due today and overdue tasks"` | ### CI & Hooks - **Pre-push hook**: `config/githooks/pre-push.sh` runs `./gradlew detekt` @@ -52,7 +52,7 @@ Additionally, the project contains email templates for authentication (e.g., sig | Functions/Properties | `camelCase` | `onEvent`, `userName` | | Constants | `PascalCase` | `DefaultTimeout` | | Test classes | `PascalCase` + `Test` | `TaskRepositoryImplTest` | -| Test methods | Backticks | `` `should return todays tasks` `` | +| Test methods | Backticks | `` `should return tasks due today and overdue tasks` `` | ### Types & Immutability - Prefer `val` over `var`; use `data class` with `val` properties - Use `sealed interface` for state/actions @@ -68,15 +68,4 @@ Additionally, the project contains email templates for authentication (e.g., sig **Important**: Always use Kotest assertions (e.g., `result shouldBe expected`) instead of standard `assert()`. -```kotlin -class FeatureTest { - @Test - fun `should do something`() = runTest { - val result = subject.doSomething() - result shouldBe expectedValue - } -} -``` - -### UI Tests -Use `runComposeUiTest` and `onNodeWithTag` (via `modifier.testTag("TagName")`). +**Skill**: Before writing, updating, or reviewing tests, load the `testing` skill. From a17827f998560e391179a36e9aaafae9a7ad64cb Mon Sep 17 00:00:00 2001 From: Messias Junior Date: Tue, 21 Jul 2026 14:15:11 +0100 Subject: [PATCH 8/9] evals --- .agents/skills/testing/evals/evals.json | 70 +++++++++++++++++++ .../skills/testing/evals/trigger-evals.json | 34 +++++++++ .gitignore | 1 + 3 files changed, 105 insertions(+) create mode 100644 .agents/skills/testing/evals/evals.json create mode 100644 .agents/skills/testing/evals/trigger-evals.json diff --git a/.agents/skills/testing/evals/evals.json b/.agents/skills/testing/evals/evals.json new file mode 100644 index 0000000..a96b445 --- /dev/null +++ b/.agents/skills/testing/evals/evals.json @@ -0,0 +1,70 @@ +{ + "skill_name": "testing", + "evals": [ + { + "id": 1, + "prompt": "Add a focused JVM test for `TaskRepositoryImpl.findTodayTasks()` in `commonTest`. Reuse existing task fixtures, mock `TaskDao` and `TimeProvider` with Mokkery, and assert the returned `Flow` with `.first()`.", + "expected_output": "A repository-style unit test that stays in commonTest, uses Mokkery mocks, uses `Task.fixture()`, stubs the DAO with `flowOf(...)`, and asserts with Kotest `shouldBe`.", + "files": [], + "expectations": [ + "The solution keeps the test in `commonTest`.", + "The solution uses Mokkery mocks for `TaskDao` and `TimeProvider`.", + "The solution reuses `Task.fixture()` instead of manually building task objects.", + "The solution stubs the DAO flow with `flowOf(...)` and reads it with `.first()`.", + "The solution uses Kotest assertions such as `shouldBe`." + ] + }, + { + "id": 2, + "prompt": "Update `TaskDaoTest` coverage for `findTodayTasks()` so it verifies overdue tasks and tasks due today. Use the real in-memory database rather than mocks.", + "expected_output": "A DAO test that extends `DaoTest`, gets the DAO from `database`, inserts fixture entities, and reads the result flow with `.first()`.", + "files": [], + "expectations": [ + "The solution extends `DaoTest`.", + "The solution gets the DAO from `database` instead of mocking it.", + "The solution inserts fixture entities rather than manually constructing them.", + "The solution reads DAO flow results with `.first()`.", + "The solution keeps the assertion focused on the query behavior." + ] + }, + { + "id": 3, + "prompt": "Create a `SignInViewModel` test for the loading state and the magic-link request flow. Keep it aligned with our Orbit test helpers.", + "expected_output": "A ViewModel test that extends `ViewModelTest`, builds the subject in `createViewModel()`, uses the inherited `test { ... }` helper, asserts states with `expectState(...)`, and verifies the repository call with Mokkery.", + "files": [], + "expectations": [ + "The solution extends `ViewModelTest`.", + "The solution creates the subject in `createViewModel()`.", + "The solution uses the inherited `test { ... }` helper.", + "The solution asserts states with `expectState(...)`.", + "The solution verifies the collaborator call with Mokkery when needed." + ] + }, + { + "id": 4, + "prompt": "Create a fixture function from scratch for this new entity and follow Foliary's fixture conventions exactly:\n\n```kotlin\npackage dev.appoutlet.foliary.feature.garden.model\n\nimport dev.appoutlet.foliary.data.task.database.entity.Location\nimport kotlin.uuid.Uuid\n\ndata class Garden(\n val id: Uuid,\n val name: String,\n val description: String?,\n val location: Location?,\n val tags: List,\n) {\n companion object\n}\n```\n\nReturn the `GardenFixture.kt` content you would add under `commonTest`.", + "expected_output": "A `GardenFixture.kt` implementation under the same package in `commonTest` that defines `fun Garden.Companion.fixture(...)`, mirrors the constructor signature exactly, keeps defaults non-null and realistic, uses `Location.fixture()` for the nested entity, and provides at least one default tag.", + "files": [], + "expectations": [ + "The solution places the fixture in the same package as `Garden`, but under `commonTest`.", + "The solution defines `fun Garden.Companion.fixture(...)`.", + "The solution mirrors the constructor signature exactly, including argument order, names, types, and nullability.", + "The solution uses non-null, realistic defaults for every parameter, including `description`.", + "The solution uses `Location.fixture()` as the default value for `location`.", + "The solution gives `tags` a default list with at least one element." + ] + }, + { + "id": 5, + "prompt": "Fix this failing test setup. `ReminderRepositoryImpl` depends on a regular Kotlin class `ReminderTimeProvider` that Mokkery cannot mock. Show the minimal production-code change needed to make mocking work and explain why.\n\n```kotlin\npackage dev.appoutlet.foliary.data.reminder\n\nclass ReminderTimeProvider {\n fun now(): Instant = Clock.System.now()\n}\n\nclass ReminderRepositoryImpl(private val timeProvider: ReminderTimeProvider) {\n fun currentTime(): Instant = timeProvider.now()\n}\n\nclass ReminderRepositoryImplTest {\n private val timeProvider = mock()\n private val subject = ReminderRepositoryImpl(timeProvider)\n}\n```", + "expected_output": "A short answer that explains Kotlin classes are final by default, Mokkery cannot mock final classes, and the fix is to annotate `ReminderTimeProvider` with `@Open` (or make it an `interface` / `open class`).", + "files": [], + "expectations": [ + "The solution explains that regular Kotlin classes are final by default.", + "The solution states that Mokkery cannot mock final classes.", + "The solution mentions applying the `@Open` annotation to `ReminderTimeProvider`.", + "The solution mentions an acceptable alternative such as making `ReminderTimeProvider` an interface or an `open class`." + ] + } + ] +} diff --git a/.agents/skills/testing/evals/trigger-evals.json b/.agents/skills/testing/evals/trigger-evals.json new file mode 100644 index 0000000..3a4bc36 --- /dev/null +++ b/.agents/skills/testing/evals/trigger-evals.json @@ -0,0 +1,34 @@ +[ + { + "query": "Add a focused JVM test for `TaskRepositoryImpl.findTodayTasks()` and reuse the existing task fixtures instead of hand-building objects.", + "should_trigger": true + }, + { + "query": "Fix `TaskDaoTest` after I changed the Room query for today's tasks. Keep it using the real database and update the assertions.", + "should_trigger": true + }, + { + "query": "Write a `SignInViewModel` test for the magic-link flow using our Orbit test helpers and whatever shared base class the repo already has.", + "should_trigger": true + }, + { + "query": "There is no fixture for this new entity yet. Create the companion `fixture()` function from scratch in `commonTest` before you add the repository test.", + "should_trigger": true + }, + { + "query": "Create a new task details screen in Compose Multiplatform and wire it into navigation.", + "should_trigger": false + }, + { + "query": "Update `AGENTS.md` so it tells agents to load the testing skill before writing tests.", + "should_trigger": false + }, + { + "query": "Run `./gradlew foliary:jvmTest --tests \"dev.appoutlet.foliary.data.task.TaskRepositoryImplTest\"` and tell me whether it passes.", + "should_trigger": false + }, + { + "query": "Refactor `TaskRepositoryImpl` production logic to use a different query, but do not add or change any tests yet.", + "should_trigger": false + } +] diff --git a/.gitignore b/.gitignore index d6a1a10..c0f5dff 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,4 @@ Pods/ *yarn.lock .opencode/ i18n.cache +.agents/skills/*-workspace From de58310c7eace4ad2f16905a15d751e345b0329f Mon Sep 17 00:00:00 2001 From: Messias Junior Date: Tue, 21 Jul 2026 14:20:25 +0100 Subject: [PATCH 9/9] detekt findings --- .../dev/appoutlet/foliary/data/task/TaskRepositoryImpl.kt | 2 +- .../dev/appoutlet/foliary/data/task/database/TaskDao.kt | 2 +- .../appoutlet/foliary/data/task/database/entity/Priority.kt | 2 +- .../dev/appoutlet/foliary/data/task/database/entity/Task.kt | 1 - .../kotlin/dev/appoutlet/foliary/data/time/TimeProvider.kt | 6 +++++- .../appoutlet/foliary/data/task/TaskRepositoryImplTest.kt | 5 ----- .../dev/appoutlet/foliary/data/task/database/TaskDaoTest.kt | 3 --- .../dev/appoutlet/foliary/data/time/TimeProviderTest.kt | 2 -- 8 files changed, 8 insertions(+), 15 deletions(-) diff --git a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/data/task/TaskRepositoryImpl.kt b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/data/task/TaskRepositoryImpl.kt index 33446d0..5bfb838 100644 --- a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/data/task/TaskRepositoryImpl.kt +++ b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/data/task/TaskRepositoryImpl.kt @@ -1,8 +1,8 @@ package dev.appoutlet.foliary.data.task -import dev.appoutlet.foliary.data.time.TimeProvider import dev.appoutlet.foliary.data.task.database.TaskDao import dev.appoutlet.foliary.data.task.database.entity.Task +import dev.appoutlet.foliary.data.time.TimeProvider import kotlinx.coroutines.flow.Flow import org.koin.core.annotation.Single diff --git a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/data/task/database/TaskDao.kt b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/data/task/database/TaskDao.kt index bae8f08..e1a6724 100644 --- a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/data/task/database/TaskDao.kt +++ b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/data/task/database/TaskDao.kt @@ -14,7 +14,7 @@ interface TaskDao { suspend fun save(vararg task: Task) @Query("SELECT * FROM Task") - suspend fun findAll() : List + suspend fun findAll(): List @Query("SELECT * FROM Task WHERE dueDate <= :endOfToday AND completionDate IS NULL ORDER BY dueDate DESC") fun findTodayTasks(endOfToday: Instant): Flow> diff --git a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/data/task/database/entity/Priority.kt b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/data/task/database/entity/Priority.kt index 921249c..a8f8735 100644 --- a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/data/task/database/entity/Priority.kt +++ b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/data/task/database/entity/Priority.kt @@ -1,5 +1,5 @@ package dev.appoutlet.foliary.data.task.database.entity enum class Priority { - LOWEST, LOW, MEDIUM, HIGH, HIGHEST, BLOCKER; + LOWEST, LOW, MEDIUM, HIGH, HIGHEST, BLOCKER } diff --git a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/data/task/database/entity/Task.kt b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/data/task/database/entity/Task.kt index 8b0f3a4..e886f08 100644 --- a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/data/task/database/entity/Task.kt +++ b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/data/task/database/entity/Task.kt @@ -3,7 +3,6 @@ package dev.appoutlet.foliary.data.task.database.entity import androidx.room3.Embedded import androidx.room3.Entity import androidx.room3.PrimaryKey -import kotlinx.serialization.internal.NamedCompanion import kotlin.time.Instant import kotlin.uuid.Uuid diff --git a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/data/time/TimeProvider.kt b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/data/time/TimeProvider.kt index 20ac6f7..80216fd 100644 --- a/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/data/time/TimeProvider.kt +++ b/foliary/src/commonMain/kotlin/dev/appoutlet/foliary/data/time/TimeProvider.kt @@ -1,14 +1,18 @@ package dev.appoutlet.foliary.data.time import dev.appoutlet.foliary.core.allopen.Open +import kotlinx.datetime.LocalTime import kotlinx.datetime.TimeZone import kotlinx.datetime.atTime import kotlinx.datetime.toInstant import kotlinx.datetime.toLocalDateTime import org.koin.core.annotation.Single import kotlin.time.Clock +import kotlin.time.Duration.Companion.days import kotlin.time.Instant +private val LastNanosecondOfTheDay = LocalTime.fromNanosecondOfDay((1.days.inWholeNanoseconds - 1)) + @Single @Open class TimeProvider(private val clock: Clock = Clock.System) { @@ -16,7 +20,7 @@ class TimeProvider(private val clock: Clock = Clock.System) { fun endOfToday(timeZone: TimeZone = TimeZone.currentSystemDefault()): Instant { return now().toLocalDateTime(timeZone).date - .atTime(23, 59, 59, 999999999) + .atTime(LastNanosecondOfTheDay) .toInstant(timeZone) } } diff --git a/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/data/task/TaskRepositoryImplTest.kt b/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/data/task/TaskRepositoryImplTest.kt index 3f64a04..8b7c1f9 100644 --- a/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/data/task/TaskRepositoryImplTest.kt +++ b/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/data/task/TaskRepositoryImplTest.kt @@ -1,7 +1,6 @@ package dev.appoutlet.foliary.data.task import dev.appoutlet.foliary.data.task.database.TaskDao -import dev.appoutlet.foliary.data.task.database.TaskDaoTest import dev.appoutlet.foliary.data.task.database.entity.Task import dev.appoutlet.foliary.data.task.database.entity.fixture import dev.appoutlet.foliary.data.time.TimeProvider @@ -12,12 +11,8 @@ import io.kotest.matchers.shouldBe import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest -import kotlin.test.BeforeTest import kotlin.test.Test -import kotlin.time.Clock -import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Instant -import kotlin.uuid.Uuid class TaskRepositoryImplTest { private val mockTaskDao = mock() diff --git a/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/data/task/database/TaskDaoTest.kt b/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/data/task/database/TaskDaoTest.kt index ef2ba73..8a52ec3 100644 --- a/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/data/task/database/TaskDaoTest.kt +++ b/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/data/task/database/TaskDaoTest.kt @@ -7,11 +7,8 @@ import io.kotest.matchers.shouldBe import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest import kotlin.test.Test -import kotlin.time.Clock -import kotlin.time.Duration.Companion.hours import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Instant -import kotlin.uuid.Uuid class TaskDaoTest : DaoTest() { private val dao by lazy { database.taskDao() } diff --git a/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/data/time/TimeProviderTest.kt b/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/data/time/TimeProviderTest.kt index 47941e7..fc9ea44 100644 --- a/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/data/time/TimeProviderTest.kt +++ b/foliary/src/commonTest/kotlin/dev/appoutlet/foliary/data/time/TimeProviderTest.kt @@ -3,8 +3,6 @@ package dev.appoutlet.foliary.data.time import io.kotest.matchers.longs.shouldBeAtLeast import io.kotest.matchers.shouldBe import kotlinx.datetime.TimeZone -import kotlinx.datetime.toLocalDateTime -import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.time.Clock import kotlin.time.Instant