feat(kanban): synchronize task mutations across columns and guard concurrent moves
This commit is contained in:
1 parent
c57e056d71
commit
6fd697f2b0
2 files changed
+339
-14
No files matched your search
@@ -26,6 +26,8 @@ import kotlinx.coroutines.launch
|
|||||||
|
|
||||||
data class KanbanUiState(
|
data class KanbanUiState(
|
||||||
val isLoading: Boolean = false,
|
val isLoading: Boolean = false,
|
||||||
|
val isCreatingTask: Boolean = false,
|
||||||
|
val operatingTaskIds: Set<String> = emptySet(),
|
||||||
val boards: List<KanbanBoard> = emptyList(),
|
val boards: List<KanbanBoard> = emptyList(),
|
||||||
val selectedBoard: KanbanBoard? = null,
|
val selectedBoard: KanbanBoard? = null,
|
||||||
val columns: List<KanbanColumn> = emptyList(),
|
val columns: List<KanbanColumn> = emptyList(),
|
||||||
@@ -180,9 +182,11 @@ class KanbanViewModel(
|
|||||||
fun createTask(
|
fun createTask(
|
||||||
title: String,
|
title: String,
|
||||||
description: String?,
|
description: String?,
|
||||||
status: String,
|
status: String = "todo",
|
||||||
) {
|
) {
|
||||||
val board = _uiState.value.selectedBoard ?: return
|
val board = _uiState.value.selectedBoard ?: return
|
||||||
|
if (_uiState.value.isCreatingTask) return
|
||||||
|
_uiState.update { it.copy(isCreatingTask = true) }
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val result =
|
val result =
|
||||||
repository.createTask(
|
repository.createTask(
|
||||||
@@ -195,12 +199,24 @@ class KanbanViewModel(
|
|||||||
)
|
)
|
||||||
when (result) {
|
when (result) {
|
||||||
is NetworkResult.Success -> {
|
is NetworkResult.Success -> {
|
||||||
_uiState.update { it.copy(toastMessage = "Task created successfully") }
|
val warning = result.data.warning
|
||||||
|
val msg =
|
||||||
|
if (!warning.isNullOrBlank()) {
|
||||||
|
"Task created: $warning"
|
||||||
|
} else {
|
||||||
|
"Task created successfully"
|
||||||
|
}
|
||||||
|
_uiState.update { it.copy(isCreatingTask = false, toastMessage = msg) }
|
||||||
reloadBoardSilently()
|
reloadBoardSilently()
|
||||||
}
|
}
|
||||||
|
|
||||||
is NetworkResult.Failure -> {
|
is NetworkResult.Failure -> {
|
||||||
_uiState.update { it.copy(toastMessage = "Failed to create task: ${result.error.message}") }
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
isCreatingTask = false,
|
||||||
|
toastMessage = "Failed to create task: ${result.error.message}",
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -212,14 +228,31 @@ class KanbanViewModel(
|
|||||||
summary: String? = null,
|
summary: String? = null,
|
||||||
) {
|
) {
|
||||||
val board = _uiState.value.selectedBoard ?: return
|
val board = _uiState.value.selectedBoard ?: return
|
||||||
|
if (task.id in _uiState.value.operatingTaskIds) return
|
||||||
val originalStatus = task.status
|
val originalStatus = task.status
|
||||||
// Optimistically update, desktop-style
|
|
||||||
|
// Optimistically update both tasks and columns, desktop-style
|
||||||
_uiState.update { state ->
|
_uiState.update { state ->
|
||||||
|
val updatedTasks =
|
||||||
|
state.tasks.map {
|
||||||
|
if (it.id == task.id) it.copy(status = action.targetStatus) else it
|
||||||
|
}
|
||||||
|
val updatedColumns =
|
||||||
|
state.columns.map { col ->
|
||||||
|
val filtered = col.tasks.filter { it.id != task.id }
|
||||||
|
val newTasks =
|
||||||
|
if (col.name.equals(action.targetStatus, ignoreCase = true)) {
|
||||||
|
val moved = updatedTasks.find { it.id == task.id }
|
||||||
|
if (moved != null) filtered + moved else filtered
|
||||||
|
} else {
|
||||||
|
filtered
|
||||||
|
}
|
||||||
|
col.copy(tasks = newTasks)
|
||||||
|
}
|
||||||
state.copy(
|
state.copy(
|
||||||
tasks =
|
tasks = updatedTasks,
|
||||||
state.tasks.map {
|
columns = updatedColumns,
|
||||||
if (it.id == task.id) it.copy(status = action.targetStatus) else it
|
operatingTaskIds = state.operatingTaskIds + task.id,
|
||||||
},
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,8 +264,38 @@ class KanbanViewModel(
|
|||||||
result = if (action.needsSummary && !summary.isNullOrBlank()) summary else null,
|
result = if (action.needsSummary && !summary.isNullOrBlank()) summary else null,
|
||||||
)
|
)
|
||||||
val result = repository.updateTask(task.id, board = board.id, body = body)
|
val result = repository.updateTask(task.id, board = board.id, body = body)
|
||||||
if (result is NetworkResult.Failure) {
|
when (result) {
|
||||||
revertTaskMove(task.id, originalStatus, "Move failed: ${result.error.message}")
|
is NetworkResult.Success -> {
|
||||||
|
val returnedTask = result.data.task
|
||||||
|
_uiState.update { state ->
|
||||||
|
val finalStatus = returnedTask?.status ?: action.targetStatus
|
||||||
|
val updatedTasks =
|
||||||
|
state.tasks.map {
|
||||||
|
if (it.id == task.id) (returnedTask ?: it.copy(status = finalStatus)) else it
|
||||||
|
}
|
||||||
|
val updatedColumns =
|
||||||
|
state.columns.map { col ->
|
||||||
|
val filtered = col.tasks.filter { it.id != task.id }
|
||||||
|
val newTasks =
|
||||||
|
if (col.name.equals(finalStatus, ignoreCase = true)) {
|
||||||
|
val moved = updatedTasks.find { it.id == task.id }
|
||||||
|
if (moved != null) filtered + moved else filtered
|
||||||
|
} else {
|
||||||
|
filtered
|
||||||
|
}
|
||||||
|
col.copy(tasks = newTasks)
|
||||||
|
}
|
||||||
|
state.copy(
|
||||||
|
tasks = updatedTasks,
|
||||||
|
columns = updatedColumns,
|
||||||
|
operatingTaskIds = state.operatingTaskIds - task.id,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is NetworkResult.Failure -> {
|
||||||
|
revertTaskMove(task.id, originalStatus, "Move failed: ${result.error.message}")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -243,11 +306,26 @@ class KanbanViewModel(
|
|||||||
errorMsg: String,
|
errorMsg: String,
|
||||||
) {
|
) {
|
||||||
_uiState.update { state ->
|
_uiState.update { state ->
|
||||||
|
val updatedTasks =
|
||||||
|
state.tasks.map {
|
||||||
|
if (it.id == taskId) it.copy(status = originalStatus) else it
|
||||||
|
}
|
||||||
|
val updatedColumns =
|
||||||
|
state.columns.map { col ->
|
||||||
|
val filtered = col.tasks.filter { it.id != taskId }
|
||||||
|
val newTasks =
|
||||||
|
if (col.name.equals(originalStatus, ignoreCase = true)) {
|
||||||
|
val moved = updatedTasks.find { it.id == taskId }
|
||||||
|
if (moved != null) filtered + moved else filtered
|
||||||
|
} else {
|
||||||
|
filtered
|
||||||
|
}
|
||||||
|
col.copy(tasks = newTasks)
|
||||||
|
}
|
||||||
state.copy(
|
state.copy(
|
||||||
tasks =
|
tasks = updatedTasks,
|
||||||
state.tasks.map {
|
columns = updatedColumns,
|
||||||
if (it.id == taskId) it.copy(status = originalStatus) else it
|
operatingTaskIds = state.operatingTaskIds - taskId,
|
||||||
},
|
|
||||||
toastMessage = errorMsg,
|
toastMessage = errorMsg,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,247 @@
|
|||||||
|
package com.m57.hermescontrol.ui.kanban
|
||||||
|
|
||||||
|
import com.m57.hermescontrol.data.local.InMemoryKanbanPreferencesStore
|
||||||
|
import com.m57.hermescontrol.data.model.CreateTaskBody
|
||||||
|
import com.m57.hermescontrol.data.model.CreateTaskResponse
|
||||||
|
import com.m57.hermescontrol.data.model.KanbanBoard
|
||||||
|
import com.m57.hermescontrol.data.model.KanbanBoardResponse
|
||||||
|
import com.m57.hermescontrol.data.model.KanbanColumn
|
||||||
|
import com.m57.hermescontrol.data.model.KanbanTask
|
||||||
|
import com.m57.hermescontrol.data.model.UpdateTaskBody
|
||||||
|
import com.m57.hermescontrol.data.model.UpdateTaskResponse
|
||||||
|
import com.m57.hermescontrol.data.remote.NetworkError
|
||||||
|
import com.m57.hermescontrol.data.remote.NetworkResult
|
||||||
|
import com.m57.hermescontrol.data.repository.KanbanRepository
|
||||||
|
import com.m57.hermescontrol.data.ws.KanbanEventsClient
|
||||||
|
import io.mockk.coEvery
|
||||||
|
import io.mockk.coVerify
|
||||||
|
import io.mockk.mockk
|
||||||
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||||
|
import kotlinx.coroutines.test.resetMain
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import kotlinx.coroutines.test.setMain
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
class KanbanMutationTest {
|
||||||
|
private val testDispatcher = StandardTestDispatcher()
|
||||||
|
private val mockRepository = mockk<KanbanRepository>(relaxed = true)
|
||||||
|
private val mockEventsClient = mockk<KanbanEventsClient>(relaxed = true)
|
||||||
|
private val preferences = InMemoryKanbanPreferencesStore()
|
||||||
|
|
||||||
|
private fun createViewModel(): KanbanViewModel =
|
||||||
|
KanbanViewModel(
|
||||||
|
repository = mockRepository,
|
||||||
|
preferences = preferences,
|
||||||
|
eventsClientProvider = { mockEventsClient },
|
||||||
|
endpointProvider = { "http://127.0.0.1:9119" },
|
||||||
|
)
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setUp() {
|
||||||
|
Dispatchers.setMain(testDispatcher)
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun tearDown() {
|
||||||
|
Dispatchers.resetMain()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testCreateTaskSuccessWithWarning() =
|
||||||
|
runTest(testDispatcher) {
|
||||||
|
val board = KanbanBoard(id = "dev", name = "Dev")
|
||||||
|
val newTask = KanbanTask(id = "t_100", title = "New task", status = "todo")
|
||||||
|
|
||||||
|
coEvery { mockRepository.getBoard("dev") } returns
|
||||||
|
NetworkResult.Success(KanbanBoardResponse(columns = listOf(KanbanColumn("todo", emptyList()))))
|
||||||
|
coEvery { mockRepository.createTask("dev", any()) } returns
|
||||||
|
NetworkResult.Success(CreateTaskResponse(task = newTask, warning = "Dispatcher not running"))
|
||||||
|
|
||||||
|
val vm = createViewModel()
|
||||||
|
vm.selectBoard(board)
|
||||||
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
|
vm.createTask("New task", "Description", "todo")
|
||||||
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
|
coVerify {
|
||||||
|
mockRepository.createTask(
|
||||||
|
"dev",
|
||||||
|
match { it.title == "New task" && it.body == "Description" },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
assertTrue(
|
||||||
|
vm.uiState.value.toastMessage
|
||||||
|
?.contains("Dispatcher not running") == true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testCreateTaskFailurePreservesStateAndShowsToast() =
|
||||||
|
runTest(testDispatcher) {
|
||||||
|
val board = KanbanBoard(id = "dev", name = "Dev")
|
||||||
|
coEvery { mockRepository.getBoard("dev") } returns
|
||||||
|
NetworkResult.Success(KanbanBoardResponse(columns = listOf(KanbanColumn("todo", emptyList()))))
|
||||||
|
coEvery { mockRepository.createTask("dev", any()) } returns
|
||||||
|
NetworkResult.Failure(NetworkError.Http(400, "Validation failed"))
|
||||||
|
|
||||||
|
val vm = createViewModel()
|
||||||
|
vm.selectBoard(board)
|
||||||
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
|
vm.createTask("Bad task", null, "todo")
|
||||||
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
|
assertTrue(
|
||||||
|
vm.uiState.value.toastMessage
|
||||||
|
?.contains("Validation failed") == true,
|
||||||
|
)
|
||||||
|
assertFalse(vm.uiState.value.isCreatingTask)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testMoveTaskUpdatesBothTasksAndColumnsOptimistically() =
|
||||||
|
runTest(testDispatcher) {
|
||||||
|
val board = KanbanBoard(id = "dev", name = "Dev")
|
||||||
|
val task = KanbanTask(id = "t_1", title = "Task 1", status = "todo")
|
||||||
|
val colTodo = KanbanColumn("todo", listOf(task))
|
||||||
|
val colReady = KanbanColumn("ready", emptyList())
|
||||||
|
|
||||||
|
val deferred = CompletableDeferred<NetworkResult<UpdateTaskResponse>>()
|
||||||
|
|
||||||
|
coEvery { mockRepository.getBoard("dev") } returns
|
||||||
|
NetworkResult.Success(KanbanBoardResponse(columns = listOf(colTodo, colReady)))
|
||||||
|
coEvery { mockRepository.updateTask("t_1", "dev", any()) } coAnswers { deferred.await() }
|
||||||
|
|
||||||
|
val vm = createViewModel()
|
||||||
|
vm.selectBoard(board)
|
||||||
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
|
// Initiate move
|
||||||
|
vm.moveTask(task, KanbanTaskAction.READY)
|
||||||
|
|
||||||
|
// Optimistic check: both tasks AND columns must reflect "ready"!
|
||||||
|
assertEquals(
|
||||||
|
"ready",
|
||||||
|
vm.uiState.value.tasks
|
||||||
|
.first { it.id == "t_1" }
|
||||||
|
.status,
|
||||||
|
)
|
||||||
|
assertTrue(
|
||||||
|
vm.uiState.value.columns
|
||||||
|
.first { it.name == "todo" }
|
||||||
|
.tasks
|
||||||
|
.none { it.id == "t_1" },
|
||||||
|
)
|
||||||
|
assertTrue(
|
||||||
|
vm.uiState.value.columns
|
||||||
|
.first { it.name == "ready" }
|
||||||
|
.tasks
|
||||||
|
.any { it.id == "t_1" },
|
||||||
|
)
|
||||||
|
assertTrue(
|
||||||
|
vm.uiState.value.operatingTaskIds
|
||||||
|
.contains("t_1"),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Complete network call
|
||||||
|
deferred.complete(NetworkResult.Success(UpdateTaskResponse(task = task.copy(status = "ready"))))
|
||||||
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
|
assertFalse(
|
||||||
|
vm.uiState.value.operatingTaskIds
|
||||||
|
.contains("t_1"),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
"ready",
|
||||||
|
vm.uiState.value.tasks
|
||||||
|
.first { it.id == "t_1" }
|
||||||
|
.status,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testMoveTaskFailureRevertsTasksAndColumns() =
|
||||||
|
runTest(testDispatcher) {
|
||||||
|
val board = KanbanBoard(id = "dev", name = "Dev")
|
||||||
|
val task = KanbanTask(id = "t_1", title = "Task 1", status = "todo")
|
||||||
|
val colTodo = KanbanColumn("todo", listOf(task))
|
||||||
|
val colReady = KanbanColumn("ready", emptyList())
|
||||||
|
|
||||||
|
coEvery { mockRepository.getBoard("dev") } returns
|
||||||
|
NetworkResult.Success(KanbanBoardResponse(columns = listOf(colTodo, colReady)))
|
||||||
|
coEvery { mockRepository.updateTask("t_1", "dev", any()) } returns
|
||||||
|
NetworkResult.Failure(NetworkError.Http(409, "Parent blocked"))
|
||||||
|
|
||||||
|
val vm = createViewModel()
|
||||||
|
vm.selectBoard(board)
|
||||||
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
|
vm.moveTask(task, KanbanTaskAction.READY)
|
||||||
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
|
// Reverted back to "todo" in both tasks and columns!
|
||||||
|
assertEquals(
|
||||||
|
"todo",
|
||||||
|
vm.uiState.value.tasks
|
||||||
|
.first { it.id == "t_1" }
|
||||||
|
.status,
|
||||||
|
)
|
||||||
|
assertTrue(
|
||||||
|
vm.uiState.value.columns
|
||||||
|
.first { it.name == "todo" }
|
||||||
|
.tasks
|
||||||
|
.any { it.id == "t_1" },
|
||||||
|
)
|
||||||
|
assertTrue(
|
||||||
|
vm.uiState.value.columns
|
||||||
|
.first { it.name == "ready" }
|
||||||
|
.tasks
|
||||||
|
.none { it.id == "t_1" },
|
||||||
|
)
|
||||||
|
assertTrue(
|
||||||
|
vm.uiState.value.toastMessage
|
||||||
|
?.contains("Parent blocked") == true,
|
||||||
|
)
|
||||||
|
assertFalse(
|
||||||
|
vm.uiState.value.operatingTaskIds
|
||||||
|
.contains("t_1"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testDuplicateConcurrentMoveIgnored() =
|
||||||
|
runTest(testDispatcher) {
|
||||||
|
val board = KanbanBoard(id = "dev", name = "Dev")
|
||||||
|
val task = KanbanTask(id = "t_1", title = "Task 1", status = "todo")
|
||||||
|
val colTodo = KanbanColumn("todo", listOf(task))
|
||||||
|
|
||||||
|
val deferred = CompletableDeferred<NetworkResult<UpdateTaskResponse>>()
|
||||||
|
coEvery { mockRepository.getBoard("dev") } returns
|
||||||
|
NetworkResult.Success(KanbanBoardResponse(columns = listOf(colTodo)))
|
||||||
|
coEvery { mockRepository.updateTask("t_1", "dev", any()) } coAnswers { deferred.await() }
|
||||||
|
|
||||||
|
val vm = createViewModel()
|
||||||
|
vm.selectBoard(board)
|
||||||
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
|
// First tap
|
||||||
|
vm.moveTask(task, KanbanTaskAction.READY)
|
||||||
|
// Second duplicate tap while in-flight
|
||||||
|
vm.moveTask(task, KanbanTaskAction.ARCHIVE)
|
||||||
|
|
||||||
|
testDispatcher.scheduler.runCurrent()
|
||||||
|
|
||||||
|
// Only 1 updateTask call initiated
|
||||||
|
coVerify(exactly = 1) { mockRepository.updateTask("t_1", "dev", any()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user