test(kanban): add payload, event formatter, runtime and instrumentation tests (#1137)
This commit is contained in:
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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user