diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/kanban/KanbanScreenTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/kanban/KanbanScreenTest.kt new file mode 100644 index 00000000..3a7a6d27 --- /dev/null +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/kanban/KanbanScreenTest.kt @@ -0,0 +1,102 @@ +package com.m57.hermescontrol.ui.kanban + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.MediumTest +import com.m57.hermescontrol.data.model.KanbanColumn +import com.m57.hermescontrol.data.model.KanbanProfile +import com.m57.hermescontrol.data.model.KanbanTask +import com.m57.hermescontrol.theme.HermesControlTheme +import com.m57.hermescontrol.ui.kanban.components.KanbanCreateTaskDialog +import com.m57.hermescontrol.ui.kanban.components.KanbanFilterSheet +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +@MediumTest +class KanbanScreenTest { + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun testShowArchivedToggleInvokesCallback() { + var toggled = false + composeTestRule.setContent { + HermesControlTheme { + KanbanFilterSheet( + assignees = emptyList(), + tenants = emptyList(), + selectedAssignee = null, + selectedTenant = null, + includeArchived = false, + groupRunning = false, + onSelectAssignee = {}, + onSelectTenant = {}, + onToggleIncludeArchived = { toggled = it }, + onToggleGroupRunning = {}, + onClearFilters = {}, + onDismiss = {}, + ) + } + } + + composeTestRule.onNodeWithText("Show archived").performClick() + assertTrue(toggled) + } + + @Test + fun testWorkspacePathVisibilityInCreateTaskDialog() { + composeTestRule.setContent { + HermesControlTheme { + KanbanCreateTaskDialog( + columns = listOf(KanbanColumn(name = "todo")), + defaultColumn = "todo", + profiles = listOf(KanbanProfile(name = "researcher")), + existingTasks = emptyList(), + isCreating = false, + onDismiss = {}, + onConfirm = { _, _ -> }, + ) + } + } + + // Expand advanced options + composeTestRule.onNodeWithText("Advanced Options").performClick() + + // By default scratch is selected, workspace path is not visible + composeTestRule.onNodeWithText("Workspace Path Override").assertDoesNotExist() + + // Select Worktree + composeTestRule.onNodeWithText("Worktree").performClick() + composeTestRule.onNodeWithText("Workspace Path Override").assertIsDisplayed() + + // Select Scratch again + composeTestRule.onNodeWithText("Scratch (Default)").performClick() + composeTestRule.onNodeWithText("Workspace Path Override").assertDoesNotExist() + } + + @Test + fun testDefaultAssigneeOptionPresent() { + composeTestRule.setContent { + HermesControlTheme { + KanbanCreateTaskDialog( + columns = listOf(KanbanColumn(name = "todo")), + defaultColumn = "todo", + profiles = listOf(KanbanProfile(name = "researcher")), + existingTasks = emptyList(), + isCreating = false, + defaultAssignee = "researcher", + onDismiss = {}, + onConfirm = { _, _ -> }, + ) + } + } + + composeTestRule.onNodeWithText("researcher (Default)").assertIsDisplayed() + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanColumnCollapseHelperTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanColumnCollapseHelperTest.kt new file mode 100644 index 00000000..51b7b6de --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanColumnCollapseHelperTest.kt @@ -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), + ), + ) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanCreateTaskPayloadTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanCreateTaskPayloadTest.kt new file mode 100644 index 00000000..648ef50b --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanCreateTaskPayloadTest.kt @@ -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(relaxed = true) + private val mockEventsClient = mockk(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 }, + ) + } + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanDispatcherNudgerTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanDispatcherNudgerTest.kt new file mode 100644 index 00000000..7f68a1bd --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanDispatcherNudgerTest.kt @@ -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() + + @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 + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanEventFormatterTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanEventFormatterTest.kt new file mode 100644 index 00000000..c882cc1f --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanEventFormatterTest.kt @@ -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) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanLaneGroupingTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanLaneGroupingTest.kt new file mode 100644 index 00000000..a033a109 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanLaneGroupingTest.kt @@ -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) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanModelOverrideTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanModelOverrideTest.kt new file mode 100644 index 00000000..4d67d94a --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanModelOverrideTest.kt @@ -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) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanMutationTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanMutationTest.kt index fa9d51ab..83a031e3 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanMutationTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanMutationTest.kt @@ -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? = 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 diff --git a/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanRuntimeHelperTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanRuntimeHelperTest.kt new file mode 100644 index 00000000..5acafe4c --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanRuntimeHelperTest.kt @@ -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)) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanTaskViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanTaskViewModelTest.kt index fd6c612a..780ea755 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanTaskViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanTaskViewModelTest.kt @@ -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(relaxed = true) + private val mockEventsClient = mockk(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) + } }