diff --git a/app/src/main/java/dev/tricked/solidverdant/data/repository/TimeEntryRepository.kt b/app/src/main/java/dev/tricked/solidverdant/data/repository/TimeEntryRepository.kt index a9feafe..5306236 100644 --- a/app/src/main/java/dev/tricked/solidverdant/data/repository/TimeEntryRepository.kt +++ b/app/src/main/java/dev/tricked/solidverdant/data/repository/TimeEntryRepository.kt @@ -226,7 +226,10 @@ class TimeEntryRepository @Inject constructor( queued + conflicts.filterNot { it.id in queuedIds }.map { conflict -> SyncOperation( entryId = conflict.id, - type = OutboxOpType.UPDATE, + // The outbox operation is removed when a conflict is captured, but a pending + // local delete remains encoded on the Room row. Preserve that intent so Sync & + // recovery does not mislabel a guarded deletion as an ordinary edit. + type = if (conflict.pendingDelete) OutboxOpType.DELETE else OutboxOpType.UPDATE, status = EntrySyncStatus.CONFLICT, attemptCount = 0, error = null, diff --git a/app/src/main/java/dev/tricked/solidverdant/ui/review/InboxPane.kt b/app/src/main/java/dev/tricked/solidverdant/ui/review/InboxPane.kt index 1e7eb60..1101803 100644 --- a/app/src/main/java/dev/tricked/solidverdant/ui/review/InboxPane.kt +++ b/app/src/main/java/dev/tricked/solidverdant/ui/review/InboxPane.kt @@ -670,7 +670,17 @@ private fun ConflictIssueCard(issue: InboxIssue, projectsById: Map, collected: List): Set { + val collectedById = collected.associateBy { it.id } + return changes.mapNotNullTo(mutableSetOf()) { (entryId, change) -> + val collectedEntry = collectedById[entryId] + when (change) { + HistoryMembershipChange.COMPLETED_ENTRY_PRESENT -> entryId.takeIf { + collectedEntry != null && isCompletedTimeEntry(collectedEntry) + } + HistoryMembershipChange.ENTRY_ABSENT -> entryId.takeIf { collectedEntry == null } + } + } +} + /** * Single source of truth for how a Room emission from the recent-window collector combines with * the list currently on screen. * * In [HistoryWindowMode.RECENT] the collector owns the list and replaces it wholesale, so live * edits and the active-entry poll stay fresh. Once the user pages or jumps to an off-window slice - * ([HistoryWindowMode.PAGINATED]) the network-fetched window is authoritative: its order and - * membership are preserved (so scroll position survives a poll emission) while any fresher copy of - * a still-visible entry carried by the recent collector is overlaid in place. + * ([HistoryWindowMode.PAGINATED]) the network-fetched window normally preserves its membership so + * scroll position survives a poll emission. Entries mutated locally are the exception: Room is + * authoritative for whether those entries are present, even while the paginated window is shown. */ internal object HistoryWindow { - fun merge(mode: HistoryWindowMode, displayed: List, collected: List): List = when (mode) { + fun merge( + mode: HistoryWindowMode, + displayed: List, + collected: List, + locallyMutatedEntryIds: Set = emptySet(), + ): List = when (mode) { HistoryWindowMode.RECENT -> collected HistoryWindowMode.PAGINATED -> { val collectedById = collected.associateBy { it.id } - displayed.map { collectedById[it.id] ?: it } + val refreshed = displayed.mapNotNull { displayedEntry -> + collectedById[displayedEntry.id] + ?: displayedEntry.takeUnless { it.id in locallyMutatedEntryIds } + } + val displayedIds = displayed.mapTo(mutableSetOf()) { it.id } + val completedAdditions = collected.filter { + it.id in locallyMutatedEntryIds && it.id !in displayedIds && isCompletedTimeEntry(it) + } + + completedAdditions.fold(refreshed) { entries, addition -> + val insertionIndex = entries.indexOfFirst { it.start < addition.start } + if (insertionIndex == -1) { + entries + addition + } else { + entries.toMutableList().apply { + add(insertionIndex, addition) + } + } + } } } } @@ -294,6 +332,7 @@ class TrackingViewModel @Inject constructor( private var historyOffset = 0 private var historyWindowStartOffset = 0 private var historyWindowMode = HistoryWindowMode.RECENT + private val pendingHistoryMembershipChanges = mutableMapOf() private var isInitialized = false /** @@ -480,6 +519,7 @@ class TrackingViewModel @Inject constructor( historyWindowStartOffset = 0 historyOffset = 0 historyWindowMode = HistoryWindowMode.RECENT + pendingHistoryMembershipChanges.clear() clearActivePollOverride() dataCollectorJob = viewModelScope.launch { combine( @@ -525,7 +565,17 @@ class TrackingViewModel @Inject constructor( // paging offset) while the recent slice is on screen. Once the user has paged or // jumped, loadMore/jump own the window and offset; here we merely refresh visible // entries in place so a poll emission cannot wipe the window or reset scroll. - val displayedEntries = HistoryWindow.merge(mode, currentState.timeEntries, data.entries) + val resolvedMembershipChanges = resolvedHistoryMembershipChangeIds( + pendingHistoryMembershipChanges, + data.entries, + ) + val displayedEntries = HistoryWindow.merge( + mode = mode, + displayed = currentState.timeEntries, + collected = data.entries, + locallyMutatedEntryIds = resolvedMembershipChanges, + ) + resolvedMembershipChanges.forEach(pendingHistoryMembershipChanges::remove) if (mode == HistoryWindowMode.RECENT) { historyOffset = data.entries.size } @@ -1532,6 +1582,7 @@ class TrackingViewModel @Inject constructor( // Active polling can complete between the local STOP transaction and the outbox observer // emission. Suppress that exact server id synchronously while the STOP is being queued. locallyStoppingEntryIds += currentEntry.id + pendingHistoryMembershipChanges[currentEntry.id] = HistoryMembershipChange.COMPLETED_ENTRY_PRESENT clearActivePollOverride() viewModelScope.launch { @@ -1899,6 +1950,7 @@ class TrackingViewModel @Inject constructor( // Optimistic local-only soft-delete; the collector removes it from the list. No outbox op // exists yet, so there is nothing here for the sync worker to act on. + pendingHistoryMembershipChanges[entry.id] = HistoryMembershipChange.ENTRY_ABSENT timeEntryRepository.softDeleteLocal(entry) pendingDeleteCommitJobs.remove(entry.id)?.cancel() @@ -1920,7 +1972,9 @@ class TrackingViewModel @Inject constructor( // this guarantees nothing was ever enqueued to the outbox for the repository undo path // to race against. pendingDeleteCommitJobs.remove(entry.id)?.cancel() + pendingHistoryMembershipChanges[entry.id] = HistoryMembershipChange.COMPLETED_ENTRY_PRESENT if (!timeEntryRepository.undoDelete(entry, historyMemberId)) { + pendingHistoryMembershipChanges.remove(entry.id) _uiState.value = _uiState.value.copy(error = context.getString(R.string.undo_delete_too_late)) } else { syncTrigger.requestSync() diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 9f0d16a..397a588 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -102,7 +102,7 @@ 履歴を検索 分類が必要 フィルターを解除 - エントリーを削除しました + 削除を保留中 元に戻す このエントリーはすでにサーバーから削除されているため、自動的に復元できません。 diff --git a/app/src/main/res/values-ja/strings_inbox.xml b/app/src/main/res/values-ja/strings_inbox.xml index 035ca94..3ac177c 100644 --- a/app/src/main/res/values-ja/strings_inbox.xml +++ b/app/src/main/res/values-ja/strings_inbox.xml @@ -52,6 +52,7 @@ %1$s · %2$s · %3$s 自分の版を残す + 項目を削除 サーバー版を残す 閉じる diff --git a/app/src/main/res/values-ja/strings_sync.xml b/app/src/main/res/values-ja/strings_sync.xml index dcc0356..60875a6 100644 --- a/app/src/main/res/values-ja/strings_sync.xml +++ b/app/src/main/res/values-ja/strings_sync.xml @@ -4,7 +4,7 @@ 同期状態 同期と復旧 同期の詳細を表示 - サーバーへの送信を待っている変更を確認できます。失敗した変更を再試行するか、送信しない変更を破棄できます。 + サーバーへの送信を待っている変更を確認できます。失敗した変更はここで再試行できます。競合はレビューで解決するまで安全に保持されます。 戻る @@ -44,7 +44,7 @@ 同期に失敗しました 確認が必要です - この項目は別のデバイスで変更されたため、確認が必要です。 + この項目は別のデバイスで変更されました。レビューを開いて、どちらを反映するか選択してください。 すべて再試行 再試行 破棄 diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 0ab3141..ae82a4a 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -111,7 +111,7 @@ Geschiedenis doorzoeken Moet worden gecategoriseerd Filters wissen - Boeking verwijderd + Verwijdering in wachtrij Ongedaan maken Deze boeking is al van de server verwijderd en kan niet automatisch worden hersteld. diff --git a/app/src/main/res/values-nl/strings_inbox.xml b/app/src/main/res/values-nl/strings_inbox.xml index 53e6972..803576f 100644 --- a/app/src/main/res/values-nl/strings_inbox.xml +++ b/app/src/main/res/values-nl/strings_inbox.xml @@ -53,6 +53,7 @@ %1$s · %2$s · %3$s Mijn versie houden + Item verwijderen Serverversie houden Negeren diff --git a/app/src/main/res/values-nl/strings_sync.xml b/app/src/main/res/values-nl/strings_sync.xml index d5c1120..6f82d87 100644 --- a/app/src/main/res/values-nl/strings_sync.xml +++ b/app/src/main/res/values-nl/strings_sync.xml @@ -4,7 +4,7 @@ Synchronisatiestatus Synchronisatie en herstel Synchronisatiedetails bekijken - Bekijk welke wijzigingen nog op de server wachten. Je kunt mislukte wijzigingen opnieuw proberen of wijzigingen verwijderen die je niet meer wilt verzenden. + Bekijk welke wijzigingen nog op de server wachten. Probeer mislukte wijzigingen hier opnieuw. Conflicten blijven veilig bewaard totdat je ze oplost onder Nakijken. Terug @@ -48,7 +48,7 @@ Synchronisatie mislukt Vraagt om je aandacht - Dit item is op een ander apparaat gewijzigd en vraagt om je aandacht. + Dit item is op een ander apparaat gewijzigd. Open Nakijken om te kiezen wat er moet gebeuren. Alles opnieuw proberen Opnieuw Verwijderen diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1a66290..ff18332 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -205,7 +205,7 @@ No time entries yet Edit Delete - Entry deleted + Deletion queued Undo This entry was already deleted on the server and can no longer be restored automatically. No description diff --git a/app/src/main/res/values/strings_inbox.xml b/app/src/main/res/values/strings_inbox.xml index f34f6ad..c3683be 100644 --- a/app/src/main/res/values/strings_inbox.xml +++ b/app/src/main/res/values/strings_inbox.xml @@ -59,6 +59,7 @@ %1$s · %2$s · %3$s Keep mine + Delete entry Keep server Dismiss diff --git a/app/src/main/res/values/strings_sync.xml b/app/src/main/res/values/strings_sync.xml index 305731b..6e8d90e 100644 --- a/app/src/main/res/values/strings_sync.xml +++ b/app/src/main/res/values/strings_sync.xml @@ -4,7 +4,7 @@ Sync status Sync & recovery View sync details - See which changes are waiting for the server. You can retry failed changes or discard ones you no longer want to send. + See which changes are waiting for the server. Retry failed changes here. Conflicts stay safe until you resolve them in Review. Back @@ -48,7 +48,7 @@ Failed to sync Need your review - This entry changed on another device and needs your review. + This entry changed on another device. Open Review to choose what should happen. Retry all Retry Discard diff --git a/app/src/test/java/dev/tricked/solidverdant/data/repository/TimeEntryRepositoryWriteTest.kt b/app/src/test/java/dev/tricked/solidverdant/data/repository/TimeEntryRepositoryWriteTest.kt index a2af490..2cfd731 100644 --- a/app/src/test/java/dev/tricked/solidverdant/data/repository/TimeEntryRepositoryWriteTest.kt +++ b/app/src/test/java/dev/tricked/solidverdant/data/repository/TimeEntryRepositoryWriteTest.kt @@ -774,6 +774,60 @@ class TimeEntryRepositoryWriteTest { assertEquals("server", Json.decodeFromString(op.baseSnapshotJson!!).description) } + @Test fun sync_operations_distinguish_delete_conflicts_from_edit_conflicts() = runTest { + val server = TimeEntry( + id = "delete-conflict", + userId = "u", + organizationId = "org1", + start = "2026-07-07T08:00:00Z", + end = "2026-07-07T09:00:00Z", + description = "server", + ) + val editConflict = server.copy(id = "edit-conflict", description = "mine") + db.timeEntryDao().upsert( + server.toEntity(2L, SyncState.CONFLICT, pendingDelete = true).copy( + conflictServerJson = testJson.encodeToString(server.copy(description = "changed elsewhere")), + ), + ) + db.timeEntryDao().upsert( + editConflict.toEntity(2L, SyncState.CONFLICT).copy( + conflictServerJson = testJson.encodeToString(editConflict.copy(description = "changed elsewhere")), + ), + ) + + val operations = repo.observeSyncOperations("org1").first().associateBy { it.entryId } + + assertEquals(OutboxOpType.DELETE, operations.getValue("delete-conflict").type) + assertEquals(OutboxOpType.UPDATE, operations.getValue("edit-conflict").type) + assertTrue(operations.values.all { it.status == TimeEntryRepository.EntrySyncStatus.CONFLICT }) + } + + @Test fun confirming_a_local_delete_conflict_requeues_the_delete() = runTest { + val server = TimeEntry( + id = "delete-conflict", + userId = "u", + organizationId = "org1", + start = "2026-07-07T08:00:00Z", + end = "2026-07-07T09:00:00Z", + description = "changed elsewhere", + ) + val local = server.copy(description = "local baseline") + db.timeEntryDao().upsert( + local.toEntity(2L, SyncState.CONFLICT, pendingDelete = true).copy( + conflictServerJson = testJson.encodeToString(server), + ), + ) + + assertTrue(repo.resolveKeepMine(local.id, "member")) + + val stored = db.timeEntryDao().getById(local.id) + val operation = db.outboxDao().peekAll().single() + assertEquals(SyncState.PENDING, stored?.syncState) + assertEquals(true, stored?.pendingDelete) + assertNull(stored?.conflictServerJson) + assertEquals(OutboxOpType.DELETE, operation.opType) + } + @Test fun keep_theirs_restores_server_copy_and_clears_conflict() = runTest { val local = TimeEntry( id = "server-1", diff --git a/app/src/test/java/dev/tricked/solidverdant/ui/tracking/HistoryWindowTest.kt b/app/src/test/java/dev/tricked/solidverdant/ui/tracking/HistoryWindowTest.kt index e308c1b..ce85fb8 100644 --- a/app/src/test/java/dev/tricked/solidverdant/ui/tracking/HistoryWindowTest.kt +++ b/app/src/test/java/dev/tricked/solidverdant/ui/tracking/HistoryWindowTest.kt @@ -44,4 +44,79 @@ class HistoryWindowTest { assertEquals("edited", merged.first { it.id == "a" }.description) assertEquals("old", merged.first { it.id == "b" }.description) } + + @Test fun `membership change resolves only after Room contains the completed stop`() { + val running = entry("timer").copy(end = null) + val stopped = running.copy(end = "2026-07-06T09:00:00Z") + val changes = mapOf("timer" to HistoryMembershipChange.COMPLETED_ENTRY_PRESENT) + + assertEquals(emptySet(), resolvedHistoryMembershipChangeIds(changes, listOf(running))) + assertEquals(setOf("timer"), resolvedHistoryMembershipChangeIds(changes, listOf(stopped))) + } + + @Test fun `delete membership change resolves only after Room hides the entry`() { + val deleted = entry("deleted") + val changes = mapOf("deleted" to HistoryMembershipChange.ENTRY_ABSENT) + + assertEquals(emptySet(), resolvedHistoryMembershipChangeIds(changes, listOf(deleted))) + assertEquals(setOf("deleted"), resolvedHistoryMembershipChangeIds(changes, emptyList())) + } + + @Test fun `paginated mode adds a locally stopped entry from Room`() { + val displayed = listOf(entry("existing")) + val stopped = entry("stopped").copy(start = "2026-07-06T10:00:00Z") + val collected = listOf(stopped, displayed.single()) + + val merged = HistoryWindow.merge( + HistoryWindowMode.PAGINATED, + displayed, + collected, + locallyMutatedEntryIds = setOf(stopped.id), + ) + + assertEquals(listOf("stopped", "existing"), merged.map { it.id }) + } + + @Test fun `paginated mode does not add a locally started entry before it is stopped`() { + val displayed = listOf(entry("existing")) + val running = entry("running").copy(end = null) + + val merged = HistoryWindow.merge( + HistoryWindowMode.PAGINATED, + displayed, + listOf(running, displayed.single()), + locallyMutatedEntryIds = setOf(running.id), + ) + + assertEquals(listOf("existing"), merged.map { it.id }) + } + + @Test fun `paginated mode removes a locally deleted entry missing from Room`() { + val deleted = entry("deleted") + val remaining = entry("remaining") + + val merged = HistoryWindow.merge( + HistoryWindowMode.PAGINATED, + listOf(deleted, remaining), + listOf(remaining), + locallyMutatedEntryIds = setOf(deleted.id), + ) + + assertEquals(listOf("remaining"), merged.map { it.id }) + } + + @Test fun `paginated mode restores an undone delete in chronological position`() { + val newest = entry("newest").copy(start = "2026-07-06T10:00:00Z") + val restored = entry("restored").copy(start = "2026-07-06T09:00:00Z") + val oldest = entry("oldest").copy(start = "2026-07-06T08:00:00Z") + + val merged = HistoryWindow.merge( + HistoryWindowMode.PAGINATED, + listOf(newest, oldest), + listOf(newest, restored, oldest), + locallyMutatedEntryIds = setOf(restored.id), + ) + + assertEquals(listOf("newest", "restored", "oldest"), merged.map { it.id }) + } } diff --git a/app/src/test/java/dev/tricked/solidverdant/ui/tracking/TrackingViewModelMutationTest.kt b/app/src/test/java/dev/tricked/solidverdant/ui/tracking/TrackingViewModelMutationTest.kt index a9d1c0e..44610ca 100644 --- a/app/src/test/java/dev/tricked/solidverdant/ui/tracking/TrackingViewModelMutationTest.kt +++ b/app/src/test/java/dev/tricked/solidverdant/ui/tracking/TrackingViewModelMutationTest.kt @@ -19,10 +19,13 @@ import dev.tricked.solidverdant.util.Clock import io.mockk.Runs import io.mockk.coEvery import io.mockk.coVerify +import io.mockk.every import io.mockk.just import io.mockk.mockk +import io.mockk.spyk import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.TestCoroutineScheduler import kotlinx.coroutines.test.TestDispatcher import kotlinx.coroutines.test.UnconfinedTestDispatcher @@ -32,6 +35,7 @@ import kotlinx.coroutines.test.setMain import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test @@ -63,7 +67,7 @@ class TrackingViewModelMutationTest { fun tearDown() { val jobs = viewModels.mapNotNull { it.cancelScopeForTest() } viewModels.clear() - dispatcher.scheduler.runCurrent() + dispatcher.scheduler.advanceUntilIdle() kotlinx.coroutines.runBlocking { jobs.forEach { it.join() } } shadowOf(Looper.getMainLooper()).idle() kotlinx.coroutines.Dispatchers.resetMain() @@ -119,13 +123,11 @@ class TrackingViewModelMutationTest { @Test fun stop_keeps_description_project_and_task_when_auto_clear_is_disabled() = runTest(dispatcher.scheduler) { - settings.setAutoClearEntryFieldsAfterStop(false) - settings.setClearDescriptionAfterStop(false) val active = activeEntry() val repository = mockk(relaxed = true) coEvery { repository.stopEntryWithEdits(any(), any(), any(), any()) } just Runs cacheActiveEntry(active) - val viewModel = viewModel(repository) + val viewModel = viewModel(repository, retentionSettings(autoClear = false, clearDescription = false)) viewModel.stopTimeEntry() dispatcher.scheduler.runCurrent() @@ -140,13 +142,11 @@ class TrackingViewModelMutationTest { @Test fun stop_clears_only_description_when_description_clear_is_enabled() = runTest(dispatcher.scheduler) { - settings.setAutoClearEntryFieldsAfterStop(false) - settings.setClearDescriptionAfterStop(true) val active = activeEntry() val repository = mockk(relaxed = true) coEvery { repository.stopEntryWithEdits(any(), any(), any(), any()) } just Runs cacheActiveEntry(active) - val viewModel = viewModel(repository) + val viewModel = viewModel(repository, retentionSettings(autoClear = false, clearDescription = true)) viewModel.stopTimeEntry() dispatcher.scheduler.runCurrent() @@ -160,12 +160,11 @@ class TrackingViewModelMutationTest { @Test fun stop_clears_entry_fields_when_auto_clear_is_enabled() = runTest(dispatcher.scheduler) { - settings.setAutoClearEntryFieldsAfterStop(true) val active = activeEntry() val repository = mockk(relaxed = true) coEvery { repository.stopEntryWithEdits(any(), any(), any(), any()) } just Runs cacheActiveEntry(active) - val viewModel = viewModel(repository) + val viewModel = viewModel(repository, retentionSettings(autoClear = true, clearDescription = false)) viewModel.stopTimeEntry() dispatcher.scheduler.runCurrent() @@ -178,11 +177,10 @@ class TrackingViewModelMutationTest { @Test fun reset_clears_only_reusable_entry_fields() = runTest(dispatcher.scheduler) { - settings.setAutoClearEntryFieldsAfterStop(false) val repository = mockk(relaxed = true) coEvery { repository.stopEntryWithEdits(any(), any(), any(), any()) } just Runs cacheActiveEntry(activeEntry()) - val viewModel = viewModel(repository) + val viewModel = viewModel(repository, retentionSettings(autoClear = false, clearDescription = false)) viewModel.stopTimeEntry() dispatcher.scheduler.runCurrent() viewModel.updateTags(listOf("tag-1")) @@ -246,6 +244,36 @@ class TrackingViewModelMutationTest { dispose(viewModel) } + @Test + fun successful_start_can_be_stopped_before_room_collectors_emit() = runTest(dispatcher.scheduler) { + val entry = TimeEntry( + id = "local-start", + userId = "user", + organizationId = "org", + start = "2026-08-10T08:00:00Z", + ) + val repository = mockk(relaxed = true) + coEvery { repository.startEntry(any(), any(), any(), any(), any(), any(), any()) } returns entry + coEvery { repository.stopEntryWithEdits(any(), any(), any(), any()) } just Runs + val immediateSettings = spyk(settings) + coEvery { immediateSettings.setWidgetTrackingState(any(), any(), any(), any(), any()) } just Runs + val viewModel = viewModel(repository, immediateSettings) + + viewModel.startTimeEntry("org", "member", "user") + dispatcher.scheduler.runCurrent() + assertEquals(entry, viewModel.uiState.value.currentTimeEntry) + + viewModel.stopTimeEntry() + dispatcher.scheduler.runCurrent() + + coVerify(exactly = 1) { + repository.stopEntryWithEdits(entry, "user", match { it.id == entry.id }, emptyList()) + } + assertFalse(viewModel.uiState.value.isTracking) + assertNull(viewModel.uiState.value.currentTimeEntry) + dispose(viewModel) + } + @Test fun repeated_stop_is_ignored_while_the_first_mutation_is_in_flight() = runTest(dispatcher.scheduler) { val release = CompletableDeferred() @@ -328,23 +356,31 @@ class TrackingViewModelMutationTest { dispose(viewModel) } - private fun viewModel(repository: TimeEntryRepository): TrackingViewModel = TrackingViewModel( - authRepository = mockk(relaxed = true), - settingsDataStore = settings, - timeEntryRepository = repository, - syncTrigger = SyncTrigger {}, - temporalPolicyProvider = TemporalPolicyProvider(settings), - context = context, - clock = clock, - ).also { viewModels += it } + private fun viewModel(repository: TimeEntryRepository, settingsDataStore: SettingsDataStore = settings): TrackingViewModel = + TrackingViewModel( + authRepository = mockk(relaxed = true), + settingsDataStore = settingsDataStore, + timeEntryRepository = repository, + syncTrigger = SyncTrigger {}, + temporalPolicyProvider = TemporalPolicyProvider(settingsDataStore), + context = context, + clock = clock, + ).also { viewModels += it } private suspend fun dispose(viewModel: TrackingViewModel) { val scopeJob = viewModel.cancelScopeForTest() - dispatcher.scheduler.runCurrent() + dispatcher.scheduler.advanceUntilIdle() scopeJob?.join() viewModels.remove(viewModel) } + private fun retentionSettings(autoClear: Boolean, clearDescription: Boolean): SettingsDataStore = spyk(settings) { + every { getCachedAutoClearEntryFieldsAfterStop() } returns autoClear + every { getCachedClearDescriptionAfterStop() } returns clearDescription + every { autoClearEntryFieldsAfterStop } returns flowOf(autoClear) + every { clearDescriptionAfterStop } returns flowOf(clearDescription) + } + private fun activeEntry() = TimeEntry( id = "active", userId = "user",