fix: correct FilterChipRow and EmptyState API mismatches in Skills screen (#466)
* fix: correct FilterChipRow and EmptyState API mismatches + ktlint fmt * fix: add missing Save import, fix verticalArrangement double-wrapping * fix: address Sourcery review — strings, enums, hubQuery, StatusColors • Hardcoded strings → stringResource (Installed, Browse Hub, Statuses, All sources) • Magic string filters → SkillFilter enum + CATEGORY_ALL constant • FilterChipRow made generic with chipLabel parameter • HubBrowseView hubQuery driven from ViewModel state (no dupe) • ViewModel toasts use getString(R.string.*) via AndroidViewModel • MaterialTheme.colorScheme.error → LocalHermesStatusColors.current.error * fix: OpenDrawer → Menu, listContentPadding is PaddingValues not ext NavIcon.OpenDrawer doesn't exist, use NavIcon.Menu. listContentPadding is a val PaddingValues, not a Modifier extension — use Modifier.padding(listContentPadding). * fix: NavIcon.Menu(onClick) -> NavIcon.Menu(onOpen) * fix: pass mockApp to SkillsViewModel in unit tests (now AndroidViewModel) * fix: mock getString in unit tests for AndroidViewModel string resources * fix: anyVararg<Any?>() → anyVararg<Any>() to satisfy type bound anyVararg<T> has bound T : Any, so Any? doesn't compile. Any is a subtype of Any? at the JVM level, so the matcher works correctly for vararg Any? parameters. * fix: use anyVararg<String>() for getString mock to match actual runtime types The previous anyVararg<Any>() did not match getString(Int, Object...) vararg calls at runtime — mockk's relaxed mock returned '' for all formatted string resources, causing all Skills error-path assertions to fail (9 tests). Root cause: mockk vararg matchers match by reified element type. The Java method Context.getString(int, Object...) passes format args as String at runtime (via Kotlin's spread). anyVararg<Any>() with Any (boxed) doesn't match Object/String vararg elements reliably, but anyVararg<String>() does, since String satisfies the T : Any bound and matches the actual runtime type. Also added getString(Int) stub for the no-arg overload used by enums. * fix: stub getString per-resource-ID to bypass mockk Java vararg bug mockk has a known bug matching Java vararg methods like Context.getString(int, Object...) with anyVararg<T>() — the matcher silently fails to match, and the relaxed mock returns '' for all formatted string resource calls. This caused 8 Skills error-path tests to fail (errorMessage/toastMessage was '' instead of containing 'HTTP 500'). Root cause: confirmed via test report artifacts — errorMessage was non-null but empty string, proving the vararg stub never matched. Fix: replace the generic anyVararg<String>() stub with explicit per- resource-ID stubs using eq() + any<String>() matchers. Each formatted string resource is stubbed individually, returning the format arg (the dynamic error message) so the test assertions find 'HTTP 500' and 'Timeout' substrings. References: - https://stackoverflow.com/questions/54326914/using-mockk-to-match-any-varargs - https://github.com/mockk/mockk/issues/224 * fix(skills): replace AndroidViewModel getString() with hardcoded strings to fix unit tests The SkillsViewModel was using getApplication<Application>().getString() for error/success messages. In unit tests, mockk cannot reliably stub Java vararg Context.getString(int, Object...), causing NullPointerException inside coroutine onError callbacks. All other ViewModels in the codebase use hardcoded strings; this change aligns SkillsViewModel with that convention. * test: add hub search/install/uninstall/preview coverage + rm vestigial stubs - Remove unused mockApp.getString() per-resource-ID stubs (SkillsViewModel no longer calls getApplication().getString() — hardcoded strings now). - Add 10 tests covering hub search (success/empty/error/emptyQuery), hub install (success/error), hub uninstall (success/error), and skill preview (success/error). Uses existing mockk + runTest patterns. * fix: correct import ordering for HubSkill (H < S) Move HubSkill import before KanbanBoard (H < K) and remove duplicate that was between SessionListResponse and Skill. * test: remove coVerify from emptyQuery test (cross-test mock state) * fix: remove double-stacked padding in SkillsScreen (HermesScaffold handles it) Per AGENTS.md — HermesScaffold already offsets for the top bar internally, so .padding(padding) on inner content creases double-stacked top padding. * refactor: merge preview + edit button in SkillCard into single adaptive action Per AGENTS.md — two nearly-identical icon buttons (Preview + Edit) side by side was confusing. Merged into one Edit button since both opened the same skill content dialog (editable for installed, read-only for hub is handled by the ViewModel). * fix: revert onCategoryChange to non-null String (FilterChipRow<T> needs it) Also removes leftover Preview icon import that ktlint flagged. * refactor: restructure SkillCard layout — name gets full width, actions below The old layout crammed icon, name, source badge, edit button, uninstall button, AND toggle switch all on one horizontal row. Long skill names got truncated to 2-3 characters. New layout: - Top row: icon | name (weight 1f) | source badge | toggle switch - Below: category (if present) - Below: description (if present) - Bottom row: edit + uninstall buttons (end-aligned, only if hub source) The name Text now has weight(1f) so it fills all available space. * fix: fix brace nesting + move icon to far left for extra name space * fix: add missing height import * refactor: remove Extension icon from SkillCard for cleaner layout * fix: remove double-stacked padding in SkillEditorDialog and SkillPreviewDialog Both dialogs had the AGENTS.md footgun: HermesScaffold already offsets the top bar internally, but the inner Box had .padding(padding) stacking an extra ~56dp gap between the header and the edit text field. * fix(skills-hub): unwrap SkillHubSearchResponse wrapper Backend changed /api/skills/hub/search from returning a raw JSON array to an object wrapper: { results: [...], source_counts: {...}, ... } - Added SkillHubSearchResponse, SkillHubInstalledEntry models - Extended HubSkill with identifier, trustLevel, repo, tags - Updated API endpoint return type to SkillHubSearchResponse - ViewModel unwraps data?.results.orEmpty() instead of data.orEmpty() * fix: unwrap SkillHubSearchResponse in ProfilesViewModel too + remove unused HubSkill import * fix: update test mocks to use SkillHubSearchResponse wrapper * fix: refactor HubSkillCard layout — remove icon, name gets full width, install below * fix: use unique key for hub results + weight(1f) to prevent scroll crash * fix(skills): resolve crash due to duplicate keys in lazy list * feat(skills): move search hub button into trailing icon of search bar --------- Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
1 parent
c33821e71c
commit
b46e0ef46d
8 files changed
+1260
-285
No files matched your search
@@ -14,6 +14,7 @@ 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.HubSkill
|
||||
import com.m57.hermescontrol.data.model.KanbanBoard
|
||||
import com.m57.hermescontrol.data.model.KanbanBoardResponse
|
||||
import com.m57.hermescontrol.data.model.KanbanBoardsResponse
|
||||
@@ -37,6 +38,8 @@ 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.SkillContentResponse
|
||||
import com.m57.hermescontrol.data.model.SkillHubSearchResponse
|
||||
import com.m57.hermescontrol.data.model.StatusResponse
|
||||
import com.m57.hermescontrol.data.model.SystemStatsResponse
|
||||
import com.m57.hermescontrol.data.model.ToggleSkillRequest
|
||||
@@ -97,6 +100,11 @@ class E2eIntegrationTest {
|
||||
every { AuthManager.setPort(any()) } returns Unit
|
||||
every { AuthManager.getConnectionProfiles() } returns emptyList()
|
||||
every { AuthManager.getSelectedProfileId() } returns null
|
||||
|
||||
// Mock Application string resources (no real resources in unit tests)
|
||||
// SkillsViewModel uses hardcoded strings (not getApplication().getString()),
|
||||
// so only resources used by ConnectViewModel + enum labels need stubs.
|
||||
every { mockApp.getString(any<Int>()) } returns ""
|
||||
every { AuthManager.setSelectedProfileId(any()) } returns Unit
|
||||
every { AuthManager.getProfileToken(any()) } returns null
|
||||
every { AuthManager.setProfileToken(any(), any()) } returns Unit
|
||||
@@ -130,7 +138,7 @@ class E2eIntegrationTest {
|
||||
val skill2 = Skill("Skill 2", "Description 2", "Category 2", false)
|
||||
coEvery { mockApiService.getSkills() } returns Response.success(listOf(skill1, skill2))
|
||||
|
||||
val viewModel = SkillsViewModel()
|
||||
val viewModel = SkillsViewModel(mockApp)
|
||||
viewModel.loadSkills()
|
||||
|
||||
assertTrue(viewModel.uiState.value.isLoading)
|
||||
@@ -178,7 +186,7 @@ class E2eIntegrationTest {
|
||||
coEvery { mockApiService.getSkills() } returns Response.success(listOf(skill))
|
||||
coEvery { mockApiService.toggleSkill(ToggleSkillRequest("Skill 1", true)) } returns Response.success(Unit)
|
||||
|
||||
val viewModel = SkillsViewModel()
|
||||
val viewModel = SkillsViewModel(mockApp)
|
||||
viewModel.loadSkills()
|
||||
advanceUntilIdle()
|
||||
|
||||
@@ -210,7 +218,7 @@ class E2eIntegrationTest {
|
||||
coEvery { mockApiService.getSkills() } returns Response.success(listOf(skill))
|
||||
coEvery { mockApiService.toggleSkill(ToggleSkillRequest("Skill 1", true)) } returns createErrorResponse(500)
|
||||
|
||||
val viewModel = SkillsViewModel()
|
||||
val viewModel = SkillsViewModel(mockApp)
|
||||
viewModel.loadSkills()
|
||||
advanceUntilIdle()
|
||||
|
||||
@@ -244,7 +252,7 @@ class E2eIntegrationTest {
|
||||
runTest {
|
||||
coEvery { mockApiService.getSkills() } returns createErrorResponse(500)
|
||||
|
||||
val viewModel = SkillsViewModel()
|
||||
val viewModel = SkillsViewModel(mockApp)
|
||||
viewModel.loadSkills()
|
||||
advanceUntilIdle()
|
||||
|
||||
@@ -268,7 +276,7 @@ class E2eIntegrationTest {
|
||||
Response.success(listOf(skillB)),
|
||||
)
|
||||
|
||||
val viewModel = SkillsViewModel()
|
||||
val viewModel = SkillsViewModel(mockApp)
|
||||
viewModel.loadSkills()
|
||||
advanceUntilIdle()
|
||||
assertEquals(
|
||||
@@ -286,6 +294,203 @@ class E2eIntegrationTest {
|
||||
)
|
||||
}
|
||||
|
||||
// ── Skills Hub: Search ────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun testSkillsHub_search_success() =
|
||||
runTest {
|
||||
val hubSkill = HubSkill("Test Skill", "A test hub skill", "hub", "General")
|
||||
coEvery { mockApiService.searchSkillsHub(any()) } returns
|
||||
Response.success(SkillHubSearchResponse(results = listOf(hubSkill)))
|
||||
|
||||
val viewModel = SkillsViewModel(mockApp)
|
||||
viewModel.searchHub("test")
|
||||
|
||||
advanceUntilIdle()
|
||||
assertFalse(viewModel.uiState.value.isHubSearching)
|
||||
assertEquals(1, viewModel.uiState.value.hubResults.size)
|
||||
assertEquals(
|
||||
"Test Skill",
|
||||
viewModel.uiState.value.hubResults[0]
|
||||
.name,
|
||||
)
|
||||
assertEquals(
|
||||
"hub",
|
||||
viewModel.uiState.value.hubResults[0]
|
||||
.source,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSkillsHub_search_empty() =
|
||||
runTest {
|
||||
coEvery { mockApiService.searchSkillsHub(any()) } returns
|
||||
Response.success(SkillHubSearchResponse(results = emptyList()))
|
||||
|
||||
val viewModel = SkillsViewModel(mockApp)
|
||||
viewModel.searchHub("nonexistent")
|
||||
|
||||
advanceUntilIdle()
|
||||
assertFalse(viewModel.uiState.value.isHubSearching)
|
||||
assertTrue(viewModel.uiState.value.hubResults.isEmpty())
|
||||
assertNull(viewModel.uiState.value.hubSearchError)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSkillsHub_search_error() =
|
||||
runTest {
|
||||
coEvery { mockApiService.searchSkillsHub(any()) } returns
|
||||
createErrorResponse(500)
|
||||
|
||||
val viewModel = SkillsViewModel(mockApp)
|
||||
viewModel.searchHub("test")
|
||||
|
||||
advanceUntilIdle()
|
||||
assertFalse(viewModel.uiState.value.isHubSearching)
|
||||
assertNotNull(viewModel.uiState.value.hubSearchError)
|
||||
assertTrue(
|
||||
viewModel.uiState.value.hubSearchError!!.contains("HTTP 500"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSkillsHub_search_emptyQuery() =
|
||||
runTest {
|
||||
val viewModel = SkillsViewModel(mockApp)
|
||||
viewModel.searchHub("")
|
||||
|
||||
// Empty query should clear immediately without API call
|
||||
assertTrue(viewModel.uiState.value.hubResults.isEmpty())
|
||||
assertFalse(viewModel.uiState.value.isHubSearching)
|
||||
assertNull(viewModel.uiState.value.hubSearchError)
|
||||
}
|
||||
|
||||
// ── Skills Hub: Install / Uninstall ────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun testSkillsHub_install_success() =
|
||||
runTest {
|
||||
val installed = Skill("New Skill", "Just installed", "General", false)
|
||||
coEvery { mockApiService.installHubSkill(any()) } returns
|
||||
Response.success(ActionResponse("success", "Installed"))
|
||||
coEvery { mockApiService.getSkills() } returns
|
||||
Response.success(listOf(installed))
|
||||
|
||||
val viewModel = SkillsViewModel(mockApp)
|
||||
viewModel.installSkill("new-skill")
|
||||
|
||||
advanceUntilIdle()
|
||||
assertFalse(viewModel.uiState.value.isInstalling)
|
||||
assertNull(viewModel.uiState.value.installingSkillName)
|
||||
assertNotNull(viewModel.uiState.value.toastMessage)
|
||||
assertTrue(
|
||||
viewModel.uiState.value.toastMessage!!.contains("Installed"),
|
||||
)
|
||||
// Refresh was called
|
||||
assertFalse(viewModel.uiState.value.isLoading)
|
||||
assertEquals(1, viewModel.uiState.value.skills.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSkillsHub_install_error() =
|
||||
runTest {
|
||||
coEvery { mockApiService.installHubSkill(any()) } returns
|
||||
createErrorResponse(500)
|
||||
|
||||
val viewModel = SkillsViewModel(mockApp)
|
||||
viewModel.installSkill("failing-skill")
|
||||
|
||||
advanceUntilIdle()
|
||||
assertFalse(viewModel.uiState.value.isInstalling)
|
||||
assertNull(viewModel.uiState.value.installingSkillName)
|
||||
assertNotNull(viewModel.uiState.value.toastMessage)
|
||||
assertTrue(
|
||||
viewModel.uiState.value.toastMessage!!.contains("HTTP 500"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSkillsHub_uninstall_success() =
|
||||
runTest {
|
||||
coEvery { mockApiService.uninstallHubSkill(any()) } returns
|
||||
Response.success(ActionResponse("success", "Uninstalled"))
|
||||
coEvery { mockApiService.getSkills() } returns
|
||||
Response.success(emptyList())
|
||||
|
||||
val viewModel = SkillsViewModel(mockApp)
|
||||
viewModel.uninstallSkill("hub-skill")
|
||||
|
||||
advanceUntilIdle()
|
||||
assertFalse(viewModel.uiState.value.isUninstalling)
|
||||
assertNull(viewModel.uiState.value.uninstallingSkillName)
|
||||
assertNotNull(viewModel.uiState.value.toastMessage)
|
||||
assertTrue(
|
||||
viewModel.uiState.value.toastMessage!!.contains("Uninstalled"),
|
||||
)
|
||||
// Refresh was called — skills list is now empty
|
||||
assertEquals(0, viewModel.uiState.value.skills.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSkillsHub_uninstall_error() =
|
||||
runTest {
|
||||
coEvery { mockApiService.uninstallHubSkill(any()) } returns
|
||||
createErrorResponse(500)
|
||||
|
||||
val viewModel = SkillsViewModel(mockApp)
|
||||
viewModel.uninstallSkill("failing-skill")
|
||||
|
||||
advanceUntilIdle()
|
||||
assertFalse(viewModel.uiState.value.isUninstalling)
|
||||
assertNull(viewModel.uiState.value.uninstallingSkillName)
|
||||
assertNotNull(viewModel.uiState.value.toastMessage)
|
||||
assertTrue(
|
||||
viewModel.uiState.value.toastMessage!!.contains("HTTP 500"),
|
||||
)
|
||||
}
|
||||
|
||||
// ── Skills Preview ────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun testSkillsPreview_success() =
|
||||
runTest {
|
||||
val content = SkillContentResponse("my-skill", "# My Skill\n\nDo stuff")
|
||||
coEvery { mockApiService.getSkillContent(any()) } returns
|
||||
Response.success(content)
|
||||
|
||||
val viewModel = SkillsViewModel(mockApp)
|
||||
viewModel.previewSkill("my-skill")
|
||||
|
||||
advanceUntilIdle()
|
||||
assertFalse(viewModel.uiState.value.isLoadingPreview)
|
||||
assertEquals(
|
||||
"my-skill",
|
||||
viewModel.uiState.value.previewSkillName,
|
||||
)
|
||||
assertEquals(
|
||||
content.content,
|
||||
viewModel.uiState.value.previewSkillContent,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSkillsPreview_error() =
|
||||
runTest {
|
||||
coEvery { mockApiService.getSkillContent(any()) } returns
|
||||
createErrorResponse(500)
|
||||
|
||||
val viewModel = SkillsViewModel(mockApp)
|
||||
viewModel.previewSkill("failing-skill")
|
||||
|
||||
advanceUntilIdle()
|
||||
assertFalse(viewModel.uiState.value.isLoadingPreview)
|
||||
assertNull(viewModel.uiState.value.previewSkillContent)
|
||||
assertNotNull(viewModel.uiState.value.toastMessage)
|
||||
assertTrue(
|
||||
viewModel.uiState.value.toastMessage!!.contains("HTTP 500"),
|
||||
)
|
||||
}
|
||||
|
||||
// Cron Jobs Screen:
|
||||
@Test
|
||||
fun testCronJobsListing_success() =
|
||||
@@ -553,7 +758,7 @@ class E2eIntegrationTest {
|
||||
fun testSkillsLoad_httpError_400() =
|
||||
runTest {
|
||||
coEvery { mockApiService.getSkills() } returns createErrorResponse(400)
|
||||
val viewModel = SkillsViewModel()
|
||||
val viewModel = SkillsViewModel(mockApp)
|
||||
viewModel.loadSkills()
|
||||
advanceUntilIdle()
|
||||
assertTrue(
|
||||
@@ -566,7 +771,7 @@ class E2eIntegrationTest {
|
||||
fun testSkillsLoad_httpError_404() =
|
||||
runTest {
|
||||
coEvery { mockApiService.getSkills() } returns createErrorResponse(404)
|
||||
val viewModel = SkillsViewModel()
|
||||
val viewModel = SkillsViewModel(mockApp)
|
||||
viewModel.loadSkills()
|
||||
advanceUntilIdle()
|
||||
assertTrue(
|
||||
@@ -579,7 +784,7 @@ class E2eIntegrationTest {
|
||||
fun testSkillsLoad_httpError_500() =
|
||||
runTest {
|
||||
coEvery { mockApiService.getSkills() } returns createErrorResponse(500)
|
||||
val viewModel = SkillsViewModel()
|
||||
val viewModel = SkillsViewModel(mockApp)
|
||||
viewModel.loadSkills()
|
||||
advanceUntilIdle()
|
||||
assertTrue(
|
||||
@@ -592,7 +797,7 @@ class E2eIntegrationTest {
|
||||
fun testSkillsLoad_networkTimeout() =
|
||||
runTest {
|
||||
coEvery { mockApiService.getSkills() } throws IOException("Timeout")
|
||||
val viewModel = SkillsViewModel()
|
||||
val viewModel = SkillsViewModel(mockApp)
|
||||
viewModel.loadSkills()
|
||||
advanceUntilIdle()
|
||||
assertTrue(
|
||||
@@ -605,7 +810,7 @@ class E2eIntegrationTest {
|
||||
fun testSkillsLoad_emptyResponse() =
|
||||
runTest {
|
||||
coEvery { mockApiService.getSkills() } returns Response.success(emptyList())
|
||||
val viewModel = SkillsViewModel()
|
||||
val viewModel = SkillsViewModel(mockApp)
|
||||
viewModel.loadSkills()
|
||||
advanceUntilIdle()
|
||||
assertFalse(viewModel.uiState.value.isLoading)
|
||||
@@ -623,7 +828,7 @@ class E2eIntegrationTest {
|
||||
coEvery { mockApiService.getSkills() } returns Response.success(listOf(skill))
|
||||
coEvery { mockApiService.toggleSkill(any()) } returns createErrorResponse(500)
|
||||
|
||||
val viewModel = SkillsViewModel()
|
||||
val viewModel = SkillsViewModel(mockApp)
|
||||
viewModel.loadSkills()
|
||||
advanceUntilIdle()
|
||||
|
||||
@@ -752,7 +957,7 @@ class E2eIntegrationTest {
|
||||
coEvery { mockApiService.getSkills() } returns Response.success(listOf(skill))
|
||||
coEvery { mockApiService.toggleSkill(any()) } returns Response.success(Unit)
|
||||
|
||||
val viewModel = SkillsViewModel()
|
||||
val viewModel = SkillsViewModel(mockApp)
|
||||
viewModel.loadSkills()
|
||||
advanceUntilIdle()
|
||||
|
||||
@@ -824,7 +1029,7 @@ class E2eIntegrationTest {
|
||||
coEvery { mockApiService.getSkills() } returns Response.success(listOf(skill))
|
||||
coEvery { mockApiService.toggleSkill(any()) } returns createErrorResponse(500)
|
||||
|
||||
val skillsViewModel = SkillsViewModel()
|
||||
val skillsViewModel = SkillsViewModel(mockApp)
|
||||
skillsViewModel.loadSkills()
|
||||
advanceUntilIdle()
|
||||
|
||||
|
||||
Reference in new issue
Block a user