feat(kanban): enforce board scoping and local selection in KanbanViewModel
This commit is contained in:
1 parent
a486191d9d
commit
c57e056d71
6 files changed
+900
-112
No files matched your search
@@ -0,0 +1,65 @@
|
|||||||
|
package com.m57.hermescontrol.data.local
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Local preferences for Kanban board viewing:
|
||||||
|
* selected board slug, view mode (list vs board), archive visibility, and group running.
|
||||||
|
*/
|
||||||
|
interface KanbanPreferencesStore {
|
||||||
|
fun getSelectedBoard(endpoint: String): String?
|
||||||
|
|
||||||
|
fun setSelectedBoard(
|
||||||
|
endpoint: String,
|
||||||
|
slug: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun getViewMode(): String
|
||||||
|
|
||||||
|
fun setViewMode(mode: String)
|
||||||
|
|
||||||
|
fun getIncludeArchived(): Boolean
|
||||||
|
|
||||||
|
fun setIncludeArchived(include: Boolean)
|
||||||
|
|
||||||
|
fun getGroupRunning(): Boolean
|
||||||
|
|
||||||
|
fun setGroupRunning(group: Boolean)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val MODE_LIST = "list"
|
||||||
|
const val MODE_BOARD = "board"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class InMemoryKanbanPreferencesStore : KanbanPreferencesStore {
|
||||||
|
private val selectedBoards = mutableMapOf<String, String>()
|
||||||
|
private var viewMode: String = KanbanPreferencesStore.MODE_LIST
|
||||||
|
private var includeArchived: Boolean = false
|
||||||
|
private var groupRunning: Boolean = false
|
||||||
|
|
||||||
|
override fun getSelectedBoard(endpoint: String): String? = selectedBoards[endpoint]
|
||||||
|
|
||||||
|
override fun setSelectedBoard(
|
||||||
|
endpoint: String,
|
||||||
|
slug: String,
|
||||||
|
) {
|
||||||
|
selectedBoards[endpoint] = slug
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getViewMode(): String = viewMode
|
||||||
|
|
||||||
|
override fun setViewMode(mode: String) {
|
||||||
|
viewMode = mode
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getIncludeArchived(): Boolean = includeArchived
|
||||||
|
|
||||||
|
override fun setIncludeArchived(include: Boolean) {
|
||||||
|
includeArchived = include
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getGroupRunning(): Boolean = groupRunning
|
||||||
|
|
||||||
|
override fun setGroupRunning(group: Boolean) {
|
||||||
|
groupRunning = group
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,366 @@
|
|||||||
|
package com.m57.hermescontrol.data.repository
|
||||||
|
|
||||||
|
import com.m57.hermescontrol.data.model.AttachmentUploadResponse
|
||||||
|
import com.m57.hermescontrol.data.model.AutoDescribeResponse
|
||||||
|
import com.m57.hermescontrol.data.model.BulkTasksBody
|
||||||
|
import com.m57.hermescontrol.data.model.BulkTasksResponse
|
||||||
|
import com.m57.hermescontrol.data.model.CreateBoardBody
|
||||||
|
import com.m57.hermescontrol.data.model.CreateBoardResponse
|
||||||
|
import com.m57.hermescontrol.data.model.CreateTaskBody
|
||||||
|
import com.m57.hermescontrol.data.model.CreateTaskResponse
|
||||||
|
import com.m57.hermescontrol.data.model.DeleteBoardResponse
|
||||||
|
import com.m57.hermescontrol.data.model.DispatchResult
|
||||||
|
import com.m57.hermescontrol.data.model.KanbanBoardResponse
|
||||||
|
import com.m57.hermescontrol.data.model.KanbanBoardsResponse
|
||||||
|
import com.m57.hermescontrol.data.model.KanbanProfilesResponse
|
||||||
|
import com.m57.hermescontrol.data.model.KanbanProjectsResponse
|
||||||
|
import com.m57.hermescontrol.data.model.KanbanTaskDetailResponse
|
||||||
|
import com.m57.hermescontrol.data.model.OrchestrationSettings
|
||||||
|
import com.m57.hermescontrol.data.model.OrchestrationSettingsUpdate
|
||||||
|
import com.m57.hermescontrol.data.model.ReassignTaskBody
|
||||||
|
import com.m57.hermescontrol.data.model.ReassignTaskResponse
|
||||||
|
import com.m57.hermescontrol.data.model.ReclaimTaskBody
|
||||||
|
import com.m57.hermescontrol.data.model.ReclaimTaskResponse
|
||||||
|
import com.m57.hermescontrol.data.model.RenameBoardBody
|
||||||
|
import com.m57.hermescontrol.data.model.RenameBoardResponse
|
||||||
|
import com.m57.hermescontrol.data.model.TaskEstimate
|
||||||
|
import com.m57.hermescontrol.data.model.UpdateTaskBody
|
||||||
|
import com.m57.hermescontrol.data.model.UpdateTaskResponse
|
||||||
|
import com.m57.hermescontrol.data.model.WorkerLog
|
||||||
|
import com.m57.hermescontrol.data.remote.ApiClient
|
||||||
|
import com.m57.hermescontrol.data.remote.KanbanApiService
|
||||||
|
import com.m57.hermescontrol.data.remote.NetworkResult
|
||||||
|
import com.m57.hermescontrol.data.remote.safeApiCall
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import okhttp3.MultipartBody
|
||||||
|
import okhttp3.ResponseBody
|
||||||
|
|
||||||
|
interface KanbanRepository {
|
||||||
|
suspend fun getBoards(includeArchived: Boolean = false): NetworkResult<KanbanBoardsResponse>
|
||||||
|
|
||||||
|
suspend fun getBoard(
|
||||||
|
board: String,
|
||||||
|
includeArchived: Boolean = false,
|
||||||
|
tenant: String? = null,
|
||||||
|
): NetworkResult<KanbanBoardResponse>
|
||||||
|
|
||||||
|
suspend fun createBoard(body: CreateBoardBody): NetworkResult<CreateBoardResponse>
|
||||||
|
|
||||||
|
suspend fun updateBoard(
|
||||||
|
slug: String,
|
||||||
|
body: RenameBoardBody,
|
||||||
|
): NetworkResult<RenameBoardResponse>
|
||||||
|
|
||||||
|
suspend fun deleteBoard(
|
||||||
|
slug: String,
|
||||||
|
delete: Boolean = false,
|
||||||
|
): NetworkResult<DeleteBoardResponse>
|
||||||
|
|
||||||
|
suspend fun getTask(
|
||||||
|
taskId: String,
|
||||||
|
board: String?,
|
||||||
|
): NetworkResult<KanbanTaskDetailResponse>
|
||||||
|
|
||||||
|
suspend fun createTask(
|
||||||
|
board: String?,
|
||||||
|
body: CreateTaskBody,
|
||||||
|
): NetworkResult<CreateTaskResponse>
|
||||||
|
|
||||||
|
suspend fun updateTask(
|
||||||
|
taskId: String,
|
||||||
|
board: String?,
|
||||||
|
body: UpdateTaskBody,
|
||||||
|
): NetworkResult<UpdateTaskResponse>
|
||||||
|
|
||||||
|
suspend fun deleteTask(
|
||||||
|
taskId: String,
|
||||||
|
board: String?,
|
||||||
|
): NetworkResult<Unit>
|
||||||
|
|
||||||
|
suspend fun bulkTasks(
|
||||||
|
board: String?,
|
||||||
|
body: BulkTasksBody,
|
||||||
|
): NetworkResult<BulkTasksResponse>
|
||||||
|
|
||||||
|
suspend fun addComment(
|
||||||
|
taskId: String,
|
||||||
|
board: String?,
|
||||||
|
body: String,
|
||||||
|
): NetworkResult<Unit>
|
||||||
|
|
||||||
|
suspend fun reassignTask(
|
||||||
|
taskId: String,
|
||||||
|
board: String?,
|
||||||
|
profile: String?,
|
||||||
|
reclaimFirst: Boolean = true,
|
||||||
|
): NetworkResult<ReassignTaskResponse>
|
||||||
|
|
||||||
|
suspend fun reclaimTask(
|
||||||
|
taskId: String,
|
||||||
|
board: String?,
|
||||||
|
reason: String? = null,
|
||||||
|
): NetworkResult<ReclaimTaskResponse>
|
||||||
|
|
||||||
|
suspend fun getTaskLog(
|
||||||
|
taskId: String,
|
||||||
|
board: String?,
|
||||||
|
tail: Int = 16384,
|
||||||
|
): NetworkResult<WorkerLog>
|
||||||
|
|
||||||
|
suspend fun estimateTask(
|
||||||
|
taskId: String,
|
||||||
|
board: String?,
|
||||||
|
): NetworkResult<TaskEstimate>
|
||||||
|
|
||||||
|
suspend fun estimateNew(
|
||||||
|
title: String,
|
||||||
|
body: String?,
|
||||||
|
): NetworkResult<TaskEstimate>
|
||||||
|
|
||||||
|
suspend fun uploadAttachment(
|
||||||
|
taskId: String,
|
||||||
|
board: String?,
|
||||||
|
file: MultipartBody.Part,
|
||||||
|
): NetworkResult<AttachmentUploadResponse>
|
||||||
|
|
||||||
|
suspend fun downloadAttachment(
|
||||||
|
attachmentId: Long,
|
||||||
|
board: String?,
|
||||||
|
): NetworkResult<ResponseBody>
|
||||||
|
|
||||||
|
suspend fun getProfiles(): NetworkResult<KanbanProfilesResponse>
|
||||||
|
|
||||||
|
suspend fun updateProfileDescription(
|
||||||
|
name: String,
|
||||||
|
description: String,
|
||||||
|
): NetworkResult<Unit>
|
||||||
|
|
||||||
|
suspend fun autoDescribeProfile(name: String): NetworkResult<AutoDescribeResponse>
|
||||||
|
|
||||||
|
suspend fun getOrchestration(): NetworkResult<OrchestrationSettings>
|
||||||
|
|
||||||
|
suspend fun updateOrchestration(body: OrchestrationSettingsUpdate): NetworkResult<OrchestrationSettings>
|
||||||
|
|
||||||
|
suspend fun getProjects(): NetworkResult<KanbanProjectsResponse>
|
||||||
|
|
||||||
|
suspend fun nudgeDispatcher(board: String?): NetworkResult<DispatchResult>
|
||||||
|
}
|
||||||
|
|
||||||
|
class KanbanRepositoryImpl(
|
||||||
|
private val apiProvider: () -> KanbanApiService = { ApiClient.kanbanApi },
|
||||||
|
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||||
|
) : KanbanRepository {
|
||||||
|
override suspend fun getBoards(includeArchived: Boolean): NetworkResult<KanbanBoardsResponse> =
|
||||||
|
withContext(ioDispatcher) {
|
||||||
|
safeApiCall { apiProvider().getBoards(includeArchived) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getBoard(
|
||||||
|
board: String,
|
||||||
|
includeArchived: Boolean,
|
||||||
|
tenant: String?,
|
||||||
|
): NetworkResult<KanbanBoardResponse> =
|
||||||
|
withContext(ioDispatcher) {
|
||||||
|
safeApiCall { apiProvider().getBoard(board = board, includeArchived = includeArchived, tenant = tenant) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun createBoard(body: CreateBoardBody): NetworkResult<CreateBoardResponse> =
|
||||||
|
withContext(ioDispatcher) {
|
||||||
|
safeApiCall { apiProvider().createBoard(body) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun updateBoard(
|
||||||
|
slug: String,
|
||||||
|
body: RenameBoardBody,
|
||||||
|
): NetworkResult<RenameBoardResponse> =
|
||||||
|
withContext(ioDispatcher) {
|
||||||
|
safeApiCall { apiProvider().updateBoard(slug, body) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun deleteBoard(
|
||||||
|
slug: String,
|
||||||
|
delete: Boolean,
|
||||||
|
): NetworkResult<DeleteBoardResponse> =
|
||||||
|
withContext(ioDispatcher) {
|
||||||
|
safeApiCall { apiProvider().deleteBoard(slug, delete) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getTask(
|
||||||
|
taskId: String,
|
||||||
|
board: String?,
|
||||||
|
): NetworkResult<KanbanTaskDetailResponse> =
|
||||||
|
withContext(ioDispatcher) {
|
||||||
|
safeApiCall { apiProvider().getTask(taskId = taskId, board = board) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun createTask(
|
||||||
|
board: String?,
|
||||||
|
body: CreateTaskBody,
|
||||||
|
): NetworkResult<CreateTaskResponse> =
|
||||||
|
withContext(ioDispatcher) {
|
||||||
|
safeApiCall { apiProvider().createTask(board = board, body = body) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun updateTask(
|
||||||
|
taskId: String,
|
||||||
|
board: String?,
|
||||||
|
body: UpdateTaskBody,
|
||||||
|
): NetworkResult<UpdateTaskResponse> =
|
||||||
|
withContext(ioDispatcher) {
|
||||||
|
safeApiCall { apiProvider().updateTask(taskId = taskId, board = board, body = body) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun deleteTask(
|
||||||
|
taskId: String,
|
||||||
|
board: String?,
|
||||||
|
): NetworkResult<Unit> =
|
||||||
|
withContext(ioDispatcher) {
|
||||||
|
safeApiCall { apiProvider().deleteTask(taskId = taskId, board = board) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun bulkTasks(
|
||||||
|
board: String?,
|
||||||
|
body: BulkTasksBody,
|
||||||
|
): NetworkResult<BulkTasksResponse> =
|
||||||
|
withContext(ioDispatcher) {
|
||||||
|
safeApiCall { apiProvider().bulkTasks(board = board, body = body) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun addComment(
|
||||||
|
taskId: String,
|
||||||
|
board: String?,
|
||||||
|
body: String,
|
||||||
|
): NetworkResult<Unit> =
|
||||||
|
withContext(ioDispatcher) {
|
||||||
|
safeApiCall {
|
||||||
|
apiProvider().addComment(
|
||||||
|
taskId = taskId,
|
||||||
|
board = board,
|
||||||
|
body = mapOf("body" to body, "author" to "mobile"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun reassignTask(
|
||||||
|
taskId: String,
|
||||||
|
board: String?,
|
||||||
|
profile: String?,
|
||||||
|
reclaimFirst: Boolean,
|
||||||
|
): NetworkResult<ReassignTaskResponse> =
|
||||||
|
withContext(ioDispatcher) {
|
||||||
|
safeApiCall {
|
||||||
|
apiProvider().reassignTask(
|
||||||
|
taskId = taskId,
|
||||||
|
board = board,
|
||||||
|
body = ReassignTaskBody(profile = profile, reclaimFirst = reclaimFirst),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun reclaimTask(
|
||||||
|
taskId: String,
|
||||||
|
board: String?,
|
||||||
|
reason: String?,
|
||||||
|
): NetworkResult<ReclaimTaskResponse> =
|
||||||
|
withContext(ioDispatcher) {
|
||||||
|
safeApiCall {
|
||||||
|
apiProvider().reclaimTask(
|
||||||
|
taskId = taskId,
|
||||||
|
board = board,
|
||||||
|
body = ReclaimTaskBody(reason = reason),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getTaskLog(
|
||||||
|
taskId: String,
|
||||||
|
board: String?,
|
||||||
|
tail: Int,
|
||||||
|
): NetworkResult<WorkerLog> =
|
||||||
|
withContext(ioDispatcher) {
|
||||||
|
safeApiCall { apiProvider().getTaskLog(taskId = taskId, tail = tail, board = board) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun estimateTask(
|
||||||
|
taskId: String,
|
||||||
|
board: String?,
|
||||||
|
): NetworkResult<TaskEstimate> =
|
||||||
|
withContext(ioDispatcher) {
|
||||||
|
safeApiCall { apiProvider().estimateTask(taskId = taskId, board = board) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun estimateNew(
|
||||||
|
title: String,
|
||||||
|
body: String?,
|
||||||
|
): NetworkResult<TaskEstimate> =
|
||||||
|
withContext(ioDispatcher) {
|
||||||
|
val payload =
|
||||||
|
buildMap {
|
||||||
|
put("title", title)
|
||||||
|
if (!body.isNullOrBlank()) put("body", body)
|
||||||
|
}
|
||||||
|
safeApiCall { apiProvider().estimateNew(payload) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun uploadAttachment(
|
||||||
|
taskId: String,
|
||||||
|
board: String?,
|
||||||
|
file: MultipartBody.Part,
|
||||||
|
): NetworkResult<AttachmentUploadResponse> =
|
||||||
|
withContext(ioDispatcher) {
|
||||||
|
safeApiCall { apiProvider().uploadAttachment(taskId = taskId, board = board, file = file) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun downloadAttachment(
|
||||||
|
attachmentId: Long,
|
||||||
|
board: String?,
|
||||||
|
): NetworkResult<ResponseBody> =
|
||||||
|
withContext(ioDispatcher) {
|
||||||
|
safeApiCall { apiProvider().downloadAttachment(attachmentId = attachmentId, board = board) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getProfiles(): NetworkResult<KanbanProfilesResponse> =
|
||||||
|
withContext(ioDispatcher) {
|
||||||
|
safeApiCall { apiProvider().getProfiles() }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun updateProfileDescription(
|
||||||
|
name: String,
|
||||||
|
description: String,
|
||||||
|
): NetworkResult<Unit> =
|
||||||
|
withContext(ioDispatcher) {
|
||||||
|
safeApiCall {
|
||||||
|
apiProvider().updateProfileDescription(
|
||||||
|
name = name,
|
||||||
|
body = mapOf("description" to description),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun autoDescribeProfile(name: String): NetworkResult<AutoDescribeResponse> =
|
||||||
|
withContext(ioDispatcher) {
|
||||||
|
safeApiCall { apiProvider().autoDescribeProfile(name) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getOrchestration(): NetworkResult<OrchestrationSettings> =
|
||||||
|
withContext(ioDispatcher) {
|
||||||
|
safeApiCall { apiProvider().getOrchestration() }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun updateOrchestration(body: OrchestrationSettingsUpdate): NetworkResult<OrchestrationSettings> =
|
||||||
|
withContext(ioDispatcher) {
|
||||||
|
safeApiCall { apiProvider().updateOrchestration(body) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getProjects(): NetworkResult<KanbanProjectsResponse> =
|
||||||
|
withContext(ioDispatcher) {
|
||||||
|
safeApiCall { apiProvider().getProjects() }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun nudgeDispatcher(board: String?): NetworkResult<DispatchResult> =
|
||||||
|
withContext(ioDispatcher) {
|
||||||
|
safeApiCall { apiProvider().nudgeDispatcher(board) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,17 +2,20 @@ package com.m57.hermescontrol.ui.kanban
|
|||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.m57.hermescontrol.data.local.AuthManager
|
||||||
|
import com.m57.hermescontrol.data.local.InMemoryKanbanPreferencesStore
|
||||||
|
import com.m57.hermescontrol.data.local.KanbanPreferencesStore
|
||||||
|
import com.m57.hermescontrol.data.model.CreateTaskBody
|
||||||
import com.m57.hermescontrol.data.model.KanbanBoard
|
import com.m57.hermescontrol.data.model.KanbanBoard
|
||||||
import com.m57.hermescontrol.data.model.KanbanColumn
|
import com.m57.hermescontrol.data.model.KanbanColumn
|
||||||
import com.m57.hermescontrol.data.model.KanbanTask
|
import com.m57.hermescontrol.data.model.KanbanTask
|
||||||
import com.m57.hermescontrol.data.remote.ApiClient
|
import com.m57.hermescontrol.data.model.UpdateTaskBody
|
||||||
import com.m57.hermescontrol.data.remote.NetworkResult
|
import com.m57.hermescontrol.data.remote.NetworkResult
|
||||||
import com.m57.hermescontrol.data.remote.safeApiCall
|
import com.m57.hermescontrol.data.repository.KanbanRepository
|
||||||
|
import com.m57.hermescontrol.data.repository.KanbanRepositoryImpl
|
||||||
import com.m57.hermescontrol.data.ws.KanbanEventsClient
|
import com.m57.hermescontrol.data.ws.KanbanEventsClient
|
||||||
import com.m57.hermescontrol.data.ws.KanbanLiveStatus
|
import com.m57.hermescontrol.data.ws.KanbanLiveStatus
|
||||||
import com.m57.hermescontrol.ui.common.ToastHost
|
import com.m57.hermescontrol.ui.common.ToastHost
|
||||||
import com.m57.hermescontrol.ui.common.safeLaunchLoad
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
@@ -20,7 +23,6 @@ import kotlinx.coroutines.flow.StateFlow
|
|||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
|
|
||||||
data class KanbanUiState(
|
data class KanbanUiState(
|
||||||
val isLoading: Boolean = false,
|
val isLoading: Boolean = false,
|
||||||
@@ -113,7 +115,12 @@ fun kanbanActionsForStatus(status: String): List<KanbanTaskAction> =
|
|||||||
}
|
}
|
||||||
|
|
||||||
class KanbanViewModel(
|
class KanbanViewModel(
|
||||||
|
private val repository: KanbanRepository = KanbanRepositoryImpl(),
|
||||||
|
private val preferences: KanbanPreferencesStore = InMemoryKanbanPreferencesStore(),
|
||||||
private val eventsClientProvider: () -> KanbanEventsClient = { KanbanEventsClient() },
|
private val eventsClientProvider: () -> KanbanEventsClient = { KanbanEventsClient() },
|
||||||
|
private val endpointProvider: () -> String = {
|
||||||
|
runCatching { AuthManager.endpointForBuild().baseUrl.toString() }.getOrDefault("default")
|
||||||
|
},
|
||||||
) : ViewModel(),
|
) : ViewModel(),
|
||||||
ToastHost {
|
ToastHost {
|
||||||
private val _uiState = MutableStateFlow(KanbanUiState())
|
private val _uiState = MutableStateFlow(KanbanUiState())
|
||||||
@@ -122,58 +129,50 @@ class KanbanViewModel(
|
|||||||
private var eventsClient: KanbanEventsClient? = null
|
private var eventsClient: KanbanEventsClient? = null
|
||||||
private var eventsBoard: String? = null
|
private var eventsBoard: String? = null
|
||||||
private var reloadJob: Job? = null
|
private var reloadJob: Job? = null
|
||||||
|
private var currentLoadGen: Int = 0
|
||||||
|
|
||||||
fun loadBoards() {
|
fun loadBoards() {
|
||||||
safeLaunchLoad(
|
val endpoint = endpointProvider()
|
||||||
apiCall = { safeApiCall { ApiClient.hermesApi.getKanbanBoards() } },
|
val savedSlug = preferences.getSelectedBoard(endpoint)
|
||||||
onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } },
|
val previouslySelectedId = _uiState.value.selectedBoard?.id ?: savedSlug
|
||||||
onSuccess = { data ->
|
|
||||||
val boards = data.boards.orEmpty()
|
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
|
||||||
_uiState.update { it.copy(isLoading = false, boards = boards) }
|
viewModelScope.launch {
|
||||||
val currentSlug = data.current
|
when (val result = repository.getBoards()) {
|
||||||
val currentBoard = boards.find { it.id == currentSlug } ?: boards.firstOrNull()
|
is NetworkResult.Success -> {
|
||||||
if (currentBoard != null) {
|
val boards = result.data.boards
|
||||||
selectBoard(currentBoard)
|
_uiState.update { it.copy(isLoading = false, boards = boards) }
|
||||||
|
val targetBoard =
|
||||||
|
boards.find { it.id == previouslySelectedId }
|
||||||
|
?: boards.find { it.id == result.data.current }
|
||||||
|
?: boards.firstOrNull()
|
||||||
|
if (targetBoard != null) {
|
||||||
|
selectBoard(targetBoard)
|
||||||
|
} else {
|
||||||
|
_uiState.update { it.copy(selectedBoard = null, columns = emptyList(), tasks = emptyList()) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
onError = { errorMsg ->
|
is NetworkResult.Failure -> {
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
errorMessage = "Failed to load Kanban boards: $errorMsg",
|
errorMessage = "Failed to load Kanban boards: ${result.error.message}",
|
||||||
)
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
)
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun selectBoard(board: KanbanBoard) {
|
fun selectBoard(board: KanbanBoard) {
|
||||||
val current = _uiState.value.selectedBoard
|
val endpoint = endpointProvider()
|
||||||
if (current?.id == board.id) {
|
preferences.setSelectedBoard(endpoint, board.id)
|
||||||
// Already-active board: reload its data and (re)connect the events
|
|
||||||
// stream WITHOUT the /switch round-trip — refresh and createTask
|
val gen = ++currentLoadGen
|
||||||
// re-select the same board and must not POST a redundant switch.
|
|
||||||
viewModelScope.launch { loadBoardIntoState(board) }
|
|
||||||
connectEvents(board)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_uiState.update { it.copy(selectedBoard = board, isLoading = true, errorMessage = null) }
|
_uiState.update { it.copy(selectedBoard = board, isLoading = true, errorMessage = null) }
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val switchResult =
|
loadBoardIntoState(board, gen)
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
safeApiCall { ApiClient.hermesApi.switchKanbanBoard(board.id) }
|
|
||||||
}
|
|
||||||
if (switchResult is NetworkResult.Failure) {
|
|
||||||
_uiState.update {
|
|
||||||
it.copy(
|
|
||||||
isLoading = false,
|
|
||||||
errorMessage = "Failed to switch Kanban board: ${switchResult.error.message}",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
|
|
||||||
loadBoardIntoState(board)
|
|
||||||
connectEvents(board)
|
connectEvents(board)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -186,22 +185,18 @@ class KanbanViewModel(
|
|||||||
val board = _uiState.value.selectedBoard ?: return
|
val board = _uiState.value.selectedBoard ?: return
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val result =
|
val result =
|
||||||
withContext(Dispatchers.IO) {
|
repository.createTask(
|
||||||
safeApiCall {
|
board = board.id,
|
||||||
ApiClient.hermesApi.createKanbanTask(
|
body =
|
||||||
board = board.id,
|
CreateTaskBody(
|
||||||
task =
|
title = title,
|
||||||
com.m57.hermescontrol.data.model.CreateTaskBody(
|
body = description,
|
||||||
title = title,
|
),
|
||||||
body = description,
|
)
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
when (result) {
|
when (result) {
|
||||||
is NetworkResult.Success -> {
|
is NetworkResult.Success -> {
|
||||||
_uiState.update { it.copy(toastMessage = "Task created successfully") }
|
_uiState.update { it.copy(toastMessage = "Task created successfully") }
|
||||||
selectBoard(board)
|
reloadBoardSilently()
|
||||||
}
|
}
|
||||||
|
|
||||||
is NetworkResult.Failure -> {
|
is NetworkResult.Failure -> {
|
||||||
@@ -216,6 +211,7 @@ class KanbanViewModel(
|
|||||||
action: KanbanTaskAction,
|
action: KanbanTaskAction,
|
||||||
summary: String? = null,
|
summary: String? = null,
|
||||||
) {
|
) {
|
||||||
|
val board = _uiState.value.selectedBoard ?: return
|
||||||
val originalStatus = task.status
|
val originalStatus = task.status
|
||||||
// Optimistically update, desktop-style
|
// Optimistically update, desktop-style
|
||||||
_uiState.update { state ->
|
_uiState.update { state ->
|
||||||
@@ -229,17 +225,12 @@ class KanbanViewModel(
|
|||||||
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val body =
|
val body =
|
||||||
buildMap<String, String?> {
|
UpdateTaskBody(
|
||||||
put("status", action.targetStatus)
|
status = action.targetStatus,
|
||||||
if (action.needsSummary && !summary.isNullOrBlank()) {
|
summary = if (action.needsSummary && !summary.isNullOrBlank()) summary else null,
|
||||||
put("result", summary)
|
result = if (action.needsSummary && !summary.isNullOrBlank()) summary else null,
|
||||||
put("summary", summary)
|
)
|
||||||
}
|
val result = repository.updateTask(task.id, board = board.id, body = body)
|
||||||
}
|
|
||||||
val result =
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
safeApiCall { ApiClient.hermesApi.updateKanbanTask(task.id, body) }
|
|
||||||
}
|
|
||||||
if (result is NetworkResult.Failure) {
|
if (result is NetworkResult.Failure) {
|
||||||
revertTaskMove(task.id, originalStatus, "Move failed: ${result.error.message}")
|
revertTaskMove(task.id, originalStatus, "Move failed: ${result.error.message}")
|
||||||
}
|
}
|
||||||
@@ -300,47 +291,60 @@ class KanbanViewModel(
|
|||||||
|
|
||||||
/** Re-fetch the current board without touching the loading spinner. */
|
/** Re-fetch the current board without touching the loading spinner. */
|
||||||
private fun reloadBoardSilently() {
|
private fun reloadBoardSilently() {
|
||||||
|
val board = _uiState.value.selectedBoard ?: return
|
||||||
|
val gen = currentLoadGen
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val result =
|
val result = repository.getBoard(board = board.id)
|
||||||
withContext(Dispatchers.IO) {
|
if (gen != currentLoadGen) return@launch
|
||||||
safeApiCall { ApiClient.hermesApi.getKanbanBoard() }
|
|
||||||
}
|
|
||||||
if (result is NetworkResult.Success) {
|
if (result is NetworkResult.Success) {
|
||||||
val body = result.data
|
val body = result.data
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
if (it.selectedBoard?.id != board.id) {
|
||||||
columns = body.columns,
|
it
|
||||||
tasks = body.columns.flatMap { it.tasks },
|
} else {
|
||||||
)
|
it.copy(
|
||||||
|
columns = body.columns,
|
||||||
|
tasks = body.columns.flatMap { col -> col.tasks },
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun loadBoardIntoState(board: KanbanBoard) {
|
private suspend fun loadBoardIntoState(
|
||||||
val result =
|
board: KanbanBoard,
|
||||||
withContext(Dispatchers.IO) {
|
gen: Int = currentLoadGen,
|
||||||
safeApiCall { ApiClient.hermesApi.getKanbanBoard() }
|
) {
|
||||||
}
|
val result = repository.getBoard(board = board.id)
|
||||||
|
if (gen != currentLoadGen) return
|
||||||
when (result) {
|
when (result) {
|
||||||
is NetworkResult.Success -> {
|
is NetworkResult.Success -> {
|
||||||
val body = result.data
|
val body = result.data
|
||||||
val allTasks = body.columns.flatMap { it.tasks }
|
val allTasks = body.columns.flatMap { it.tasks }
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
if (it.selectedBoard?.id != board.id) {
|
||||||
isLoading = false,
|
it
|
||||||
columns = body.columns,
|
} else {
|
||||||
tasks = allTasks,
|
it.copy(
|
||||||
)
|
isLoading = false,
|
||||||
|
columns = body.columns,
|
||||||
|
tasks = allTasks,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
is NetworkResult.Failure -> {
|
is NetworkResult.Failure -> {
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
if (it.selectedBoard?.id != board.id) {
|
||||||
isLoading = false,
|
it
|
||||||
errorMessage = "Failed to load Kanban tasks: ${result.error.message}",
|
} else {
|
||||||
)
|
it.copy(
|
||||||
|
isLoading = false,
|
||||||
|
errorMessage = "Failed to load Kanban tasks: ${result.error.message}",
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
package com.m57.hermescontrol.data.repository
|
||||||
|
|
||||||
|
import com.m57.hermescontrol.data.model.CreateTaskBody
|
||||||
|
import com.m57.hermescontrol.data.model.CreateTaskResponse
|
||||||
|
import com.m57.hermescontrol.data.model.KanbanBoardResponse
|
||||||
|
import com.m57.hermescontrol.data.model.KanbanBoardsResponse
|
||||||
|
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.KanbanApiService
|
||||||
|
import com.m57.hermescontrol.data.remote.NetworkResult
|
||||||
|
import io.mockk.coEvery
|
||||||
|
import io.mockk.coVerify
|
||||||
|
import io.mockk.mockk
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
import retrofit2.Response
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
class KanbanRepositoryTest {
|
||||||
|
private val testDispatcher = UnconfinedTestDispatcher()
|
||||||
|
private val mockApi = mockk<KanbanApiService>(relaxed = true)
|
||||||
|
private lateinit var repository: KanbanRepository
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setUp() {
|
||||||
|
repository = KanbanRepositoryImpl(apiProvider = { mockApi }, ioDispatcher = testDispatcher)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testGetBoardsPassesIncludeArchived() =
|
||||||
|
runTest(testDispatcher) {
|
||||||
|
coEvery { mockApi.getBoards(true) } returns Response.success(KanbanBoardsResponse())
|
||||||
|
val result = repository.getBoards(includeArchived = true)
|
||||||
|
assertTrue(result is NetworkResult.Success)
|
||||||
|
coVerify { mockApi.getBoards(true) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testGetBoardScopedPassesBoardAndTenant() =
|
||||||
|
runTest(testDispatcher) {
|
||||||
|
coEvery {
|
||||||
|
mockApi.getBoard(
|
||||||
|
board = "custom",
|
||||||
|
includeArchived = false,
|
||||||
|
tenant = "team-a",
|
||||||
|
)
|
||||||
|
} returns Response.success(KanbanBoardResponse())
|
||||||
|
|
||||||
|
val result = repository.getBoard(board = "custom", includeArchived = false, tenant = "team-a")
|
||||||
|
assertTrue(result is NetworkResult.Success)
|
||||||
|
coVerify { mockApi.getBoard(board = "custom", includeArchived = false, tenant = "team-a") }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testCreateTaskScopesToBoard() =
|
||||||
|
runTest(testDispatcher) {
|
||||||
|
val body = CreateTaskBody(title = "New Task")
|
||||||
|
coEvery { mockApi.createTask(board = "ops", body = body) } returns
|
||||||
|
Response.success(
|
||||||
|
CreateTaskResponse(task = KanbanTask(id = "t_1", title = "New Task", status = "triage")),
|
||||||
|
)
|
||||||
|
|
||||||
|
val result = repository.createTask(board = "ops", body = body)
|
||||||
|
assertTrue(result is NetworkResult.Success)
|
||||||
|
assertEquals("t_1", (result as NetworkResult.Success).data.task?.id)
|
||||||
|
coVerify { mockApi.createTask(board = "ops", body = body) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testUpdateTaskScopesToBoard() =
|
||||||
|
runTest(testDispatcher) {
|
||||||
|
val body = UpdateTaskBody(status = "ready")
|
||||||
|
coEvery { mockApi.updateTask(taskId = "t_1", board = "ops", body = body) } returns
|
||||||
|
Response.success(
|
||||||
|
UpdateTaskResponse(task = KanbanTask(id = "t_1", title = "New Task", status = "ready")),
|
||||||
|
)
|
||||||
|
|
||||||
|
val result = repository.updateTask(taskId = "t_1", board = "ops", body = body)
|
||||||
|
assertTrue(result is NetworkResult.Success)
|
||||||
|
assertEquals("ready", (result as NetworkResult.Success).data.task?.status)
|
||||||
|
coVerify { mockApi.updateTask(taskId = "t_1", board = "ops", body = body) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testDeleteTaskScopesToBoard() =
|
||||||
|
runTest(testDispatcher) {
|
||||||
|
coEvery { mockApi.deleteTask(taskId = "t_1", board = "ops") } returns Response.success(Unit)
|
||||||
|
|
||||||
|
val result = repository.deleteTask(taskId = "t_1", board = "ops")
|
||||||
|
assertTrue(result is NetworkResult.Success)
|
||||||
|
coVerify { mockApi.deleteTask(taskId = "t_1", board = "ops") }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testHttpErrorWrapsFailure() =
|
||||||
|
runTest(testDispatcher) {
|
||||||
|
coEvery { mockApi.getBoard("missing", false, null) } returns
|
||||||
|
Response.error(404, "Not Found".toResponseBody())
|
||||||
|
|
||||||
|
val result = repository.getBoard("missing")
|
||||||
|
assertTrue(result is NetworkResult.Failure)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
package com.m57.hermescontrol.ui.kanban
|
||||||
|
|
||||||
|
import com.m57.hermescontrol.data.local.InMemoryKanbanPreferencesStore
|
||||||
|
import com.m57.hermescontrol.data.model.KanbanBoard
|
||||||
|
import com.m57.hermescontrol.data.model.KanbanBoardResponse
|
||||||
|
import com.m57.hermescontrol.data.model.KanbanBoardsResponse
|
||||||
|
import com.m57.hermescontrol.data.model.KanbanColumn
|
||||||
|
import com.m57.hermescontrol.data.model.KanbanTask
|
||||||
|
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.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
class KanbanScopeTest {
|
||||||
|
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 },
|
||||||
|
)
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setUp() {
|
||||||
|
Dispatchers.setMain(testDispatcher)
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun tearDown() {
|
||||||
|
Dispatchers.resetMain()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testBoardSwitchScopesCorrectlyForSameTaskId() =
|
||||||
|
runTest(testDispatcher) {
|
||||||
|
val boardA = KanbanBoard(id = "board_a", name = "Board A")
|
||||||
|
val boardB = KanbanBoard(id = "board_b", name = "Board B")
|
||||||
|
|
||||||
|
val taskA = KanbanTask(id = "t_1", title = "Task in Board A", status = "todo")
|
||||||
|
val taskB = KanbanTask(id = "t_1", title = "Task in Board B", status = "ready")
|
||||||
|
|
||||||
|
coEvery { mockRepository.getBoards() } returns
|
||||||
|
NetworkResult.Success(KanbanBoardsResponse(boards = listOf(boardA, boardB), current = "board_a"))
|
||||||
|
coEvery { mockRepository.getBoard("board_a") } returns
|
||||||
|
NetworkResult.Success(KanbanBoardResponse(columns = listOf(KanbanColumn("todo", listOf(taskA)))))
|
||||||
|
coEvery { mockRepository.getBoard("board_b") } returns
|
||||||
|
NetworkResult.Success(KanbanBoardResponse(columns = listOf(KanbanColumn("ready", listOf(taskB)))))
|
||||||
|
|
||||||
|
val vm = createViewModel()
|
||||||
|
vm.loadBoards()
|
||||||
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
"board_a",
|
||||||
|
vm.uiState.value.selectedBoard
|
||||||
|
?.id,
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
"Task in Board A",
|
||||||
|
vm.uiState.value.tasks
|
||||||
|
.first()
|
||||||
|
.title,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Switch to board B
|
||||||
|
vm.selectBoard(boardB)
|
||||||
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
"board_b",
|
||||||
|
vm.uiState.value.selectedBoard
|
||||||
|
?.id,
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
"Task in Board B",
|
||||||
|
vm.uiState.value.tasks
|
||||||
|
.first()
|
||||||
|
.title,
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
"ready",
|
||||||
|
vm.uiState.value.tasks
|
||||||
|
.first()
|
||||||
|
.status,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testDelayedLateResponseDoesNotOverwriteNewerSelection() =
|
||||||
|
runTest(testDispatcher) {
|
||||||
|
val boardA = KanbanBoard(id = "board_a", name = "Board A")
|
||||||
|
val boardB = KanbanBoard(id = "board_b", name = "Board B")
|
||||||
|
|
||||||
|
val taskA = KanbanTask(id = "t_a", title = "Task A", status = "todo")
|
||||||
|
val taskB = KanbanTask(id = "t_b", title = "Task B", status = "todo")
|
||||||
|
|
||||||
|
val deferredB = CompletableDeferred<NetworkResult<KanbanBoardResponse>>()
|
||||||
|
|
||||||
|
coEvery { mockRepository.getBoard("board_a") } returns
|
||||||
|
NetworkResult.Success(KanbanBoardResponse(columns = listOf(KanbanColumn("todo", listOf(taskA)))))
|
||||||
|
coEvery { mockRepository.getBoard("board_b") } coAnswers { deferredB.await() }
|
||||||
|
|
||||||
|
val vm = createViewModel()
|
||||||
|
|
||||||
|
// User selects board B (deferred response)
|
||||||
|
vm.selectBoard(boardB)
|
||||||
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
assertEquals(
|
||||||
|
"board_b",
|
||||||
|
vm.uiState.value.selectedBoard
|
||||||
|
?.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
// User quickly selects board A before B responds
|
||||||
|
vm.selectBoard(boardA)
|
||||||
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
assertEquals(
|
||||||
|
"board_a",
|
||||||
|
vm.uiState.value.selectedBoard
|
||||||
|
?.id,
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
"Task A",
|
||||||
|
vm.uiState.value.tasks
|
||||||
|
.first()
|
||||||
|
.title,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Now B finally resolves late
|
||||||
|
deferredB.complete(
|
||||||
|
NetworkResult.Success(KanbanBoardResponse(columns = listOf(KanbanColumn("todo", listOf(taskB))))),
|
||||||
|
)
|
||||||
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
|
// State MUST remain Board A and Task A!
|
||||||
|
assertEquals(
|
||||||
|
"board_a",
|
||||||
|
vm.uiState.value.selectedBoard
|
||||||
|
?.id,
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
"Task A",
|
||||||
|
vm.uiState.value.tasks
|
||||||
|
.first()
|
||||||
|
.title,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testBoardListRefreshPreservesUserSelection() =
|
||||||
|
runTest(testDispatcher) {
|
||||||
|
val boardA = KanbanBoard(id = "board_a", name = "Board A")
|
||||||
|
val boardB = KanbanBoard(id = "board_b", name = "Board B")
|
||||||
|
|
||||||
|
coEvery { mockRepository.getBoards() } returns
|
||||||
|
NetworkResult.Success(KanbanBoardsResponse(boards = listOf(boardA, boardB), current = "board_a"))
|
||||||
|
coEvery { mockRepository.getBoard("board_b") } returns
|
||||||
|
NetworkResult.Success(KanbanBoardResponse(columns = listOf(KanbanColumn("todo", emptyList()))))
|
||||||
|
|
||||||
|
val vm = createViewModel()
|
||||||
|
vm.selectBoard(boardB)
|
||||||
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
"board_b",
|
||||||
|
vm.uiState.value.selectedBoard
|
||||||
|
?.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Refresh boards — server says current is "board_a"
|
||||||
|
vm.loadBoards()
|
||||||
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
|
// Local user selection "board_b" must be preserved!
|
||||||
|
assertEquals(
|
||||||
|
"board_b",
|
||||||
|
vm.uiState.value.selectedBoard
|
||||||
|
?.id,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testEmptyBoardListHandledGracefully() =
|
||||||
|
runTest(testDispatcher) {
|
||||||
|
coEvery { mockRepository.getBoards() } returns
|
||||||
|
NetworkResult.Success(KanbanBoardsResponse(boards = emptyList(), current = null))
|
||||||
|
|
||||||
|
val vm = createViewModel()
|
||||||
|
vm.loadBoards()
|
||||||
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
|
assertNull(vm.uiState.value.selectedBoard)
|
||||||
|
assertTrue(
|
||||||
|
vm.uiState.value.tasks
|
||||||
|
.isEmpty(),
|
||||||
|
)
|
||||||
|
assertTrue(
|
||||||
|
vm.uiState.value.columns
|
||||||
|
.isEmpty(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testZeroSwitchCallsDuringNavigation() =
|
||||||
|
runTest(testDispatcher) {
|
||||||
|
val boardA = KanbanBoard(id = "board_a", name = "Board A")
|
||||||
|
val boardB = KanbanBoard(id = "board_b", name = "Board B")
|
||||||
|
|
||||||
|
coEvery { mockRepository.getBoards() } returns
|
||||||
|
NetworkResult.Success(KanbanBoardsResponse(boards = listOf(boardA, boardB), current = "board_a"))
|
||||||
|
coEvery { mockRepository.getBoard(any()) } returns
|
||||||
|
NetworkResult.Success(KanbanBoardResponse())
|
||||||
|
|
||||||
|
val vm = createViewModel()
|
||||||
|
vm.loadBoards()
|
||||||
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
|
vm.selectBoard(boardB)
|
||||||
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
|
// Zero calls to switchBoard anywhere!
|
||||||
|
coVerify(exactly = 0) { mockRepository.updateBoard(any(), any()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,8 +5,9 @@ import com.m57.hermescontrol.data.model.KanbanBoardResponse
|
|||||||
import com.m57.hermescontrol.data.model.KanbanBoardsResponse
|
import com.m57.hermescontrol.data.model.KanbanBoardsResponse
|
||||||
import com.m57.hermescontrol.data.model.KanbanColumn
|
import com.m57.hermescontrol.data.model.KanbanColumn
|
||||||
import com.m57.hermescontrol.data.model.KanbanTask
|
import com.m57.hermescontrol.data.model.KanbanTask
|
||||||
|
import com.m57.hermescontrol.data.model.UpdateTaskResponse
|
||||||
import com.m57.hermescontrol.data.remote.ApiClient
|
import com.m57.hermescontrol.data.remote.ApiClient
|
||||||
import com.m57.hermescontrol.data.remote.HermesApiService
|
import com.m57.hermescontrol.data.remote.KanbanApiService
|
||||||
import com.m57.hermescontrol.data.ws.KanbanEvent
|
import com.m57.hermescontrol.data.ws.KanbanEvent
|
||||||
import com.m57.hermescontrol.data.ws.KanbanEventsClient
|
import com.m57.hermescontrol.data.ws.KanbanEventsClient
|
||||||
import com.m57.hermescontrol.data.ws.KanbanEventsEnvelope
|
import com.m57.hermescontrol.data.ws.KanbanEventsEnvelope
|
||||||
@@ -23,7 +24,6 @@ import kotlinx.coroutines.Dispatchers
|
|||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||||
import kotlinx.coroutines.test.advanceTimeBy
|
import kotlinx.coroutines.test.advanceTimeBy
|
||||||
import kotlinx.coroutines.test.advanceUntilIdle
|
|
||||||
import kotlinx.coroutines.test.resetMain
|
import kotlinx.coroutines.test.resetMain
|
||||||
import kotlinx.coroutines.test.setMain
|
import kotlinx.coroutines.test.setMain
|
||||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||||
@@ -38,7 +38,7 @@ import retrofit2.Response
|
|||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
class KanbanViewModelTest {
|
class KanbanViewModelTest {
|
||||||
private val testDispatcher = StandardTestDispatcher()
|
private val testDispatcher = StandardTestDispatcher()
|
||||||
private val mockApi = mockk<HermesApiService>(relaxed = true)
|
private val mockApi = mockk<KanbanApiService>(relaxed = true)
|
||||||
private val mockEventsClient = mockk<KanbanEventsClient>(relaxed = true)
|
private val mockEventsClient = mockk<KanbanEventsClient>(relaxed = true)
|
||||||
|
|
||||||
private fun createViewModel(): KanbanViewModel {
|
private fun createViewModel(): KanbanViewModel {
|
||||||
@@ -60,23 +60,24 @@ class KanbanViewModelTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun stubBoard(tasks: List<KanbanTask> = listOf(KanbanTask(id = "t1", title = "Task 1", status = "todo"))) {
|
private fun stubBoard(tasks: List<KanbanTask> = listOf(KanbanTask(id = "t1", title = "Task 1", status = "todo"))) {
|
||||||
coEvery { mockApi.getKanbanBoards() } returns
|
coEvery { mockApi.getBoards(any()) } returns
|
||||||
Response.success(
|
Response.success(
|
||||||
KanbanBoardsResponse(
|
KanbanBoardsResponse(
|
||||||
boards = listOf(KanbanBoard(id = "work", name = "Work")),
|
boards = listOf(KanbanBoard(id = "work", name = "Work")),
|
||||||
current = "work",
|
current = "work",
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
coEvery { mockApi.switchKanbanBoard("work") } returns Response.success(Unit)
|
coEvery { mockApi.getBoard(board = "work", any(), any()) } returns
|
||||||
coEvery { mockApi.getKanbanBoard() } returns
|
|
||||||
Response.success(KanbanBoardResponse(columns = listOf(KanbanColumn(name = "todo", tasks = tasks))))
|
Response.success(KanbanBoardResponse(columns = listOf(KanbanColumn(name = "todo", tasks = tasks))))
|
||||||
|
coEvery { mockApi.getBoard(board = "ops", any(), any()) } returns
|
||||||
|
Response.success(KanbanBoardResponse(columns = listOf(KanbanColumn(name = "todo", tasks = emptyList()))))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Before
|
@Before
|
||||||
fun setUp() {
|
fun setUp() {
|
||||||
Dispatchers.setMain(testDispatcher)
|
Dispatchers.setMain(testDispatcher)
|
||||||
mockkObject(ApiClient)
|
mockkObject(ApiClient)
|
||||||
every { ApiClient.hermesApi } returns mockApi
|
every { ApiClient.kanbanApi } returns mockApi
|
||||||
stubBoard()
|
stubBoard()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,7 +116,7 @@ class KanbanViewModelTest {
|
|||||||
val eventsSlot = slot<(KanbanEventsEnvelope) -> Unit>()
|
val eventsSlot = slot<(KanbanEventsEnvelope) -> Unit>()
|
||||||
verify { mockEventsClient.connect(any(), any(), any(), capture(eventsSlot), any()) }
|
verify { mockEventsClient.connect(any(), any(), any(), capture(eventsSlot), any()) }
|
||||||
|
|
||||||
coEvery { mockApi.getKanbanBoard() } returns
|
coEvery { mockApi.getBoard(board = "work", any(), any()) } returns
|
||||||
Response.success(
|
Response.success(
|
||||||
KanbanBoardResponse(
|
KanbanBoardResponse(
|
||||||
columns =
|
columns =
|
||||||
@@ -138,7 +139,7 @@ class KanbanViewModelTest {
|
|||||||
testDispatcher.scheduler.advanceTimeBy(1)
|
testDispatcher.scheduler.advanceTimeBy(1)
|
||||||
settle()
|
settle()
|
||||||
assertEquals(2, vm.uiState.value.tasks.size)
|
assertEquals(2, vm.uiState.value.tasks.size)
|
||||||
coVerify(exactly = 2) { mockApi.getKanbanBoard() }
|
coVerify(atLeast = 2) { mockApi.getBoard(board = "work", any(), any()) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -146,9 +147,6 @@ class KanbanViewModelTest {
|
|||||||
val vm = createViewModel()
|
val vm = createViewModel()
|
||||||
vm.loadBoards()
|
vm.loadBoards()
|
||||||
settle()
|
settle()
|
||||||
coEvery { mockApi.switchKanbanBoard("ops") } returns Response.success(Unit)
|
|
||||||
coEvery { mockApi.getKanbanBoard() } returns
|
|
||||||
Response.success(KanbanBoardResponse(columns = listOf(KanbanColumn(name = "todo", tasks = emptyList()))))
|
|
||||||
vm.selectBoard(KanbanBoard(id = "ops", name = "Ops"))
|
vm.selectBoard(KanbanBoard(id = "ops", name = "Ops"))
|
||||||
settle()
|
settle()
|
||||||
verify { mockEventsClient.connect(any(), eq("ops"), any(), any(), any()) }
|
verify { mockEventsClient.connect(any(), eq("ops"), any(), any(), any()) }
|
||||||
@@ -219,7 +217,7 @@ class KanbanViewModelTest {
|
|||||||
val vm = createViewModel()
|
val vm = createViewModel()
|
||||||
vm.loadBoards()
|
vm.loadBoards()
|
||||||
settle()
|
settle()
|
||||||
coEvery { mockApi.updateKanbanTask(any(), any()) } returns Response.success(Unit)
|
coEvery { mockApi.updateTask(any(), any(), any()) } returns Response.success(UpdateTaskResponse())
|
||||||
vm.moveTask(KanbanTask(id = "t1", title = "Task 1", status = "todo"), KanbanTaskAction.READY)
|
vm.moveTask(KanbanTask(id = "t1", title = "Task 1", status = "todo"), KanbanTaskAction.READY)
|
||||||
settle()
|
settle()
|
||||||
assertEquals(
|
assertEquals(
|
||||||
@@ -228,7 +226,7 @@ class KanbanViewModelTest {
|
|||||||
.first { it.id == "t1" }
|
.first { it.id == "t1" }
|
||||||
.status,
|
.status,
|
||||||
)
|
)
|
||||||
coVerify { mockApi.updateKanbanTask("t1", mapOf("status" to "ready")) }
|
coVerify { mockApi.updateTask("t1", "work", match { it.status == "ready" }) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -236,7 +234,7 @@ class KanbanViewModelTest {
|
|||||||
val vm = createViewModel()
|
val vm = createViewModel()
|
||||||
vm.loadBoards()
|
vm.loadBoards()
|
||||||
settle()
|
settle()
|
||||||
coEvery { mockApi.updateKanbanTask(any(), any()) } returns Response.error(409, "".toResponseBody())
|
coEvery { mockApi.updateTask(any(), any(), any()) } returns Response.error(409, "".toResponseBody())
|
||||||
vm.moveTask(KanbanTask(id = "t1", title = "Task 1", status = "todo"), KanbanTaskAction.READY)
|
vm.moveTask(KanbanTask(id = "t1", title = "Task 1", status = "todo"), KanbanTaskAction.READY)
|
||||||
settle()
|
settle()
|
||||||
assertEquals(
|
assertEquals(
|
||||||
@@ -256,7 +254,7 @@ class KanbanViewModelTest {
|
|||||||
val vm = createViewModel()
|
val vm = createViewModel()
|
||||||
vm.loadBoards()
|
vm.loadBoards()
|
||||||
settle()
|
settle()
|
||||||
coEvery { mockApi.updateKanbanTask(any(), any()) } returns Response.success(Unit)
|
coEvery { mockApi.updateTask(any(), any(), any()) } returns Response.success(UpdateTaskResponse())
|
||||||
vm.moveTask(
|
vm.moveTask(
|
||||||
KanbanTask(id = "t1", title = "Task 1", status = "ready"),
|
KanbanTask(id = "t1", title = "Task 1", status = "ready"),
|
||||||
KanbanTaskAction.COMPLETE,
|
KanbanTaskAction.COMPLETE,
|
||||||
@@ -264,9 +262,10 @@ class KanbanViewModelTest {
|
|||||||
)
|
)
|
||||||
settle()
|
settle()
|
||||||
coVerify {
|
coVerify {
|
||||||
mockApi.updateKanbanTask(
|
mockApi.updateTask(
|
||||||
"t1",
|
"t1",
|
||||||
mapOf("status" to "done", "result" to "Shipped it", "summary" to "Shipped it"),
|
"work",
|
||||||
|
match { it.status == "done" && it.summary == "Shipped it" && it.result == "Shipped it" },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user