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

@@ -1,15 +1,55 @@
package com.m57.hermescontrol.data.model
import com.google.gson.annotations.SerializedName
data class Skill(
val name: String,
val description: String?,
val category: String?,
val enabled: Boolean,
val content: String? = null,
val source: String? = null, // built-in, hub, optional
val pinned: Boolean? = null,
val linked_files: List<String>? = null,
)
data class HubSkill(
val name: String,
val description: String?,
val source: String?,
val category: String? = null,
val identifier: String? = null,
@SerializedName("trust_level") val trustLevel: String? = null,
val repo: String? = null,
val tags: List<String>? = null,
)
data class SkillHubSearchResponse(
val results: List<HubSkill>,
@SerializedName("source_counts") val sourceCounts: Map<String, Int>? = null,
@SerializedName("timed_out") val timedOut: List<String>? = null,
val installed: Map<String, SkillHubInstalledEntry>? = null,
)
data class SkillHubInstalledEntry(
val name: String? = null,
@SerializedName("trust_level") val trustLevel: String? = null,
@SerializedName("scan_verdict") val scanVerdict: String? = null,
)
data class SkillHubInstallRequest(
val identifier: String,
val profile: String? = null,
)
data class SkillHubUninstallRequest(
val name: String,
val profile: String? = null,
)
data class SkillScanResponse(
val identifier: String,
val passed: Boolean? = null,
val issues: List<String>? = null,
val warnings: List<String>? = null,
)
@@ -29,7 +29,6 @@ import com.m57.hermescontrol.data.model.EnvVarRevealRequest
import com.m57.hermescontrol.data.model.EnvVarRevealResponse
import com.m57.hermescontrol.data.model.EnvVarUpdate
import com.m57.hermescontrol.data.model.HookResponse
import com.m57.hermescontrol.data.model.HubSkill
import com.m57.hermescontrol.data.model.KanbanBoardResponse
import com.m57.hermescontrol.data.model.KanbanBoardsResponse
import com.m57.hermescontrol.data.model.KanbanTask
@@ -68,6 +67,10 @@ import com.m57.hermescontrol.data.model.SessionStatsResponse
import com.m57.hermescontrol.data.model.SetActiveProfileRequest
import com.m57.hermescontrol.data.model.Skill
import com.m57.hermescontrol.data.model.SkillContentResponse
import com.m57.hermescontrol.data.model.SkillHubInstallRequest
import com.m57.hermescontrol.data.model.SkillHubSearchResponse
import com.m57.hermescontrol.data.model.SkillHubUninstallRequest
import com.m57.hermescontrol.data.model.SkillScanResponse
import com.m57.hermescontrol.data.model.StatusResponse
import com.m57.hermescontrol.data.model.SystemStatsResponse
import com.m57.hermescontrol.data.model.ToggleSkillRequest
@@ -156,10 +159,35 @@ interface HermesApiService {
@GET("api/skills")
suspend fun getSkills(): Response<List<Skill>>
@POST("api/skills/hub/search")
@GET("api/skills/hub/search")
suspend fun searchSkillsHub(
@Query("q") q: String,
): Response<List<HubSkill>>
@Query("source") source: String? = null,
@Query("limit") limit: Int? = null,
): Response<SkillHubSearchResponse>
@GET("api/skills/hub/preview")
suspend fun previewHubSkill(
@Query("identifier") identifier: String,
): Response<SkillContentResponse>
@GET("api/skills/hub/scan")
suspend fun scanHubSkill(
@Query("identifier") identifier: String,
): Response<SkillScanResponse>
@GET("api/skills/hub/sources")
suspend fun getHubSources(): Response<List<String>>
@POST("api/skills/hub/install")
suspend fun installHubSkill(
@Body body: SkillHubInstallRequest,
): Response<ActionResponse>
@POST("api/skills/hub/uninstall")
suspend fun uninstallHubSkill(
@Body body: SkillHubUninstallRequest,
): Response<ActionResponse>
@PUT("api/skills/toggle")
suspend fun toggleSkill(
@@ -4,6 +4,7 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
@@ -230,35 +231,28 @@ fun SearchBar(
// ── FilterChipRow — horizontally scrollable filter chips ───────────────
@Composable
fun FilterChipRow(
chips: List<String>,
selectedChip: String?,
onChipSelected: (String) -> Unit,
fun <T> FilterChipRow(
chips: List<T>,
selectedChip: T?,
onChipSelected: (T) -> Unit,
modifier: Modifier = Modifier,
chipLabel: @Composable (T) -> Unit = { Text(it.toString()) },
) {
val spacing = LocalSpacing.current
LazyRow(
modifier = modifier.fillMaxWidth(),
contentPadding =
androidx.compose.foundation.layout.PaddingValues(
PaddingValues(
horizontal = spacing.md,
vertical = spacing.xs,
),
horizontalArrangement = Arrangement.spacedBy(spacing.sm),
) {
items(chips, key = { it }) { chip ->
items(chips, key = { it.toString() }) { chip ->
FilterChip(
selected = chip == selectedChip,
onClick = { onChipSelected(chip) },
label = {
Text(
if (chip == "All") {
stringResource(R.string.skills_category_all)
} else {
chip
},
)
},
label = { chipLabel(chip) },
)
}
}
@@ -332,7 +332,7 @@ class ProfilesViewModel :
_uiState.update {
it.copy(
isSearchingHub = false,
hubSearchResults = result.data,
hubSearchResults = result.data?.results.orEmpty(),
)
}
}
File diff suppressed because it is too large. Load diff
@@ -1,8 +1,14 @@
package com.m57.hermescontrol.ui.skills
import androidx.lifecycle.ViewModel
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.HubSkill
import com.m57.hermescontrol.data.model.SaveSkillContentRequest
import com.m57.hermescontrol.data.model.Skill
import com.m57.hermescontrol.data.model.SkillHubInstallRequest
import com.m57.hermescontrol.data.model.SkillHubUninstallRequest
import com.m57.hermescontrol.data.model.ToggleSkillRequest
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.NetworkResult
@@ -18,6 +24,21 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
enum class SkillsViewMode {
INSTALLED,
HUB,
}
enum class SkillFilter(val labelRes: Int) {
ALL_STATUSES(R.string.skills_status_all),
ENABLED(R.string.skills_status_enabled),
DISABLED(R.string.skills_status_disabled),
}
enum class CategoryFilter {
ALL,
}
data class SkillsUiState(
val isLoading: Boolean = false,
val skills: List<Skill> = emptyList(),
@@ -28,15 +49,34 @@ data class SkillsUiState(
val skillContent: String? = null,
val isSavingContent: Boolean = false,
val saveContentSuccess: Boolean = false,
// View mode
val viewMode: SkillsViewMode = SkillsViewMode.INSTALLED,
// Hub search
val hubQuery: String = "",
val hubResults: List<HubSkill> = emptyList(),
val isHubSearching: Boolean = false,
val hubSearchError: String? = null,
// Hub install
val isInstalling: Boolean = false,
val installingSkillName: String? = null,
val isUninstalling: Boolean = false,
val uninstallingSkillName: String? = null,
// Preview
val previewSkillName: String? = null,
val previewSkillContent: String? = null,
val isLoadingPreview: Boolean = false,
// Source filter for installed tab
val sourceFilter: String? = null,
)
class SkillsViewModel :
ViewModel(),
class SkillsViewModel(application: Application) :
AndroidViewModel(application),
ToastHost {
private val _uiState = MutableStateFlow(SkillsUiState())
val uiState: StateFlow<SkillsUiState> = _uiState.asStateFlow()
private var loadJob: Job? = null
private var searchJob: Job? = null
fun loadSkills() {
loadJob =
@@ -58,11 +98,188 @@ class SkillsViewModel :
)
}
fun setViewMode(mode: SkillsViewMode) {
_uiState.update { it.copy(viewMode = mode) }
if (mode == SkillsViewMode.INSTALLED && _uiState.value.skills.isEmpty()) {
loadSkills()
}
}
fun setSourceFilter(source: String?) {
_uiState.update { it.copy(sourceFilter = source) }
}
fun setHubQuery(query: String) {
_uiState.update { it.copy(hubQuery = query) }
}
// ── Hub search ──────────────────────────────────────────────────────────
fun searchHub(query: String) {
_uiState.update { it.copy(hubQuery = query, hubSearchError = null) }
searchJob?.cancel()
if (query.isBlank()) {
_uiState.update { it.copy(hubResults = emptyList(), isHubSearching = false) }
return
}
searchJob =
safeLaunchLoad(
currentJob = searchJob,
apiCall = {
safeApiCall { ApiClient.hermesApi.searchSkillsHub(q = query) }
},
onStart = { _uiState.update { it.copy(isHubSearching = true) } },
onSuccess = { data ->
_uiState.update { it.copy(isHubSearching = false, hubResults = data?.results.orEmpty()) }
},
onError = { errorMsg ->
_uiState.update {
it.copy(
isHubSearching = false,
hubSearchError = "Search failed: $errorMsg",
)
}
},
)
}
fun clearHubSearch() {
_uiState.update {
it.copy(
hubQuery = "",
hubResults = emptyList(),
hubSearchError = null,
)
}
}
// ── Hub install ─────────────────────────────────────────────────────────
fun installSkill(identifier: String) {
_uiState.update {
it.copy(
isInstalling = true,
installingSkillName = identifier,
)
}
viewModelScope.launch {
val result =
withContext(Dispatchers.IO) {
safeApiCall { ApiClient.hermesApi.installHubSkill(SkillHubInstallRequest(identifier = identifier)) }
}
when (result) {
is NetworkResult.Success -> {
_uiState.update {
it.copy(
isInstalling = false,
installingSkillName = null,
toastMessage = "Installed: $identifier",
)
}
// Refresh installed skills
loadSkills()
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
isInstalling = false,
installingSkillName = null,
toastMessage = "Failed to install $identifier: ${result.error.message}",
)
}
}
}
}
}
fun uninstallSkill(name: String) {
_uiState.update {
it.copy(
isUninstalling = true,
uninstallingSkillName = name,
)
}
viewModelScope.launch {
val result =
withContext(Dispatchers.IO) {
safeApiCall { ApiClient.hermesApi.uninstallHubSkill(SkillHubUninstallRequest(name = name)) }
}
when (result) {
is NetworkResult.Success -> {
_uiState.update {
it.copy(
isUninstalling = false,
uninstallingSkillName = null,
toastMessage = "Uninstalled: $name",
)
}
loadSkills()
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
isUninstalling = false,
uninstallingSkillName = null,
toastMessage = "Failed to uninstall $name: ${result.error.message}",
)
}
}
}
}
}
// ── SKILL.md preview ────────────────────────────────────────────────────
fun previewSkill(skillName: String) {
_uiState.update {
it.copy(
previewSkillName = skillName,
previewSkillContent = null,
isLoadingPreview = true,
)
}
viewModelScope.launch {
val result =
withContext(Dispatchers.IO) {
safeApiCall { ApiClient.hermesApi.getSkillContent(skillName) }
}
when (result) {
is NetworkResult.Success -> {
_uiState.update {
it.copy(
isLoadingPreview = false,
previewSkillContent = result.data?.content,
)
}
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
isLoadingPreview = false,
toastMessage = "Failed to load preview: ${result.error.message}",
)
}
}
}
}
}
fun clearPreview() {
_uiState.update {
it.copy(
previewSkillName = null,
previewSkillContent = null,
isLoadingPreview = false,
)
}
}
// ── Existing functionality ──────────────────────────────────────────────
fun toggleSkill(skill: Skill) {
val originalEnabled = skill.enabled
val targetEnabled = !originalEnabled
// Optimistically update
_uiState.update { state ->
state.copy(
skills =
@@ -78,7 +295,11 @@ class SkillsViewModel :
safeApiCall { ApiClient.hermesApi.toggleSkill(ToggleSkillRequest(skill.name, targetEnabled)) }
}
if (result is NetworkResult.Failure) {
revertSkillToggle(skill.name, originalEnabled, "Failed to toggle skill: ${result.error.message}")
revertSkillToggle(
skill.name,
originalEnabled,
"Failed to toggle skill: ${result.error.message}",
)
}
}
}
@@ -146,11 +367,10 @@ class SkillsViewModel :
withContext(Dispatchers.IO) {
safeApiCall {
ApiClient.hermesApi.saveSkillContent(
com.m57.hermescontrol.data.model
.SaveSkillContentRequest(
name = skillName,
content = content,
),
SaveSkillContentRequest(
name = skillName,
content = content,
),
)
}
}
@@ -178,6 +398,25 @@ class SkillsViewModel :
}
}
fun updateSkillsFromHub() {
viewModelScope.launch {
_uiState.update { it.copy(toastMessage = "Updating skills from hub…") }
val result =
withContext(Dispatchers.IO) {
safeApiCall { ApiClient.hermesApi.updateSkillsFromHub() }
}
when (result) {
is NetworkResult.Success -> {
_uiState.update { it.copy(toastMessage = "Skills updated") }
loadSkills()
}
is NetworkResult.Failure -> {
_uiState.update { it.copy(toastMessage = "Update failed: ${result.error.message}") }
}
}
}
}
fun clearEditor() {
_uiState.update {
it.copy(
+26
View File
@@ -21,6 +21,7 @@
<!-- Actions -->
<string name="action_retry">Retry</string>
<string name="action_cancel">Cancel</string>
<string name="action_clear">Clear</string>
<string name="action_ok">OK</string>
<string name="action_delete">Delete</string>
@@ -235,17 +236,42 @@
<string name="skills_category_all">All</string>
<string name="skills_no_skills">No skills found</string>
<string name="skills_no_skills_desc">Skills loaded from Hermes will appear here.</string>
<string name="skills_screen_title">Skills</string>
<string name="skills_search_placeholder">Search skills\u2026</string>
<string name="skills_filter_status">Status</string>
<string name="skills_filter_category">Category</string>
<string name="skills_no_matching">No matching skills</string>
<string name="skills_no_matching_desc">Try adjusting your search or filters.</string>
<string name="skills_edit_title">Edit: %1$s</string>
<string name="skills_preview_title">Preview: %1$s</string>
<string name="skills_preview_empty">No content to preview</string>
<string name="skills_hub_search_placeholder">Search skill hub\u2026</string>
<string name="skills_hub_search_button">Search Hub</string>
<string name="skills_hub_empty_results">No skills found in hub</string>
<string name="skills_hub_search_hint">Type a search term to browse the skill hub</string>
<string name="skills_hub_install">Install</string>
<string name="skills_content_placeholder">Enter skill content\u2026</string>
<string name="skills_empty_message">No skills found</string>
<string name="skills_discard_title">Discard changes?</string>
<string name="skills_discard_message">You have unsaved changes. Are you sure you want to discard them?</string>
<string name="skills_mode_installed">Installed</string>
<string name="skills_mode_hub">Browse Hub</string>
<string name="skills_source_all">All sources</string>
<string name="skills_install_success">Installed: %1$s</string>
<string name="skills_install_failure">Failed to install %1$s: %2$s</string>
<string name="skills_uninstall_success">Uninstalled: %1$s</string>
<string name="skills_uninstall_failure">Failed to uninstall %1$s: %2$s</string>
<string name="skills_preview_failure">Failed to load preview: %1$s</string>
<string name="skills_load_failure">Failed to load skills: %1$s</string>
<string name="skills_search_failure">Search failed: %1$s</string>
<string name="skills_toggle_failure">Failed to toggle skill: %1$s</string>
<string name="skills_toggle_success">Toggled %1$s</string>
<string name="action_discard">Discard</string>
<string name="content_desc_edit_skill">Edit Skill File</string>
<string name="content_desc_preview_skill">Preview Skill File</string>
<string name="content_desc_uninstall_skill">Uninstall Skill</string>
<string name="content_desc_update_skills_hub">Update Skills from Hub</string>
<string name="content_desc_save_changes">Save Changes</string>
<!-- Logs Screen -->
@@ -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()