diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt index 594815ab..68dcfa4b 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt @@ -33,7 +33,6 @@ import com.m57.hermescontrol.data.model.HealthStatus import com.m57.hermescontrol.data.model.HookResponse 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.LearningGraphResponse import com.m57.hermescontrol.data.model.LogResponse import com.m57.hermescontrol.data.model.ManagedDirectoryCreate @@ -663,7 +662,7 @@ interface HermesApiService { suspend fun createKanbanTask( @Query("board") board: String?, @Body task: CreateTaskBody, - ): Response + ): Response // ── Admin: Hermes update ────────────────────────────────────────── @GET("api/hermes/update/check") diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/ServerEndpoint.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/ServerEndpoint.kt index 08da6c9f..29f24f91 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/remote/ServerEndpoint.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/ServerEndpoint.kt @@ -33,15 +33,31 @@ class ServerEndpoint private constructor( fun webSocketUrl( authParameter: String, credential: String, + ): String = + webSocketUrl( + path = "api/ws", + authParameter = authParameter, + credential = credential, + ) + + /** + * Build a WebSocket URL for an arbitrary dashboard WS endpoint (e.g. the + * kanban plugin's live-events stream), preserving the reverse-proxy path + * prefix, converting http(s) to ws(s), and appending the auth parameter + * plus any extra query parameters (e.g. `since`, `board`). + */ + fun webSocketUrl( + path: String, + authParameter: String, + credential: String, + extraParams: Map = emptyMap(), ): String { require(authParameter == "token" || authParameter == "ticket") { "Unsupported WebSocket authentication parameter" } - val socketHttpUrl = - resolve("api/ws") - .newBuilder() - .addQueryParameter(authParameter, credential) - .build() + val builder = resolve(path).newBuilder().addQueryParameter(authParameter, credential) + extraParams.forEach { (key, value) -> builder.addQueryParameter(key, value) } + val socketHttpUrl = builder.build() val socketScheme = if (baseUrl.isHttps) "wss" else "ws" return replaceScheme(socketHttpUrl.toString(), socketScheme) } diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt index 7a930e28..c3e5b137 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt @@ -260,7 +260,7 @@ object HermesWsClient { * or because ticket refresh succeeded). Returns false if we are in gated mode * and ticket refresh failed or cannot be performed. */ - private fun refreshWsTicketIfNeeded(): Boolean { + internal fun refreshWsTicketIfNeeded(): Boolean { val isGated = try { AuthManager.serverStore.getLatestState().wsAuthParam == "ticket" @@ -279,6 +279,59 @@ object HermesWsClient { return true } + val ticketResult = requestWsTicket() + if (!ticketResult.ticket.isNullOrBlank()) { + AuthManager.setToken(ticketResult.ticket) + if (BuildConfig.DEBUG) Log.d(TAG, "WS ticket refreshed") + return true + } + val status = + when { + ticketResult.exception -> ConnectionStatus.RECONNECTING + ticketResult.httpCode == 401 || ticketResult.httpCode == 403 -> ConnectionStatus.AUTH_EXPIRED + ticketResult.httpCode == 408 || ticketResult.httpCode == 429 -> ConnectionStatus.RECONNECTING + ticketResult.httpCode != null && ticketResult.httpCode >= 500 -> ConnectionStatus.RECONNECTING + else -> ConnectionStatus.DISCONNECTED + } + return handleWsTicketRefreshFailure(status) + } + + /** + * Mint a fresh WS ticket for a *secondary* consumer (e.g. the kanban + * events stream) WITHOUT touching the shared token slot. + * + * The shared slot is a single-use ticket that the chat WebSocket consumes + * at handshake. If two clients mint and stash into the same slot on their + * own schedules, each can grab the other's just-consumed ticket and the + * pair starves each other into a permanent reconnect loop. Secondary + * consumers must use their own ticket per connection. + * + * Gated mode: cookie-auth'd POST to /api/auth/ws-ticket, returns the + * ticket. Loopback mode: refresh the dashboard token and return it. + * Returns null when a ticket could not be obtained. + */ + internal fun mintWsTicket(): String? { + val isGated = + try { + AuthManager.serverStore.getLatestState().wsAuthParam == "ticket" + } catch (_: IllegalStateException) { + false + } + if (!isGated) { + DashboardSessionTokenRefresher.refresh() + return AuthManager.getToken() + } + return requestWsTicket().ticket + } + + private data class TicketRequestResult( + val ticket: String?, + val httpCode: Int?, + val exception: Boolean = false, + ) + + /** POST /api/auth/ws-ticket (cookie-auth'd via the shared CookieJar) and parse the ticket. */ + private fun requestWsTicket(): TicketRequestResult { try { val client = OkHttpProvider.probe val request = @@ -300,29 +353,19 @@ object HermesWsClient { val ticketMatch = Regex("""\"ticket\":\"([^\"]+)\"""").find(body) val ticket = ticketMatch?.groupValues?.getOrNull(1) if (!ticket.isNullOrBlank()) { - AuthManager.setToken(ticket) - if (BuildConfig.DEBUG) Log.d(TAG, "WS ticket refreshed") - return true - } else { - Log.w(TAG, "WS ticket refresh failed: response body did not contain ticket") - return handleWsTicketRefreshFailure(ConnectionStatus.DISCONNECTED) + return TicketRequestResult(ticket, null) } + Log.w(TAG, "WS ticket mint failed: response body did not contain ticket") } else { - Log.w(TAG, "WS ticket refresh failed: HTTP ${response.code}") - val status = - when { - response.code == 401 || response.code == 403 -> ConnectionStatus.AUTH_EXPIRED - response.code == 408 || response.code == 429 || response.code >= 500 -> - ConnectionStatus.RECONNECTING - else -> ConnectionStatus.DISCONNECTED - } - return handleWsTicketRefreshFailure(status) + Log.w(TAG, "WS ticket mint failed: HTTP ${response.code}") + return TicketRequestResult(null, response.code) } } } catch (e: Exception) { - Log.w(TAG, "WS ticket refresh failed: ${e.javaClass.simpleName}") - return handleWsTicketRefreshFailure(ConnectionStatus.RECONNECTING) + Log.w(TAG, "WS ticket mint failed: ${e.javaClass.simpleName}") + return TicketRequestResult(null, null, exception = true) } + return TicketRequestResult(null, null) } private fun handleWsTicketRefreshFailure(status: ConnectionStatus): Boolean { diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/KanbanEventsClient.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/KanbanEventsClient.kt new file mode 100644 index 00000000..e17e87c1 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/KanbanEventsClient.kt @@ -0,0 +1,239 @@ +package com.m57.hermescontrol.data.ws + +import android.util.Log +import com.m57.hermescontrol.BuildConfig +import com.m57.hermescontrol.data.local.AuthManager +import com.m57.hermescontrol.data.remote.OkHttpProvider +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.SerializationException +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.JsonObject +import okhttp3.Request +import okhttp3.Response +import okhttp3.WebSocket +import okhttp3.WebSocketListener +import okio.ByteString + +/** Connection state of the kanban live-events stream. */ +enum class KanbanLiveStatus { + /** Stream open and receiving events. */ + CONNECTED, + + /** Stream closed; reconnecting with backoff (or waiting for a board switch). */ + DISCONNECTED, + + /** Stream rejected with 1008 / credential refresh failed; not retrying. */ + AUTH_FAILED, +} + +/** One row of the backend's ``task_events`` table (``/api/plugins/kanban/events``). */ +@Serializable +data class KanbanEvent( + val id: Long, + @SerialName("task_id") val taskId: String? = null, + @SerialName("run_id") val runId: String? = null, + val kind: String, + /** Partial update (e.g. ``{"status": "done"}``) — never applied directly. */ + val payload: JsonObject? = null, + @SerialName("created_at") val createdAt: Long? = null, +) + +/** Server frame: ``{"events": [...], "cursor": N}``. */ +@Serializable +data class KanbanEventsEnvelope( + val events: List = emptyList(), + val cursor: Long? = null, +) + +/** + * Parse a kanban events WS frame. Returns null when the frame is not a valid + * envelope (heartbeats, partial writes, unrelated payloads) so the caller can + * ignore it safely. + */ +internal fun parseKanbanEventsFrame(json: String): KanbanEventsEnvelope? = + try { + OkHttpProvider.json.decodeFromString(json) + } catch (_: SerializationException) { + null + } catch (_: IllegalArgumentException) { + null + } + +/** + * Scoped WebSocket tail of the kanban plugin's live-events stream + * (``/api/plugins/kanban/events`` — backend: plugin_api.py ``stream_events``). + * + * The backend pins the board at the WS handshake (``board`` query param) and + * replays events past the ``since`` cursor, so switching boards means opening a + * new connection with a fresh cursor. Event payloads are partial updates, so + * consumers are expected to re-fetch the board from REST on any event batch — + * the desktop dashboard uses the same debounced-reload pattern. + */ +class KanbanEventsClient { + // Deferred: building the OkHttp client touches the shared CookieJar + // (Android context), which must not happen at construction time — the + // client is only built when a stream is actually opened. + private val httpClient by lazy { OkHttpProvider.websocket } + + private var webSocket: WebSocket? = null + private var reconnectJob: Job? = null + private var generation = 0 + private var closed = true + private var backoffMs = INITIAL_BACKOFF_MS + private var cursor: Long = 0 + + /** + * Open (or re-open) the events stream for [board]. A previous connection is + * torn down first — safe to call on board switch. + * + * [onEvents] is invoked with each non-empty envelope; [onStatus] with every + * state transition. Both are dispatched on [scope] (caller's main scope). + */ + fun connect( + scope: CoroutineScope, + board: String, + since: Long = 0, + onEvents: (KanbanEventsEnvelope) -> Unit, + onStatus: (KanbanLiveStatus) -> Unit, + ) { + val gen = ++generation + reconnectJob?.cancel() + webSocket?.cancel() + closed = false + backoffMs = INITIAL_BACKOFF_MS + cursor = since + openSocket(scope, gen, board, onEvents, onStatus) + } + + /** Close the stream and cancel any pending reconnect. */ + fun disconnect() { + closed = true + generation++ + reconnectJob?.cancel() + reconnectJob = null + webSocket?.cancel() + webSocket = null + } + + private fun openSocket( + scope: CoroutineScope, + gen: Int, + board: String, + onEvents: (KanbanEventsEnvelope) -> Unit, + onStatus: (KanbanLiveStatus) -> Unit, + ) { + if (closed || gen != generation) return + + val credential = HermesWsClient.mintWsTicket() + if (credential.isNullOrBlank()) { + onStatus(KanbanLiveStatus.AUTH_FAILED) + return + } + + val rawAuthParam = AuthManager.serverStore.getLatestState().wsAuthParam + val authParam = if (rawAuthParam.isBlank()) "token" else rawAuthParam + val url = + AuthManager.endpointForBuild().webSocketUrl( + path = KANBAN_EVENTS_PATH, + authParameter = authParam, + credential = credential, + extraParams = mapOf("since" to cursor.toString(), "board" to board), + ) + val safeUrl = url.replace(Regex("(token|ticket)=[^&]+"), "$1=REDACTED") + if (BuildConfig.DEBUG) Log.d(TAG, "Connecting to $safeUrl") + + val request = Request.Builder().url(url).build() + webSocket = + httpClient.newWebSocket( + request, + object : WebSocketListener() { + override fun onOpen( + ws: WebSocket, + response: Response, + ) { + if (closed || gen != generation) return + backoffMs = INITIAL_BACKOFF_MS + scope.launch { onStatus(KanbanLiveStatus.CONNECTED) } + } + + override fun onMessage( + ws: WebSocket, + text: String, + ) { + if (closed || gen != generation) return + val envelope = parseKanbanEventsFrame(text) ?: return + if (envelope.events.isEmpty()) return + envelope.cursor?.let { cursor = it } + scope.launch { onEvents(envelope) } + } + + override fun onMessage( + ws: WebSocket, + bytes: ByteString, + ) = Unit + + override fun onClosing( + ws: WebSocket, + code: Int, + reason: String, + ) { + ws.close(code, reason) + } + + override fun onClosed( + ws: WebSocket, + code: Int, + reason: String, + ) { + if (closed || gen != generation) return + if (code == WS_1008_POLICY_VIOLATION) { + scope.launch { onStatus(KanbanLiveStatus.AUTH_FAILED) } + return + } + scheduleReconnect(scope, gen, board, onEvents, onStatus) + } + + override fun onFailure( + ws: WebSocket, + t: Throwable, + response: Response?, + ) { + if (closed || gen != generation) return + if (BuildConfig.DEBUG) Log.d(TAG, "Stream failure: ${t.javaClass.simpleName}") + scheduleReconnect(scope, gen, board, onEvents, onStatus) + } + }, + ) + } + + private fun scheduleReconnect( + scope: CoroutineScope, + gen: Int, + board: String, + onEvents: (KanbanEventsEnvelope) -> Unit, + onStatus: (KanbanLiveStatus) -> Unit, + ) { + if (closed || gen != generation) return + scope.launch { onStatus(KanbanLiveStatus.DISCONNECTED) } + reconnectJob?.cancel() + reconnectJob = + scope.launch { + delay(backoffMs) + backoffMs = (backoffMs * 2).coerceAtMost(MAX_BACKOFF_MS) + openSocket(scope, gen, board, onEvents, onStatus) + } + } + + private companion object { + const val TAG = "KanbanEvents" + const val KANBAN_EVENTS_PATH = "api/plugins/kanban/events" + const val INITIAL_BACKOFF_MS = 1_000L + const val MAX_BACKOFF_MS = 30_000L + const val WS_1008_POLICY_VIOLATION = 1008 + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanScreen.kt index 220f219d..95cdf199 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanScreen.kt @@ -1,5 +1,6 @@ package com.m57.hermescontrol.ui.kanban +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -15,9 +16,11 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.automirrored.filled.ArrowForward +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Person +import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CircularProgressIndicator @@ -39,6 +42,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel @@ -79,6 +83,9 @@ fun KanbanScreen( filteredTasks.groupBy { it.status.lowercase() } } var showAddTaskDialog by remember { mutableStateOf(false) } + var taskForActions by remember { mutableStateOf(null) } + var confirmTarget by remember { mutableStateOf?>(null) } + var summaryTarget by remember { mutableStateOf?>(null) } LaunchedEffect(Unit) { viewModel.loadBoards() @@ -150,9 +157,33 @@ fun KanbanScreen( Text(stringResource(R.string.kanban_no_columns)) } } else { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + ) { + LiveStatusPill(isLive = state.isLive) + Spacer(modifier = Modifier.width(8.dp)) + IconButton(onClick = { showAddTaskDialog = true }) { + Icon( + imageVector = Icons.Filled.Add, + contentDescription = stringResource(R.string.kanban_add_task), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } LazyRow( modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(16.dp), + contentPadding = + PaddingValues( + start = 16.dp, + top = 16.dp, + end = 16.dp, + bottom = 16.dp, + ), horizontalArrangement = Arrangement.spacedBy(16.dp), ) { items(state.columns.size, key = { index -> @@ -161,8 +192,6 @@ fun KanbanScreen( val column = state.columns[columnIndex] val colName = column.name val colTasks = tasksByColumn[colName.lowercase()] ?: emptyList() - val prevColumn = state.columns.getOrNull(columnIndex - 1) - val nextColumn = state.columns.getOrNull(columnIndex + 1) Column( modifier = @@ -179,23 +208,32 @@ fun KanbanScreen( modifier = Modifier.padding(bottom = 8.dp), ) - LazyColumn( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - items(colTasks, key = { it.id }) { task -> - TaskCard( - task = task, - onMoveLeft = - prevColumn?.let { - { viewModel.moveTask(task, it.name) } - }, - onMoveRight = - nextColumn?.let { - { viewModel.moveTask(task, it.name) } - }, + if (colTasks.isEmpty()) { + Box( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 16.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = stringResource(R.string.kanban_no_tasks), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } + } else { + LazyColumn( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(colTasks, key = { it.id }) { task -> + TaskCard( + task = task, + onTaskClick = { taskForActions = it }, + ) + } + } } } } @@ -213,6 +251,47 @@ fun KanbanScreen( }, ) } + + taskForActions?.let { task -> + val actions = kanbanActionsForStatus(task.status) + if (actions.isNotEmpty()) { + TaskActionSheet( + task = task, + actions = actions, + onAction = { action -> + taskForActions = null + when { + action.needsSummary -> summaryTarget = task to action + action.needsConfirm -> confirmTarget = task to action + else -> viewModel.moveTask(task, action) + } + }, + onDismiss = { taskForActions = null }, + ) + } + } + + confirmTarget?.let { (task, action) -> + ConfirmActionDialog( + message = stringResource(action.confirmRes()), + onConfirm = { + confirmTarget = null + if (action.needsSummary) { + summaryTarget = task to action + } else { + viewModel.moveTask(task, action) + } + }, + onDismiss = { confirmTarget = null }, + ) + } + + summaryTarget?.let { (task, action) -> + CompleteTaskDialog( + onConfirm = { summary -> viewModel.moveTask(task, action, summary) }, + onDismiss = { summaryTarget = null }, + ) + } } } } @@ -222,48 +301,65 @@ fun KanbanScreen( @Composable fun TaskCard( task: KanbanTask, - onMoveLeft: (() -> Unit)?, - onMoveRight: (() -> Unit)?, + onTaskClick: (KanbanTask) -> Unit, ) { - Card(modifier = Modifier.fillMaxWidth()) { + Card(onClick = { onTaskClick(task) }, modifier = Modifier.fillMaxWidth()) { Column(modifier = Modifier.padding(12.dp)) { Text(text = task.title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) Spacer(modifier = Modifier.height(4.dp)) task.description?.let { - Text(text = it, style = MaterialTheme.typography.bodyMedium) + Text( + text = it, + style = MaterialTheme.typography.bodyMedium, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) } - Spacer(modifier = Modifier.height(8.dp)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - if (onMoveLeft != null) { - IconButton(onClick = onMoveLeft) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = stringResource(R.string.content_desc_move_left), - ) - } - } else { - Spacer(modifier = Modifier.size(48.dp)) - } - - if (onMoveRight != null) { - IconButton(onClick = onMoveRight) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowForward, - contentDescription = stringResource(R.string.content_desc_move_right), - ) - } - } else { - Spacer(modifier = Modifier.size(48.dp)) + task.assignedTo?.let { assignee -> + Spacer(modifier = Modifier.height(6.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Filled.Person, + contentDescription = null, + modifier = Modifier.size(14.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = assignee, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) } } } } } +@Composable +private fun LiveStatusPill(isLive: Boolean) { + val color = + if (isLive) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = + Modifier + .size(8.dp) + .background(color = color, shape = CircleShape), + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = stringResource(if (isLive) R.string.kanban_live else R.string.kanban_offline), + style = MaterialTheme.typography.labelMedium, + color = color, + ) + } +} + @OptIn(ExperimentalMaterial3Api::class) @Composable fun AddTaskDialog( @@ -308,3 +404,111 @@ fun AddTaskDialog( }, ) } + +private fun KanbanTaskAction.labelRes(): Int = + when (this) { + KanbanTaskAction.TRIAGE -> R.string.kanban_action_triage + KanbanTaskAction.READY -> R.string.kanban_action_ready + KanbanTaskAction.UNBLOCK -> R.string.kanban_action_unblock + KanbanTaskAction.BLOCK -> R.string.kanban_action_block + KanbanTaskAction.COMPLETE -> R.string.kanban_action_complete + KanbanTaskAction.ARCHIVE -> R.string.kanban_action_archive + } + +private fun KanbanTaskAction.confirmRes(): Int = + when (this) { + KanbanTaskAction.BLOCK -> R.string.kanban_confirm_blocked + KanbanTaskAction.COMPLETE -> R.string.kanban_confirm_done + KanbanTaskAction.ARCHIVE -> R.string.kanban_confirm_archive + else -> error("Action $this has no confirm message") + } + +@Composable +private fun TaskActionSheet( + task: KanbanTask, + actions: List, + onAction: (KanbanTaskAction) -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(task.title, maxLines = 2, overflow = TextOverflow.Ellipsis) }, + text = { + Column { + actions.forEach { action -> + Button( + onClick = { onAction(action) }, + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + ) { + Text(stringResource(action.labelRes())) + } + } + } + }, + confirmButton = {}, + dismissButton = { + Button(onClick = onDismiss) { + Text(stringResource(R.string.action_cancel)) + } + }, + ) +} + +@Composable +private fun ConfirmActionDialog( + message: String, + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + text = { Text(message) }, + confirmButton = { + Button(onClick = onConfirm) { + Text(stringResource(R.string.action_confirm)) + } + }, + dismissButton = { + Button(onClick = onDismiss) { + Text(stringResource(R.string.action_cancel)) + } + }, + ) +} + +@Composable +private fun CompleteTaskDialog( + onConfirm: (String) -> Unit, + onDismiss: () -> Unit, +) { + var summary by remember { mutableStateOf("") } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.kanban_complete_title)) }, + text = { + OutlinedTextField( + value = summary, + onValueChange = { summary = it }, + label = { Text(stringResource(R.string.kanban_complete_summary_label)) }, + supportingText = { Text(stringResource(R.string.kanban_complete_summary_hint)) }, + modifier = Modifier.fillMaxWidth(), + ) + }, + confirmButton = { + Button( + onClick = { onConfirm(summary.trim()) }, + enabled = summary.isNotBlank(), + ) { + Text(stringResource(R.string.kanban_action_complete)) + } + }, + dismissButton = { + Button(onClick = onDismiss) { + Text(stringResource(R.string.action_cancel)) + } + }, + ) +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanViewModel.kt index c185e62f..1c2dbac4 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanViewModel.kt @@ -8,9 +8,13 @@ import com.m57.hermescontrol.data.model.KanbanTask import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.remote.NetworkResult import com.m57.hermescontrol.data.remote.safeApiCall +import com.m57.hermescontrol.data.ws.KanbanEventsClient +import com.m57.hermescontrol.data.ws.KanbanLiveStatus 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.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -24,16 +28,74 @@ data class KanbanUiState( val selectedBoard: KanbanBoard? = null, val columns: List = emptyList(), val tasks: List = emptyList(), + val isLive: Boolean = false, val errorMessage: String? = null, val toastMessage: String? = null, ) -class KanbanViewModel : - ViewModel(), - ToastHost { +/** + * Task actions mirroring the desktop kanban's transition-gated buttons. + * Only moves the backend PATCH route accepts are offered: never `running` + * (dispatcher-only, backend rejects with 400) and never `review` (not in + * the dashboard whitelist). + */ +enum class KanbanTaskAction( + val targetStatus: String, + val needsConfirm: Boolean = false, + val needsSummary: Boolean = false, +) { + TRIAGE(targetStatus = "triage"), + READY(targetStatus = "ready"), + UNBLOCK(targetStatus = "ready"), + BLOCK(targetStatus = "blocked", needsConfirm = true), + COMPLETE(targetStatus = "done", needsConfirm = true, needsSummary = true), + ARCHIVE(targetStatus = "archived", needsConfirm = true), +} + +/** Desktop parity: which actions are valid from a given status. */ +fun kanbanActionsForStatus(status: String): List = + when (status) { + "triage" -> listOf(KanbanTaskAction.READY, KanbanTaskAction.ARCHIVE) + "todo" -> listOf(KanbanTaskAction.TRIAGE, KanbanTaskAction.READY, KanbanTaskAction.ARCHIVE) + "scheduled" -> listOf(KanbanTaskAction.TRIAGE, KanbanTaskAction.READY, KanbanTaskAction.ARCHIVE) + "ready" -> + listOf( + KanbanTaskAction.TRIAGE, + KanbanTaskAction.BLOCK, + KanbanTaskAction.COMPLETE, + KanbanTaskAction.ARCHIVE, + ) + "running" -> + listOf( + KanbanTaskAction.TRIAGE, + KanbanTaskAction.READY, + KanbanTaskAction.BLOCK, + KanbanTaskAction.COMPLETE, + KanbanTaskAction.ARCHIVE, + ) + "blocked" -> + listOf( + KanbanTaskAction.TRIAGE, + KanbanTaskAction.UNBLOCK, + KanbanTaskAction.COMPLETE, + KanbanTaskAction.ARCHIVE, + ) + "review" -> listOf(KanbanTaskAction.TRIAGE, KanbanTaskAction.READY, KanbanTaskAction.ARCHIVE) + "done" -> listOf(KanbanTaskAction.TRIAGE, KanbanTaskAction.READY, KanbanTaskAction.ARCHIVE) + "archived" -> listOf(KanbanTaskAction.TRIAGE, KanbanTaskAction.READY) + else -> emptyList() + } + +class KanbanViewModel( + private val eventsClientProvider: () -> KanbanEventsClient = { KanbanEventsClient() }, +) : ViewModel(), ToastHost { private val _uiState = MutableStateFlow(KanbanUiState()) val uiState: StateFlow = _uiState.asStateFlow() + private var eventsClient: KanbanEventsClient? = null + private var eventsBoard: String? = null + private var reloadJob: Job? = null + fun loadBoards() { safeLaunchLoad( apiCall = { safeApiCall { ApiClient.hermesApi.getKanbanBoards() } }, @@ -75,32 +137,8 @@ class KanbanViewModel : return@launch } - val result = - withContext(Dispatchers.IO) { - safeApiCall { ApiClient.hermesApi.getKanbanBoard() } - } - when (result) { - is NetworkResult.Success -> { - val body = result.data - val allTasks = body.columns.flatMap { it.tasks } - _uiState.update { - it.copy( - isLoading = false, - columns = body.columns, - tasks = allTasks, - ) - } - } - - is NetworkResult.Failure -> { - _uiState.update { - it.copy( - isLoading = false, - errorMessage = "Failed to load Kanban tasks: ${result.error.message}", - ) - } - } - } + loadBoardIntoState(board) + connectEvents(board) } } @@ -139,28 +177,35 @@ class KanbanViewModel : fun moveTask( task: KanbanTask, - newStatus: String, + action: KanbanTaskAction, + summary: String? = null, ) { val originalStatus = task.status - val board = _uiState.value.selectedBoard ?: return - - // Optimistically update + // Optimistically update, desktop-style _uiState.update { state -> state.copy( tasks = state.tasks.map { - if (it.id == task.id) it.copy(status = newStatus) else it + if (it.id == task.id) it.copy(status = action.targetStatus) else it }, ) } viewModelScope.launch { + val body = + buildMap { + put("status", action.targetStatus) + if (action.needsSummary && !summary.isNullOrBlank()) { + put("result", summary) + put("summary", summary) + } + } val result = withContext(Dispatchers.IO) { - safeApiCall { ApiClient.hermesApi.updateKanbanTask(task.id, mapOf("status" to newStatus)) } + safeApiCall { ApiClient.hermesApi.updateKanbanTask(task.id, body) } } if (result is NetworkResult.Failure) { - revertTaskMove(task.id, originalStatus, "Failed to move task: ${result.error.message}") + revertTaskMove(task.id, originalStatus, "Move failed: ${result.error.message}") } } } @@ -181,7 +226,95 @@ class KanbanViewModel : } } + override fun onCleared() { + eventsClient?.disconnect() + super.onCleared() + } + + // ── Live events (issue #775) ───────────────────────────────────────── + + /** + * Tail the kanban events WebSocket for [board]. The backend pins the board + * at the WS handshake, so a board switch opens a fresh stream; re-selecting + * the already-live board is a no-op (the REST load already refreshed it). + */ + private fun connectEvents(board: KanbanBoard) { + if (eventsBoard == board.id && _uiState.value.isLive) return + eventsBoard = board.id + val client = eventsClient ?: eventsClientProvider().also { eventsClient = it } + client.connect( + scope = viewModelScope, + board = board.id, + onEvents = { scheduleBoardReload() }, + onStatus = { status -> + _uiState.update { it.copy(isLive = status == KanbanLiveStatus.CONNECTED) } + }, + ) + } + + /** Debounced REST refresh after an events batch — mirrors the desktop pattern. */ + private fun scheduleBoardReload() { + reloadJob?.cancel() + reloadJob = + viewModelScope.launch { + delay(RELOAD_DEBOUNCE_MS) + reloadBoardSilently() + } + } + + /** Re-fetch the current board without touching the loading spinner. */ + private fun reloadBoardSilently() { + viewModelScope.launch { + val result = + withContext(Dispatchers.IO) { + safeApiCall { ApiClient.hermesApi.getKanbanBoard() } + } + if (result is NetworkResult.Success) { + val body = result.data + _uiState.update { + it.copy( + columns = body.columns, + tasks = body.columns.flatMap { it.tasks }, + ) + } + } + } + } + + private suspend fun loadBoardIntoState(board: KanbanBoard) { + val result = + withContext(Dispatchers.IO) { + safeApiCall { ApiClient.hermesApi.getKanbanBoard() } + } + when (result) { + is NetworkResult.Success -> { + val body = result.data + val allTasks = body.columns.flatMap { it.tasks } + _uiState.update { + it.copy( + isLoading = false, + columns = body.columns, + tasks = allTasks, + ) + } + } + + is NetworkResult.Failure -> { + _uiState.update { + it.copy( + isLoading = false, + errorMessage = "Failed to load Kanban tasks: ${result.error.message}", + ) + } + } + } + } + override fun clearToast() { _uiState.update { it.copy(toastMessage = null) } } + + private companion object { + const val RELOAD_DEBOUNCE_MS = 250L + } } diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index cdf204ec..b4051f49 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -211,8 +211,6 @@ 작업 제목 작업 설명 추가 - 왼쪽으로 - 오른쪽으로 모든 상태 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5f614ee8..21b01197 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -23,6 +23,7 @@ Retry Cancel + Confirm Clear OK Delete @@ -242,10 +243,24 @@ Add New Task Task Title Task Description + Add task + Triage + Ready + Block + Unblock + Complete + Archive + Mark this task as blocked? The worker\'s claim is released. + Complete this task? The worker\'s claim is released. + Archive this task? It disappears from the default board view. + Complete task + Completion summary + Stored as the task result + Move failed: %1$s + Live + Offline + No tasks Add - Move Left - Move Right - All Statuses Enabled diff --git a/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt b/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt index b4751a86..e0ddd831 100644 --- a/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt @@ -47,9 +47,11 @@ import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.remote.CleartextPolicy import com.m57.hermescontrol.data.remote.HermesApiService import com.m57.hermescontrol.data.remote.ServerEndpoint +import com.m57.hermescontrol.data.ws.KanbanEventsClient import com.m57.hermescontrol.ui.channels.ChannelsViewModel import com.m57.hermescontrol.ui.connect.ConnectViewModel import com.m57.hermescontrol.ui.cron.CronJobsViewModel +import com.m57.hermescontrol.ui.kanban.KanbanTaskAction import com.m57.hermescontrol.ui.kanban.KanbanViewModel import com.m57.hermescontrol.ui.keys.KeysViewModel import com.m57.hermescontrol.ui.logs.LogsViewModel @@ -1205,10 +1207,8 @@ class E2eIntegrationTest { coEvery { mockApiService.switchKanbanBoard("board-1") } returns Response.success(Unit) coEvery { mockApiService.getKanbanBoard() } returns Response.success(KanbanBoardResponse(listOf(KanbanColumn("todo", listOf(task))), null, null)) - coEvery { mockApiService.updateKanbanTask("task-1", mapOf("status" to "in_progress")) } returns - Response.success(Unit) - val viewModel = KanbanViewModel() + val viewModel = KanbanViewModel(eventsClientProvider = { mockk(relaxed = true) }) viewModel.loadBoards() advanceUntilIdle() @@ -1224,20 +1224,28 @@ class E2eIntegrationTest { .status, ) - viewModel.moveTask(viewModel.uiState.value.tasks[0], "in_progress") - // Optimistic move check + // Desktop-style transition: todo -> ready is a direct write the + // backend accepts; the VM PATCHes the target status. + coEvery { + mockApiService.updateKanbanTask( + "task-1", + mapOf("status" to "ready"), + ) + } returns Response.success(Unit) + viewModel.moveTask(viewModel.uiState.value.tasks[0], KanbanTaskAction.READY) + advanceUntilIdle() assertEquals( - "in_progress", + "ready", viewModel.uiState.value.tasks[0] .status, ) + // Create path: the wrapped {"task": ...} response must not + // break deserialization — the API layer reads it as Unit. + coEvery { mockApiService.createKanbanTask(any(), any()) } returns Response.success(Unit) + viewModel.createTask("Fresh task", null, "todo") advanceUntilIdle() - assertEquals( - "in_progress", - viewModel.uiState.value.tasks[0] - .status, - ) + assertEquals("Task created successfully", viewModel.uiState.value.toastMessage) } @Test diff --git a/app/src/test/java/com/m57/hermescontrol/data/remote/ServerEndpointTest.kt b/app/src/test/java/com/m57/hermescontrol/data/remote/ServerEndpointTest.kt index cc95023f..606e1673 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/remote/ServerEndpointTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/remote/ServerEndpointTest.kt @@ -48,6 +48,26 @@ class ServerEndpointTest { } } + @Test + fun `webSocketUrl with custom path and extra params`() { + val ep = ServerEndpoint.parse("https://example.com/hermes", CleartextPolicy.ALLOW_WITH_WARNING) + val url = + ep.webSocketUrl( + path = "api/plugins/kanban/events", + authParameter = "token", + credential = "secret", + extraParams = mapOf("since" to "0", "board" to "work"), + ) + assertEquals("wss://example.com/hermes/api/plugins/kanban/events?token=secret&since=0&board=work", url) + } + + @Test + fun `webSocketUrl with custom path keeps http scheme as ws`() { + val ep = ServerEndpoint.parse("http://127.0.0.1:9119/", CleartextPolicy.ALLOW_WITH_WARNING) + val url = ep.webSocketUrl("api/plugins/kanban/events", "ticket", "t1", mapOf("board" to "default")) + assertEquals("ws://127.0.0.1:9119/api/plugins/kanban/events?ticket=t1&board=default", url) + } + @Test fun `DENY policy rejects http`() { assertThrows { diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/KanbanEventsClientTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/KanbanEventsClientTest.kt new file mode 100644 index 00000000..22aa2276 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/KanbanEventsClientTest.kt @@ -0,0 +1,48 @@ +package com.m57.hermescontrol.data.ws + +import kotlinx.serialization.json.jsonPrimitive +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Test + +class KanbanEventsClientTest { + @Test + fun `parses envelope with snake_case fields and cursor`() { + val frame = + """{"events":[{"id":7,"task_id":"t-1","run_id":"r-9","kind":"status","payload":{"status":"done"},"created_at":1710000000}],"cursor":7}""" + val envelope = parseKanbanEventsFrame(frame) + assertNotNull(envelope) + assertEquals(7L, envelope!!.cursor) + assertEquals(1, envelope.events.size) + val event = envelope.events.first() + assertEquals(7L, event.id) + assertEquals("t-1", event.taskId) + assertEquals("r-9", event.runId) + assertEquals("status", event.kind) + assertEquals("done", event.payload?.get("status")?.jsonPrimitive?.content) + assertEquals(1710000000L, event.createdAt) + } + + @Test + fun `parses empty events batch`() { + val envelope = parseKanbanEventsFrame("""{"events":[],"cursor":3}""") + assertNotNull(envelope) + assertEquals(3L, envelope!!.cursor) + assertEquals(0, envelope.events.size) + } + + @Test + fun `null payload tolerated`() { + val envelope = parseKanbanEventsFrame("""{"events":[{"id":1,"kind":"edited","payload":null}],"cursor":1}""") + assertNotNull(envelope) + assertNull(envelope!!.events.first().payload) + } + + @Test + fun `malformed frames return null`() { + assertNull(parseKanbanEventsFrame("not json")) + assertNull(parseKanbanEventsFrame("""{"events": 42}""")) + assertNull(parseKanbanEventsFrame("")) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanViewModelTest.kt new file mode 100644 index 00000000..f2e15a1c --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanViewModelTest.kt @@ -0,0 +1,260 @@ +package com.m57.hermescontrol.ui.kanban + +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.ApiClient +import com.m57.hermescontrol.data.remote.HermesApiService +import com.m57.hermescontrol.data.ws.KanbanEvent +import com.m57.hermescontrol.data.ws.KanbanEventsClient +import com.m57.hermescontrol.data.ws.KanbanEventsEnvelope +import com.m57.hermescontrol.data.ws.KanbanLiveStatus +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.slot +import io.mockk.unmockkAll +import io.mockk.verify +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import okhttp3.ResponseBody.Companion.toResponseBody +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 +import retrofit2.Response + +@OptIn(ExperimentalCoroutinesApi::class) +class KanbanViewModelTest { + private val testDispatcher = StandardTestDispatcher() + private val mockApi = mockk(relaxed = true) + private val mockEventsClient = mockk(relaxed = true) + + private fun createViewModel(): KanbanViewModel { + val vm = KanbanViewModel(eventsClientProvider = { mockEventsClient }) + testDispatcher.scheduler.advanceUntilIdle() + return vm + } + + /** + * Pump the test scheduler while letting the real Dispatchers.IO hops + * (safeLaunchLoad / withContext(IO)) land their resumptions. + */ + private fun settle() { + repeat(20) { + testDispatcher.scheduler.advanceUntilIdle() + Thread.sleep(10) + } + testDispatcher.scheduler.advanceUntilIdle() + } + + private fun stubBoard(tasks: List = listOf(KanbanTask(id = "t1", title = "Task 1", status = "todo"))) { + coEvery { mockApi.getKanbanBoards() } returns + Response.success( + KanbanBoardsResponse( + boards = listOf(KanbanBoard(id = "work", name = "Work")), + current = "work", + ), + ) + coEvery { mockApi.switchKanbanBoard("work") } returns Response.success(Unit) + coEvery { mockApi.getKanbanBoard() } returns + Response.success(KanbanBoardResponse(columns = listOf(KanbanColumn(name = "todo", tasks = tasks)))) + } + + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + mockkObject(ApiClient) + every { ApiClient.hermesApi } returns mockApi + stubBoard() + } + + @After + fun tearDown() { + Dispatchers.resetMain() + unmockkAll() + } + + @Test + fun `loadBoards connects events stream for current board`() { + val vm = createViewModel() + vm.loadBoards() + settle() + verify { mockEventsClient.connect(any(), eq("work"), any(), any(), any()) } + } + + @Test + fun `connected status flips isLive`() { + val vm = createViewModel() + vm.loadBoards() + settle() + val statusSlot = slot<(KanbanLiveStatus) -> Unit>() + verify { mockEventsClient.connect(any(), any(), any(), any(), capture(statusSlot)) } + statusSlot.captured(KanbanLiveStatus.CONNECTED) + assertTrue(vm.uiState.value.isLive) + statusSlot.captured(KanbanLiveStatus.DISCONNECTED) + assertFalse(vm.uiState.value.isLive) + } + + @Test + fun `events batch triggers debounced board reload`() { + val vm = createViewModel() + vm.loadBoards() + settle() + val eventsSlot = slot<(KanbanEventsEnvelope) -> Unit>() + verify { mockEventsClient.connect(any(), any(), any(), capture(eventsSlot), any()) } + + coEvery { mockApi.getKanbanBoard() } returns + Response.success( + KanbanBoardResponse( + columns = + listOf( + KanbanColumn( + name = "todo", + tasks = + listOf( + KanbanTask(id = "t1", title = "Task 1", status = "todo"), + KanbanTask(id = "t2", title = "Task 2", status = "todo"), + ), + ), + ), + ), + ) + + eventsSlot.captured(KanbanEventsEnvelope(events = listOf(KanbanEvent(id = 1, kind = "created")), cursor = 1)) + testDispatcher.scheduler.advanceTimeBy(249) + assertEquals(1, vm.uiState.value.tasks.size) + testDispatcher.scheduler.advanceTimeBy(1) + settle() + assertEquals(2, vm.uiState.value.tasks.size) + coVerify(exactly = 2) { mockApi.getKanbanBoard() } + } + + @Test + fun `board switch reconnects events stream with new board`() { + val vm = createViewModel() + vm.loadBoards() + 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")) + settle() + verify { mockEventsClient.connect(any(), eq("ops"), any(), any(), any()) } + } + + @Test + fun `re-selecting already-live board does not reconnect`() { + val vm = createViewModel() + vm.loadBoards() + settle() + val statusSlot = slot<(KanbanLiveStatus) -> Unit>() + verify { mockEventsClient.connect(any(), any(), any(), any(), capture(statusSlot)) } + statusSlot.captured(KanbanLiveStatus.CONNECTED) + + vm.selectBoard(KanbanBoard(id = "work", name = "Work")) + settle() + verify(exactly = 1) { mockEventsClient.connect(any(), any(), any(), any(), any()) } + } + + @Test + fun `onCleared disconnects events stream`() { + val vm = createViewModel() + vm.loadBoards() + settle() + val method = KanbanViewModel::class.java.getDeclaredMethod("onCleared") + method.isAccessible = true + method.invoke(vm) + verify { mockEventsClient.disconnect() } + } + + @Test + fun `kanbanActionsForStatus gates transitions like the desktop`() { + assertEquals( + listOf(KanbanTaskAction.TRIAGE, KanbanTaskAction.READY, KanbanTaskAction.ARCHIVE), + kanbanActionsForStatus("todo"), + ) + assertEquals( + listOf( + KanbanTaskAction.TRIAGE, + KanbanTaskAction.UNBLOCK, + KanbanTaskAction.COMPLETE, + KanbanTaskAction.ARCHIVE, + ), + kanbanActionsForStatus("blocked"), + ) + assertEquals( + listOf( + KanbanTaskAction.TRIAGE, + KanbanTaskAction.READY, + KanbanTaskAction.BLOCK, + KanbanTaskAction.COMPLETE, + KanbanTaskAction.ARCHIVE, + ), + kanbanActionsForStatus("running"), + ) + assertEquals( + listOf(KanbanTaskAction.TRIAGE, KanbanTaskAction.READY, KanbanTaskAction.ARCHIVE), + kanbanActionsForStatus("done"), + ) + // Backend-rejected targets are never offered. + assertFalse(kanbanActionsForStatus("todo").any { it.targetStatus == "review" }) + assertFalse(kanbanActionsForStatus("todo").any { it.targetStatus == "running" }) + assertFalse(kanbanActionsForStatus("ready").any { it.targetStatus == "running" }) + } + + @Test + fun `moveTask updates status and PATCHes the target`() { + val vm = createViewModel() + vm.loadBoards() + settle() + coEvery { mockApi.updateKanbanTask(any(), any()) } returns Response.success(Unit) + vm.moveTask(KanbanTask(id = "t1", title = "Task 1", status = "todo"), KanbanTaskAction.READY) + settle() + assertEquals("ready", vm.uiState.value.tasks.first { it.id == "t1" }.status) + coVerify { mockApi.updateKanbanTask("t1", mapOf("status" to "ready")) } + } + + @Test + fun `moveTask failure reverts status and shows toast`() { + val vm = createViewModel() + vm.loadBoards() + settle() + coEvery { mockApi.updateKanbanTask(any(), any()) } returns Response.error(409, "".toResponseBody()) + vm.moveTask(KanbanTask(id = "t1", title = "Task 1", status = "todo"), KanbanTaskAction.READY) + settle() + assertEquals("todo", vm.uiState.value.tasks.first { it.id == "t1" }.status) + assertTrue(vm.uiState.value.toastMessage?.contains("Move failed") == true) + } + + @Test + fun `moveTask complete sends the completion summary`() { + val vm = createViewModel() + vm.loadBoards() + settle() + coEvery { mockApi.updateKanbanTask(any(), any()) } returns Response.success(Unit) + vm.moveTask( + KanbanTask(id = "t1", title = "Task 1", status = "ready"), + KanbanTaskAction.COMPLETE, + summary = "Shipped it", + ) + settle() + coVerify { + mockApi.updateKanbanTask( + "t1", + mapOf("status" to "done", "result" to "Shipped it", "summary" to "Shipped it"), + ) + } + } +}