test(kanban): add payload, event formatter, runtime and instrumentation tests (#1137)

This commit is contained in:
M57 committed 2026-09-16 18:15:36 +03:00
1 parent f533477bd9
commit 190a93a4cf
10 files changed
+1001 -7

No files matched your search

@@ -0,0 +1,63 @@
package com.m57.hermescontrol.ui.kanban
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class KanbanColumnCollapseHelperTest {
@Test
fun testBoardWithoutWorkDoesNotCollapse() {
assertFalse(
KanbanColumnCollapseHelper.isColumnCollapsed(
columnName = "todo",
columnTaskCount = 0,
boardHasWork = false,
overrides = emptyMap(),
),
)
}
@Test
fun testBoardWithWorkAutoCollapsesEmptyColumn() {
assertTrue(
KanbanColumnCollapseHelper.isColumnCollapsed(
columnName = "todo",
columnTaskCount = 0,
boardHasWork = true,
overrides = emptyMap(),
),
)
assertFalse(
KanbanColumnCollapseHelper.isColumnCollapsed(
columnName = "ready",
columnTaskCount = 3,
boardHasWork = true,
overrides = emptyMap(),
),
)
}
@Test
fun testManualOverrideTakesPrecedence() {
// Force expand an empty column
assertFalse(
KanbanColumnCollapseHelper.isColumnCollapsed(
columnName = "todo",
columnTaskCount = 0,
boardHasWork = true,
overrides = mapOf("todo" to false),
),
)
// Force collapse an occupied column
assertTrue(
KanbanColumnCollapseHelper.isColumnCollapsed(
columnName = "ready",
columnTaskCount = 3,
boardHasWork = true,
overrides = mapOf("ready" to true),
),
)
}
}
@@ -0,0 +1,174 @@
package com.m57.hermescontrol.ui.kanban
import com.m57.hermescontrol.data.model.CreateTaskBody
import com.m57.hermescontrol.data.model.CreateTaskResponse
import com.m57.hermescontrol.data.model.KanbanBoard
import com.m57.hermescontrol.data.model.KanbanBoardResponse
import com.m57.hermescontrol.data.model.KanbanColumn
import com.m57.hermescontrol.data.model.KanbanTask
import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.repository.KanbanRepository
import com.m57.hermescontrol.data.ws.KanbanEventsClient
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class KanbanCreateTaskPayloadTest {
private val testDispatcher = StandardTestDispatcher()
private val mockRepository = mockk<KanbanRepository>(relaxed = true)
private val mockEventsClient = mockk<KanbanEventsClient>(relaxed = true)
private fun createViewModel(): KanbanViewModel =
KanbanViewModel(
repository = mockRepository,
eventsClientProvider = { mockEventsClient },
)
@Before
fun setUp() {
Dispatchers.setMain(testDispatcher)
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
@Test
fun testCreateTaskPayloadWithAllFields() =
runTest(testDispatcher) {
val board = KanbanBoard(id = "dev", name = "Dev")
val newTask = KanbanTask(id = "t_100", title = "Full task", status = "todo")
coEvery { mockRepository.getBoard("dev", any(), any()) } returns
NetworkResult.Success(KanbanBoardResponse(columns = listOf(KanbanColumn("todo", emptyList()))))
coEvery { mockRepository.createTask("dev", any()) } returns
NetworkResult.Success(CreateTaskResponse(task = newTask))
val vm = createViewModel()
vm.selectBoard(board)
testDispatcher.scheduler.advanceUntilIdle()
val body =
CreateTaskBody(
title = "Full task",
body = "Rich details",
assignee = "researcher",
priority = 2,
goalMode = true,
parents = listOf("p_1", "p_2"),
modelOverride = "claude-3-5-sonnet",
providerOverride = "anthropic",
reasoningEffort = "high",
workspaceKind = "worktree",
workspacePath = "/custom/wt/path",
skills = listOf("git", "testing"),
triage = true,
)
vm.createTask(body, targetStatus = "triage")
testDispatcher.scheduler.advanceUntilIdle()
coVerify {
mockRepository.createTask(
"dev",
match {
it.title == "Full task" &&
it.body == "Rich details" &&
it.assignee == "researcher" &&
it.priority == 2 &&
it.goalMode &&
it.parents == listOf("p_1", "p_2") &&
it.modelOverride == "claude-3-5-sonnet" &&
it.providerOverride == "anthropic" &&
it.reasoningEffort == "high" &&
it.workspaceKind == "worktree" &&
it.workspacePath == "/custom/wt/path" &&
it.skills == listOf("git", "testing") &&
it.triage
},
)
}
}
@Test
fun testScratchWorkspaceNeverSendsPath() =
runTest(testDispatcher) {
val board = KanbanBoard(id = "dev", name = "Dev")
coEvery { mockRepository.getBoard("dev", any(), any()) } returns
NetworkResult.Success(KanbanBoardResponse(columns = emptyList()))
coEvery { mockRepository.createTask("dev", any()) } returns
NetworkResult.Success(
CreateTaskResponse(task = KanbanTask(id = "t_1", title = "Task", status = "todo")),
)
val vm = createViewModel()
vm.selectBoard(board)
testDispatcher.scheduler.advanceUntilIdle()
// In our dialog logic, scratch sets workspaceKind = null (or scratch) and workspacePath = null
val body =
CreateTaskBody(
title = "Scratch Task",
workspaceKind = null,
workspacePath = null,
)
vm.createTask(body)
testDispatcher.scheduler.advanceUntilIdle()
coVerify {
mockRepository.createTask(
"dev",
match {
it.workspaceKind == null && it.workspacePath == null
},
)
}
}
@Test
fun testParkedAssigneeSendsNull() =
runTest(testDispatcher) {
val board = KanbanBoard(id = "dev", name = "Dev")
coEvery { mockRepository.getBoard("dev", any(), any()) } returns
NetworkResult.Success(KanbanBoardResponse(columns = emptyList()))
coEvery { mockRepository.createTask("dev", any()) } returns
NetworkResult.Success(
CreateTaskResponse(task = KanbanTask(id = "t_1", title = "Task", status = "todo")),
)
val vm = createViewModel()
vm.selectBoard(board)
testDispatcher.scheduler.advanceUntilIdle()
val body =
CreateTaskBody(
title = "Parked Task",
assignee = null, // Parked
)
vm.createTask(body)
testDispatcher.scheduler.advanceUntilIdle()
coVerify {
mockRepository.createTask(
"dev",
match { it.assignee == null },
)
}
}
}
@@ -0,0 +1,89 @@
package com.m57.hermescontrol.ui.kanban
import com.m57.hermescontrol.data.model.DispatchResult
import com.m57.hermescontrol.data.remote.KanbanApiService
import com.m57.hermescontrol.data.repository.DefaultKanbanDispatcherNudger
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runTest
import org.junit.Test
import retrofit2.Response
@OptIn(ExperimentalCoroutinesApi::class)
class KanbanDispatcherNudgerTest {
private val testDispatcher = StandardTestDispatcher()
private val testScope = TestScope(testDispatcher)
private val mockApi = mockk<KanbanApiService>()
@Test
fun testNudgeDebouncingSchedulesSingleCall() =
testScope.runTest {
coEvery { mockApi.nudgeDispatcher(any()) } returns Response.success(DispatchResult())
val nudger =
DefaultKanbanDispatcherNudger(
apiProvider = { mockApi },
scope = this,
debounceMs = 400L,
)
nudger.scheduleNudge("board-a")
advanceTimeBy(100L)
nudger.scheduleNudge("board-a")
advanceTimeBy(100L)
nudger.scheduleNudge("board-a")
// Before 400ms passes from last call
advanceTimeBy(300L)
coVerify(exactly = 0) { mockApi.nudgeDispatcher("board-a") }
// After 400ms passes
advanceTimeBy(150L)
coVerify(exactly = 1) { mockApi.nudgeDispatcher("board-a") }
}
@Test
fun testNudgeScopedPerBoard() =
testScope.runTest {
coEvery { mockApi.nudgeDispatcher(any()) } returns Response.success(DispatchResult())
val nudger =
DefaultKanbanDispatcherNudger(
apiProvider = { mockApi },
scope = this,
debounceMs = 400L,
)
nudger.scheduleNudge("board-a")
nudger.scheduleNudge("board-b")
advanceTimeBy(450L)
coVerify(exactly = 1) { mockApi.nudgeDispatcher("board-a") }
coVerify(exactly = 1) { mockApi.nudgeDispatcher("board-b") }
}
@Test
fun testNudgeFailureAbsorbedSilently() =
testScope.runTest {
coEvery { mockApi.nudgeDispatcher(any()) } throws RuntimeException("Network timeout")
val nudger =
DefaultKanbanDispatcherNudger(
apiProvider = { mockApi },
scope = this,
debounceMs = 100L,
)
nudger.scheduleNudge("board-a")
advanceTimeBy(150L)
coVerify(exactly = 1) { mockApi.nudgeDispatcher("board-a") }
// No unhandled exception thrown
}
}
@@ -0,0 +1,197 @@
package com.m57.hermescontrol.ui.kanban
import com.m57.hermescontrol.data.model.KanbanDetailEvent
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class KanbanEventFormatterTest {
@Test
fun testCreatedEvent() {
val payload =
buildJsonObject {
put("status", "ready")
put("assignee", "coder")
}
val event = KanbanDetailEvent(id = 1, kind = "created", payload = payload, createdAt = 1000)
val formatted = KanbanEventFormatter.format(event)
assertEquals("Created in Ready by coder", formatted.label)
assertNull(formatted.detail)
}
@Test
fun testStatusEventWithReason() {
val payload =
buildJsonObject {
put("status", "done")
put("reason", "completed successfully")
}
val event = KanbanDetailEvent(id = 2, kind = "status", payload = payload, createdAt = 1000)
val formatted = KanbanEventFormatter.format(event)
assertEquals("Moved to Done", formatted.label)
assertEquals("completed successfully", formatted.detail)
}
@Test
fun testParentReopenedStatusEvent() {
val payload =
buildJsonObject {
put("status", "todo")
put("reason", "parent_reopened")
put("parent", "t_parent1")
}
val event = KanbanDetailEvent(id = 3, kind = "status", payload = payload, createdAt = 1000)
val formatted = KanbanEventFormatter.format(event)
assertEquals("Moved to Todo", formatted.label)
assertEquals("Parent t_parent1 reopened", formatted.detail)
}
@Test
fun testAssignedEvent() {
val payload = buildJsonObject { put("assignee", "reviewer") }
val event = KanbanDetailEvent(id = 4, kind = "assigned", payload = payload, createdAt = 1000)
val formatted = KanbanEventFormatter.format(event)
assertEquals("Assigned to reviewer", formatted.label)
val unassignedEvent = KanbanDetailEvent(id = 5, kind = "assigned", payload = null, createdAt = 1000)
assertEquals("Unassigned", KanbanEventFormatter.format(unassignedEvent).label)
}
@Test
fun testClaimedReviewAndWorker() {
val reviewClaim =
KanbanDetailEvent(
id = 6,
kind = "claimed",
payload = buildJsonObject { put("source_status", "review") },
createdAt = 1000,
)
assertEquals("Claimed for review", KanbanEventFormatter.format(reviewClaim).label)
val workerClaim =
KanbanDetailEvent(
id = 7,
kind = "claimed",
payload = buildJsonObject { put("source_status", "ready") },
createdAt = 1000,
)
assertEquals("Claimed by worker", KanbanEventFormatter.format(workerClaim).label)
}
@Test
fun testSpawnedEvent() {
val event =
KanbanDetailEvent(
id = 8,
kind = "spawned",
payload = buildJsonObject { put("pid", 12345) },
createdAt = 1000,
)
val formatted = KanbanEventFormatter.format(event)
assertEquals("Worker started", formatted.label)
assertEquals("PID 12345", formatted.detail)
}
@Test
fun testLifecycleEvents() {
assertEquals("Completed", KanbanEventFormatter.format(KanbanDetailEvent(9, "completed", null, 1000)).label)
assertEquals(
"Blocked",
KanbanEventFormatter
.format(
KanbanDetailEvent(
10,
"blocked",
buildJsonObject { put("reason", "waiting on CI") },
1000,
),
).label,
)
assertEquals(
"Unblocked (Ready)",
KanbanEventFormatter
.format(
KanbanDetailEvent(
11,
"unblocked",
buildJsonObject { put("status", "ready") },
1000,
),
).label,
)
assertEquals(
"Reclaimed",
KanbanEventFormatter
.format(
KanbanDetailEvent(
12,
"reclaimed",
buildJsonObject { put("reason", "timeout") },
1000,
),
).label,
)
assertEquals("Specified", KanbanEventFormatter.format(KanbanDetailEvent(13, "specified", null, 1000)).label)
assertEquals(
"Promoted to ready",
KanbanEventFormatter.format(KanbanDetailEvent(14, "promoted", null, 1000)).label,
)
assertEquals("Archived", KanbanEventFormatter.format(KanbanDetailEvent(15, "archived", null, 1000)).label)
assertEquals(
"Priority set to 2",
KanbanEventFormatter
.format(
KanbanDetailEvent(
16,
"reprioritized",
buildJsonObject { put("priority", 2) },
1000,
),
).label,
)
}
@Test
fun testUnknownEventWithScalarPayload() {
val payload =
buildJsonObject {
put("foo", "bar")
put("count", 42)
}
val event = KanbanDetailEvent(id = 17, kind = "custom_hook", payload = payload, createdAt = 1000)
val formatted = KanbanEventFormatter.format(event)
assertEquals("Custom hook", formatted.label)
assertEquals("foo=bar count=42", formatted.detail)
}
@Test
fun testStringJsonPayload() {
val event =
KanbanDetailEvent(
id = 18,
kind = "status",
payload = JsonPrimitive("{\"status\":\"blocked\",\"reason\":\"rate limit\"}"),
createdAt = 1000,
)
val formatted = KanbanEventFormatter.format(event)
assertEquals("Moved to Blocked", formatted.label)
assertEquals("rate limit", formatted.detail)
}
@Test
fun testMalformedPayloadGraceful() {
val event =
KanbanDetailEvent(
id = 19,
kind = "custom_test",
payload = JsonPrimitive("{not valid json"),
createdAt = 1000,
)
val formatted = KanbanEventFormatter.format(event)
assertEquals("Custom test", formatted.label)
assertEquals("raw={not valid json", formatted.detail)
}
}
@@ -0,0 +1,50 @@
package com.m57.hermescontrol.ui.kanban
import com.m57.hermescontrol.data.model.KanbanTask
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class KanbanLaneGroupingTest {
@Test
fun testGroupRunningTasksSeparatesProfilesDeterministically() {
val tasks =
listOf(
KanbanTask(id = "t1", title = "Task 1", status = "running", assignee = "reviewer"),
KanbanTask(id = "t2", title = "Task 2", status = "running", assignee = "coder"),
KanbanTask(id = "t3", title = "Task 3", status = "running", assignee = null),
KanbanTask(id = "t4", title = "Task 4", status = "running", assignee = "coder"),
)
val groups = KanbanLaneGrouping.groupRunningTasks(tasks)
// Coder first (alphabetical), then Reviewer, then Unassigned
assertEquals(3, groups.size)
assertEquals("coder", groups[0].assignee)
assertEquals(listOf("t2", "t4"), groups[0].tasks.map { it.id })
assertEquals("reviewer", groups[1].assignee)
assertEquals(listOf("t1"), groups[1].tasks.map { it.id })
assertNull(groups[2].assignee)
assertEquals(listOf("t3"), groups[2].tasks.map { it.id })
}
@Test
fun testGroupRunningTasksWithoutUnassigned() {
val tasks =
listOf(
KanbanTask(id = "t1", title = "Task 1", status = "running", assignee = "coder"),
)
val groups = KanbanLaneGrouping.groupRunningTasks(tasks)
assertEquals(1, groups.size)
assertEquals("coder", groups[0].assignee)
}
@Test
fun testGroupRunningTasksEmpty() {
val groups = KanbanLaneGrouping.groupRunningTasks(emptyList())
assertEquals(0, groups.size)
}
}
@@ -0,0 +1,66 @@
package com.m57.hermescontrol.ui.kanban
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class KanbanModelOverrideTest {
@Test
fun testEmptyOverrideIsInherited() {
val override = KanbanModelOverride.EMPTY
assertTrue(override.isInherited)
assertEquals("Inherit profile", override.displayLabel("Inherit profile"))
val patch = override.toUpdateTaskBody()
assertNull(patch.modelOverride)
assertNull(patch.providerOverride)
assertTrue(patch.clearModelOverride)
assertNull(patch.reasoningEffort)
assertTrue(patch.clearReasoningEffort)
}
@Test
fun testCustomModelAndEffortPatch() {
val override =
KanbanModelOverride(
model = "claude-3-5-sonnet",
provider = "anthropic",
effort = "high",
)
assertFalse(override.isInherited)
assertEquals("anthropic: claude-3-5-sonnet · High", override.displayLabel())
val patch = override.toUpdateTaskBody()
assertEquals("claude-3-5-sonnet", patch.modelOverride)
assertEquals("anthropic", patch.providerOverride)
assertFalse(patch.clearModelOverride)
assertEquals("high", patch.reasoningEffort)
assertFalse(patch.clearReasoningEffort)
}
@Test
fun testExplicitNoneReasoningEffort() {
val override =
KanbanModelOverride(
model = "gpt-4o",
provider = "openai",
effort = "none",
)
assertEquals("openai: gpt-4o · None", override.displayLabel())
val patch = override.toUpdateTaskBody()
assertEquals("none", patch.reasoningEffort)
assertFalse(patch.clearReasoningEffort)
}
@Test
fun testModelClearedLeavesEffortIntact() {
val override = KanbanModelOverride(model = "", provider = "", effort = "low")
val patch = override.toUpdateTaskBody()
assertTrue(patch.clearModelOverride)
assertEquals("low", patch.reasoningEffort)
assertFalse(patch.clearReasoningEffort)
}
}
@@ -393,15 +393,15 @@ class KanbanMutationTest {
coVerify {
mockRepository.updateBoard("dev", match { it.name == "Dev Renamed" })
}
assertEquals("Board renamed to Dev Renamed", vm.uiState.value.toastMessage)
assertEquals("Board updated: Dev Renamed", vm.uiState.value.toastMessage)
}
@Test
fun testDeleteBoardSuccess() =
runTest(testDispatcher) {
coEvery { mockRepository.deleteBoard("old-board", delete = true) } returns
coEvery { mockRepository.deleteBoard("old-board", delete = false) } returns
NetworkResult.Success(DeleteBoardResponse(current = null))
coEvery { mockRepository.getBoards() } returns
coEvery { mockRepository.getBoards(any()) } returns
NetworkResult.Success(
com.m57.hermescontrol.data.model
.KanbanBoardsResponse(emptyList(), null),
@@ -411,8 +411,102 @@ class KanbanMutationTest {
vm.deleteBoard("old-board")
testDispatcher.scheduler.advanceUntilIdle()
coVerify { mockRepository.deleteBoard("old-board", delete = true) }
assertEquals("Board deleted", vm.uiState.value.toastMessage)
coVerify { mockRepository.deleteBoard("old-board", delete = false) }
assertEquals("Board archived", vm.uiState.value.toastMessage)
}
@Test
fun testDeleteDefaultBoardRefused() =
runTest(testDispatcher) {
val vm = createViewModel()
vm.deleteBoard("default")
testDispatcher.scheduler.advanceUntilIdle()
coVerify(exactly = 0) { mockRepository.deleteBoard(any(), any()) }
assertTrue(
vm.uiState.value.toastMessage
?.contains("default board") == true,
)
}
@Test
fun testSetIncludeArchivedTogglesAndReloadsBoard() =
runTest(testDispatcher) {
val board = KanbanBoard(id = "dev", name = "Dev")
coEvery { mockRepository.getBoard(board = "dev", includeArchived = any(), any()) } returns
NetworkResult.Success(KanbanBoardResponse(columns = listOf(KanbanColumn("todo", emptyList()))))
val vm = createViewModel()
vm.selectBoard(board)
testDispatcher.scheduler.advanceUntilIdle()
vm.setIncludeArchived(true)
testDispatcher.scheduler.advanceUntilIdle()
assertTrue(vm.uiState.value.includeArchived)
coVerify { mockRepository.getBoard(board = "dev", includeArchived = true, any()) }
vm.setIncludeArchived(false)
testDispatcher.scheduler.advanceUntilIdle()
assertFalse(vm.uiState.value.includeArchived)
coVerify { mockRepository.getBoard(board = "dev", includeArchived = false, any()) }
}
@Test
fun testBulkMovePartialFailureKeepsFailedIds() =
runTest(testDispatcher) {
val board = KanbanBoard(id = "dev", name = "Dev")
coEvery { mockRepository.getBoard("dev", any(), any()) } returns
NetworkResult.Success(KanbanBoardResponse(columns = listOf(KanbanColumn("done", emptyList()))))
coEvery { mockRepository.bulkTasks("dev", any()) } returns
NetworkResult.Success(
BulkTasksResponse(
listOf(
BulkTaskResult("t_1", true),
BulkTaskResult("t_2", false, "Conflict"),
),
),
)
val vm = createViewModel()
vm.selectBoard(board)
testDispatcher.scheduler.advanceUntilIdle()
var reportedFailed: Set<String>? = null
vm.bulkMove(listOf("t_1", "t_2"), "done") { failed ->
reportedFailed = failed
}
testDispatcher.scheduler.advanceUntilIdle()
assertEquals(setOf("t_2"), reportedFailed)
assertTrue(
vm.uiState.value.toastMessage
?.contains("1 failed") == true,
)
}
@Test
fun testCreateBoardWithProject() =
runTest(testDispatcher) {
val board = KanbanBoard(id = "scoped", name = "Scoped", projectId = "proj-123")
coEvery { mockRepository.createBoard(any()) } returns
NetworkResult.Success(CreateBoardResponse(board = board))
coEvery { mockRepository.getBoards(any()) } returns
NetworkResult.Success(
com.m57.hermescontrol.data.model
.KanbanBoardsResponse(listOf(board), "scoped"),
)
val vm = createViewModel()
vm.createBoard(slug = "scoped", name = "Scoped", projectId = "proj-123")
testDispatcher.scheduler.advanceUntilIdle()
coVerify {
mockRepository.createBoard(
match { it.slug == "scoped" && it.projectId == "proj-123" },
)
}
}
@Test
@@ -0,0 +1,71 @@
package com.m57.hermescontrol.ui.kanban
import com.m57.hermescontrol.data.model.KanbanTask
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class KanbanRuntimeHelperTest {
@Test
fun testRunningTaskWithFreshHeartbeat() {
val now = 1000L
val task = KanbanTask(id = "t1", title = "Task 1", status = "running", lastHeartbeatAt = now - 30)
val state = KanbanRuntimeHelper.arcState(task, nowSeconds = now)
assertEquals(KanbanArcState.RUNNING, state)
}
@Test
fun testRunningTaskWithStaleHeartbeat() {
val now = 1000L
val task = KanbanTask(id = "t1", title = "Task 1", status = "running", lastHeartbeatAt = now - 150)
val state = KanbanRuntimeHelper.arcState(task, nowSeconds = now)
assertEquals(KanbanArcState.STALE, state)
}
@Test
fun testQueuedTask() {
val triageTask = KanbanTask(id = "t1", title = "Task 1", status = "triage")
assertEquals(KanbanArcState.QUEUED, KanbanRuntimeHelper.arcState(triageTask))
val reviewTask = KanbanTask(id = "t2", title = "Task 2", status = "review")
assertEquals(KanbanArcState.QUEUED, KanbanRuntimeHelper.arcState(reviewTask))
val readyAssigned = KanbanTask(id = "t3", title = "Task 3", status = "ready", assignee = "coder")
assertEquals(KanbanArcState.QUEUED, KanbanRuntimeHelper.arcState(readyAssigned))
val readyWithFallback = KanbanTask(id = "t4", title = "Task 4", status = "ready", assignee = null)
assertEquals(
KanbanArcState.QUEUED,
KanbanRuntimeHelper.arcState(readyWithFallback, fallbackAssignee = "default"),
)
val readyUnassigned = KanbanTask(id = "t5", title = "Task 5", status = "ready", assignee = null)
assertNull(KanbanRuntimeHelper.arcState(readyUnassigned, fallbackAssignee = null))
}
@Test
fun testIsWontRun() {
val readyUnassigned = KanbanTask(id = "t1", title = "Task 1", status = "ready", assignee = null)
assertTrue(KanbanRuntimeHelper.isWontRun(readyUnassigned, fallbackAssignee = null))
assertFalse(KanbanRuntimeHelper.isWontRun(readyUnassigned, fallbackAssignee = "default"))
val readyAssigned = KanbanTask(id = "t2", title = "Task 2", status = "ready", assignee = "coder")
assertFalse(KanbanRuntimeHelper.isWontRun(readyAssigned, fallbackAssignee = null))
val todoUnassigned = KanbanTask(id = "t3", title = "Task 3", status = "todo", assignee = null)
assertFalse(KanbanRuntimeHelper.isWontRun(todoUnassigned))
}
@Test
fun testFormatElapsed() {
val now = 200_000L
assertEquals("45s", KanbanRuntimeHelper.formatElapsed(now - 45, nowSeconds = now))
assertEquals("5m", KanbanRuntimeHelper.formatElapsed(now - 300, nowSeconds = now))
assertEquals("2h", KanbanRuntimeHelper.formatElapsed(now - 7200, nowSeconds = now))
assertEquals("1d", KanbanRuntimeHelper.formatElapsed(now - 90000, nowSeconds = now))
assertNull(KanbanRuntimeHelper.formatElapsed(null))
assertNull(KanbanRuntimeHelper.formatElapsed(now + 10, nowSeconds = now))
}
}
@@ -8,6 +8,7 @@ import com.m57.hermescontrol.data.model.UpdateTaskResponse
import com.m57.hermescontrol.data.model.WorkerLog
import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.repository.KanbanRepository
import com.m57.hermescontrol.data.ws.KanbanEventsClient
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
@@ -28,10 +29,12 @@ import org.junit.Test
class KanbanTaskViewModelTest {
private val testDispatcher = StandardTestDispatcher()
private val mockRepository = mockk<KanbanRepository>(relaxed = true)
private val mockEventsClient = mockk<KanbanEventsClient>(relaxed = true)
private fun createViewModel(): KanbanTaskViewModel =
KanbanTaskViewModel(
repository = mockRepository,
eventsClientProvider = { mockEventsClient },
)
@Before
@@ -75,7 +78,7 @@ class KanbanTaskViewModelTest {
val vm = createViewModel()
vm.loadTask("dev", "t_1")
testDispatcher.scheduler.advanceUntilIdle()
testDispatcher.scheduler.runCurrent()
assertEquals(
"Log line 1",
@@ -83,6 +86,7 @@ class KanbanTaskViewModelTest {
?.content,
)
coVerify { mockRepository.getTaskLog("t_1", "dev", any()) }
vm.onCleared()
}
@Test
@@ -133,7 +137,7 @@ class KanbanTaskViewModelTest {
@Test
fun testPostNoteAndRequeue() =
runTest(testDispatcher) {
val task = KanbanTaskFull(id = "t_1", title = "Running task", status = "running")
val task = KanbanTaskFull(id = "t_1", title = "Task 1", status = "todo")
coEvery { mockRepository.getTask("t_1", "dev") } returns
NetworkResult.Success(KanbanTaskDetailResponse(task = task))
coEvery { mockRepository.addComment("t_1", "dev", "Stop now") } returns
@@ -197,4 +201,88 @@ class KanbanTaskViewModelTest {
coVerify { mockRepository.uploadAttachment("t_1", "dev", any()) }
assertEquals("Attachment uploaded", vm.uiState.value.toastMessage)
}
@Test
fun testUpdateModelOverride() =
runTest(testDispatcher) {
val task = KanbanTaskFull(id = "t_1", title = "Task 1", status = "todo")
coEvery { mockRepository.getTask("t_1", "dev") } returns
NetworkResult.Success(KanbanTaskDetailResponse(task = task))
coEvery { mockRepository.updateTask("t_1", "dev", any()) } returns
NetworkResult.Success(UpdateTaskResponse())
val vm = createViewModel()
vm.loadTask("dev", "t_1")
testDispatcher.scheduler.advanceUntilIdle()
val override = KanbanModelOverride(model = "claude-3-5-sonnet", provider = "anthropic", effort = "high")
vm.updateModelOverride("dev", "t_1", override)
testDispatcher.scheduler.advanceUntilIdle()
coVerify {
mockRepository.updateTask(
"t_1",
"dev",
match {
it.modelOverride == "claude-3-5-sonnet" &&
it.providerOverride == "anthropic" &&
it.reasoningEffort == "high" &&
!it.clearModelOverride
},
)
}
assertEquals("Model override updated", vm.uiState.value.toastMessage)
}
@Test
fun testEstimateTask() =
runTest(testDispatcher) {
val task = KanbanTaskFull(id = "t_1", title = "Task 1", status = "todo")
coEvery { mockRepository.getTask("t_1", "dev") } returns
NetworkResult.Success(KanbanTaskDetailResponse(task = task))
coEvery { mockRepository.estimateTask("t_1", "dev") } returns
NetworkResult.Success(
com.m57.hermescontrol.data.model.TaskEstimate(
ok = true,
estTokens = 1500,
complexity = "medium",
),
)
val vm = createViewModel()
vm.loadTask("dev", "t_1")
testDispatcher.scheduler.advanceUntilIdle()
vm.estimateTask("dev", "t_1")
testDispatcher.scheduler.advanceUntilIdle()
coVerify { mockRepository.estimateTask("t_1", "dev") }
assertEquals(
1500,
vm.uiState.value.estimate
?.tokens,
)
assertEquals(
"medium",
vm.uiState.value.estimate
?.complexity,
)
}
@Test
fun testDeleteTask() =
runTest(testDispatcher) {
coEvery { mockRepository.deleteTask("t_1", "dev") } returns NetworkResult.Success(Unit)
val vm = createViewModel()
var deleted = false
vm.deleteTask("dev", "t_1") {
deleted = true
}
testDispatcher.scheduler.advanceUntilIdle()
coVerify { mockRepository.deleteTask("t_1", "dev") }
assertTrue(deleted)
assertEquals("Task deleted", vm.uiState.value.toastMessage)
}
}