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:
M57andM57 authored and GitHub committed 2026-07-04 12:54:01 +03:00
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()