diff --git a/app/src/main/java/dev/tricked/solidverdant/data/local/db/OutboxDao.kt b/app/src/main/java/dev/tricked/solidverdant/data/local/db/OutboxDao.kt index b19cb9a..fb1833b 100644 --- a/app/src/main/java/dev/tricked/solidverdant/data/local/db/OutboxDao.kt +++ b/app/src/main/java/dev/tricked/solidverdant/data/local/db/OutboxDao.kt @@ -104,6 +104,17 @@ interface OutboxDao { ) suspend fun countNewerContentMutations(entryId: String, afterId: Long): Int + /** + * Metadata writes attempted before a STOP but still present because they failed or will retry. + * Once STOP succeeds, their payload/base must be advanced to the authoritative stopped + * interval so retrying the metadata cannot restart the timer and a pull cannot erase it. + */ + @Query( + "SELECT * FROM outbox WHERE timeEntryId = :entryId AND id < :stopId " + + "AND opType = 'UPDATE' ORDER BY id ASC", + ) + suspend fun getUpdatesBeforeStop(entryId: String, stopId: Long): List + /** * Discard every queued operation for an entry that never reached the server (still on its * `local-` id). Used when deleting a never-synced entry (SV-008): the entry's own 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 2f69d42..a9feafe 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 @@ -349,6 +349,21 @@ class TimeEntryRepository @Inject constructor( } suspend fun stopEntry(entry: TimeEntry, userId: String) { + stopEntryInternal(entry, userId, editedEntry = null, editedTagIds = null) + } + + /** + * Stop a running entry while committing the metadata currently visible in Track. + * + * A STOP request carries only timestamps, so edits need their own UPDATE operation. The Room + * write and both ordered outbox operations stay in one transaction so refresh or process death + * cannot expose a stopped row whose metadata existed only in Compose. + */ + suspend fun stopEntryWithEdits(entry: TimeEntry, userId: String, editedEntry: TimeEntry, tagIds: List) { + stopEntryInternal(entry, userId, editedEntry, tagIds) + } + + private suspend fun stopEntryInternal(entry: TimeEntry, userId: String, editedEntry: TimeEntry?, editedTagIds: List?) { val now = clock.nowMs() val end = nowIso() database.withTransaction { @@ -394,8 +409,59 @@ class TimeEntryRepository @Inject constructor( return@withTransaction } val base = captureBaseSnapshot(targetId) + val currentTagIds = timeEntryDao.tagIdsFor(targetId) + val contentChanged = editedEntry != null && + ( + current == null || + current.description.orEmpty() != editedEntry.description.orEmpty() || + current.projectId != editedEntry.projectId || + current.taskId != editedEntry.taskId || + current.billable != editedEntry.billable || + current.type != editedEntry.type || + currentTagIds.toSet() != editedTagIds.orEmpty().toSet() + ) + val content = if (contentChanged) { + val updated = (current ?: entry.toEntity(updatedAt = now, syncState = SyncState.PENDING)).copy( + id = targetId, + description = editedEntry.description, + projectId = editedEntry.projectId, + taskId = editedEntry.taskId, + billable = editedEntry.billable, + type = editedEntry.type, + updatedAt = now, + syncState = SyncState.PENDING, + ) + timeEntryDao.upsert(updated) + timeEntryDao.replaceTagRefs(targetId, editedTagIds.orEmpty()) + outboxDao.insert( + OutboxEntity( + opType = OutboxOpType.UPDATE, + organizationId = targetOrganizationId, + timeEntryId = targetId, + createdAtMs = now, + clientId = newClientId(), + payloadJson = json.encodeToString( + UpdatePayload( + updated.userId, + targetStart, + end = null, + updated.description, + updated.projectId, + updated.taskId, + updated.billable, + editedTagIds.orEmpty(), + type = updated.type, + ), + ), + baseSnapshotJson = base, + ), + ) + updated + } else { + current + } val duration = completedDurationSeconds(targetStart, end) - val stopped = current?.copy(end = end, duration = duration, updatedAt = now, syncState = SyncState.PENDING) + val stopped = content?.copy(end = end, duration = duration, updatedAt = now, syncState = SyncState.PENDING) ?: entry.copy(end = end, duration = duration).toEntity(updatedAt = now, syncState = SyncState.PENDING) timeEntryDao.upsert(stopped) outboxDao.insert( diff --git a/app/src/main/java/dev/tricked/solidverdant/sync/SyncWorker.kt b/app/src/main/java/dev/tricked/solidverdant/sync/SyncWorker.kt index 85dffa4..35072aa 100644 --- a/app/src/main/java/dev/tricked/solidverdant/sync/SyncWorker.kt +++ b/app/src/main/java/dev/tricked/solidverdant/sync/SyncWorker.kt @@ -340,7 +340,37 @@ class SyncWorker @AssistedInject constructor( ), ) } else { - persistSynced(server) + val unresolvedMetadata = outboxDao.getUpdatesBeforeStop(op.timeEntryId, op.id) + if (current != null && unresolvedMetadata.isNotEmpty()) { + // A failed metadata UPDATE remains authoritative locally even if STOP succeeds. + // Advance its interval/base so retry cannot restart the timer and a pull cannot + // erase the user's description or catalogue selections. + val stoppedBase = json.encodeToString(server.toConflictSnapshot()) + database.withTransaction { + unresolvedMetadata.forEach { updateOperation -> + val updatePayload = json.decodeFromString(updateOperation.payloadJson) + outboxDao.update( + updateOperation.copy( + payloadJson = json.encodeToString( + updatePayload.copy(start = server.start, end = server.end), + ), + baseSnapshotJson = stoppedBase, + ), + ) + } + timeEntryDao.upsert( + current.copy( + start = server.start, + end = server.end, + duration = server.duration, + updatedAt = clock.nowMs(), + syncState = SyncState.PENDING, + ), + ) + } + } else { + persistSynced(server) + } } return Outcome.Success() } diff --git a/app/src/main/java/dev/tricked/solidverdant/ui/tracking/TrackingScreen.kt b/app/src/main/java/dev/tricked/solidverdant/ui/tracking/TrackingScreen.kt index 2a12439..6e7609f 100644 --- a/app/src/main/java/dev/tricked/solidverdant/ui/tracking/TrackingScreen.kt +++ b/app/src/main/java/dev/tricked/solidverdant/ui/tracking/TrackingScreen.kt @@ -2971,7 +2971,7 @@ private fun CompactTimeEntryRow( @Suppress("LongParameterList", "LongMethod", "CyclomaticComplexMethod") @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @Composable -private fun TimeEntryFormSheet( +internal fun TimeEntryFormSheet( entry: TimeEntry?, // null = create mode zone: ZoneId, // account temporal-policy zone for the new-entry fallback start suggestedStart: ZonedDateTime?, // create mode: pre-filled start (end of last entry / now-1h) @@ -3054,7 +3054,11 @@ private fun TimeEntryFormSheet( } ModalBottomSheet( - onDismissRequest = onDismiss, + // Child date/time/split pickers use separate dialog windows. Do not let the parent sheet + // interpret their focus change as a request to close the whole editor. + onDismissRequest = { + if (canDismissTimeEntryFormSheet(editingTime != null, editingDate != null, showSplitPicker)) onDismiss() + }, modifier = Modifier.testTag(TrackingTestTags.SHEET), sheetState = sheetState, shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp) @@ -3514,6 +3518,12 @@ private fun EntryTimePickerDialog( ) } +internal fun canDismissTimeEntryFormSheet( + hasTimePicker: Boolean, + hasDatePicker: Boolean, + hasSplitPicker: Boolean, +): Boolean = !hasTimePicker && !hasDatePicker && !hasSplitPicker + /** * About section with version info, verification details, and Obtainium button */ diff --git a/app/src/main/java/dev/tricked/solidverdant/ui/tracking/TrackingViewModel.kt b/app/src/main/java/dev/tricked/solidverdant/ui/tracking/TrackingViewModel.kt index a677466..544502c 100644 --- a/app/src/main/java/dev/tricked/solidverdant/ui/tracking/TrackingViewModel.kt +++ b/app/src/main/java/dev/tricked/solidverdant/ui/tracking/TrackingViewModel.kt @@ -1536,8 +1536,22 @@ class TrackingViewModel @Inject constructor( viewModelScope.launch { try { - // Optimistic local stop + outbox enqueue. The collector clears the active entry. - timeEntryRepository.stopEntry(currentEntry, currentEntry.userId) + // Commit the editable running-entry fields atomically with the stop. Otherwise a + // fast Stop can leave metadata only in UI state while timestamp sync wins. + val editingTags = _uiState.value.editingTags + val editedEntry = currentEntry.copy( + description = _uiState.value.editingDescription, + projectId = _uiState.value.editingProjectId, + taskId = _uiState.value.editingTaskId, + billable = _uiState.value.editingBillable, + tags = editingTags.map(::Tag), + ) + timeEntryRepository.stopEntryWithEdits( + entry = currentEntry, + userId = currentEntry.userId, + editedEntry = editedEntry, + tagIds = editingTags, + ) syncTrigger.requestSync() val currentState = _uiState.value diff --git a/app/src/test/java/dev/tricked/solidverdant/data/remote/FakeRemoteDataSource.kt b/app/src/test/java/dev/tricked/solidverdant/data/remote/FakeRemoteDataSource.kt index 27e2625..f515e8f 100644 --- a/app/src/test/java/dev/tricked/solidverdant/data/remote/FakeRemoteDataSource.kt +++ b/app/src/test/java/dev/tricked/solidverdant/data/remote/FakeRemoteDataSource.kt @@ -26,6 +26,7 @@ class FakeRemoteDataSource( var failNextWrite: Boolean = false, /** When set, every write fails with this throwable (use a non-IOException to exercise FAIL). */ var writeError: Throwable? = null, + var updateError: Throwable? = null, var startResult: (TimeEntry) -> TimeEntry = { it }, var stopResult: (TimeEntry) -> TimeEntry = { it }, var updateResult: (TimeEntry) -> TimeEntry = { it }, @@ -118,7 +119,8 @@ class FakeRemoteDataSource( ) } override suspend fun updateTimeEntry(organizationId: String, timeEntry: TimeEntry, tags: List) = - writeError?.let { Result.failure(it) } + updateError?.let { Result.failure(it) } + ?: writeError?.let { Result.failure(it) } ?: if (failNextWrite) Result.failure(java.io.IOException("offline")) else Result.success(updateResult(timeEntry)) override suspend fun deleteTimeEntry(organizationId: String, timeEntryId: String): Result { writeError?.let { return Result.failure(it) } 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 d565a11..a2af490 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 @@ -19,6 +19,7 @@ import dev.tricked.solidverdant.data.model.TimeEntryType import dev.tricked.solidverdant.data.remote.FakeRemoteDataSource import dev.tricked.solidverdant.sync.ConflictSnapshot import dev.tricked.solidverdant.sync.CreatePayload +import dev.tricked.solidverdant.sync.StopPayload import dev.tricked.solidverdant.sync.UpdatePayload import dev.tricked.solidverdant.util.Clock import kotlinx.coroutines.flow.first @@ -524,6 +525,40 @@ class TimeEntryRepositoryWriteTest { assertEquals(reconciled.id, stop.timeEntryId) } + @Test fun stop_with_unsaved_fields_persists_metadata_and_orders_update_before_stop() = runTest { + val running = repo.startEntry("org1", "member", "u", null, null, "", emptyList()) + val edited = running.copy( + description = "prep", + projectId = "project-1", + taskId = "task-1", + billable = true, + tags = listOf(Tag("tag-1")), + ) + + repo.stopEntryWithEdits(running, "u", edited, listOf("tag-1")) + + val stored = db.timeEntryDao().getById(running.id) + assertEquals("prep", stored?.description) + assertEquals("project-1", stored?.projectId) + assertEquals("task-1", stored?.taskId) + assertEquals(true, stored?.billable) + assertTrue(stored?.end != null) + assertEquals(listOf("tag-1"), db.timeEntryDao().tagIdsFor(running.id)) + + val operations = db.outboxDao().peekAll() + assertEquals( + listOf(OutboxOpType.START, OutboxOpType.UPDATE, OutboxOpType.STOP), + operations.map { it.opType }, + ) + val update = testJson.decodeFromString(operations[1].payloadJson) + assertEquals("prep", update.description) + assertEquals("project-1", update.projectId) + assertEquals("task-1", update.taskId) + val stop = testJson.decodeFromString(operations[2].payloadJson) + assertEquals(running.start, stop.start) + assertTrue(stop.end.isNotBlank()) + } + @Test fun repeated_stop_is_idempotent_and_preserves_the_first_end_time() = runTest { val entry = TimeEntry( id = "server-1", diff --git a/app/src/test/java/dev/tricked/solidverdant/sync/SyncWorkerTest.kt b/app/src/test/java/dev/tricked/solidverdant/sync/SyncWorkerTest.kt index a7ebde7..cb4c17b 100644 --- a/app/src/test/java/dev/tricked/solidverdant/sync/SyncWorkerTest.kt +++ b/app/src/test/java/dev/tricked/solidverdant/sync/SyncWorkerTest.kt @@ -724,6 +724,107 @@ class SyncWorkerTest { assertTrue(db.outboxDao().peekAll().isEmpty()) } + @Test fun rejected_update_then_successful_stop_preserves_metadata_through_refresh() = runTest { + val serverActive = TimeEntry( + id = "server-1", + userId = "u1", + organizationId = "org1", + start = "2026-08-24T08:00:00Z", + end = null, + description = null, + ) + val localStopped = serverActive.copy( + end = "2026-08-24T09:00:00Z", + duration = 3_600, + description = "prep", + projectId = "project-1", + taskId = "task-1", + ) + db.timeEntryDao().upsert(localStopped.toEntity(updatedAt = 2L, syncState = SyncState.PENDING)) + val activeBase = json.encodeToString( + ConflictSnapshot.of( + serverActive.start, + serverActive.end, + serverActive.description, + serverActive.projectId, + serverActive.taskId, + serverActive.billable, + emptyList(), + ), + ) + db.outboxDao().insert( + OutboxEntity( + opType = OutboxOpType.UPDATE, + organizationId = "org1", + timeEntryId = serverActive.id, + createdAtMs = 1L, + payloadJson = json.encodeToString( + UpdatePayload( + "u1", + serverActive.start, + null, + localStopped.description, + localStopped.projectId, + localStopped.taskId, + false, + emptyList(), + ), + ), + baseSnapshotJson = activeBase, + ), + ) + db.outboxDao().insert( + OutboxEntity( + opType = OutboxOpType.STOP, + organizationId = "org1", + timeEntryId = serverActive.id, + createdAtMs = 2L, + payloadJson = json.encodeToString( + StopPayload("u1", serverActive.start, localStopped.end!!), + ), + baseSnapshotJson = activeBase, + ), + ) + remote.entries = listOf(serverActive) + remote.memberships = listOf(Membership("m1", "member", Organization("org1", "Org", "USD"))) + remote.updateError = IllegalStateException("metadata rejected") + remote.stopResult = { request -> serverActive.copy(end = request.end, duration = 3_600) } + + assertEquals(ListenableWorker.Result.success(), buildWorker().doWork()) + + val failedUpdate = db.outboxDao().peekAll().single() + assertEquals(OutboxOpType.UPDATE, failedUpdate.opType) + assertTrue(failedUpdate.deadLettered) + val retryPayload = json.decodeFromString(failedUpdate.payloadJson) + assertEquals(localStopped.end, retryPayload.end) + val afterStop = db.timeEntryDao().getById(serverActive.id) + assertEquals(SyncState.PENDING, afterStop?.syncState) + assertEquals("prep", afterStop?.description) + assertEquals("project-1", afterStop?.projectId) + assertEquals("task-1", afterStop?.taskId) + + remote.entries = listOf(serverActive.copy(end = localStopped.end, duration = 3_600)) + val repository = TimeEntryRepository( + db.timeEntryDao(), + db.catalogDao(), + db.outboxDao(), + db.syncMetaDao(), + remote, + clock, + json, + db, + ) + assertTrue(repository.refreshAll("org1", "m1").isSuccess) + + val refreshed = db.timeEntryDao().getById(serverActive.id) + assertEquals(SyncState.PENDING, refreshed?.syncState) + assertEquals("prep", refreshed?.description) + assertEquals("project-1", refreshed?.projectId) + assertEquals("task-1", refreshed?.taskId) + assertEquals(localStopped.end, refreshed?.end) + assertTrue(db.outboxDao().peekAll().single().deadLettered) + } + @Test fun update_conflict_preserves_mine_and_does_not_write_server() = runTest { val base = TimeEntry( id = "server-1", diff --git a/app/src/test/java/dev/tricked/solidverdant/ui/tracking/TimeEntryFormSheetTest.kt b/app/src/test/java/dev/tricked/solidverdant/ui/tracking/TimeEntryFormSheetTest.kt new file mode 100644 index 0000000..8f3d8df --- /dev/null +++ b/app/src/test/java/dev/tricked/solidverdant/ui/tracking/TimeEntryFormSheetTest.kt @@ -0,0 +1,71 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +package dev.tricked.solidverdant.ui.tracking + +import androidx.compose.material3.MaterialTheme +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import dev.tricked.solidverdant.data.model.TimeEntry +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.time.ZoneId + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class TimeEntryFormSheetTest { + + @get:Rule + val composeRule = createComposeRule() + + @Test + fun completed_entry_start_time_opens_picker_without_dismissing_parent_sheet() { + var dismissCount = 0 + composeRule.setContent { + MaterialTheme { + TimeEntryFormSheet( + entry = TimeEntry( + id = "completed-overnight", + userId = "user", + organizationId = "org", + start = "2026-08-21T23:30:00-06:00", + end = "2026-08-22T01:00:00-06:00", + description = "Milling", + ), + zone = ZoneId.of("America/Edmonton"), + suggestedStart = null, + projects = emptyList(), + tasks = emptyList(), + tags = emptyList(), + onDismiss = { dismissCount++ }, + onSave = { _, _, _, _, _, _, _ -> }, + ) + } + } + + composeRule.onNodeWithTag(TrackingTestTags.SHEET_START_TIME).performScrollTo().performClick() + + composeRule.onNodeWithTag(TrackingTestTags.SHEET_TIME_PICKER_CONFIRM).assertExists() + composeRule.onNodeWithTag(TrackingTestTags.SHEET).assertExists() + assertEquals(0, dismissCount) + } + + @Test + fun every_child_picker_blocks_parent_sheet_dismissal() { + assertFalse(canDismissTimeEntryFormSheet(hasTimePicker = true, hasDatePicker = false, hasSplitPicker = false)) + assertFalse(canDismissTimeEntryFormSheet(hasTimePicker = false, hasDatePicker = true, hasSplitPicker = false)) + assertFalse(canDismissTimeEntryFormSheet(hasTimePicker = false, hasDatePicker = false, hasSplitPicker = true)) + assertTrue(canDismissTimeEntryFormSheet(hasTimePicker = false, hasDatePicker = false, hasSplitPicker = false)) + } +} 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 10123f9..a9d1c0e 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 @@ -16,8 +16,10 @@ import dev.tricked.solidverdant.data.repository.TimeEntryRepository import dev.tricked.solidverdant.domain.time.TemporalPolicyProvider import dev.tricked.solidverdant.sync.SyncTrigger import dev.tricked.solidverdant.util.Clock +import io.mockk.Runs import io.mockk.coEvery import io.mockk.coVerify +import io.mockk.just import io.mockk.mockk import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -93,7 +95,7 @@ class TrackingViewModelMutationTest { description = "work", ) val repository = mockk(relaxed = true) - coEvery { repository.stopEntry(any(), any()) } throws IOException("network disappeared") + coEvery { repository.stopEntryWithEdits(any(), any(), any(), any()) } throws IOException("network disappeared") settings.cacheTrackingState( SettingsDataStore.CachedTrackingState( organizationId = "org", @@ -121,6 +123,7 @@ class TrackingViewModelMutationTest { 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) @@ -131,7 +134,7 @@ class TrackingViewModelMutationTest { assertEquals("project-1", viewModel.uiState.value.editingProjectId) assertEquals("task-1", viewModel.uiState.value.editingTaskId) assertFalse(viewModel.uiState.value.editingBillable) - coVerify(exactly = 1) { repository.stopEntry(active, "user") } + coVerify(exactly = 1) { repository.stopEntryWithEdits(active, "user", any(), any()) } dispose(viewModel) } @@ -141,6 +144,7 @@ class TrackingViewModelMutationTest { 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) @@ -150,7 +154,7 @@ class TrackingViewModelMutationTest { assertEquals("", viewModel.uiState.value.editingDescription) assertEquals("project-1", viewModel.uiState.value.editingProjectId) assertEquals("task-1", viewModel.uiState.value.editingTaskId) - coVerify(exactly = 1) { repository.stopEntry(active, "user") } + coVerify(exactly = 1) { repository.stopEntryWithEdits(active, "user", any(), any()) } dispose(viewModel) } @@ -159,6 +163,7 @@ class TrackingViewModelMutationTest { 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) @@ -175,6 +180,7 @@ class TrackingViewModelMutationTest { 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) viewModel.stopTimeEntry() @@ -252,7 +258,7 @@ class TrackingViewModelMutationTest { description = "work", ) val repository = mockk(relaxed = true) - coEvery { repository.stopEntry(any(), any()) } coAnswers { + coEvery { repository.stopEntryWithEdits(any(), any(), any(), any()) } coAnswers { stopped.complete(Unit) release.await() } @@ -273,7 +279,7 @@ class TrackingViewModelMutationTest { assertTrue(stopped.isCompleted) viewModel.stopTimeEntry() - coVerify(exactly = 1) { repository.stopEntry(any(), any()) } + coVerify(exactly = 1) { repository.stopEntryWithEdits(any(), any(), any(), any()) } release.complete(Unit) dispatcher.scheduler.runCurrent() dispose(viewModel)