From 44767fef4aa1011bad6ecf40a29806a51d8e85cf Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Mon, 3 Aug 2026 18:19:08 +0300 Subject: [PATCH] feat(kanban): live board updates over plugin events WebSocket + kanban UI pass (#789) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(kanban): live board updates over plugin events WebSocket (#775) Tail /api/plugins/kanban/events per board with cursor tracking and backoff reconnect; debounced REST reload on event batches (desktop's proven pattern — event payloads are partial updates). Board switch re-subscribes with a fresh stream. Reuses HermesWsClient's ticket refresh (now internal). Adds ServerEndpoint.webSocketUrl(path, ...) overload for non-chat WS endpoints. Kanban UI pass: add-task FAB (the AddTaskDialog was unreachable), live/offline status pill, assignee chip on task cards, 2-line description clamp, empty-column hint. Tests: KanbanEventsClient frame parsing, KanbanViewModel WS wiring (connect on load, debounced reload, board-switch reconnect, no-reconnect guard, onCleared), ServerEndpoint overload. * fix(kanban): create task response is wrapped {"task": ...} — read it as Unit Backend POST /api/plugins/kanban/tasks returns {"task": {...}} (wrapped), but the app declared Response — with ignoreUnknownKeys=true the wrapper key is skipped and the required id/title/status fields are missing, so deserialization threw and the create showed a failure toast while the task WAS created server-side (ghost tasks). The body is never consumed by the ViewModel, so Response matches updateKanbanTask. FAB from the live-events PR (#775) made this latent bug reachable. Adds an E2e regression assertion for the create path. * feat(kanban): remove move left/right arrows — backend rejects the transitions The dashboard PATCH route's status whitelist omits 'review' (a first-class kanban_db status), so any move touching that column 400s, and reopen transitions (done->blocked) 409. Every task on the live board sits in 'done', making the arrows 100% failure. Removing them keeps the UI honest; tasks are managed from the desktop/CLI. Drops moveTask/revertTaskMove from the VM, the arrows from TaskCard, the now-dead updateKanbanTask API method and move strings; E2e test keeps load/create coverage. * feat(kanban): desktop-style task transitions — gated action sheet Mirrors the desktop kanban's move model: tap a card to open an action sheet with only the transitions the backend PATCH route accepts: - triage / ready from any status (direct write; unblock from blocked) - block only from running/ready, complete only from running/ready/blocked - archive from any non-archived status - never running (dispatcher-only, backend 400s) and never review (not in the dashboard whitelist) — the moves that made the old arrows lie Complete prompts for a mandatory completion summary (stored as result), matching the desktop; block/complete/archive ask for confirmation. Optimistic update with revert + toast on failure. Tests: transition-gating map, move PATCH body, failure revert, completion summary body; E2e kanban test covers a desktop-style todo->ready move. * ui(kanban): replace oversized FAB with compact top-bar add button The 56dp FAB floated over the board's bottom-right corner and covered a chunk of the last column. The add action now lives in the header row next to the live pill (input-top style), and the board's bottom contentPadding drops back to 16dp — no more reserved dead zone. * fix(kanban): mint an independent WS ticket — don't touch the chat's shared slot The dashboard WS ticket is single-use and consumed at handshake. Both the chat client and the kanban events client were refreshing into the SAME shared AuthManager token slot on their own schedules (the kanban client on every backoff reconnect, forever). Whichever client built its URL second grabbed the other's just-consumed ticket, got 1008, reconnected, and stole the other's next ticket — mutual starvation that left the app stuck at 'Reconnecting...' (repro on main: none, because main has one consumer). HermesWsClient now exposes mintWsTicket(): a ticket minted WITHOUT touching the shared slot (cookie-auth'd, same endpoint). The kanban events client uses its own ticket per connection; the chat WS keeps the shared slot. Refresh's HTTP-error -> status mapping is preserved via a small result holder. * fix(kanban): drop orphaned ko translations for removed move buttons The move arrows were removed earlier but values-ko still shipped content_desc_move_left/right — Android lint ExtraTranslation failed CI. * fix(ws): keep RECONNECTING on transient ticket-mint exceptions My refactor flattened the ticket-mint failure mapping: a network exception during POST /api/auth/ws-ticket was reported as a null HTTP code and mapped to DISCONNECTED, breaking the #740 contract that transient failures stay in RECONNECTING (and with it testGatedMode_transientTicketFailure- KeepsReconnectFlow). TicketRequestResult now carries an exception flag and refreshWsTicketIfNeeded maps it to RECONNECTING. --------- Co-authored-by: M57 --- .../data/remote/HermesApiService.kt | 3 +- .../data/remote/ServerEndpoint.kt | 26 +- .../hermescontrol/data/ws/HermesWsClient.kt | 79 +++-- .../data/ws/KanbanEventsClient.kt | 239 ++++++++++++++ .../hermescontrol/ui/kanban/KanbanScreen.kt | 304 +++++++++++++++--- .../ui/kanban/KanbanViewModel.kt | 205 +++++++++--- app/src/main/res/values-ko/strings.xml | 2 - app/src/main/res/values/strings.xml | 21 +- .../m57/hermescontrol/E2eIntegrationTest.kt | 30 +- .../data/remote/ServerEndpointTest.kt | 20 ++ .../data/ws/KanbanEventsClientTest.kt | 48 +++ .../ui/kanban/KanbanViewModelTest.kt | 260 +++++++++++++++ 12 files changed, 1110 insertions(+), 127 deletions(-) create mode 100644 app/src/main/java/com/m57/hermescontrol/data/ws/KanbanEventsClient.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/data/ws/KanbanEventsClientTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanViewModelTest.kt 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"), + ) + } + } +}