feat(store): implement TasksDataSource over Room
Joins phases 1 and 2 and covers phase 3's semantics. RoomTasksDataSource implements all 14 seam methods; a StorageMode-routing delegate picks it or the provider per call, since the mode is a setting the user can change while the process lives. There is no instances table, so a series is expanded at read time by RecurrenceExpander and any RECURRENCE-ID override is substituted for the occurrence it replaces. A timed series carries each occurrence's length across; a due-anchored one has no start to offset from, so the anchor is the due date — matching how the provider instantiated the same series. Editing one occurrence writes a RECURRENCE-ID override sharing the master's UID (RFC 5545 model (a)). The provider's Detaching.java forked a brand-new task with its own UID instead — model (d), the one least compatible with CalDAV. We inherited that without ever choosing it; this is the choice. TaskFormWriter states the completion rules directly instead of working around the provider: progress and status now move together in both directions, so a task can no longer strand itself "done at 75%". TaskWriteMapper keeps the workarounds for External mode. Deletes are hard when the list has no account and tombstones when it does; master_id cascades, so a deleted series takes its overrides.
This commit is contained in:
@@ -0,0 +1,286 @@
|
||||
package de.jeanlucmakiola.agendula.data.tasks.room
|
||||
|
||||
import androidx.room.Room
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TaskQuery
|
||||
import de.jeanlucmakiola.agendula.domain.TaskForm
|
||||
import de.jeanlucmakiola.agendula.domain.TaskStatus
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Duration.Companion.days
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* The seam over Room, exercised through [de.jeanlucmakiola.agendula.data.tasks
|
||||
* .TasksDataSource] rather than the DAOs — recurrence expansion and override
|
||||
* forking only exist at this level.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class RoomTasksDataSourceTest {
|
||||
|
||||
private lateinit var db: TasksDatabase
|
||||
private lateinit var source: RoomTasksDataSource
|
||||
private var listId = 0L
|
||||
|
||||
private val now get() = Clock.System.now()
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
db = Room.inMemoryDatabaseBuilder(
|
||||
ApplicationProvider.getApplicationContext(),
|
||||
TasksDatabase::class.java,
|
||||
).allowMainThreadQueries().build()
|
||||
source = RoomTasksDataSource(db)
|
||||
listId = source.createLocalList("Personal", 0xFF112233.toInt())
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() = db.close()
|
||||
|
||||
private fun form(
|
||||
title: String = "task",
|
||||
due: Instant? = null,
|
||||
percentComplete: Int? = null,
|
||||
) = TaskForm(title = title, listId = listId, due = due, percentComplete = percentComplete)
|
||||
|
||||
/** Turns [taskId] into a weekly series anchored at [anchor]. */
|
||||
private fun makeRecurring(taskId: Long, anchor: Instant, rule: String = "FREQ=WEEKLY") {
|
||||
val entity = db.tasks().entity(taskId)!!
|
||||
db.tasks().update(entity.copy(dtstart = anchor, due = anchor + 1.days, rrule = rule))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun createsAndReadsBackALocalList() {
|
||||
val lists = source.taskLists()
|
||||
|
||||
assertThat(lists).hasSize(1)
|
||||
assertThat(lists.single().name).isEqualTo("Personal")
|
||||
// No account, so the list still has to report something the lists screen
|
||||
// can group under.
|
||||
assertThat(lists.single().isLocal).isTrue()
|
||||
assertThat(lists.single().accountName).isEqualTo("Local")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun createsAndReadsBackANonRecurringTask() {
|
||||
val due = now + 1.days
|
||||
val id = source.insertTask(form(title = "Buy milk", due = due))
|
||||
|
||||
val task = source.task(id)!!
|
||||
|
||||
assertThat(task.taskId).isEqualTo(id)
|
||||
assertThat(task.title).isEqualTo("Buy milk")
|
||||
assertThat(task.due).isEqualTo(due)
|
||||
assertThat(task.isRecurring).isFalse()
|
||||
// A task that does not recur has no occurrence anchor, so it keys and edits
|
||||
// by task id exactly as it did against the provider.
|
||||
assertThat(task.occurrenceStart).isNull()
|
||||
assertThat(task.occurrenceKey).isEqualTo("$id")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mintsAUidForEveryTask() {
|
||||
val id = source.insertTask(form())
|
||||
|
||||
assertThat(db.tasks().entity(id)!!.uid).isNotEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun expandsARecurringSeriesIntoManyOccurrences() {
|
||||
val anchor = now
|
||||
val id = source.insertTask(form(title = "Water the plants"))
|
||||
makeRecurring(id, anchor)
|
||||
|
||||
val occurrences = source.tasks(TaskQuery(listId = listId)).filter { it.taskId == id }
|
||||
|
||||
// The provider materialised exactly one upcoming occurrence; we expand the
|
||||
// whole window, so a weekly series yields well over a hundred.
|
||||
assertThat(occurrences.size).isGreaterThan(100)
|
||||
assertThat(occurrences.map { it.occurrenceStart }).containsNoDuplicates()
|
||||
assertThat(occurrences.map { it.occurrenceKey }).containsNoDuplicates()
|
||||
assertThat(occurrences.all { it.isRecurring }).isTrue()
|
||||
// Each occurrence keeps the series' length rather than the master's dates.
|
||||
val first = occurrences.minBy { it.occurrenceStart!! }
|
||||
assertThat(first.due!! - first.start!!).isEqualTo(1.days)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun exactlyOneOccurrenceIsTheCurrentOne() {
|
||||
val id = source.insertTask(form())
|
||||
makeRecurring(id, now - 30.days)
|
||||
|
||||
val occurrences = source.tasks(TaskQuery(listId = listId)).filter { it.taskId == id }
|
||||
|
||||
assertThat(occurrences.count { it.distanceFromCurrent == 0 }).isEqualTo(1)
|
||||
assertThat(source.task(id)!!.distanceFromCurrent).isEqualTo(0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun editingOneOccurrenceForksARecurrenceIdOverride() {
|
||||
val anchor = now
|
||||
val id = source.insertTask(form(title = "Water the plants"))
|
||||
makeRecurring(id, anchor)
|
||||
val target = source.tasks(TaskQuery(listId = listId))
|
||||
.filter { it.taskId == id }
|
||||
.first { it.distanceFromCurrent == 1 }
|
||||
|
||||
source.updateInstance(id, target.occurrenceStart!!, form(title = "Water them twice"))
|
||||
|
||||
val override = db.tasks().override(id, target.occurrenceStart)!!
|
||||
// RFC 5545's model: the override shares its master's UID — that is what
|
||||
// makes it an override rather than a separate task. The dmfs provider
|
||||
// detached the occurrence into a new task with its own UID instead.
|
||||
assertThat(override.uid).isEqualTo(db.tasks().entity(id)!!.uid)
|
||||
assertThat(override.masterId).isEqualTo(id)
|
||||
assertThat(override.recurrenceId).isEqualTo(target.occurrenceStart)
|
||||
assertThat(override.rrule).isNull()
|
||||
assertThat(override.title).isEqualTo("Water them twice")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anOverrideReplacesOnlyItsOwnOccurrence() {
|
||||
val id = source.insertTask(form(title = "Water the plants"))
|
||||
makeRecurring(id, now)
|
||||
val before = source.tasks(TaskQuery(listId = listId)).filter { it.taskId == id }
|
||||
val target = before.first { it.distanceFromCurrent == 1 }
|
||||
|
||||
source.updateInstance(id, target.occurrenceStart!!, form(title = "Water them twice"))
|
||||
|
||||
val after = source.tasks(TaskQuery(listId = listId)).filter { it.taskId == id }
|
||||
assertThat(after).hasSize(before.size)
|
||||
assertThat(after.filter { it.title == "Water them twice" }).hasSize(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun editingASeriesDoesNotReAnchorItWhenOneOccurrenceIsEdited() {
|
||||
val anchor = now
|
||||
val id = source.insertTask(form())
|
||||
makeRecurring(id, anchor)
|
||||
val target = source.tasks(TaskQuery(listId = listId))
|
||||
.filter { it.taskId == id }
|
||||
.first { it.distanceFromCurrent == 2 }
|
||||
|
||||
source.updateInstance(id, target.occurrenceStart!!, form(due = now + 99.days))
|
||||
|
||||
assertThat(db.tasks().entity(id)!!.dtstart).isEqualTo(anchor)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun updatingANonRecurringTaskWritesThroughToItsRow() {
|
||||
val id = source.insertTask(form(title = "old"))
|
||||
|
||||
source.updateTask(id, form(title = "new"))
|
||||
|
||||
assertThat(source.task(id)!!.title).isEqualTo("new")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun completionTogglesTheWholeTriple() {
|
||||
val id = source.insertTask(form())
|
||||
|
||||
source.setCompleted(id, completed = true)
|
||||
val done = db.tasks().entity(id)!!
|
||||
assertThat(done.status).isEqualTo(TaskStatus.COMPLETED)
|
||||
assertThat(done.percentComplete).isEqualTo(100)
|
||||
assertThat(done.completedAt).isNotNull()
|
||||
|
||||
source.setCompleted(id, completed = false)
|
||||
assertThat(db.tasks().entity(id)!!.completedAt).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun completedTasksAreExcludedUnlessAskedFor() {
|
||||
val id = source.insertTask(form())
|
||||
source.setCompleted(id, completed = true)
|
||||
|
||||
assertThat(source.tasks(TaskQuery(listId = listId, includeCompleted = false))).isEmpty()
|
||||
assertThat(source.tasks(TaskQuery(listId = listId, includeCompleted = true))).hasSize(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun alarmsRoundTripAndReplaceRatherThanAccumulate() {
|
||||
val id = source.insertTask(form(due = now + 1.days))
|
||||
|
||||
source.setAlarm(id, 30)
|
||||
assertThat(source.alarms()[id]).isEqualTo(30)
|
||||
|
||||
source.setAlarm(id, 60)
|
||||
assertThat(db.alarms().forTask(id)).hasSize(1)
|
||||
assertThat(source.alarms()[id]).isEqualTo(60)
|
||||
|
||||
source.setAlarm(id, null)
|
||||
assertThat(source.alarms()).doesNotContainKey(id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun forkingAnOccurrenceCarriesTheReminderOntoIt() {
|
||||
val id = source.insertTask(form(due = now + 1.days))
|
||||
makeRecurring(id, now)
|
||||
source.setAlarm(id, 30)
|
||||
val target = source.tasks(TaskQuery(listId = listId))
|
||||
.filter { it.taskId == id }
|
||||
.first { it.distanceFromCurrent == 1 }
|
||||
|
||||
source.updateInstance(id, target.occurrenceStart!!, form())
|
||||
|
||||
val override = db.tasks().override(id, target.occurrenceStart)!!
|
||||
assertThat(db.alarms().forTask(override.id).single().minutesBefore).isEqualTo(30)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deletingATaskInALocalListRemovesItOutright() {
|
||||
val id = source.insertTask(form())
|
||||
|
||||
source.deleteTask(id)
|
||||
|
||||
// No account knows about it, so there is nothing to tombstone for.
|
||||
assertThat(db.tasks().entity(id)).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deletingASeriesTakesItsOverridesWithIt() {
|
||||
val id = source.insertTask(form())
|
||||
makeRecurring(id, now)
|
||||
val target = source.tasks(TaskQuery(listId = listId))
|
||||
.filter { it.taskId == id }
|
||||
.first { it.distanceFromCurrent == 1 }
|
||||
source.updateInstance(id, target.occurrenceStart!!, form(title = "moved"))
|
||||
|
||||
source.deleteTask(id)
|
||||
|
||||
assertThat(db.tasks().allOverrides(listId)).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun subtasksReadBackUnderTheirParent() {
|
||||
val parent = source.insertTask(form(title = "Prepare invoice"))
|
||||
val child = source.insertTask(form(title = "Gather receipts").copy(parentId = parent))
|
||||
|
||||
assertThat(source.subtasks(parent).map { it.taskId }).containsExactly(child)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun exportReadsMastersNotOccurrences() {
|
||||
val id = source.insertTask(form(title = "Water the plants"))
|
||||
makeRecurring(id, now)
|
||||
|
||||
val exported = source.exportTasks(listId)
|
||||
|
||||
// One row carrying the rule, not one row per occurrence with the rule lost.
|
||||
assertThat(exported).hasSize(1)
|
||||
assertThat(exported.single().rrule).isEqualTo("FREQ=WEEKLY")
|
||||
assertThat(exported.single().uid).isNotEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun insertingIntoAMissingListFails() {
|
||||
val thrown = runCatching { source.insertTask(form().copy(listId = 9_999)) }.exceptionOrNull()
|
||||
|
||||
assertThat(thrown).isNotNull()
|
||||
}
|
||||
}
|
||||
@@ -147,7 +147,7 @@ class TasksDatabaseTest {
|
||||
assertThat(tasks.tasks(listId, includeCompleted = true).map { it.task.id })
|
||||
.containsExactly(master)
|
||||
assertThat(tasks.overrides(master).map { it.id }).containsExactly(override)
|
||||
assertThat(tasks.overridesForList(listId).map { it.id }).containsExactly(override)
|
||||
assertThat(tasks.allOverrides(listId).map { it.id }).containsExactly(override)
|
||||
assertThat(tasks.override(master, Instant.fromEpochMilliseconds(5_000))?.id)
|
||||
.isEqualTo(override)
|
||||
assertThat(tasks.exportTasks(listId).map { it.id }).containsExactly(master)
|
||||
|
||||
@@ -3,6 +3,8 @@ package de.jeanlucmakiola.agendula.data.di
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.room.Room
|
||||
import androidx.room.RoomDatabase
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
@@ -12,14 +14,19 @@ import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import de.jeanlucmakiola.agendula.data.tasks.AndroidProviderEnvironment
|
||||
import de.jeanlucmakiola.agendula.data.tasks.AndroidTasksDataSource
|
||||
import de.jeanlucmakiola.agendula.data.tasks.ModeRoutingTasksDataSource
|
||||
import de.jeanlucmakiola.agendula.data.tasks.ProviderEnvironment
|
||||
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksRepositoryImpl
|
||||
import de.jeanlucmakiola.agendula.data.tasks.room.RoomTasksDataSource
|
||||
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import javax.inject.Provider
|
||||
import javax.inject.Singleton
|
||||
|
||||
private val Context.agendulaDataStore: DataStore<Preferences> by preferencesDataStore(
|
||||
@@ -30,10 +37,6 @@ private val Context.agendulaDataStore: DataStore<Preferences> by preferencesData
|
||||
@InstallIn(SingletonComponent::class)
|
||||
abstract class DataBindModule {
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindTasksDataSource(impl: AndroidTasksDataSource): TasksDataSource
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindTasksRepository(impl: TasksRepositoryImpl): TasksRepository
|
||||
@@ -52,6 +55,33 @@ object DataProvideModule {
|
||||
fun provideDataStore(@ApplicationContext context: Context): DataStore<Preferences> =
|
||||
context.agendulaDataStore
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideTasksDatabase(@ApplicationContext context: Context): TasksDatabase =
|
||||
Room.databaseBuilder(context, TasksDatabase::class.java, TasksDatabase.NAME)
|
||||
// Room's default, stated rather than assumed: Auto Backup copies files
|
||||
// without checkpointing, so a `-wal` sidecar can hold writes the
|
||||
// backed-up `.db` does not. The backup rules carry all three files and
|
||||
// the app checkpoints on ON_STOP.
|
||||
.setJournalMode(RoomDatabase.JournalMode.WRITE_AHEAD_LOGGING)
|
||||
.build()
|
||||
|
||||
/**
|
||||
* The active store, chosen by [StorageMode].
|
||||
*
|
||||
* Resolved per injection point rather than bound once, because the mode is a
|
||||
* user setting that [de.jeanlucmakiola.agendula.data.tasks.StorageModeHolder]
|
||||
* can change while the process lives. Both implementations are singletons, so
|
||||
* this picks between two long-lived objects rather than building either.
|
||||
*/
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideTasksDataSource(
|
||||
resolver: ProviderResolver,
|
||||
room: Provider<RoomTasksDataSource>,
|
||||
external: Provider<AndroidTasksDataSource>,
|
||||
): TasksDataSource = ModeRoutingTasksDataSource(resolver, room, external)
|
||||
|
||||
@Provides
|
||||
@IoDispatcher
|
||||
fun provideIoDispatcher(): CoroutineDispatcher = Dispatchers.IO
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package de.jeanlucmakiola.agendula.data.tasks
|
||||
|
||||
import de.jeanlucmakiola.agendula.data.tasks.room.RoomTasksDataSource
|
||||
import de.jeanlucmakiola.agendula.domain.Task
|
||||
import de.jeanlucmakiola.agendula.domain.TaskForm
|
||||
import de.jeanlucmakiola.agendula.domain.TaskList
|
||||
import de.jeanlucmakiola.agendula.domain.export.ExportTask
|
||||
import javax.inject.Provider
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* Routes every call to the store [StorageMode] selects.
|
||||
*
|
||||
* Per-call rather than bound once: the mode is a setting the user can change
|
||||
* while the process lives, and [StorageModeHolder] pushes the new value into
|
||||
* [ProviderResolver] without rebuilding the object graph. Both delegates are
|
||||
* singletons, so this chooses between two existing objects.
|
||||
*
|
||||
* The [StorageMode.LOCAL] branch disappears with the `:provider` module; after
|
||||
* that this is just Room versus a third-party ContentProvider.
|
||||
*/
|
||||
class ModeRoutingTasksDataSource(
|
||||
private val resolver: ProviderResolver,
|
||||
private val room: Provider<RoomTasksDataSource>,
|
||||
private val external: Provider<AndroidTasksDataSource>,
|
||||
) : TasksDataSource {
|
||||
|
||||
private fun active(): TasksDataSource =
|
||||
when (resolver.storageMode ?: resolver.autoMode()) {
|
||||
StorageMode.OWN -> room.get()
|
||||
StorageMode.LOCAL, StorageMode.EXTERNAL -> external.get()
|
||||
}
|
||||
|
||||
override fun taskLists(): List<TaskList> = active().taskLists()
|
||||
override fun tasks(query: TaskQuery): List<Task> = active().tasks(query)
|
||||
override fun task(taskId: Long): Task? = active().task(taskId)
|
||||
override fun subtasks(parentTaskId: Long): List<Task> = active().subtasks(parentTaskId)
|
||||
override fun insertTask(form: TaskForm): Long = active().insertTask(form)
|
||||
override fun updateTask(taskId: Long, form: TaskForm) = active().updateTask(taskId, form)
|
||||
|
||||
override fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm) =
|
||||
active().updateInstance(taskId, occurrenceStart, form)
|
||||
|
||||
override fun setAlarm(taskId: Long, minutesBeforeDue: Int?) = active().setAlarm(taskId, minutesBeforeDue)
|
||||
override fun alarms(): Map<Long, Int> = active().alarms()
|
||||
override fun exportTasks(listId: Long): List<ExportTask> = active().exportTasks(listId)
|
||||
override fun setCompleted(taskId: Long, completed: Boolean) = active().setCompleted(taskId, completed)
|
||||
override fun deleteTask(taskId: Long) = active().deleteTask(taskId)
|
||||
override fun createLocalList(name: String, color: Int): Long = active().createLocalList(name, color)
|
||||
override fun registerObserver(onChange: () -> Unit): AutoCloseable = active().registerObserver(onChange)
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package de.jeanlucmakiola.agendula.data.tasks.room
|
||||
|
||||
import de.jeanlucmakiola.agendula.domain.LocalAccount
|
||||
import de.jeanlucmakiola.agendula.domain.Task
|
||||
import de.jeanlucmakiola.agendula.domain.TaskList
|
||||
import de.jeanlucmakiola.agendula.domain.export.ExportTask
|
||||
import de.jeanlucmakiola.agendula.domain.priorityFromICal
|
||||
import de.jeanlucmakiola.agendula.domain.recurrence.RecurrenceSpec
|
||||
import kotlin.time.Instant
|
||||
|
||||
/** Account type reported for a list attached to one of ours. */
|
||||
const val CALDAV_ACCOUNT_TYPE = "caldav"
|
||||
|
||||
/** Maps Room rows to domain models. Pure + testable, like [de.jeanlucmakiola.agendula.data.tasks.TaskMapper]. */
|
||||
object RoomTaskMapper {
|
||||
|
||||
fun taskList(row: TaskListRow): TaskList = TaskList(
|
||||
id = row.list.id,
|
||||
name = row.list.name,
|
||||
color = row.list.color,
|
||||
// TaskList.accountName is non-null and the lists screen groups by it, so a
|
||||
// list with no account still has to report something to group under.
|
||||
accountName = row.accountDisplayName ?: LocalAccount.NAME,
|
||||
accountType = if (row.list.accountId == null) LocalAccount.TYPE else CALDAV_ACCOUNT_TYPE,
|
||||
isSynced = row.list.isSynced,
|
||||
isVisible = row.list.isVisible,
|
||||
owner = row.list.owner,
|
||||
)
|
||||
|
||||
/**
|
||||
* One occurrence of [row]. [occurrenceStart] is the occurrence's
|
||||
* `RECURRENCE-ID` anchor and `null` for a task that does not recur;
|
||||
* [start] / [due] are that occurrence's resolved times.
|
||||
*/
|
||||
fun task(
|
||||
row: TaskRow,
|
||||
occurrenceStart: Instant? = null,
|
||||
start: Instant? = row.task.dtstart,
|
||||
due: Instant? = row.task.due,
|
||||
distanceFromCurrent: Int? = null,
|
||||
): Task = Task(
|
||||
taskId = row.task.id,
|
||||
listId = row.task.listId,
|
||||
title = row.task.title.orEmpty(),
|
||||
description = row.task.description,
|
||||
location = row.task.location,
|
||||
url = row.task.url,
|
||||
priority = priorityFromICal(row.task.priority),
|
||||
status = row.task.status,
|
||||
percentComplete = row.task.percentComplete,
|
||||
start = start,
|
||||
due = due,
|
||||
isAllDay = row.task.isAllDay,
|
||||
timeZone = row.task.timezone,
|
||||
completedAt = row.task.completedAt,
|
||||
listColor = row.listColor,
|
||||
taskColor = row.task.color,
|
||||
listName = row.listName,
|
||||
accountName = row.accountDisplayName ?: LocalAccount.NAME,
|
||||
parentId = row.task.parentId,
|
||||
isRecurring = row.task.isRecurring,
|
||||
occurrenceStart = occurrenceStart,
|
||||
distanceFromCurrent = distanceFromCurrent,
|
||||
created = row.task.createdAt,
|
||||
lastModified = row.task.lastModified,
|
||||
)
|
||||
|
||||
fun exportTask(task: TaskEntity): ExportTask = ExportTask(
|
||||
taskId = task.id,
|
||||
uid = task.uid,
|
||||
title = task.title.orEmpty(),
|
||||
description = task.description,
|
||||
location = task.location,
|
||||
url = task.url,
|
||||
priority = priorityFromICal(task.priority),
|
||||
status = task.status,
|
||||
percentComplete = task.percentComplete,
|
||||
start = task.dtstart,
|
||||
due = task.due,
|
||||
isAllDay = task.isAllDay,
|
||||
completedAt = task.completedAt,
|
||||
created = task.createdAt,
|
||||
lastModified = task.lastModified,
|
||||
rrule = task.rrule,
|
||||
rdate = task.rdate,
|
||||
parentId = task.parentId?.takeIf { it > 0 },
|
||||
)
|
||||
}
|
||||
|
||||
/** A row carries a recurrence rule if it has an `RRULE` or an `RDATE`. */
|
||||
val TaskEntity.isRecurring: Boolean
|
||||
get() = !rrule.isNullOrBlank() || !rdate.isNullOrBlank()
|
||||
|
||||
/**
|
||||
* The series anchor: `DTSTART` when present, else `DUE`. A `VTODO` may carry only
|
||||
* a due date, and RFC 5545 then anchors the recurrence on it — matching how the
|
||||
* dmfs provider instantiated the same series.
|
||||
*/
|
||||
val TaskEntity.recurrenceAnchor: Instant?
|
||||
get() = dtstart ?: due
|
||||
|
||||
/** The rule set of this series, or `null` when it does not recur. */
|
||||
fun TaskEntity.recurrenceSpec(): RecurrenceSpec? {
|
||||
if (!isRecurring) return null
|
||||
val anchor = recurrenceAnchor ?: return null
|
||||
return RecurrenceSpec(
|
||||
rrule = rrule,
|
||||
rdate = rdate,
|
||||
exdate = exdate,
|
||||
anchor = anchor,
|
||||
isAllDay = isAllDay,
|
||||
timeZone = timezone,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
package de.jeanlucmakiola.agendula.data.tasks.room
|
||||
|
||||
import androidx.room.InvalidationTracker
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TaskQuery
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TaskWriteFailedException
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
|
||||
import de.jeanlucmakiola.agendula.domain.Task
|
||||
import de.jeanlucmakiola.agendula.domain.TaskForm
|
||||
import de.jeanlucmakiola.agendula.domain.TaskList
|
||||
import de.jeanlucmakiola.agendula.domain.export.ExportTask
|
||||
import de.jeanlucmakiola.agendula.domain.recurrence.ExpansionWindow
|
||||
import de.jeanlucmakiola.agendula.domain.recurrence.RecurrenceExpander
|
||||
import java.time.ZoneId
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Duration.Companion.days
|
||||
import kotlin.time.Instant
|
||||
|
||||
/** How far either side of now a series is expanded. */
|
||||
private val WINDOW_BACK = 365.days
|
||||
private val WINDOW_FORWARD = 730.days
|
||||
|
||||
private val OBSERVED_TABLES = arrayOf("tasks", "task_lists", "task_alarms", "accounts")
|
||||
|
||||
/**
|
||||
* [TasksDataSource] over Agendula's own Room store.
|
||||
*
|
||||
* The one structural difference from [de.jeanlucmakiola.agendula.data.tasks
|
||||
* .AndroidTasksDataSource]: there is no materialised instances table, so a
|
||||
* recurring series is expanded here, at read time, by [RecurrenceExpander].
|
||||
* Nothing above this cares — the repository already filters and sorts in Kotlin.
|
||||
*/
|
||||
@Singleton
|
||||
class RoomTasksDataSource @Inject constructor(
|
||||
private val database: TasksDatabase,
|
||||
) : TasksDataSource {
|
||||
|
||||
private val clock: Clock = Clock.System
|
||||
|
||||
private val tasks get() = database.tasks()
|
||||
private val lists get() = database.taskLists()
|
||||
private val alarms get() = database.alarms()
|
||||
|
||||
// --- reads ----------------------------------------------------------------
|
||||
|
||||
override fun taskLists(): List<TaskList> = lists.lists().map(RoomTaskMapper::taskList)
|
||||
|
||||
override fun tasks(query: TaskQuery): List<Task> {
|
||||
val now = clock.now()
|
||||
val overrides = tasks.allOverrides(query.listId).groupBy { it.masterId }
|
||||
return tasks.tasks(query.listId, query.includeCompleted)
|
||||
.flatMap { occurrencesOf(it, overrides[it.task.id].orEmpty(), now) }
|
||||
.filter { query.includeCompleted || !it.isClosed }
|
||||
}
|
||||
|
||||
override fun task(taskId: Long): Task? {
|
||||
val row = tasks.task(taskId) ?: return null
|
||||
// An override row is one occurrence in its own right; it names the
|
||||
// occurrence it replaces rather than expanding to a series.
|
||||
row.task.recurrenceId?.let { return RoomTaskMapper.task(row, occurrenceStart = it) }
|
||||
val now = clock.now()
|
||||
val occurrences = occurrencesOf(row, tasks.overrides(taskId), now)
|
||||
return occurrences.firstOrNull { it.distanceFromCurrent == 0 } ?: occurrences.firstOrNull()
|
||||
}
|
||||
|
||||
override fun subtasks(parentTaskId: Long): List<Task> {
|
||||
val now = clock.now()
|
||||
return tasks.subtasks(parentTaskId)
|
||||
.flatMap { occurrencesOf(it, tasks.overrides(it.task.id), now) }
|
||||
}
|
||||
|
||||
override fun exportTasks(listId: Long): List<ExportTask> =
|
||||
tasks.exportTasks(listId).map(RoomTaskMapper::exportTask)
|
||||
|
||||
override fun alarms(): Map<Long, Int> =
|
||||
alarms.all().associate { it.taskId to it.minutesBefore }
|
||||
|
||||
/**
|
||||
* Every occurrence of [row] inside the expansion window, with any
|
||||
* `RECURRENCE-ID` override substituted for the occurrence it replaces.
|
||||
*
|
||||
* A non-recurring task is its own single occurrence and carries a null
|
||||
* [Task.occurrenceStart], so it keys and edits by task id exactly as before.
|
||||
*/
|
||||
private fun occurrencesOf(row: TaskRow, overrides: List<TaskEntity>, now: Instant): List<Task> {
|
||||
val spec = row.task.recurrenceSpec() ?: return listOf(RoomTaskMapper.task(row))
|
||||
val window = ExpansionWindow(from = now - WINDOW_BACK, until = now + WINDOW_FORWARD)
|
||||
val anchors = RecurrenceExpander.expand(spec, window)
|
||||
if (anchors.isEmpty()) return emptyList()
|
||||
|
||||
val distances = RecurrenceExpander.distancesFromCurrent(anchors, now)
|
||||
val byAnchor = overrides.associateBy { it.recurrenceId }
|
||||
// A timed series keeps each occurrence's duration; a due-anchored one has
|
||||
// no start to offset from, so the anchor *is* the due date.
|
||||
val length = row.task.dtstart?.let { start -> row.task.due?.let { it - start } }
|
||||
|
||||
return anchors.mapIndexedNotNull { index, anchor ->
|
||||
val override = byAnchor[anchor]
|
||||
when {
|
||||
override != null -> RoomTaskMapper.task(
|
||||
row = row.copy(task = override),
|
||||
occurrenceStart = anchor,
|
||||
start = override.dtstart,
|
||||
due = override.due,
|
||||
distanceFromCurrent = distances[index],
|
||||
)
|
||||
|
||||
row.task.dtstart != null -> RoomTaskMapper.task(
|
||||
row = row,
|
||||
occurrenceStart = anchor,
|
||||
start = anchor,
|
||||
due = length?.let { anchor + it },
|
||||
distanceFromCurrent = distances[index],
|
||||
)
|
||||
|
||||
else -> RoomTaskMapper.task(
|
||||
row = row,
|
||||
occurrenceStart = anchor,
|
||||
start = null,
|
||||
due = anchor,
|
||||
distanceFromCurrent = distances[index],
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- writes ---------------------------------------------------------------
|
||||
|
||||
override fun insertTask(form: TaskForm): Long {
|
||||
if (lists.exists(form.listId) == 0) throw TaskWriteFailedException("insert task: no list ${form.listId}")
|
||||
val entity = TaskFormWriter.newTask(form, uid = UUID.randomUUID().toString(), now = clock.now(), tzId = zone())
|
||||
return tasks.insert(entity)
|
||||
}
|
||||
|
||||
override fun updateTask(taskId: Long, form: TaskForm) {
|
||||
val current = tasks.entity(taskId) ?: throw TaskWriteFailedException("update task $taskId")
|
||||
tasks.update(TaskFormWriter.apply(current, form, clock.now(), zone()))
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes one occurrence as a `RECURRENCE-ID` override — RFC 5545's model, and
|
||||
* what every other CalDAV client expects to receive. The dmfs provider
|
||||
* detached the occurrence into a brand-new task with its own UID instead,
|
||||
* which is the model least compatible with sync; the override shares its
|
||||
* master's UID, which is exactly what makes it an override.
|
||||
*/
|
||||
override fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm) {
|
||||
val master = tasks.entity(taskId) ?: throw TaskWriteFailedException("update instance $taskId")
|
||||
val now = clock.now()
|
||||
val existing = tasks.override(taskId, occurrenceStart)
|
||||
if (existing != null) {
|
||||
tasks.update(TaskFormWriter.apply(existing, form, now, zone()))
|
||||
return
|
||||
}
|
||||
val fork = TaskFormWriter.apply(
|
||||
master.copy(
|
||||
id = 0,
|
||||
masterId = taskId,
|
||||
recurrenceId = occurrenceStart,
|
||||
rrule = null,
|
||||
rdate = null,
|
||||
exdate = null,
|
||||
href = null,
|
||||
etag = null,
|
||||
),
|
||||
form,
|
||||
now,
|
||||
zone(),
|
||||
)
|
||||
// The list and parent come from the master: moving one occurrence between
|
||||
// lists or parents is not something the override model expresses.
|
||||
val id = tasks.insert(fork.copy(listId = master.listId, parentId = master.parentId))
|
||||
alarms.forTask(taskId).firstOrNull()?.let { alarms.replaceForTask(id, it) }
|
||||
}
|
||||
|
||||
override fun setAlarm(taskId: Long, minutesBeforeDue: Int?) {
|
||||
alarms.replaceForTask(
|
||||
taskId,
|
||||
minutesBeforeDue?.let { TaskAlarmEntity(taskId = taskId, minutesBefore = it) },
|
||||
)
|
||||
}
|
||||
|
||||
override fun setCompleted(taskId: Long, completed: Boolean) {
|
||||
val current = tasks.entity(taskId) ?: throw TaskWriteFailedException("complete task $taskId")
|
||||
tasks.update(TaskFormWriter.completed(current, completed, clock.now()))
|
||||
}
|
||||
|
||||
/**
|
||||
* Hard delete for a row no server knows about, tombstone for one that is
|
||||
* still owed to a collection. `master_id` cascades, so deleting a series
|
||||
* takes its overrides with it.
|
||||
*/
|
||||
override fun deleteTask(taskId: Long) {
|
||||
val current = tasks.entity(taskId) ?: return
|
||||
val listAccount = lists.entity(current.listId)?.accountId
|
||||
if (listAccount == null) tasks.delete(taskId) else tasks.markDeleted(taskId, clock.now())
|
||||
}
|
||||
|
||||
override fun createLocalList(name: String, color: Int): Long =
|
||||
lists.insert(TaskListEntity(name = name.trim(), color = color))
|
||||
|
||||
// --- observation ----------------------------------------------------------
|
||||
|
||||
override fun registerObserver(onChange: () -> Unit): AutoCloseable {
|
||||
val observer = object : InvalidationTracker.Observer(OBSERVED_TABLES) {
|
||||
override fun onInvalidated(tables: Set<String>) = onChange()
|
||||
}
|
||||
database.invalidationTracker.addObserver(observer)
|
||||
return AutoCloseable { database.invalidationTracker.removeObserver(observer) }
|
||||
}
|
||||
|
||||
private fun zone(): String = ZoneId.systemDefault().id
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import kotlin.time.Instant
|
||||
*
|
||||
* Reads split masters from overrides on purpose: [tasks] returns the rows a
|
||||
* recurrence expander expands (a non-recurring task is its own single
|
||||
* occurrence), and [overridesForList] / [overrides] return the
|
||||
* occurrence), and [allOverrides] / [overrides] return the
|
||||
* `RECURRENCE-ID` rows that replace individual occurrences. Nothing here
|
||||
* expands anything — that is phase 2's job, in Kotlin.
|
||||
*/
|
||||
@@ -68,9 +68,19 @@ interface TaskDao {
|
||||
@Query("SELECT * FROM tasks WHERE id = :taskId")
|
||||
fun entity(taskId: Long): TaskEntity?
|
||||
|
||||
/** Every override in [listId], for expansion alongside [tasks]. */
|
||||
@Query("SELECT * FROM tasks WHERE list_id = :listId AND master_id IS NOT NULL AND is_deleted = 0")
|
||||
fun overridesForList(listId: Long): List<TaskEntity>
|
||||
/**
|
||||
* Every override, optionally narrowed to one list — read alongside [tasks] so
|
||||
* expansion can replace the occurrences they override in one pass rather than
|
||||
* querying per series.
|
||||
*/
|
||||
@Query(
|
||||
"""
|
||||
SELECT * FROM tasks
|
||||
WHERE master_id IS NOT NULL AND is_deleted = 0
|
||||
AND (:listId IS NULL OR list_id = :listId)
|
||||
"""
|
||||
)
|
||||
fun allOverrides(listId: Long?): List<TaskEntity>
|
||||
|
||||
@Query("SELECT * FROM tasks WHERE master_id = :masterId AND is_deleted = 0")
|
||||
fun overrides(masterId: Long): List<TaskEntity>
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
package de.jeanlucmakiola.agendula.data.tasks.room
|
||||
|
||||
import de.jeanlucmakiola.agendula.domain.TaskForm
|
||||
import de.jeanlucmakiola.agendula.domain.TaskStatus
|
||||
import de.jeanlucmakiola.agendula.domain.toICal
|
||||
import kotlin.time.Instant
|
||||
|
||||
private const val MILLIS_PER_DAY = 24L * 60 * 60 * 1000
|
||||
|
||||
/** Floor to UTC midnight when [allDay], else pass through unchanged. */
|
||||
internal fun Instant.forAllDay(allDay: Boolean): Instant =
|
||||
if (!allDay) this
|
||||
else Instant.fromEpochMilliseconds(
|
||||
Math.floorDiv(toEpochMilliseconds(), MILLIS_PER_DAY) * MILLIS_PER_DAY,
|
||||
)
|
||||
|
||||
/**
|
||||
* Applies a [TaskForm] to a [TaskEntity]. Pure, so the semantics below are
|
||||
* testable on the JVM without a database.
|
||||
*
|
||||
* This is the Room counterpart of
|
||||
* [de.jeanlucmakiola.agendula.data.tasks.TaskWriteMapper], which stays for
|
||||
* External mode. It is a separate object rather than a shared one because most
|
||||
* of what that mapper does is work around the provider — clearing `DURATION`
|
||||
* because the provider validates a merged row, writing `STATUS` both ways
|
||||
* because the provider auto-completes at 100% but will not reopen below it. Here
|
||||
* those rules are ours to state directly.
|
||||
*/
|
||||
object TaskFormWriter {
|
||||
|
||||
/** A brand-new task. [uid] is minted by the caller and never null. */
|
||||
fun newTask(form: TaskForm, uid: String, now: Instant, tzId: String): TaskEntity =
|
||||
apply(
|
||||
TaskEntity(listId = form.listId, uid = uid, createdAt = now),
|
||||
form,
|
||||
now,
|
||||
tzId,
|
||||
)
|
||||
|
||||
/** [current] with [form] applied. Identity, recurrence and sync columns are left alone. */
|
||||
fun apply(current: TaskEntity, form: TaskForm, now: Instant, tzId: String): TaskEntity {
|
||||
val percent = form.percentComplete?.coerceIn(0, 100)
|
||||
val timed = !form.isAllDay && (form.start != null || form.due != null)
|
||||
return current.copy(
|
||||
listId = form.listId,
|
||||
title = form.title.trim(),
|
||||
description = form.description?.trim()?.ifBlank { null },
|
||||
priority = form.priority.toICal(),
|
||||
percentComplete = percent,
|
||||
status = statusFor(percent, current.status),
|
||||
completedAt = completedAtFor(percent, current, now),
|
||||
dtstart = form.start?.forAllDay(form.isAllDay),
|
||||
due = form.due?.forAllDay(form.isAllDay),
|
||||
// DUE and DURATION are mutually exclusive (RFC 5545 §3.6.2).
|
||||
duration = null,
|
||||
isAllDay = form.isAllDay,
|
||||
timezone = if (timed) tzId else null,
|
||||
parentId = form.parentId?.takeIf { it > 0 },
|
||||
lastModified = now,
|
||||
isDirty = true,
|
||||
)
|
||||
}
|
||||
|
||||
/** The completion triple, for the standalone complete toggle. */
|
||||
fun completed(current: TaskEntity, completed: Boolean, now: Instant): TaskEntity = current.copy(
|
||||
status = if (completed) TaskStatus.COMPLETED else TaskStatus.NEEDS_ACTION,
|
||||
percentComplete = if (completed) 100 else null,
|
||||
completedAt = if (completed) now else null,
|
||||
lastModified = now,
|
||||
isDirty = true,
|
||||
)
|
||||
|
||||
/**
|
||||
* A form carrying no percent leaves status alone — the standalone toggle stays
|
||||
* authoritative. Otherwise progress and status move together in both
|
||||
* directions, which is the asymmetry the provider never had: it auto-completed
|
||||
* at 100% but would not reopen below it, stranding a task "done at 75%".
|
||||
*/
|
||||
private fun statusFor(percent: Int?, current: TaskStatus): TaskStatus = when {
|
||||
percent == null -> current
|
||||
percent >= 100 -> TaskStatus.COMPLETED
|
||||
percent > 0 -> TaskStatus.IN_PROCESS
|
||||
else -> TaskStatus.NEEDS_ACTION
|
||||
}
|
||||
|
||||
private fun completedAtFor(percent: Int?, current: TaskEntity, now: Instant): Instant? = when {
|
||||
percent == null -> current.completedAt
|
||||
percent >= 100 -> current.completedAt ?: now
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package de.jeanlucmakiola.agendula.data.tasks.room
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.agendula.domain.Priority
|
||||
import de.jeanlucmakiola.agendula.domain.TaskForm
|
||||
import de.jeanlucmakiola.agendula.domain.TaskStatus
|
||||
import org.junit.jupiter.api.Test
|
||||
import kotlin.time.Instant
|
||||
|
||||
private val NOW = Instant.fromEpochMilliseconds(1_768_467_600_000)
|
||||
private const val ZONE = "Europe/Berlin"
|
||||
|
||||
private fun task(
|
||||
status: TaskStatus = TaskStatus.NEEDS_ACTION,
|
||||
percentComplete: Int? = null,
|
||||
completedAt: Instant? = null,
|
||||
) = TaskEntity(
|
||||
id = 1,
|
||||
listId = 1,
|
||||
uid = "uid-1",
|
||||
status = status,
|
||||
percentComplete = percentComplete,
|
||||
completedAt = completedAt,
|
||||
)
|
||||
|
||||
private fun form(
|
||||
title: String = "task",
|
||||
percentComplete: Int? = null,
|
||||
start: Instant? = null,
|
||||
due: Instant? = null,
|
||||
isAllDay: Boolean = false,
|
||||
) = TaskForm(
|
||||
title = title,
|
||||
listId = 1,
|
||||
percentComplete = percentComplete,
|
||||
start = start,
|
||||
due = due,
|
||||
isAllDay = isAllDay,
|
||||
)
|
||||
|
||||
class TaskFormWriterTest {
|
||||
|
||||
@Test
|
||||
fun `mints the task with its uid and creation time`() {
|
||||
val entity = TaskFormWriter.newTask(form(title = " Buy milk "), "uid-9", NOW, ZONE)
|
||||
|
||||
assertThat(entity.uid).isEqualTo("uid-9")
|
||||
assertThat(entity.title).isEqualTo("Buy milk")
|
||||
assertThat(entity.createdAt).isEqualTo(NOW)
|
||||
assertThat(entity.lastModified).isEqualTo(NOW)
|
||||
assertThat(entity.isDirty).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `progress and status move together in both directions`() {
|
||||
assertThat(TaskFormWriter.apply(task(), form(percentComplete = 100), NOW, ZONE).status)
|
||||
.isEqualTo(TaskStatus.COMPLETED)
|
||||
assertThat(TaskFormWriter.apply(task(), form(percentComplete = 40), NOW, ZONE).status)
|
||||
.isEqualTo(TaskStatus.IN_PROCESS)
|
||||
assertThat(TaskFormWriter.apply(task(), form(percentComplete = 0), NOW, ZONE).status)
|
||||
.isEqualTo(TaskStatus.NEEDS_ACTION)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dropping below 100 percent reopens the task`() {
|
||||
// The provider auto-completed at 100% but would not reopen below it, which
|
||||
// stranded a task "done at 75%". TaskWriteMapper works around that for
|
||||
// External mode; on our own store the rule is simply symmetric.
|
||||
val completed = task(status = TaskStatus.COMPLETED, percentComplete = 100, completedAt = NOW)
|
||||
|
||||
val reopened = TaskFormWriter.apply(completed, form(percentComplete = 75), NOW, ZONE)
|
||||
|
||||
assertThat(reopened.status).isEqualTo(TaskStatus.IN_PROCESS)
|
||||
assertThat(reopened.completedAt).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a form with no percent leaves the completion state alone`() {
|
||||
val completed = task(status = TaskStatus.COMPLETED, percentComplete = 100, completedAt = NOW)
|
||||
|
||||
val saved = TaskFormWriter.apply(completed, form(title = "renamed"), NOW, ZONE)
|
||||
|
||||
assertThat(saved.status).isEqualTo(TaskStatus.COMPLETED)
|
||||
assertThat(saved.completedAt).isEqualTo(NOW)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `re-saving a finished task keeps its original completion time`() {
|
||||
val earlier = Instant.fromEpochMilliseconds(1_000_000)
|
||||
val completed = task(status = TaskStatus.COMPLETED, percentComplete = 100, completedAt = earlier)
|
||||
|
||||
val saved = TaskFormWriter.apply(completed, form(percentComplete = 100), NOW, ZONE)
|
||||
|
||||
assertThat(saved.completedAt).isEqualTo(earlier)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `all-day times are pinned to UTC midnight`() {
|
||||
// Date-only in iCalendar. Storing a local-midnight instant would land on the
|
||||
// previous day for anyone west of UTC.
|
||||
val midMorning = Instant.fromEpochMilliseconds(1_768_467_600_000)
|
||||
|
||||
val saved = TaskFormWriter.apply(
|
||||
task(),
|
||||
form(start = midMorning, due = midMorning, isAllDay = true),
|
||||
NOW,
|
||||
ZONE,
|
||||
)
|
||||
|
||||
assertThat(saved.dtstart!!.toEpochMilliseconds() % (24L * 60 * 60 * 1000)).isEqualTo(0)
|
||||
assertThat(saved.due!!.toEpochMilliseconds() % (24L * 60 * 60 * 1000)).isEqualTo(0)
|
||||
assertThat(saved.timezone).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a timed task records the zone, an undated one does not`() {
|
||||
val timed = TaskFormWriter.apply(task(), form(due = NOW), NOW, ZONE)
|
||||
assertThat(timed.timezone).isEqualTo(ZONE)
|
||||
|
||||
val undated = TaskFormWriter.apply(task(), form(), NOW, ZONE)
|
||||
assertThat(undated.timezone).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `writing a due date clears any duration`() {
|
||||
// RFC 5545 §3.6.2: DUE and DURATION are mutually exclusive.
|
||||
val withDuration = task().copy(duration = "PT1H")
|
||||
|
||||
assertThat(TaskFormWriter.apply(withDuration, form(due = NOW), NOW, ZONE).duration).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the complete toggle sets and clears the whole triple`() {
|
||||
val done = TaskFormWriter.completed(task(), completed = true, now = NOW)
|
||||
assertThat(done.status).isEqualTo(TaskStatus.COMPLETED)
|
||||
assertThat(done.percentComplete).isEqualTo(100)
|
||||
assertThat(done.completedAt).isEqualTo(NOW)
|
||||
|
||||
val reopened = TaskFormWriter.completed(done, completed = false, now = NOW)
|
||||
assertThat(reopened.status).isEqualTo(TaskStatus.NEEDS_ACTION)
|
||||
assertThat(reopened.percentComplete).isNull()
|
||||
assertThat(reopened.completedAt).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `priority is written as the raw iCalendar integer`() {
|
||||
assertThat(TaskFormWriter.apply(task(), form().copy(priority = Priority.HIGH), NOW, ZONE).priority)
|
||||
.isEqualTo(1)
|
||||
assertThat(TaskFormWriter.apply(task(), form().copy(priority = Priority.NONE), NOW, ZONE).priority)
|
||||
.isEqualTo(0)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user