* feat(#434): add system tab model files * feat(#434): add system API endpoints * feat(#434): rewrite SystemViewModel with full parity * feat(#434): add system screen UI and string resources * feat(#434): update tests for new SystemStatsResponse & ViewModel * fix(#434): fix compile errors — nullable Boolean, SmallFilledTonalButton -> FilledTonalButton * fix(#434): fix old SystemStatsResponse refs in ChatViewModel, wrap sections in item{} blocks * fix(#434): rename sections to lowercase, fix LazyListScope extensions * fix(#434): wrap hook dialog in item{}, pass spacing param to actionLogSection * fix(#434): update HermesApiServiceTest for new typed SystemStatsResponse * fix(#434): fix E2E test coroutine timing, remove outdated Gson edge-case test * fix(#434): fix JUnit 4 assertNotNull argument order * fix(#434): simplify HermesApiServiceTest, restore all mocks in E2e test * fix(#434): remove Gson-edge-case test, fix E2E test dispatcher wiring - Removed testGetSystemStats_nonNumericPercent_returnsNull: tests Gson type-coercion behavior, not app logic — throws JsonSyntaxException - Fixed E2eIntegrationTest dispatcher: mock Dispatchers.IO with answers {} that returns the current Dispatchers.Main dynamically, so runTest's per-test dispatcher override is honored by both viewModelScope.launch and async(Dispatchers.IO) blocks → advanceUntilIdle() advances all work - Removed Dispatchers.Main static mock so runTest can override it * fix(#434): fix E2E test dispatcher for SystemViewModel - Removed Dispatchers.setMain + testDispatcher field from setUp - runTest handles Dispatchers.Main automatically — don't override it - Mock Dispatchers.IO with answers { Dispatchers.Main } so it dynamically resolves to whatever Main is at call time (runTest's TestScope dispatcher) - This ensures viewModelScope.launch and async(Dispatchers.IO) both run on the same TestScope dispatcher, making advanceUntilIdle() effective for all work * fix(#434): use runTest(testDispatcher) for SystemViewModel test The SystemViewModel.loadAll() uses async(Dispatchers.IO) inside coroutineScope { }. With the mocked Dispatchers setup, both Main and IO return testDispatcher, but runTest()'s default TestScope uses a different dispatcher instance whose advanceUntilIdle() can't advance testDispatcher's scheduler. Fix: pass testDispatcher to runTest() so the TestScope's scheduler matches testDispatcher's scheduler, making advanceUntilIdle() effective for all async work. * fix(#434): align test assertion with toast message change The SystemViewModel.triggerBackup() toast message was changed from 'Backup triggered successfully' to 'Backup triggered', but the E2eIntegrationTest assertion wasn't updated, causing a ComparisonFailure on the unit-tests CI job. * fix(#434): address review issues - Fix #1: Change triggerBackup() return type from Unit to ActionResponse so backupArchive is captured, enabling the download button - Fix #2: Replace deprecated LocalClipboardManager with LocalClipboard suspend API (removes CI deprecation warning) - Fix #3: Consolidate redundant 'Hermes version' InfoRow in gateway section (sessions + PID shown in one row) - Remove orphaned system_gateway_state_pid string resource - Fix import ordering for ktlint compliance * fix(#434): revert LocalClipboard change, use @Suppress('DEPRECATION') instead LocalClipboard.setText is not available in the Compose version used by the project. Reverting to LocalClipboardManager with a suppress annotation to keep the deprecation warning clean without breaking compilation. --------- Co-authored-by: M57 <hy4ri@users.noreply.github.com> Co-authored-by: Hermes Agent <hermes@agent.local>
This commit is contained in:
21 files changed
+3083
-230
No files matched your search
@@ -4,11 +4,16 @@ 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.ActionResponse
|
||||
import com.m57.hermescontrol.data.model.ActiveProfileResponse
|
||||
import com.m57.hermescontrol.data.model.AuxiliaryModelsResponse
|
||||
import com.m57.hermescontrol.data.model.CheckpointsResponse
|
||||
import com.m57.hermescontrol.data.model.CredentialPoolResponse
|
||||
import com.m57.hermescontrol.data.model.CronJob
|
||||
import com.m57.hermescontrol.data.model.CuratorResponse
|
||||
import com.m57.hermescontrol.data.model.DoctorResponse
|
||||
import com.m57.hermescontrol.data.model.EnvVarConfig
|
||||
import com.m57.hermescontrol.data.model.HookResponse
|
||||
import com.m57.hermescontrol.data.model.KanbanBoard
|
||||
import com.m57.hermescontrol.data.model.KanbanBoardResponse
|
||||
import com.m57.hermescontrol.data.model.KanbanBoardsResponse
|
||||
@@ -16,6 +21,8 @@ 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.MainModelAssignment
|
||||
import com.m57.hermescontrol.data.model.MemoryResponse
|
||||
import com.m57.hermescontrol.data.model.MemoryStats
|
||||
import com.m57.hermescontrol.data.model.MessagingPlatform
|
||||
import com.m57.hermescontrol.data.model.MessagingPlatformResponse
|
||||
import com.m57.hermescontrol.data.model.MoaConfigResponse
|
||||
@@ -24,6 +31,7 @@ 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.PortalResponse
|
||||
import com.m57.hermescontrol.data.model.ProfileInfo
|
||||
import com.m57.hermescontrol.data.model.ProfilesResponse
|
||||
import com.m57.hermescontrol.data.model.SessionInfo
|
||||
@@ -32,6 +40,7 @@ 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.model.UpdateCheckResponse
|
||||
import com.m57.hermescontrol.data.remote.ApiClient
|
||||
import com.m57.hermescontrol.data.remote.HermesApiService
|
||||
import com.m57.hermescontrol.ui.channels.ChannelsViewModel
|
||||
@@ -66,8 +75,8 @@ class E2eIntegrationTest {
|
||||
@Before
|
||||
fun setUp() {
|
||||
Dispatchers.setMain(testDispatcher)
|
||||
val testMainDispatcher = Dispatchers.Main
|
||||
mockkStatic(Dispatchers::class)
|
||||
val testMainDispatcher = Dispatchers.Main
|
||||
every { Dispatchers.IO } returns testDispatcher
|
||||
every { Dispatchers.Main } returns testMainDispatcher
|
||||
|
||||
@@ -966,32 +975,43 @@ class E2eIntegrationTest {
|
||||
|
||||
@Test
|
||||
fun testSystemDiagnostics_success() =
|
||||
runTest {
|
||||
val stats = SystemStatsResponse(cpu = mapOf("percent" to 50.0), memory = mapOf("percent" to 60.0))
|
||||
runTest(testDispatcher) {
|
||||
val stats =
|
||||
SystemStatsResponse(
|
||||
cpu_percent = 50.0,
|
||||
memory = MemoryStats(total = 8192, used = 4096, percent = 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)
|
||||
coEvery { mockApiService.getStatus() } returns
|
||||
Response.success(StatusResponse("1.0", true, 0, false, null))
|
||||
coEvery { mockApiService.getPortal() } returns
|
||||
Response.success(PortalResponse(logged_in = false))
|
||||
coEvery { mockApiService.getCurator() } returns Response.success(CuratorResponse())
|
||||
coEvery { mockApiService.getMemory() } returns Response.success(MemoryResponse())
|
||||
coEvery { mockApiService.getCredentialPool() } returns
|
||||
Response.success(CredentialPoolResponse())
|
||||
coEvery { mockApiService.getCheckpoints() } returns
|
||||
Response.success(CheckpointsResponse())
|
||||
coEvery { mockApiService.getHooks() } returns Response.success(HookResponse())
|
||||
coEvery { mockApiService.checkHermesUpdate(false) } returns
|
||||
Response.success(UpdateCheckResponse())
|
||||
coEvery { mockApiService.triggerBackup() } returns Response.success(ActionResponse())
|
||||
|
||||
val viewModel = SystemViewModel()
|
||||
viewModel.loadSystemData()
|
||||
viewModel.loadAll()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(
|
||||
50.0,
|
||||
viewModel.uiState.value.stats
|
||||
?.cpuPercent,
|
||||
)
|
||||
assertEquals(
|
||||
true,
|
||||
viewModel.uiState.value.doctorReport
|
||||
?.ok,
|
||||
viewModel.uiState.value.stats?.cpu_percent,
|
||||
)
|
||||
|
||||
viewModel.triggerBackup()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals("Backup triggered successfully", viewModel.uiState.value.toastMessage)
|
||||
assertEquals("Backup triggered", viewModel.uiState.value.toastMessage)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -5,6 +5,7 @@ import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertNotNull
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class ModelSerializationTest {
|
||||
@@ -371,30 +372,49 @@ class ModelSerializationTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSystemStatsResponse_cpuPercentTypeSafety() {
|
||||
val jsonNumber = """{"cpu":{"percent":45.2}}"""
|
||||
val responseNumber = gson.fromJson(jsonNumber, SystemStatsResponse::class.java)
|
||||
assertEquals(45.2, responseNumber.cpuPercent)
|
||||
|
||||
val jsonStringNum = """{"cpu":{"percent":"45.2"}}"""
|
||||
val responseStringNum = gson.fromJson(jsonStringNum, SystemStatsResponse::class.java)
|
||||
assertNull(responseStringNum.cpuPercent)
|
||||
|
||||
val jsonNullPercent = """{"cpu":{"percent":null}}"""
|
||||
val responseNullPercent = gson.fromJson(jsonNullPercent, SystemStatsResponse::class.java)
|
||||
assertNull(responseNullPercent.cpuPercent)
|
||||
|
||||
val jsonMissingPercent = """{"cpu":{}}"""
|
||||
val responseMissingPercent = gson.fromJson(jsonMissingPercent, SystemStatsResponse::class.java)
|
||||
assertNull(responseMissingPercent.cpuPercent)
|
||||
|
||||
val jsonNullCpu = """{"cpu":null}"""
|
||||
val responseNullCpu = gson.fromJson(jsonNullCpu, SystemStatsResponse::class.java)
|
||||
assertNull(responseNullCpu.cpuPercent)
|
||||
|
||||
val jsonMissingCpu = "{}"
|
||||
val responseMissingCpu = gson.fromJson(jsonMissingCpu, SystemStatsResponse::class.java)
|
||||
assertNull(responseMissingCpu.cpuPercent)
|
||||
fun testSystemStatsResponse_deserialization() {
|
||||
val json =
|
||||
"""
|
||||
{
|
||||
"os": "Linux",
|
||||
"os_release": "5.4.274",
|
||||
"arch": "aarch64",
|
||||
"hostname": "hermes-box",
|
||||
"python_version": "3.12.0",
|
||||
"python_impl": "CPython",
|
||||
"hermes_version": "1.2.3",
|
||||
"cpu_count": 8,
|
||||
"cpu_percent": 45.2,
|
||||
"psutil": true,
|
||||
"load_avg": [1.5, 1.2, 0.9],
|
||||
"uptime_seconds": 86400.0,
|
||||
"memory": {
|
||||
"total": 8388608,
|
||||
"available": 4194304,
|
||||
"used": 4194304,
|
||||
"percent": 50.0
|
||||
},
|
||||
"disk": {
|
||||
"total": 1073741824,
|
||||
"used": 536870912,
|
||||
"free": 536870912,
|
||||
"percent": 50.0
|
||||
}
|
||||
}
|
||||
""".trimIndent()
|
||||
val response = gson.fromJson(json, SystemStatsResponse::class.java)
|
||||
assertEquals("Linux", response.os)
|
||||
assertEquals("aarch64", response.arch)
|
||||
assertEquals("hermes-box", response.hostname)
|
||||
assertEquals("1.2.3", response.hermes_version)
|
||||
assertEquals(45.2, response.cpu_percent)
|
||||
assertEquals(8, response.cpu_count)
|
||||
assertTrue(response.psutil ?: false)
|
||||
assertEquals(1.5, response.load_avg?.get(0) ?: 0.0, 0.01)
|
||||
assertEquals(86400.0, response.uptime_seconds ?: 0.0, 0.01)
|
||||
assertEquals(50.0, response.memory?.percent ?: 0.0, 0.01)
|
||||
assertEquals(8388608, response.memory?.total)
|
||||
assertEquals(50.0, response.disk?.percent ?: 0.0, 0.01)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -317,40 +317,7 @@ class HermesApiServiceTest {
|
||||
assertTrue(response.isSuccessful)
|
||||
val stats = response.body()
|
||||
assertNotNull(stats)
|
||||
assertNull(stats?.cpuPercent)
|
||||
assertNull(stats?.memoryPercent)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testGetSystemStats_nonNumericPercent_returnsNull() =
|
||||
runTest {
|
||||
mockWebServer.enqueue(
|
||||
MockResponse()
|
||||
.setResponseCode(200)
|
||||
.setBody("""{"cpu":{"percent":"not_a_number"}}"""),
|
||||
)
|
||||
|
||||
val response = apiService.getSystemStats()
|
||||
assertTrue(response.isSuccessful)
|
||||
val stats = response.body()
|
||||
assertNotNull(stats)
|
||||
assertNull(stats?.cpuPercent)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testGetSystemStats_typeMismatchArray_behavior() =
|
||||
runTest {
|
||||
mockWebServer.enqueue(
|
||||
MockResponse()
|
||||
.setResponseCode(200)
|
||||
.setBody("""{"cpu": []}"""),
|
||||
)
|
||||
|
||||
val response = apiService.getSystemStats()
|
||||
assertTrue(response.isSuccessful)
|
||||
val stats = response.body()
|
||||
assertNotNull(stats)
|
||||
// Gson deserializes an empty array [] into an empty map {} for Map fields
|
||||
assertTrue(stats?.cpu?.isEmpty() == true)
|
||||
assertNull(stats?.cpu_percent)
|
||||
assertNull(stats?.memory?.percent)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user