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

@@ -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()