feat(#1220): complete Kanban management operations parity (#1234)

This commit is contained in:
m57 authored and GitHub committed 2026-09-23 12:25:37 +03:00
1 parent 33d87a11ec
commit 1270a08810
23 files changed
+1777 -51

No files matched your search

@@ -1256,6 +1256,17 @@ class E2eIntegrationTest {
com.m57.hermescontrol.data.model
.UpdateTaskResponse(task = task.copy(status = "ready")),
)
coEvery { mockApiService.getBoard("board-1", any(), any()) } returns
Response.success(
KanbanBoardResponse(
listOf(
KanbanColumn("todo", emptyList()),
KanbanColumn("ready", listOf(task.copy(status = "ready"))),
),
null,
null,
),
)
viewModel.moveTask(viewModel.uiState.value.tasks[0], KanbanTaskAction.READY)
advanceUntilIdle()
assertEquals(
@@ -314,4 +314,57 @@ class KanbanSerializationTest {
assertTrue(unassignJson.contains("\"assignee\":\"\""))
assertTrue(unassignJson.contains("\"reclaim_first\":true"))
}
@Test
fun testRunAndWorkerWireShapesRetainScopeAndOptionalInspectionState() {
val run =
json
.decodeFromString<KanbanRunResponse>(
"""{"run":{"id":7,"task_id":"t_1","profile":"builder","step_key":"implement", "status":"running","worker_pid":42,"started_at":100,"max_runtime_seconds":3600,"last_heartbeat_at":110}}""",
).run
assertEquals("t_1", run.taskId)
assertEquals("implement", run.stepKey)
assertEquals(3600, run.maxRuntimeSeconds)
assertNull(run.endedAt)
val workers =
json.decodeFromString<ActiveWorkersResponse>(
"""{"workers":[{"run_id":7,"task_id":"t_1","task_title":"Build", "task_status":"running","worker_pid":42}],"count":1,"checked_at":123}""",
)
assertEquals(7L, workers.workers.single().runId)
assertEquals(123L, workers.checkedAt)
val unavailable =
json.decodeFromString<KanbanRunInspection>(
"""{"run_id":7,"alive":false,"reason":"psutil not available"}""",
)
assertFalse(unavailable.alive)
assertEquals("psutil not available", unavailable.reason)
assertNull(unavailable.cpuPercent)
}
@Test
fun testBoardTaskWorkflowFieldsAndNullableAttachments() {
val task =
json.decodeFromString<KanbanTask>(
"""{"id":"t_1","title":"Build","status":"running","workflow_template_id":"release","current_step_key":"verify","current_run_id":7}""",
)
assertEquals("release", task.workflowTemplateId)
assertEquals("verify", task.currentStepKey)
assertEquals(7L, task.currentRunId)
val detail =
json.decodeFromString<KanbanTaskDetailResponse>(
"""{"task":{"id":"t_1","title":"Build","status":"running"},"attachments":null,"runs":[]}""",
)
assertNull(detail.attachments)
assertTrue(detail.runs.isEmpty())
}
@Test
fun testTaskLinkPayloadUsesBackendSnakeCase() {
val wire = json.encodeToString(TaskLinkBody(parentId = "t_parent", childId = "t_child"))
assertTrue(wire.contains("\"parent_id\":\"t_parent\""))
assertTrue(wire.contains("\"child_id\":\"t_child\""))
}
}
@@ -7,6 +7,9 @@ import com.m57.hermescontrol.data.model.OrchestrationSettingsUpdate
import com.m57.hermescontrol.data.model.ReassignTaskBody
import com.m57.hermescontrol.data.model.ReclaimTaskBody
import com.m57.hermescontrol.data.model.RenameBoardBody
import com.m57.hermescontrol.data.model.SpecifyTaskBody
import com.m57.hermescontrol.data.model.TaskLinkBody
import com.m57.hermescontrol.data.model.TerminateRunBody
import com.m57.hermescontrol.data.model.UpdateTaskBody
import kotlinx.coroutines.runBlocking
import okhttp3.MediaType.Companion.toMediaType
@@ -308,4 +311,155 @@ class KanbanApiServiceTest {
assertEquals("POST", nudgeReq.method)
assertEquals("/api/plugins/kanban/dispatch?board=dev", nudgeReq.path)
}
@Test
fun testBoardAndTaskFiltersAreExplicitlyScoped() =
runBlocking {
mockServer.enqueue(MockResponse().setResponseCode(200).setBody("""{ "columns": [] }"""))
api.getBoard(
board = "dev",
includeArchived = false,
tenant = null,
workflowTemplateId = "release",
currentStepKey = "verify",
)
val boardRequest = mockServer.takeRequest()
assertEquals("GET", boardRequest.method)
assertTrue(boardRequest.path?.contains("board=dev") == true)
assertTrue(boardRequest.path?.contains("workflow_template_id=release") == true)
assertTrue(boardRequest.path?.contains("current_step_key=verify") == true)
mockServer.enqueue(
MockResponse()
.setResponseCode(200)
.setBody("""{ "task": { "id": "t_1", "title": "Task", "status": "done" } }"""),
)
api.getTask(
taskId = "t_1",
board = "dev",
runStateType = "outcome",
runStateName = "completed",
)
val taskRequest = mockServer.takeRequest()
assertEquals("GET", taskRequest.method)
assertTrue(taskRequest.path?.contains("board=dev") == true)
assertTrue(taskRequest.path?.contains("run_state_type=outcome") == true)
assertTrue(taskRequest.path?.contains("run_state_name=completed") == true)
}
@Test
fun testAttachmentListAndDeleteAreBoardScoped() =
runBlocking {
mockServer.enqueue(
MockResponse()
.setResponseCode(200)
.setBody("""{ "attachments": [{ "id": 9, "filename": "proof.txt" }] }"""),
)
val listed = api.listAttachments(taskId = "t_1", board = "dev")
assertEquals(
"proof.txt",
listed
.body()
?.attachments
?.single()
?.filename,
)
assertEquals("/api/plugins/kanban/tasks/t_1/attachments?board=dev", mockServer.takeRequest().path)
mockServer.enqueue(MockResponse().setResponseCode(200).setBody("""{ "ok": true, "id": 9 }"""))
val deleted = api.deleteAttachment(attachmentId = 9L, board = "dev")
assertTrue(deleted.body()?.ok == true)
val request = mockServer.takeRequest()
assertEquals("DELETE", request.method)
assertEquals("/api/plugins/kanban/attachments/9?board=dev", request.path)
}
@Test
fun testWorkerRunAndSpecificationOperationsAreBoardScoped() =
runBlocking {
mockServer.enqueue(
MockResponse()
.setResponseCode(200)
.setBody(
"""{ "workers": [{ "run_id": 7, "task_id": "t_1", "task_title": "Task", "task_status": "running", "worker_pid": 42 }], "count": 1, "checked_at": 123 }""",
),
)
val workers = api.getActiveWorkers(board = "dev")
assertEquals(
7L,
workers
.body()
?.workers
?.single()
?.runId,
)
assertEquals("/api/plugins/kanban/workers/active?board=dev", mockServer.takeRequest().path)
mockServer.enqueue(
MockResponse()
.setResponseCode(200)
.setBody("""{ "run": { "id": 7, "task_id": "t_1", "status": "running", "started_at": 100 } }"""),
)
val run = api.getRun(runId = 7L, board = "dev")
assertEquals("t_1", run.body()?.run?.taskId)
assertEquals("/api/plugins/kanban/runs/7?board=dev", mockServer.takeRequest().path)
mockServer.enqueue(
MockResponse()
.setResponseCode(200)
.setBody("""{ "run_id": 7, "alive": true, "pid": 42, "cpu_percent": 1.5 }"""),
)
val inspection = api.inspectRun(runId = 7L, board = "dev")
assertTrue(inspection.body()?.alive == true)
assertEquals("/api/plugins/kanban/runs/7/inspect?board=dev", mockServer.takeRequest().path)
mockServer.enqueue(
MockResponse()
.setResponseCode(200)
.setBody("""{ "ok": true, "run_id": 7, "task_id": "t_1" }"""),
)
val terminated =
api.terminateRun(
runId = 7L,
board = "dev",
body = TerminateRunBody(reason = "Operator requested stop"),
)
assertTrue(terminated.body()?.ok == true)
val terminateRequest = mockServer.takeRequest()
assertEquals("POST", terminateRequest.method)
assertEquals("/api/plugins/kanban/runs/7/terminate?board=dev", terminateRequest.path)
assertTrue(terminateRequest.body.readUtf8().contains("Operator requested stop"))
mockServer.enqueue(
MockResponse()
.setResponseCode(200)
.setBody("""{ "ok": true, "task_id": "t_1", "new_title": "Specified task" }"""),
)
val specified = api.specifyTask("t_1", "dev", SpecifyTaskBody(author = "mobile"))
assertEquals("Specified task", specified.body()?.newTitle)
assertEquals("/api/plugins/kanban/tasks/t_1/specify?board=dev", mockServer.takeRequest().path)
}
@Test
fun testTaskLinkConsumesGatingResult() =
runBlocking {
mockServer.enqueue(MockResponse().setResponseCode(200).setBody("""{ "ok": true, "gated": true }"""))
val response =
api.createTaskLink(
board = "dev",
body = TaskLinkBody(parentId = "parent", childId = "child"),
)
assertTrue(response.body()?.ok == true)
assertTrue(response.body()?.gated == true)
val request = mockServer.takeRequest()
assertEquals("POST", request.method)
assertEquals("/api/plugins/kanban/links?board=dev", request.path)
}
}
@@ -2,9 +2,13 @@ package com.m57.hermescontrol.data.repository
import com.m57.hermescontrol.data.model.CreateTaskBody
import com.m57.hermescontrol.data.model.CreateTaskResponse
import com.m57.hermescontrol.data.model.DeleteAttachmentResponse
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.SpecifyTaskResponse
import com.m57.hermescontrol.data.model.TaskLinkResponse
import com.m57.hermescontrol.data.model.TerminateRunResponse
import com.m57.hermescontrol.data.model.UpdateTaskBody
import com.m57.hermescontrol.data.model.UpdateTaskResponse
import com.m57.hermescontrol.data.remote.KanbanApiService
@@ -107,4 +111,28 @@ class KanbanRepositoryTest {
val result = repository.getBoard("missing")
assertTrue(result is NetworkResult.Failure)
}
@Test
fun testNewTaskOperationsCarryBoardAndTypedResults() =
runTest(testDispatcher) {
coEvery { mockApi.deleteAttachment(9L, "ops") } returns
Response.success(DeleteAttachmentResponse(ok = true, id = 9L))
coEvery { mockApi.terminateRun(7L, "ops", any()) } returns
Response.success(TerminateRunResponse(ok = true, runId = 7L, taskId = "t_1"))
coEvery { mockApi.specifyTask("t_1", "ops", any()) } returns
Response.success(SpecifyTaskResponse(ok = false, taskId = "t_1", reason = "Not configured"))
coEvery { mockApi.createTaskLink("ops", any()) } returns
Response.success(TaskLinkResponse(ok = true, gated = true))
assertTrue((repository.deleteAttachment(9L, "ops") as NetworkResult.Success).data.ok)
assertEquals("t_1", (repository.terminateRun(7L, "ops") as NetworkResult.Success).data.taskId)
assertEquals("Not configured", (repository.specifyTask("t_1", "ops") as NetworkResult.Success).data.reason)
assertTrue((repository.createTaskLink("ops", "parent", "t_1") as NetworkResult.Success).data.gated)
coVerify { mockApi.deleteAttachment(9L, "ops") }
coVerify { mockApi.terminateRun(7L, "ops", any()) }
coVerify { mockApi.specifyTask("t_1", "ops", any()) }
coVerify {
mockApi.createTaskLink("ops", match { it.parentId == "parent" && it.childId == "t_1" })
}
}
}
@@ -161,6 +161,16 @@ class KanbanMutationTest {
// Complete network call
deferred.complete(NetworkResult.Success(UpdateTaskResponse(task = task.copy(status = "ready"))))
coEvery { mockRepository.getBoard("dev") } returns
NetworkResult.Success(
KanbanBoardResponse(
columns =
listOf(
KanbanColumn("todo", emptyList()),
KanbanColumn("ready", listOf(task.copy(status = "ready"))),
),
),
)
testDispatcher.scheduler.advanceUntilIdle()
assertFalse(
@@ -1,11 +1,21 @@
package com.m57.hermescontrol.ui.kanban
import com.m57.hermescontrol.data.model.AttachmentListResponse
import com.m57.hermescontrol.data.model.DeleteAttachmentResponse
import com.m57.hermescontrol.data.model.KanbanAttachment
import com.m57.hermescontrol.data.model.KanbanRun
import com.m57.hermescontrol.data.model.KanbanRunInspection
import com.m57.hermescontrol.data.model.KanbanRunResponse
import com.m57.hermescontrol.data.model.KanbanTaskDetailResponse
import com.m57.hermescontrol.data.model.KanbanTaskFull
import com.m57.hermescontrol.data.model.ReassignTaskResponse
import com.m57.hermescontrol.data.model.ReclaimTaskResponse
import com.m57.hermescontrol.data.model.SpecifyTaskResponse
import com.m57.hermescontrol.data.model.TaskLinkResponse
import com.m57.hermescontrol.data.model.TerminateRunResponse
import com.m57.hermescontrol.data.model.UpdateTaskResponse
import com.m57.hermescontrol.data.model.WorkerLog
import com.m57.hermescontrol.data.remote.NetworkError
import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.repository.KanbanRepository
import com.m57.hermescontrol.data.ws.KanbanEventsClient
@@ -19,6 +29,8 @@ import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import okhttp3.MultipartBody
import okhttp3.RequestBody.Companion.toRequestBody
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
@@ -197,7 +209,11 @@ class KanbanTaskViewModelTest {
)
val vm = createViewModel()
vm.uploadAttachment("dev", "t_1", "test.png", "image/png", byteArrayOf(1, 2, 3))
vm.uploadAttachment(
"dev",
"t_1",
MultipartBody.Part.createFormData("file", "test.png", byteArrayOf(1, 2, 3).toRequestBody()),
)
testDispatcher.scheduler.advanceUntilIdle()
coVerify { mockRepository.uploadAttachment("t_1", "dev", any()) }
@@ -321,4 +337,160 @@ class KanbanTaskViewModelTest {
coVerify(exactly = 2) { mockRepository.getTask("t_1", "dev") }
vm.onCleared()
}
@Test
fun testTerminateRunRefreshesRunAndExactTask() =
runTest(testDispatcher) {
val running = KanbanTaskFull(id = "t_1", title = "Task", status = "running")
val ready = running.copy(status = "ready")
coEvery { mockRepository.getTask("t_1", "dev", any(), any()) } returnsMany
listOf(
NetworkResult.Success(KanbanTaskDetailResponse(task = running)),
NetworkResult.Success(KanbanTaskDetailResponse(task = ready)),
)
coEvery { mockRepository.terminateRun(7L, "dev") } returns
NetworkResult.Success(TerminateRunResponse(ok = true, runId = 7L, taskId = "t_1"))
coEvery { mockRepository.getRun(7L, "dev") } returns
NetworkResult.Success(KanbanRunResponse(KanbanRun(id = 7L, status = "reclaimed")))
val vm = createViewModel()
vm.loadTask("dev", "t_1")
testDispatcher.scheduler.runCurrent()
vm.terminateRun("dev", "t_1", 7L)
testDispatcher.scheduler.runCurrent()
assertEquals(
"ready",
vm.uiState.value.detail
?.task
?.status,
)
assertEquals(
"reclaimed",
vm.uiState.value.selectedRun
?.status,
)
coVerify(exactly = 2) { mockRepository.getTask("t_1", "dev", any(), any()) }
coVerify { mockRepository.getRun(7L, "dev") }
vm.onCleared()
}
@Test
fun testRunInspectionUnavailableIsRepresentedHonestly() =
runTest(testDispatcher) {
coEvery { mockRepository.getRun(7L, "dev") } returns
NetworkResult.Success(KanbanRunResponse(KanbanRun(id = 7L, status = "running")))
coEvery { mockRepository.inspectRun(7L, "dev") } returns
NetworkResult.Success(KanbanRunInspection(runId = 7L, alive = false, reason = "psutil not available"))
val vm = createViewModel()
vm.inspectRun("dev", "t_1", 7L)
testDispatcher.scheduler.runCurrent()
assertEquals(
"psutil not available",
vm.uiState.value.runInspection
?.reason,
)
assertEquals(
7L,
vm.uiState.value.selectedRun
?.id,
)
}
@Test
fun testSpecifyFailureUsesTypedReasonWithoutRefreshingTask() =
runTest(testDispatcher) {
coEvery { mockRepository.specifyTask("t_1", "dev") } returns
NetworkResult.Success(SpecifyTaskResponse(ok = false, taskId = "t_1", reason = "Not configured"))
val vm = createViewModel()
vm.specifyTask("dev", "t_1")
testDispatcher.scheduler.runCurrent()
assertTrue(
vm.uiState.value.toastMessage
?.contains("Not configured") == true,
)
coVerify(exactly = 0) { mockRepository.getTask("t_1", "dev", any(), any()) }
}
@Test
fun testLinkGatingOutcomeIsVisibleAndReloadsTask() =
runTest(testDispatcher) {
coEvery { mockRepository.createTaskLink("dev", "parent", "t_1") } returns
NetworkResult.Success(TaskLinkResponse(ok = true, gated = true))
coEvery { mockRepository.getTask("t_1", "dev", any(), any()) } returns
NetworkResult.Success(
KanbanTaskDetailResponse(task = KanbanTaskFull(id = "t_1", title = "Child", status = "todo")),
)
val vm = createViewModel()
vm.createParentLink("dev", "t_1", "parent")
testDispatcher.scheduler.runCurrent()
assertTrue(
vm.uiState.value.toastMessage
?.contains("gated") == true,
)
coVerify { mockRepository.getTask("t_1", "dev", any(), any()) }
}
@Test
fun testDeleteAttachmentReloadsExactTaskAttachmentList() =
runTest(testDispatcher) {
val task = KanbanTaskFull(id = "t_1", title = "Task", status = "todo")
coEvery { mockRepository.getTask("t_1", "dev", any(), any()) } returns
NetworkResult.Success(
KanbanTaskDetailResponse(task = task, attachments = listOf(KanbanAttachment(9L, filename = "old"))),
)
coEvery { mockRepository.deleteAttachment(9L, "dev") } returns
NetworkResult.Success(DeleteAttachmentResponse(ok = true, id = 9L))
coEvery { mockRepository.listAttachments("t_1", "dev") } returns
NetworkResult.Success(AttachmentListResponse(emptyList()))
val vm = createViewModel()
vm.loadTask("dev", "t_1")
testDispatcher.scheduler.runCurrent()
vm.deleteAttachment("dev", "t_1", 9L)
testDispatcher.scheduler.runCurrent()
assertTrue(
vm.uiState.value.detail
?.attachments
?.isEmpty() == true,
)
coVerify { mockRepository.listAttachments("t_1", "dev") }
}
@Test
fun testRunFilterPassesPairedBackendParameters() =
runTest(testDispatcher) {
val detail = KanbanTaskDetailResponse(task = KanbanTaskFull("t_1", "Task", status = "done"))
coEvery { mockRepository.getTask("t_1", "dev", any(), any()) } returns NetworkResult.Success(detail)
val vm = createViewModel()
vm.loadTask("dev", "t_1")
testDispatcher.scheduler.runCurrent()
vm.setRunFilter("dev", "t_1", "status", "reclaimed")
testDispatcher.scheduler.runCurrent()
coVerify { mockRepository.getTask("t_1", "dev", "status", "reclaimed") }
assertEquals("reclaimed", vm.uiState.value.runStateName)
}
@Test
fun testTerminateRefusalPreservesTaskAndReportsReason() =
runTest(testDispatcher) {
coEvery { mockRepository.terminateRun(7L, "dev") } returns
NetworkResult.Failure(NetworkError.Http(409, "run 7 already ended"))
val vm = createViewModel()
vm.terminateRun("dev", "t_1", 7L)
testDispatcher.scheduler.runCurrent()
assertEquals("run 7 already ended", vm.uiState.value.toastMessage)
assertTrue(!vm.uiState.value.isTerminatingRun)
coVerify(exactly = 0) { mockRepository.getRun(7L, "dev") }
}
}
@@ -0,0 +1,34 @@
package com.m57.hermescontrol.ui.kanban
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class KanbanTransitionRefusalTest {
@Test
fun `ready refusal names blocking parent ids and statuses`() {
val refusal =
kanbanTransitionRefusal(
"Cannot move to 'ready': blocked by parent(s) not done — 'Build' (t_a, status=ready), " +
"'Review' (t_b, status=review)",
)
assertEquals(listOf("t_a", "t_b"), refusal?.blockingParentIds)
assertEquals(listOf("ready", "review"), refusal?.blockingParentStatuses)
}
@Test
fun `done refusal identifies unsatisfied parent relationship`() {
val refusal =
kanbanTransitionRefusal(
"cannot move t_c to 'done': unsatisfied parent dependencies: t_a (ready), t_b (running); " +
"complete the parents first (done or archived)",
)
assertEquals(listOf("t_a", "t_b"), refusal?.blockingParentIds)
assertEquals(listOf("ready", "running"), refusal?.blockingParentStatuses)
}
@Test
fun `generic conflict is not falsely classified as dependency refusal`() {
assertNull(kanbanTransitionRefusal("status transition to 'done' not valid from current state"))
}
}
@@ -223,6 +223,19 @@ class KanbanViewModelTest {
val vm = createViewModel()
vm.loadBoards()
settle()
coEvery { mockApi.getBoard(board = "work", any(), any()) } returns
Response.success(
KanbanBoardResponse(
columns =
listOf(
KanbanColumn(name = "todo", tasks = emptyList()),
KanbanColumn(
name = "ready",
tasks = listOf(KanbanTask(id = "t1", title = "Task 1", status = "ready")),
),
),
),
)
coEvery { mockApi.updateTask(any(), any(), any()) } returns Response.success(UpdateTaskResponse())
vm.moveTask(KanbanTask(id = "t1", title = "Task 1", status = "todo"), KanbanTaskAction.READY)
settle()
@@ -255,6 +268,132 @@ class KanbanViewModelTest {
)
}
@Test
fun `moveTask dependency conflict surfaces blocking parent ids`() {
val vm = createViewModel()
vm.loadBoards()
settle()
coEvery { mockApi.updateTask(any(), any(), any()) } returns
Response.error(
409,
"""{"detail":"Cannot move to 'ready': blocked by parent(s) not done — 'Build' (t_parent, status=running)"}"""
.toResponseBody(),
)
vm.moveTask(KanbanTask(id = "t1", title = "Task 1", status = "todo"), KanbanTaskAction.READY)
settle()
assertEquals(
"todo",
vm.uiState.value.tasks
.single()
.status,
)
assertTrue(
vm.uiState.value.toastMessage
.orEmpty()
.contains("t_parent"),
)
}
@Test
fun `workflow filters retain only matching tasks`() {
val tasks =
listOf(
KanbanTask("a", "A", status = "todo", workflowTemplateId = "wf", currentStepKey = "build"),
KanbanTask("b", "B", status = "todo", workflowTemplateId = "other", currentStepKey = "test"),
)
assertEquals(listOf("a"), filterKanbanTasks(tasks, "wf", "build").map { it.id })
assertEquals(listOf("a"), filterKanbanTasks(tasks, "wf", null).map { it.id })
}
@Test
fun `active workers are loaded for selected board`() {
val vm = createViewModel()
vm.loadBoards()
settle()
coEvery { mockApi.getActiveWorkers("work") } returns
Response.success(
com.m57.hermescontrol.data.model.ActiveWorkersResponse(
workers =
listOf(
com.m57.hermescontrol.data.model.KanbanActiveWorker(
runId = 1,
taskId = "t1",
taskTitle = "Task 1",
taskStatus = "running",
workerPid = 42,
),
),
checkedAt = 1,
),
)
vm.loadActiveWorkers()
settle()
assertEquals(1, vm.uiState.value.activeWorkers.size)
assertEquals(
"t1",
vm.uiState.value.activeWorkers
.single()
.taskId,
)
vm.selectBoard(KanbanBoard(id = "ops", name = "Ops"))
settle()
assertTrue(
vm.uiState.value.activeWorkers
.isEmpty(),
)
}
@Test
fun `successful move refreshes board to reconcile done ordering`() {
coEvery { mockApi.getBoard(board = "work", any(), any()) } returns
Response.success(
KanbanBoardResponse(
columns =
listOf(
KanbanColumn(
name = "todo",
tasks = listOf(KanbanTask(id = "t1", title = "Task 1", status = "todo")),
),
KanbanColumn(
name = "done",
tasks = listOf(KanbanTask(id = "older", title = "Older", status = "done")),
),
),
),
)
val vm = createViewModel()
vm.loadBoards()
settle()
coEvery { mockApi.getBoard(board = "work", any(), any()) } returns
Response.success(
KanbanBoardResponse(
columns =
listOf(
KanbanColumn(name = "todo", tasks = emptyList()),
KanbanColumn(
name = "done",
tasks =
listOf(
KanbanTask(id = "t1", title = "Task 1", status = "done"),
KanbanTask(id = "older", title = "Older", status = "done"),
),
),
),
),
)
coEvery { mockApi.updateTask(any(), any(), any()) } returns Response.success(UpdateTaskResponse())
vm.moveTask(KanbanTask("t1", "Task 1", status = "todo"), KanbanTaskAction.COMPLETE, "done")
settle()
coVerify(atLeast = 2) { mockApi.getBoard(board = "work", any(), any()) }
assertEquals(
listOf("t1", "older"),
vm.uiState.value.columns
.single { it.name == "done" }
.tasks
.map { it.id },
)
}
@Test
fun `moveTask complete sends the completion summary`() {
val vm = createViewModel()