* fix(#298): internationalize ConnectViewModel strings, fix null-assert crash on profile selector E1: Add all connect error resources to strings.xml for i18n readiness E2: Migrate ConnectViewModel to AndroidViewModel, replace all hardcoded user-facing strings with getString(R.string.*) calls F1: Replace state.selectedProfile!! with .?.let { } pattern in ConnectScreen.kt to eliminate NPE risk on mutable state copy Closes #298 * fix(#298): update test files for AndroidViewModel constructor Update ConnectViewModelTest.kt and E2eIntegrationTest.kt to pass mock Application to the new ConnectViewModel(app) constructor. Add getString() MockK stubs matching the string resource keys. * fix(#298): add missing LocalContext import and restore connect_pairing_string Two compilation errors: - ConnectScreen.kt was missing import for LocalContext - strings.xml was missing connect_pairing_string resource (accidentally removed in earlier patch) * fix(#298): extract ViewModelFactory to avoid composable-in-non-composable error LocalContext.current cannot be called inside a viewModel { } factory lambda because the lambda is not @Composable. Fix by extracting a ConnectViewModelFactory class and using run { } to capture the Application value at the composable call site before passing to the factory constructor. * fix(#298): fix MockK varargs matching in test stubs getString(int, Object...) takes varargs. MockK's any<Int>() and any<String>() didn't match the vararg Object... slot. Changed to any() (untyped) which matches correctly. * fix(#298): fix MockK answers — arg indexing was wrong In getString(int, Object...), firstArg is the resource ID, secondArg is the format argument. Using firstArg<Int>() in the answers block was returning the res ID, not 500/etc. Changed to secondArg<Any>(). * fix(#298): replace getString(int,Object...) with String.format(getString(int),...) Avoid MockK varargs matching issues by calling app.getString(int) for the format template and String.format() to apply args. Simplifies test stubs to pure getString(int) returns. --------- Co-authored-by: M57 <hy4ri@users.noreply.github.com>
1115 lines
39 KiB
Kotlin
1115 lines
39 KiB
Kotlin
package com.m57.hermescontrol
|
|
|
|
import android.app.Application
|
|
import androidx.navigation3.runtime.NavBackStack
|
|
import androidx.navigation3.runtime.NavKey
|
|
import com.m57.hermescontrol.data.local.AuthManager
|
|
import com.m57.hermescontrol.data.model.ActiveProfileResponse
|
|
import com.m57.hermescontrol.data.model.CronJob
|
|
import com.m57.hermescontrol.data.model.DoctorResponse
|
|
import com.m57.hermescontrol.data.model.EnvVarConfig
|
|
import com.m57.hermescontrol.data.model.KanbanBoard
|
|
import com.m57.hermescontrol.data.model.KanbanBoardResponse
|
|
import com.m57.hermescontrol.data.model.KanbanBoardsResponse
|
|
import com.m57.hermescontrol.data.model.KanbanColumn
|
|
import com.m57.hermescontrol.data.model.KanbanTask
|
|
import com.m57.hermescontrol.data.model.LogResponse
|
|
import com.m57.hermescontrol.data.model.MessagingPlatform
|
|
import com.m57.hermescontrol.data.model.MessagingPlatformResponse
|
|
import com.m57.hermescontrol.data.model.ModelOptionsResponse
|
|
import com.m57.hermescontrol.data.model.ModelProvider
|
|
import com.m57.hermescontrol.data.model.PluginInfo
|
|
import com.m57.hermescontrol.data.model.PluginsHubResponse
|
|
import com.m57.hermescontrol.data.model.ProfileInfo
|
|
import com.m57.hermescontrol.data.model.ProfilesResponse
|
|
import com.m57.hermescontrol.data.model.SessionInfo
|
|
import com.m57.hermescontrol.data.model.SessionListResponse
|
|
import com.m57.hermescontrol.data.model.Skill
|
|
import com.m57.hermescontrol.data.model.StatusResponse
|
|
import com.m57.hermescontrol.data.model.SystemStatsResponse
|
|
import com.m57.hermescontrol.data.model.ToggleSkillRequest
|
|
import com.m57.hermescontrol.data.remote.ApiClient
|
|
import com.m57.hermescontrol.data.remote.HermesApiService
|
|
import com.m57.hermescontrol.ui.channels.ChannelsViewModel
|
|
import com.m57.hermescontrol.ui.connect.ConnectViewModel
|
|
import com.m57.hermescontrol.ui.cron.CronJobsViewModel
|
|
import com.m57.hermescontrol.ui.kanban.KanbanViewModel
|
|
import com.m57.hermescontrol.ui.keys.KeysViewModel
|
|
import com.m57.hermescontrol.ui.logs.LogsViewModel
|
|
import com.m57.hermescontrol.ui.model.ModelViewModel
|
|
import com.m57.hermescontrol.ui.plugins.PluginsViewModel
|
|
import com.m57.hermescontrol.ui.sessions.SessionsViewModel
|
|
import com.m57.hermescontrol.ui.skills.SkillsViewModel
|
|
import com.m57.hermescontrol.ui.system.SystemViewModel
|
|
import io.mockk.*
|
|
import kotlinx.coroutines.Dispatchers
|
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
|
import kotlinx.coroutines.test.*
|
|
import okhttp3.ResponseBody.Companion.toResponseBody
|
|
import org.junit.After
|
|
import org.junit.Assert.*
|
|
import org.junit.Before
|
|
import org.junit.Test
|
|
import retrofit2.Response
|
|
import java.io.IOException
|
|
|
|
@OptIn(ExperimentalCoroutinesApi::class)
|
|
class E2eIntegrationTest {
|
|
private val testDispatcher = StandardTestDispatcher()
|
|
private lateinit var mockApiService: HermesApiService
|
|
private val mockApp = mockk<Application>(relaxed = true)
|
|
|
|
@Before
|
|
fun setUp() {
|
|
Dispatchers.setMain(testDispatcher)
|
|
val testMainDispatcher = Dispatchers.Main
|
|
mockkStatic(Dispatchers::class)
|
|
every { Dispatchers.IO } returns testDispatcher
|
|
every { Dispatchers.Main } returns testMainDispatcher
|
|
|
|
mockkObject(AuthManager)
|
|
mockkObject(ApiClient)
|
|
|
|
mockApiService = mockk()
|
|
every { ApiClient.hermesApi } returns mockApiService
|
|
every { ApiClient.rebuild() } returns Unit
|
|
|
|
// Default AuthManager stubs
|
|
every { AuthManager.getToken() } returns "mock-token"
|
|
every { AuthManager.getHost() } returns "127.0.0.1"
|
|
every { AuthManager.getPort() } returns 9119
|
|
every { AuthManager.setToken(any()) } returns Unit
|
|
every { AuthManager.setHost(any()) } returns Unit
|
|
every { AuthManager.setPort(any()) } returns Unit
|
|
every { AuthManager.getConnectionProfiles() } returns emptyList()
|
|
every { AuthManager.getSelectedProfileId() } returns null
|
|
every { AuthManager.setSelectedProfileId(any()) } returns Unit
|
|
every { AuthManager.getProfileToken(any()) } returns null
|
|
every { AuthManager.setProfileToken(any(), any()) } returns Unit
|
|
every { AuthManager.saveConnectionProfiles(any()) } returns Unit
|
|
|
|
// Application stubs
|
|
every { mockApp.getString(R.string.connect_error_401) } returns "Invalid token (401 Unauthorized)"
|
|
}
|
|
|
|
@After
|
|
fun tearDown() {
|
|
Dispatchers.resetMain()
|
|
unmockkAll()
|
|
NavigationController.backStack = null
|
|
}
|
|
|
|
private fun <T> createErrorResponse(code: Int): Response<T> {
|
|
val errorBody = "".toResponseBody(null)
|
|
return Response.error(code, errorBody)
|
|
}
|
|
|
|
// ── Tier 1: Feature Coverage (>=5 per feature) ───────────────────────
|
|
|
|
// Skills Management Screen:
|
|
@Test
|
|
fun testSkillsListing_success() =
|
|
runTest {
|
|
val skill1 = Skill("Skill 1", "Description 1", "Category 1", true)
|
|
val skill2 = Skill("Skill 2", "Description 2", "Category 2", false)
|
|
coEvery { mockApiService.getSkills() } returns Response.success(listOf(skill1, skill2))
|
|
|
|
val viewModel = SkillsViewModel()
|
|
viewModel.loadSkills()
|
|
|
|
assertTrue(viewModel.uiState.value.isLoading)
|
|
assertNull(viewModel.uiState.value.errorMessage)
|
|
|
|
advanceUntilIdle()
|
|
|
|
assertFalse(viewModel.uiState.value.isLoading)
|
|
assertNull(viewModel.uiState.value.errorMessage)
|
|
assertEquals(2, viewModel.uiState.value.skills.size)
|
|
assertEquals(
|
|
"Skill 1",
|
|
viewModel.uiState.value.skills[0]
|
|
.name,
|
|
)
|
|
assertEquals(
|
|
"Description 1",
|
|
viewModel.uiState.value.skills[0]
|
|
.description,
|
|
)
|
|
assertEquals(
|
|
"Category 1",
|
|
viewModel.uiState.value.skills[0]
|
|
.category,
|
|
)
|
|
assertTrue(
|
|
viewModel.uiState.value.skills[0]
|
|
.enabled,
|
|
)
|
|
assertEquals(
|
|
"Skill 2",
|
|
viewModel.uiState.value.skills[1]
|
|
.name,
|
|
)
|
|
assertFalse(
|
|
viewModel.uiState.value.skills[1]
|
|
.enabled,
|
|
)
|
|
}
|
|
|
|
@Test
|
|
fun testSkillsToggle_success() =
|
|
runTest {
|
|
val skill = Skill("Skill 1", "Description 1", "Category 1", false)
|
|
coEvery { mockApiService.getSkills() } returns Response.success(listOf(skill))
|
|
coEvery { mockApiService.toggleSkill(ToggleSkillRequest("Skill 1", true)) } returns Response.success(Unit)
|
|
|
|
val viewModel = SkillsViewModel()
|
|
viewModel.loadSkills()
|
|
advanceUntilIdle()
|
|
|
|
assertFalse(
|
|
viewModel.uiState.value.skills[0]
|
|
.enabled,
|
|
)
|
|
|
|
viewModel.toggleSkill(viewModel.uiState.value.skills[0])
|
|
// Verify optimistic update
|
|
assertTrue(
|
|
viewModel.uiState.value.skills[0]
|
|
.enabled,
|
|
)
|
|
|
|
advanceUntilIdle()
|
|
// Verify it remains enabled
|
|
assertTrue(
|
|
viewModel.uiState.value.skills[0]
|
|
.enabled,
|
|
)
|
|
assertNull(viewModel.uiState.value.toastMessage)
|
|
}
|
|
|
|
@Test
|
|
fun testSkillsToggle_failure() =
|
|
runTest {
|
|
val skill = Skill("Skill 1", "Description 1", "Category 1", false)
|
|
coEvery { mockApiService.getSkills() } returns Response.success(listOf(skill))
|
|
coEvery { mockApiService.toggleSkill(ToggleSkillRequest("Skill 1", true)) } returns createErrorResponse(500)
|
|
|
|
val viewModel = SkillsViewModel()
|
|
viewModel.loadSkills()
|
|
advanceUntilIdle()
|
|
|
|
assertFalse(
|
|
viewModel.uiState.value.skills[0]
|
|
.enabled,
|
|
)
|
|
|
|
viewModel.toggleSkill(viewModel.uiState.value.skills[0])
|
|
// Verify optimistic update
|
|
assertTrue(
|
|
viewModel.uiState.value.skills[0]
|
|
.enabled,
|
|
)
|
|
|
|
advanceUntilIdle()
|
|
// Verify reverted state and toast error message
|
|
assertFalse(
|
|
viewModel.uiState.value.skills[0]
|
|
.enabled,
|
|
)
|
|
assertNotNull(viewModel.uiState.value.toastMessage)
|
|
assertTrue(
|
|
viewModel.uiState.value.toastMessage!!
|
|
.contains("HTTP 500"),
|
|
)
|
|
}
|
|
|
|
@Test
|
|
fun testSkillsLoad_failure() =
|
|
runTest {
|
|
coEvery { mockApiService.getSkills() } returns createErrorResponse(500)
|
|
|
|
val viewModel = SkillsViewModel()
|
|
viewModel.loadSkills()
|
|
advanceUntilIdle()
|
|
|
|
assertFalse(viewModel.uiState.value.isLoading)
|
|
assertNotNull(viewModel.uiState.value.errorMessage)
|
|
assertTrue(
|
|
viewModel.uiState.value.errorMessage!!
|
|
.contains("HTTP 500"),
|
|
)
|
|
}
|
|
|
|
@Test
|
|
fun testSkillsRefresh() =
|
|
runTest {
|
|
val skillA = Skill("Skill A", null, null, false)
|
|
val skillB = Skill("Skill B", null, null, true)
|
|
|
|
coEvery { mockApiService.getSkills() } returnsMany
|
|
listOf(
|
|
Response.success(listOf(skillA)),
|
|
Response.success(listOf(skillB)),
|
|
)
|
|
|
|
val viewModel = SkillsViewModel()
|
|
viewModel.loadSkills()
|
|
advanceUntilIdle()
|
|
assertEquals(
|
|
"Skill A",
|
|
viewModel.uiState.value.skills[0]
|
|
.name,
|
|
)
|
|
|
|
viewModel.loadSkills()
|
|
advanceUntilIdle()
|
|
assertEquals(
|
|
"Skill B",
|
|
viewModel.uiState.value.skills[0]
|
|
.name,
|
|
)
|
|
}
|
|
|
|
// Cron Jobs Screen:
|
|
@Test
|
|
fun testCronJobsListing_success() =
|
|
runTest {
|
|
val job = CronJob("id1", "Job 1", "*/5 * * * *", "active", "success", "2026-06-15T15:10:00Z")
|
|
coEvery { mockApiService.getCronJobs() } returns Response.success(listOf(job))
|
|
|
|
val viewModel = CronJobsViewModel()
|
|
viewModel.loadCronJobs()
|
|
assertTrue(viewModel.uiState.value.isLoading)
|
|
|
|
advanceUntilIdle()
|
|
|
|
assertFalse(viewModel.uiState.value.isLoading)
|
|
assertEquals(1, viewModel.uiState.value.jobs.size)
|
|
assertEquals(
|
|
"id1",
|
|
viewModel.uiState.value.jobs[0]
|
|
.id,
|
|
)
|
|
assertEquals(
|
|
"Job 1",
|
|
viewModel.uiState.value.jobs[0]
|
|
.name,
|
|
)
|
|
assertEquals(
|
|
"active",
|
|
viewModel.uiState.value.jobs[0]
|
|
.state,
|
|
)
|
|
assertEquals(
|
|
"success",
|
|
viewModel.uiState.value.jobs[0]
|
|
.last_run_status,
|
|
)
|
|
assertEquals(
|
|
"2026-06-15T15:10:00Z",
|
|
viewModel.uiState.value.jobs[0]
|
|
.next_run,
|
|
)
|
|
}
|
|
|
|
@Test
|
|
fun testCronJobPause_success() =
|
|
runTest {
|
|
val job = CronJob("id1", "Job 1", "*/5 * * * *", "active", null, null)
|
|
coEvery { mockApiService.getCronJobs() } returns Response.success(listOf(job))
|
|
coEvery { mockApiService.pauseCronJob("id1") } returns Response.success(Unit)
|
|
|
|
val viewModel = CronJobsViewModel()
|
|
viewModel.loadCronJobs()
|
|
advanceUntilIdle()
|
|
|
|
viewModel.pauseCronJob("id1")
|
|
// Verify optimistic update
|
|
assertEquals(
|
|
"paused",
|
|
viewModel.uiState.value.jobs[0]
|
|
.state,
|
|
)
|
|
|
|
advanceUntilIdle()
|
|
assertEquals(
|
|
"paused",
|
|
viewModel.uiState.value.jobs[0]
|
|
.state,
|
|
)
|
|
}
|
|
|
|
@Test
|
|
fun testCronJobResume_success() =
|
|
runTest {
|
|
val job = CronJob("id1", "Job 1", "*/5 * * * *", "paused", null, null)
|
|
coEvery { mockApiService.getCronJobs() } returns Response.success(listOf(job))
|
|
coEvery { mockApiService.resumeCronJob("id1") } returns Response.success(Unit)
|
|
|
|
val viewModel = CronJobsViewModel()
|
|
viewModel.loadCronJobs()
|
|
advanceUntilIdle()
|
|
|
|
viewModel.resumeCronJob("id1")
|
|
// Verify optimistic update
|
|
assertEquals(
|
|
"active",
|
|
viewModel.uiState.value.jobs[0]
|
|
.state,
|
|
)
|
|
|
|
advanceUntilIdle()
|
|
assertEquals(
|
|
"active",
|
|
viewModel.uiState.value.jobs[0]
|
|
.state,
|
|
)
|
|
}
|
|
|
|
@Test
|
|
fun testCronJobTrigger_success() =
|
|
runTest {
|
|
coEvery { mockApiService.triggerCronJob("id1") } returns Response.success(Unit)
|
|
|
|
val viewModel = CronJobsViewModel()
|
|
viewModel.triggerCronJob("id1")
|
|
advanceUntilIdle()
|
|
|
|
assertEquals("Job triggered successfully", viewModel.uiState.value.toastMessage)
|
|
}
|
|
|
|
@Test
|
|
fun testCronJobDelete_success() =
|
|
runTest {
|
|
val job = CronJob("id1", "Job 1", null, null, null, null)
|
|
coEvery { mockApiService.getCronJobs() } returns Response.success(listOf(job))
|
|
coEvery { mockApiService.deleteCronJob("id1") } returns Response.success(Unit)
|
|
|
|
val viewModel = CronJobsViewModel()
|
|
viewModel.loadCronJobs()
|
|
advanceUntilIdle()
|
|
|
|
assertEquals(1, viewModel.uiState.value.jobs.size)
|
|
|
|
viewModel.deleteCronJob("id1")
|
|
// Verify optimistic update (immediate removal)
|
|
assertTrue(
|
|
viewModel.uiState.value.jobs
|
|
.isEmpty(),
|
|
)
|
|
|
|
advanceUntilIdle()
|
|
assertTrue(
|
|
viewModel.uiState.value.jobs
|
|
.isEmpty(),
|
|
)
|
|
}
|
|
|
|
@Test
|
|
fun testCronJobsLoad_failure() =
|
|
runTest {
|
|
coEvery { mockApiService.getCronJobs() } returns createErrorResponse(500)
|
|
|
|
val viewModel = CronJobsViewModel()
|
|
viewModel.loadCronJobs()
|
|
advanceUntilIdle()
|
|
|
|
assertFalse(viewModel.uiState.value.isLoading)
|
|
assertNotNull(viewModel.uiState.value.errorMessage)
|
|
assertTrue(
|
|
viewModel.uiState.value.errorMessage!!
|
|
.contains("HTTP 500"),
|
|
)
|
|
}
|
|
|
|
// Sessions History Screen:
|
|
@Test
|
|
fun testSessionsListing_success() =
|
|
runTest {
|
|
val session = SessionInfo("session-123", "Session 1", "2026-06-15T15:10:00Z", 5, "active")
|
|
coEvery { mockApiService.getSessions() } returns Response.success(SessionListResponse(listOf(session)))
|
|
|
|
val viewModel = SessionsViewModel()
|
|
viewModel.loadSessions()
|
|
assertTrue(viewModel.uiState.value.isLoading)
|
|
|
|
advanceUntilIdle()
|
|
|
|
assertFalse(viewModel.uiState.value.isLoading)
|
|
assertEquals(1, viewModel.uiState.value.sessions.size)
|
|
assertEquals(
|
|
"session-123",
|
|
viewModel.uiState.value.sessions[0]
|
|
.id,
|
|
)
|
|
assertEquals(
|
|
"Session 1",
|
|
viewModel.uiState.value.sessions[0]
|
|
.title,
|
|
)
|
|
assertEquals(
|
|
5,
|
|
viewModel.uiState.value.sessions[0]
|
|
.message_count,
|
|
)
|
|
assertEquals(
|
|
"active",
|
|
viewModel.uiState.value.sessions[0]
|
|
.status,
|
|
)
|
|
}
|
|
|
|
@Test
|
|
fun testSessionsLoad_failure() =
|
|
runTest {
|
|
coEvery { mockApiService.getSessions() } returns createErrorResponse(500)
|
|
|
|
val viewModel = SessionsViewModel()
|
|
viewModel.loadSessions()
|
|
advanceUntilIdle()
|
|
|
|
assertFalse(viewModel.uiState.value.isLoading)
|
|
assertNotNull(viewModel.uiState.value.errorMessage)
|
|
assertTrue(
|
|
viewModel.uiState.value.errorMessage!!
|
|
.contains("HTTP 500"),
|
|
)
|
|
}
|
|
|
|
@Test
|
|
fun testCronJobsRefresh() =
|
|
runTest {
|
|
val jobA = CronJob("idA", "Job A", null, null, null, null)
|
|
val jobB = CronJob("idB", "Job B", null, null, null, null)
|
|
coEvery { mockApiService.getCronJobs() } returnsMany
|
|
listOf(
|
|
Response.success(listOf(jobA)),
|
|
Response.success(listOf(jobB)),
|
|
)
|
|
|
|
val viewModel = CronJobsViewModel()
|
|
viewModel.loadCronJobs()
|
|
advanceUntilIdle()
|
|
assertEquals(
|
|
"Job A",
|
|
viewModel.uiState.value.jobs[0]
|
|
.name,
|
|
)
|
|
|
|
viewModel.loadCronJobs()
|
|
advanceUntilIdle()
|
|
assertEquals(
|
|
"Job B",
|
|
viewModel.uiState.value.jobs[0]
|
|
.name,
|
|
)
|
|
}
|
|
|
|
// Navigation Drawer:
|
|
@Test
|
|
fun testNavigationDrawerTransitions() {
|
|
val backStack = NavBackStack<NavKey>(ChatScreen)
|
|
NavigationController.backStack = backStack
|
|
|
|
assertEquals(ChatScreen, backStack.lastOrNull())
|
|
|
|
NavigationController.navigateTo(SkillsScreen)
|
|
assertEquals(SkillsScreen, backStack.lastOrNull())
|
|
assertEquals(1, backStack.size)
|
|
|
|
NavigationController.navigateTo(CronJobsScreen)
|
|
assertEquals(CronJobsScreen, backStack.lastOrNull())
|
|
assertEquals(1, backStack.size)
|
|
|
|
// Non-clearing drawer screen adds to stack
|
|
NavigationController.navigateTo(ProfilesScreen)
|
|
assertEquals(ProfilesScreen, backStack.lastOrNull())
|
|
assertEquals(2, backStack.size)
|
|
assertEquals(CronJobsScreen, backStack[0])
|
|
}
|
|
|
|
// ── Tier 2: Boundary & Corner Cases (>=5 per feature) ────────────────
|
|
|
|
// Skills Screen boundary cases:
|
|
@Test
|
|
fun testSkillsLoad_httpError_400() =
|
|
runTest {
|
|
coEvery { mockApiService.getSkills() } returns createErrorResponse(400)
|
|
val viewModel = SkillsViewModel()
|
|
viewModel.loadSkills()
|
|
advanceUntilIdle()
|
|
assertTrue(
|
|
viewModel.uiState.value.errorMessage!!
|
|
.contains("HTTP 400"),
|
|
)
|
|
}
|
|
|
|
@Test
|
|
fun testSkillsLoad_httpError_404() =
|
|
runTest {
|
|
coEvery { mockApiService.getSkills() } returns createErrorResponse(404)
|
|
val viewModel = SkillsViewModel()
|
|
viewModel.loadSkills()
|
|
advanceUntilIdle()
|
|
assertTrue(
|
|
viewModel.uiState.value.errorMessage!!
|
|
.contains("HTTP 404"),
|
|
)
|
|
}
|
|
|
|
@Test
|
|
fun testSkillsLoad_httpError_500() =
|
|
runTest {
|
|
coEvery { mockApiService.getSkills() } returns createErrorResponse(500)
|
|
val viewModel = SkillsViewModel()
|
|
viewModel.loadSkills()
|
|
advanceUntilIdle()
|
|
assertTrue(
|
|
viewModel.uiState.value.errorMessage!!
|
|
.contains("HTTP 500"),
|
|
)
|
|
}
|
|
|
|
@Test
|
|
fun testSkillsLoad_networkTimeout() =
|
|
runTest {
|
|
coEvery { mockApiService.getSkills() } throws IOException("Timeout")
|
|
val viewModel = SkillsViewModel()
|
|
viewModel.loadSkills()
|
|
advanceUntilIdle()
|
|
assertTrue(
|
|
viewModel.uiState.value.errorMessage!!
|
|
.contains("Timeout"),
|
|
)
|
|
}
|
|
|
|
@Test
|
|
fun testSkillsLoad_emptyResponse() =
|
|
runTest {
|
|
coEvery { mockApiService.getSkills() } returns Response.success(emptyList())
|
|
val viewModel = SkillsViewModel()
|
|
viewModel.loadSkills()
|
|
advanceUntilIdle()
|
|
assertFalse(viewModel.uiState.value.isLoading)
|
|
assertTrue(
|
|
viewModel.uiState.value.skills
|
|
.isEmpty(),
|
|
)
|
|
assertNull(viewModel.uiState.value.errorMessage)
|
|
}
|
|
|
|
@Test
|
|
fun testSkillsToggle_httpError_500() =
|
|
runTest {
|
|
val skill = Skill("Skill 1", null, null, false)
|
|
coEvery { mockApiService.getSkills() } returns Response.success(listOf(skill))
|
|
coEvery { mockApiService.toggleSkill(any()) } returns createErrorResponse(500)
|
|
|
|
val viewModel = SkillsViewModel()
|
|
viewModel.loadSkills()
|
|
advanceUntilIdle()
|
|
|
|
viewModel.toggleSkill(viewModel.uiState.value.skills[0])
|
|
advanceUntilIdle()
|
|
|
|
assertFalse(
|
|
viewModel.uiState.value.skills[0]
|
|
.enabled,
|
|
)
|
|
assertTrue(
|
|
viewModel.uiState.value.toastMessage!!
|
|
.contains("HTTP 500"),
|
|
)
|
|
}
|
|
|
|
// Cron Jobs Screen boundary cases:
|
|
@Test
|
|
fun testCronJobsLoad_httpError_500() =
|
|
runTest {
|
|
coEvery { mockApiService.getCronJobs() } returns createErrorResponse(500)
|
|
val viewModel = CronJobsViewModel()
|
|
viewModel.loadCronJobs()
|
|
advanceUntilIdle()
|
|
assertTrue(
|
|
viewModel.uiState.value.errorMessage!!
|
|
.contains("HTTP 500"),
|
|
)
|
|
}
|
|
|
|
@Test
|
|
fun testCronJobPause_httpError_500() =
|
|
runTest {
|
|
val job = CronJob("id1", "Job 1", null, "active", null, null)
|
|
coEvery { mockApiService.getCronJobs() } returns Response.success(listOf(job))
|
|
coEvery { mockApiService.pauseCronJob("id1") } returns createErrorResponse(500)
|
|
|
|
val viewModel = CronJobsViewModel()
|
|
viewModel.loadCronJobs()
|
|
advanceUntilIdle()
|
|
|
|
viewModel.pauseCronJob("id1")
|
|
advanceUntilIdle()
|
|
|
|
assertEquals(
|
|
"active",
|
|
viewModel.uiState.value.jobs[0]
|
|
.state,
|
|
)
|
|
assertTrue(
|
|
viewModel.uiState.value.toastMessage!!
|
|
.contains("HTTP 500"),
|
|
)
|
|
}
|
|
|
|
@Test
|
|
fun testCronJobTrigger_httpError_500() =
|
|
runTest {
|
|
coEvery { mockApiService.triggerCronJob("id1") } returns createErrorResponse(500)
|
|
|
|
val viewModel = CronJobsViewModel()
|
|
viewModel.triggerCronJob("id1")
|
|
advanceUntilIdle()
|
|
|
|
assertTrue(
|
|
viewModel.uiState.value.toastMessage!!
|
|
.contains("HTTP 500"),
|
|
)
|
|
}
|
|
|
|
@Test
|
|
fun testCronJobDelete_httpError_500() =
|
|
runTest {
|
|
val job = CronJob("id1", "Job 1", null, null, null, null)
|
|
coEvery { mockApiService.getCronJobs() } returns Response.success(listOf(job))
|
|
coEvery { mockApiService.deleteCronJob("id1") } returns createErrorResponse(500)
|
|
|
|
val viewModel = CronJobsViewModel()
|
|
viewModel.loadCronJobs()
|
|
advanceUntilIdle()
|
|
|
|
viewModel.deleteCronJob("id1")
|
|
advanceUntilIdle()
|
|
|
|
assertEquals(1, viewModel.uiState.value.jobs.size)
|
|
assertTrue(
|
|
viewModel.uiState.value.toastMessage!!
|
|
.contains("HTTP 500"),
|
|
)
|
|
}
|
|
|
|
@Test
|
|
fun testCronJobsLoad_networkTimeout() =
|
|
runTest {
|
|
coEvery { mockApiService.getCronJobs() } throws IOException("Timeout")
|
|
val viewModel = CronJobsViewModel()
|
|
viewModel.loadCronJobs()
|
|
advanceUntilIdle()
|
|
assertTrue(
|
|
viewModel.uiState.value.errorMessage!!
|
|
.contains("Timeout"),
|
|
)
|
|
}
|
|
|
|
@Test
|
|
fun testCronJobsLoad_emptyResponse() =
|
|
runTest {
|
|
coEvery { mockApiService.getCronJobs() } returns Response.success(emptyList())
|
|
val viewModel = CronJobsViewModel()
|
|
viewModel.loadCronJobs()
|
|
advanceUntilIdle()
|
|
assertFalse(viewModel.uiState.value.isLoading)
|
|
assertTrue(
|
|
viewModel.uiState.value.jobs
|
|
.isEmpty(),
|
|
)
|
|
assertNull(viewModel.uiState.value.errorMessage)
|
|
}
|
|
|
|
// ── Tier 3: Cross-Feature Combinations ────────────────────────────────
|
|
|
|
@Test
|
|
fun testDrawerTransitionDuringAction() =
|
|
runTest {
|
|
val skill = Skill("Skill 1", null, null, false)
|
|
coEvery { mockApiService.getSkills() } returns Response.success(listOf(skill))
|
|
coEvery { mockApiService.toggleSkill(any()) } returns Response.success(Unit)
|
|
|
|
val viewModel = SkillsViewModel()
|
|
viewModel.loadSkills()
|
|
advanceUntilIdle()
|
|
|
|
// Set up drawer/backstack
|
|
val backStack = NavBackStack<NavKey>(SkillsScreen)
|
|
NavigationController.backStack = backStack
|
|
|
|
viewModel.toggleSkill(viewModel.uiState.value.skills[0])
|
|
|
|
// Transition during action
|
|
NavigationController.navigateTo(CronJobsScreen)
|
|
|
|
assertEquals(CronJobsScreen, NavigationController.backStack?.lastOrNull())
|
|
|
|
// Complete the action
|
|
advanceUntilIdle()
|
|
|
|
// Verify the state is correct and didn't crash
|
|
assertTrue(
|
|
viewModel.uiState.value.skills[0]
|
|
.enabled,
|
|
)
|
|
}
|
|
|
|
@Test
|
|
fun testAuthTokenRevocation() =
|
|
runTest {
|
|
val mockResponse = createErrorResponse<StatusResponse>(401)
|
|
coEvery { mockApiService.getStatus() } returns mockResponse
|
|
|
|
val viewModel = ConnectViewModel(mockApp)
|
|
viewModel.onTokenChange("expired-token")
|
|
viewModel.connect()
|
|
advanceUntilIdle()
|
|
|
|
// Verify state is updated with error message
|
|
assertEquals("Invalid token (401 Unauthorized)", viewModel.uiState.value.errorMessage)
|
|
assertFalse(viewModel.uiState.value.connectionSuccess)
|
|
// Verify AuthManager.setToken(null) was called
|
|
verify { AuthManager.setToken(null) }
|
|
}
|
|
|
|
// ── Tier 4: Real-World Scenarios ─────────────────────────────────────
|
|
|
|
@Test
|
|
fun testFullUserSessionFlow() =
|
|
runTest {
|
|
// Step 1: User connects with valid token
|
|
val statusResponse = mockk<StatusResponse>()
|
|
coEvery { mockApiService.getStatus() } returns Response.success(statusResponse)
|
|
|
|
val connectViewModel = ConnectViewModel(mockApp)
|
|
connectViewModel.onTokenChange("valid-token")
|
|
connectViewModel.onHostChange("127.0.0.1")
|
|
connectViewModel.onPortChange("9119")
|
|
connectViewModel.connect()
|
|
advanceUntilIdle()
|
|
|
|
assertTrue(connectViewModel.uiState.value.connectionSuccess)
|
|
verify { AuthManager.setToken("valid-token") }
|
|
|
|
// Step 2: User navigates to SkillsScreen
|
|
val backStack = NavBackStack<NavKey>(ChatScreen)
|
|
NavigationController.backStack = backStack
|
|
NavigationController.navigateTo(SkillsScreen)
|
|
assertEquals(SkillsScreen, NavigationController.backStack?.lastOrNull())
|
|
|
|
// Step 3: User toggles a skill, but API fails
|
|
val skill = Skill("Skill X", "Desc X", "Cat X", false)
|
|
coEvery { mockApiService.getSkills() } returns Response.success(listOf(skill))
|
|
coEvery { mockApiService.toggleSkill(any()) } returns createErrorResponse(500)
|
|
|
|
val skillsViewModel = SkillsViewModel()
|
|
skillsViewModel.loadSkills()
|
|
advanceUntilIdle()
|
|
|
|
assertEquals(1, skillsViewModel.uiState.value.skills.size)
|
|
assertFalse(
|
|
skillsViewModel.uiState.value.skills[0]
|
|
.enabled,
|
|
)
|
|
|
|
skillsViewModel.toggleSkill(skill)
|
|
// Verify optimistic update
|
|
assertTrue(
|
|
skillsViewModel.uiState.value.skills[0]
|
|
.enabled,
|
|
)
|
|
|
|
advanceUntilIdle()
|
|
// Verify state reverts and toast is shown
|
|
assertFalse(
|
|
skillsViewModel.uiState.value.skills[0]
|
|
.enabled,
|
|
)
|
|
assertTrue(
|
|
skillsViewModel.uiState.value.toastMessage!!
|
|
.contains("HTTP 500"),
|
|
)
|
|
|
|
// Step 4: User navigates to CronJobsScreen
|
|
NavigationController.navigateTo(CronJobsScreen)
|
|
assertEquals(CronJobsScreen, NavigationController.backStack?.lastOrNull())
|
|
|
|
// Step 5: User triggers a cron job
|
|
coEvery { mockApiService.triggerCronJob("job-1") } returns Response.success(Unit)
|
|
val cronViewModel = CronJobsViewModel()
|
|
cronViewModel.triggerCronJob("job-1")
|
|
advanceUntilIdle()
|
|
|
|
assertEquals("Job triggered successfully", cronViewModel.uiState.value.toastMessage)
|
|
}
|
|
|
|
@Test
|
|
fun testLogsListing_success() =
|
|
runTest {
|
|
coEvery { mockApiService.getLogs() } returns
|
|
Response.success(LogResponse(listOf("Log line 1", "Log line 2")))
|
|
|
|
val viewModel = LogsViewModel()
|
|
viewModel.loadLogs()
|
|
assertTrue(viewModel.uiState.value.isLoading)
|
|
|
|
advanceUntilIdle()
|
|
|
|
assertFalse(viewModel.uiState.value.isLoading)
|
|
assertEquals(2, viewModel.uiState.value.logs.size)
|
|
assertEquals("Log line 1", viewModel.uiState.value.logs[0])
|
|
assertEquals("Log line 2", viewModel.uiState.value.logs[1])
|
|
}
|
|
|
|
@Test
|
|
fun testPluginsManagement_success() =
|
|
runTest {
|
|
val plugin = PluginInfo("plugin-1", "Desc", "1.0", null, "disabled")
|
|
coEvery { mockApiService.getPlugins() } returns Response.success(PluginsHubResponse(listOf(plugin)))
|
|
coEvery { mockApiService.enablePlugin("plugin-1") } returns Response.success(Unit)
|
|
|
|
val viewModel = PluginsViewModel()
|
|
viewModel.loadPlugins()
|
|
advanceUntilIdle()
|
|
|
|
assertEquals(1, viewModel.uiState.value.plugins.size)
|
|
assertFalse(
|
|
viewModel.uiState.value.plugins[0]
|
|
.enabled,
|
|
)
|
|
|
|
viewModel.togglePlugin(viewModel.uiState.value.plugins[0])
|
|
// Optimistic toggle check
|
|
assertTrue(
|
|
viewModel.uiState.value.plugins[0]
|
|
.enabled,
|
|
)
|
|
|
|
advanceUntilIdle()
|
|
assertTrue(
|
|
viewModel.uiState.value.plugins[0]
|
|
.enabled,
|
|
)
|
|
}
|
|
|
|
@Test
|
|
fun testChannelsConfiguration_success() =
|
|
runTest {
|
|
val platform = MessagingPlatform("telegram", "Telegram", false, false, emptyList())
|
|
coEvery { mockApiService.getMessagingPlatforms() } returns
|
|
Response.success(
|
|
MessagingPlatformResponse(
|
|
env_path = "/tmp/.env",
|
|
gateway_start_command = "hermes gateway start",
|
|
platforms = listOf(platform),
|
|
),
|
|
)
|
|
coEvery { mockApiService.configurePlatform("telegram", any()) } returns Response.success(Unit)
|
|
|
|
val viewModel = ChannelsViewModel()
|
|
viewModel.loadPlatforms()
|
|
advanceUntilIdle()
|
|
|
|
assertEquals(1, viewModel.uiState.value.platforms.size)
|
|
|
|
viewModel.configurePlatform("telegram", mapOf("bot_token" to "xyz"))
|
|
advanceUntilIdle()
|
|
|
|
assertEquals(
|
|
"telegram configured successfully — restart the gateway for changes to take effect",
|
|
viewModel.uiState.value.toastMessage,
|
|
)
|
|
}
|
|
|
|
@Test
|
|
fun testKeysManagement_success() =
|
|
runTest {
|
|
coEvery { mockApiService.getEnvVars() } returns
|
|
Response.success(mapOf("KEY1" to EnvVarConfig(true, "value1", "desc", null, null, false)))
|
|
coEvery { mockApiService.updateEnvVar(any()) } returns Response.success(Unit)
|
|
|
|
val viewModel = KeysViewModel()
|
|
viewModel.loadKeys(reveal = false)
|
|
advanceUntilIdle()
|
|
|
|
assertEquals(
|
|
"value1",
|
|
viewModel.uiState.value.envVars["KEY1"]
|
|
?.redactedValue,
|
|
)
|
|
|
|
viewModel.updateKey("KEY1", "newValue")
|
|
advanceUntilIdle()
|
|
|
|
assertEquals("Key updated successfully", viewModel.uiState.value.toastMessage)
|
|
}
|
|
|
|
@Test
|
|
fun testSystemDiagnostics_success() =
|
|
runTest {
|
|
val stats = SystemStatsResponse(cpu = mapOf("percent" to 50.0), memory = mapOf("percent" to 60.0))
|
|
val doctor = DoctorResponse(true, 24856, "doctor")
|
|
coEvery { mockApiService.getSystemStats() } returns Response.success(stats)
|
|
coEvery { mockApiService.runDoctor() } returns Response.success(doctor)
|
|
coEvery { mockApiService.triggerBackup() } returns Response.success(Unit)
|
|
|
|
val viewModel = SystemViewModel()
|
|
viewModel.loadSystemData()
|
|
advanceUntilIdle()
|
|
|
|
assertEquals(
|
|
50.0,
|
|
viewModel.uiState.value.stats
|
|
?.cpuPercent,
|
|
)
|
|
assertEquals(
|
|
true,
|
|
viewModel.uiState.value.doctorReport
|
|
?.ok,
|
|
)
|
|
|
|
viewModel.triggerBackup()
|
|
advanceUntilIdle()
|
|
|
|
assertEquals("Backup triggered successfully", viewModel.uiState.value.toastMessage)
|
|
}
|
|
|
|
@Test
|
|
fun testKanbanBoardAndTasks_success() =
|
|
runTest {
|
|
val board = KanbanBoard("board-1", "Backlog", null)
|
|
val task = KanbanTask("task-1", "Do laundry", null, "todo", null)
|
|
coEvery { mockApiService.getKanbanBoards() } returns
|
|
Response.success(KanbanBoardsResponse(listOf(board), "board-1"))
|
|
coEvery { mockApiService.switchKanbanBoard("board-1") } returns Response.success(Unit)
|
|
coEvery { mockApiService.getKanbanBoard() } returns
|
|
Response.success(KanbanBoardResponse(listOf(KanbanColumn("todo", listOf(task))), null, null))
|
|
coEvery { mockApiService.updateKanbanTask("task-1", mapOf("status" to "in_progress")) } returns
|
|
Response.success(Unit)
|
|
|
|
val viewModel = KanbanViewModel()
|
|
viewModel.loadBoards()
|
|
advanceUntilIdle()
|
|
|
|
assertEquals(
|
|
"board-1",
|
|
viewModel.uiState.value.selectedBoard
|
|
?.id,
|
|
)
|
|
assertEquals(1, viewModel.uiState.value.tasks.size)
|
|
assertEquals(
|
|
"todo",
|
|
viewModel.uiState.value.tasks[0]
|
|
.status,
|
|
)
|
|
|
|
viewModel.moveTask(viewModel.uiState.value.tasks[0], "in_progress")
|
|
// Optimistic move check
|
|
assertEquals(
|
|
"in_progress",
|
|
viewModel.uiState.value.tasks[0]
|
|
.status,
|
|
)
|
|
|
|
advanceUntilIdle()
|
|
assertEquals(
|
|
"in_progress",
|
|
viewModel.uiState.value.tasks[0]
|
|
.status,
|
|
)
|
|
}
|
|
|
|
@Test
|
|
fun testModelOptionsSelection_success() =
|
|
runTest {
|
|
val provider = ModelProvider("ollama", "Ollama", false, true, listOf("llama3"), 1, null, true, null, null)
|
|
val profile = ProfileInfo("default", null, true, "llama3", "ollama", null, null, null, null)
|
|
|
|
coEvery { mockApiService.getModelOptions() } returns
|
|
Response.success(ModelOptionsResponse(listOf(provider)))
|
|
coEvery { mockApiService.getActiveProfile() } returns
|
|
Response.success(ActiveProfileResponse("default", null))
|
|
coEvery { mockApiService.getProfiles() } returns Response.success(ProfilesResponse(listOf(profile)))
|
|
coEvery { mockApiService.updateProfileModel("default", any()) } returns Response.success(Unit)
|
|
|
|
val viewModel = ModelViewModel()
|
|
viewModel.loadModelOptions()
|
|
advanceUntilIdle()
|
|
|
|
assertEquals(1, viewModel.uiState.value.providers.size)
|
|
assertEquals(
|
|
"default",
|
|
viewModel.uiState.value.activeProfile
|
|
?.name,
|
|
)
|
|
assertEquals(
|
|
"llama3",
|
|
viewModel.uiState.value.activeProfile
|
|
?.model,
|
|
)
|
|
|
|
viewModel.selectModel("ollama", "llama3")
|
|
advanceUntilIdle()
|
|
|
|
coVerify { mockApiService.updateProfileModel("default", any()) }
|
|
assertEquals("Successfully set model to llama3", viewModel.uiState.value.toastMessage)
|
|
}
|
|
|
|
@Test
|
|
fun testConnectViewModel_onPairingString_urlFormat() =
|
|
runTest {
|
|
mockkStatic(android.net.Uri::class)
|
|
val mockUri = mockk<android.net.Uri>()
|
|
every { android.net.Uri.parse("hermes://connect?host=192.168.1.5&port=9119&token=TEST_TOKEN") } returns
|
|
mockUri
|
|
every { mockUri.getQueryParameter("host") } returns "192.168.1.5"
|
|
every { mockUri.getQueryParameter("port") } returns "9119"
|
|
every { mockUri.getQueryParameter("token") } returns "TEST_TOKEN"
|
|
|
|
val statusResponse = mockk<StatusResponse>()
|
|
coEvery { mockApiService.getStatus() } returns Response.success(statusResponse)
|
|
|
|
val viewModel = ConnectViewModel(mockApp)
|
|
viewModel.onPairingString("hermes://connect?host=192.168.1.5&port=9119&token=TEST_TOKEN")
|
|
advanceUntilIdle()
|
|
|
|
assertEquals("192.168.1.5", viewModel.uiState.value.host)
|
|
assertEquals("9119", viewModel.uiState.value.port)
|
|
assertEquals("TEST_TOKEN", viewModel.uiState.value.token)
|
|
assertTrue(viewModel.uiState.value.connectionSuccess)
|
|
|
|
unmockkStatic(android.net.Uri::class)
|
|
}
|
|
|
|
@Test
|
|
fun testConnectViewModel_onPairingString_base64Format() =
|
|
runTest {
|
|
mockkStatic(android.util.Base64::class)
|
|
val base64Str = "eyJob3N0IjoiMTkyLjE2OC4xLjUiLCJwb3J0Ijo5MTE5LCJ0b2tlbiI6IlRFU1RfVE9LRU4ifQ=="
|
|
every { android.util.Base64.decode(base64Str, any()) } answers {
|
|
java.util.Base64
|
|
.getDecoder()
|
|
.decode(base64Str)
|
|
}
|
|
|
|
val statusResponse = mockk<StatusResponse>()
|
|
coEvery { mockApiService.getStatus() } returns Response.success(statusResponse)
|
|
|
|
val viewModel = ConnectViewModel(mockApp)
|
|
viewModel.onPairingString(base64Str)
|
|
advanceUntilIdle()
|
|
|
|
assertEquals("192.168.1.5", viewModel.uiState.value.host)
|
|
assertEquals("9119", viewModel.uiState.value.port)
|
|
assertEquals("TEST_TOKEN", viewModel.uiState.value.token)
|
|
assertTrue(viewModel.uiState.value.connectionSuccess)
|
|
|
|
unmockkStatic(android.util.Base64::class)
|
|
}
|
|
}
|