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()
|
||||
|
||||
Reference in new issue
Block a user