Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
1 parent
00a0eedc77
commit
1870b375b1
7 files changed
+812
-304
No files matched your search
@@ -56,7 +56,39 @@ data class SkillHubUninstallRequest(
|
||||
@Serializable
|
||||
data class SkillScanResponse(
|
||||
val identifier: String,
|
||||
val passed: Boolean? = null,
|
||||
val issues: List<String>? = null,
|
||||
val warnings: List<String>? = null,
|
||||
val name: String? = null,
|
||||
val source: String? = null,
|
||||
@SerialName("trust_level") val trustLevel: String? = null,
|
||||
val verdict: String? = null,
|
||||
val summary: String? = null,
|
||||
val policy: String? = null,
|
||||
@SerialName("policy_reason") val policyReason: String? = null,
|
||||
val findings: List<SkillScanFinding> = emptyList(),
|
||||
@SerialName("severity_counts") val severityCounts: Map<String, Int> = emptyMap(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SkillScanFinding(
|
||||
val severity: String,
|
||||
val category: String,
|
||||
val file: String? = null,
|
||||
val line: Int? = null,
|
||||
val description: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SkillHubSource(
|
||||
val id: String,
|
||||
val label: String? = null,
|
||||
@SerialName("rate_limited") val rateLimited: Boolean? = null,
|
||||
val available: Boolean? = null,
|
||||
val searchable: Boolean? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SkillHubSourcesResponse(
|
||||
val sources: List<SkillHubSource> = emptyList(),
|
||||
@SerialName("index_available") val indexAvailable: Boolean? = null,
|
||||
val featured: List<HubSkill> = emptyList(),
|
||||
val installed: Map<String, SkillHubInstalledEntry>? = null,
|
||||
)
|
||||
@@ -88,7 +88,9 @@ 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.SkillHubSourcesResponse
|
||||
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.TelegramOnboardingApplyRequest
|
||||
@@ -240,6 +242,11 @@ interface HermesApiService {
|
||||
@Query("identifier") identifier: String,
|
||||
): Response<SkillContentResponse>
|
||||
|
||||
@GET("api/skills/hub/scan")
|
||||
suspend fun scanHubSkill(
|
||||
@Query("identifier") identifier: String,
|
||||
): Response<SkillScanResponse>
|
||||
|
||||
@POST("api/skills/hub/install")
|
||||
suspend fun installHubSkill(
|
||||
@Body body: SkillHubInstallRequest,
|
||||
@@ -250,6 +257,9 @@ interface HermesApiService {
|
||||
@Body body: SkillHubUninstallRequest,
|
||||
): Response<ActionResponse>
|
||||
|
||||
@GET("api/skills/hub/sources")
|
||||
suspend fun getSkillHubSources(): Response<SkillHubSourcesResponse>
|
||||
|
||||
@PUT("api/skills/toggle")
|
||||
suspend fun toggleSkill(
|
||||
@Body body: ToggleSkillRequest,
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.m57.hermescontrol.ui.skills
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -30,12 +31,14 @@ import androidx.compose.material.icons.filled.Extension
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material.icons.filled.Save
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.Shield
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -55,6 +58,7 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
@@ -69,6 +73,9 @@ import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.data.model.HubSkill
|
||||
import com.m57.hermescontrol.data.model.Skill
|
||||
import com.m57.hermescontrol.data.model.SkillHubSource
|
||||
import com.m57.hermescontrol.data.model.SkillScanFinding
|
||||
import com.m57.hermescontrol.data.model.SkillScanResponse
|
||||
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
||||
import com.m57.hermescontrol.ui.common.DetailDialog
|
||||
import com.m57.hermescontrol.ui.common.DetailRow
|
||||
@@ -174,6 +181,9 @@ fun SkillsScreen(
|
||||
onClearSearch = viewModel::clearHubSearch,
|
||||
onInstall = viewModel::installSkill,
|
||||
onPreviewHubSkill = viewModel::previewHubSkill,
|
||||
onScanHubSkill = viewModel::scanHubSkill,
|
||||
onClearHubScan = viewModel::clearHubScan,
|
||||
onLoadHubSources = viewModel::loadHubSources,
|
||||
isInstalling = state.isInstalling,
|
||||
installingSkillName = state.installingSkillName,
|
||||
)
|
||||
@@ -533,6 +543,9 @@ private fun HubBrowseView(
|
||||
onClearSearch: () -> Unit,
|
||||
onInstall: (String) -> Unit,
|
||||
onPreviewHubSkill: (String) -> Unit,
|
||||
onScanHubSkill: (String) -> Unit,
|
||||
onClearHubScan: () -> Unit,
|
||||
onLoadHubSources: () -> Unit,
|
||||
isInstalling: Boolean,
|
||||
installingSkillName: String?,
|
||||
) {
|
||||
@@ -629,9 +642,16 @@ private fun HubBrowseView(
|
||||
}
|
||||
|
||||
else -> {
|
||||
EmptyState(
|
||||
icon = Icons.Filled.Search,
|
||||
title = stringResource(R.string.skills_hub_search_hint),
|
||||
HubLandingView(
|
||||
state = state,
|
||||
onLoadHubSources = onLoadHubSources,
|
||||
onInstall = onInstall,
|
||||
isInstalling = isInstalling,
|
||||
installingSkillName = installingSkillName,
|
||||
onOpenDetail = { skill ->
|
||||
hubShowDetail = skill
|
||||
skill.identifier?.let { onPreviewHubSkill(it) }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -655,17 +675,435 @@ private fun HubBrowseView(
|
||||
DetailRow(stringResource(R.string.detail_dialog_tags), hubSkill.tags.orEmpty().joinToString(", ")),
|
||||
DetailRow(stringResource(R.string.detail_dialog_trust_level), hubSkill.trustLevel),
|
||||
),
|
||||
actions =
|
||||
if (previewReady && state.isHubPreviewing) {
|
||||
{ CircularProgressIndicator(modifier = Modifier.padding(top = 8.dp)) }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onDismiss = { hubShowDetail = null },
|
||||
actions = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
if (previewReady && state.isHubPreviewing) {
|
||||
CircularProgressIndicator(modifier = Modifier.padding(top = 8.dp))
|
||||
}
|
||||
hubSkill.identifier?.let { identifier ->
|
||||
SkillScanSection(
|
||||
scanResult =
|
||||
state.hubScanResult.takeIf {
|
||||
state.hubScanIdentifier == identifier
|
||||
},
|
||||
isScanning =
|
||||
state.isHubScanning &&
|
||||
state.hubScanIdentifier == identifier,
|
||||
scanError =
|
||||
state.hubScanError.takeIf {
|
||||
state.hubScanIdentifier == identifier
|
||||
},
|
||||
onScan = { onScanHubSkill(identifier) },
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
onDismiss = {
|
||||
hubShowDetail = null
|
||||
onClearHubScan()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Hub Landing (sources + featured) ───────────────────────────────────
|
||||
|
||||
@Composable
|
||||
private fun HubLandingView(
|
||||
state: SkillsUiState,
|
||||
onLoadHubSources: () -> Unit,
|
||||
onInstall: (String) -> Unit,
|
||||
isInstalling: Boolean,
|
||||
installingSkillName: String?,
|
||||
onOpenDetail: (HubSkill) -> Unit,
|
||||
) {
|
||||
when {
|
||||
state.isHubSourcesLoading -> {
|
||||
SkeletonListState()
|
||||
}
|
||||
|
||||
state.hubSourcesError != null -> {
|
||||
ErrorState(
|
||||
message = state.hubSourcesError,
|
||||
onRetry = onLoadHubSources,
|
||||
)
|
||||
}
|
||||
|
||||
state.hubSources.isEmpty() && state.hubFeatured.isEmpty() -> {
|
||||
EmptyState(
|
||||
icon = Icons.Filled.Extension,
|
||||
title = stringResource(R.string.skills_hub_landing_hint),
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 8.dp),
|
||||
verticalArrangement = listItemSpacing,
|
||||
) {
|
||||
if (state.hubSources.isNotEmpty()) {
|
||||
item(key = "hub_sources_header") {
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 4.dp, vertical = 4.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.skills_hub_sources_title),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Row(
|
||||
modifier =
|
||||
Modifier.horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
state.hubSources.forEach { source ->
|
||||
SourceChip(source = source)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (state.hubFeatured.isNotEmpty()) {
|
||||
item(key = "hub_featured_header") {
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 4.dp, vertical = 4.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.skills_hub_featured_title),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.skills_hub_featured_subtitle),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
itemsIndexed(
|
||||
state.hubFeatured,
|
||||
key = { index, hubSkill ->
|
||||
"featured:${hubSkill.name}:${hubSkill.source ?: "unknown"}:$index"
|
||||
},
|
||||
) { _, hubSkill ->
|
||||
HubSkillCard(
|
||||
hubSkill = hubSkill,
|
||||
onInstall = { onInstall(hubSkill.name) },
|
||||
isInstalling =
|
||||
isInstalling && installingSkillName == hubSkill.name,
|
||||
onClick = { onOpenDetail(hubSkill) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SourceChip(source: SkillHubSource) {
|
||||
val statusColors = LocalHermesStatusColors.current
|
||||
val label =
|
||||
buildString {
|
||||
append(source.label ?: source.id)
|
||||
when {
|
||||
source.rateLimited == true -> {
|
||||
append(" · ")
|
||||
append(stringResource(R.string.skills_hub_source_rate_limited))
|
||||
}
|
||||
|
||||
source.available == false -> {
|
||||
append(" · ")
|
||||
append(stringResource(R.string.skills_hub_source_offline))
|
||||
}
|
||||
|
||||
source.searchable == false -> {
|
||||
append(" · ")
|
||||
append(stringResource(R.string.skills_hub_source_via_index))
|
||||
}
|
||||
}
|
||||
}
|
||||
val color =
|
||||
when {
|
||||
source.rateLimited == true -> statusColors.warning
|
||||
source.available == false -> statusColors.error
|
||||
else -> MaterialTheme.colorScheme.primary
|
||||
}
|
||||
Surface(
|
||||
color = color.copy(alpha = 0.12f),
|
||||
shape = RoundedCornerShape(50),
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = color,
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 5.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Hub security scan ──────────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
private fun SkillScanSection(
|
||||
scanResult: SkillScanResponse?,
|
||||
isScanning: Boolean,
|
||||
scanError: String?,
|
||||
onScan: () -> Unit,
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(
|
||||
onClick = onScan,
|
||||
enabled = !isScanning,
|
||||
) {
|
||||
if (isScanning) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(18.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Shield,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(stringResource(R.string.skills_hub_scan))
|
||||
}
|
||||
}
|
||||
|
||||
when {
|
||||
isScanning -> Unit
|
||||
|
||||
scanError != null -> {
|
||||
Surface(
|
||||
color = LocalHermesStatusColors.current.errorContainer,
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
) {
|
||||
Text(
|
||||
text = scanError,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.padding(12.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
scanResult != null -> {
|
||||
ScanResultCard(result = scanResult)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ScanResultCard(result: SkillScanResponse) {
|
||||
val statusColors = LocalHermesStatusColors.current
|
||||
val verdictColor =
|
||||
when (result.verdict) {
|
||||
"safe" -> statusColors.success
|
||||
"dangerous" -> statusColors.error
|
||||
else -> statusColors.warning
|
||||
}
|
||||
val policyLabel =
|
||||
when (result.policy) {
|
||||
"allow" -> stringResource(R.string.skills_hub_scan_policy_allow)
|
||||
"ask" -> stringResource(R.string.skills_hub_scan_policy_ask)
|
||||
"block" -> stringResource(R.string.skills_hub_scan_policy_block)
|
||||
else -> result.policy.orEmpty()
|
||||
}
|
||||
val policyColor =
|
||||
when (result.policy) {
|
||||
"allow" -> statusColors.success
|
||||
"ask" -> statusColors.warning
|
||||
"block" -> statusColors.error
|
||||
else -> statusColors.neutral
|
||||
}
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
),
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
// ── Verdict header ──
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Shield,
|
||||
contentDescription = null,
|
||||
tint = verdictColor,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.skills_hub_scan_verdict_label, result.verdict.orEmpty()),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = verdictColor,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Surface(
|
||||
color = policyColor.copy(alpha = 0.15f),
|
||||
shape = RoundedCornerShape(50),
|
||||
) {
|
||||
Text(
|
||||
text = policyLabel,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = policyColor,
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Trust + finding count ──
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
if (!result.trustLevel.isNullOrBlank()) {
|
||||
Text(
|
||||
text = result.trustLevel,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text =
|
||||
pluralStringResource(
|
||||
R.plurals.skills_hub_scan_findings_count,
|
||||
result.findings.size,
|
||||
result.findings.size,
|
||||
),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
// ── Severity tally ──
|
||||
val severityColors =
|
||||
mapOf(
|
||||
"critical" to statusColors.error,
|
||||
"high" to statusColors.error,
|
||||
"medium" to statusColors.warning,
|
||||
"low" to statusColors.neutral,
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
listOf("critical", "high", "medium", "low").forEach { sev ->
|
||||
val count = result.severityCounts[sev] ?: 0
|
||||
if (count > 0) {
|
||||
val sevColor = severityColors[sev] ?: statusColors.neutral
|
||||
Surface(
|
||||
color = sevColor.copy(alpha = 0.15f),
|
||||
shape = RoundedCornerShape(50),
|
||||
) {
|
||||
Text(
|
||||
text = "$count $sev",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = sevColor,
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (result.findings.isEmpty()) {
|
||||
Text(
|
||||
text = stringResource(R.string.skills_hub_scan_no_findings),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = statusColors.success,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Policy reason ──
|
||||
if (!result.policyReason.isNullOrBlank()) {
|
||||
Text(
|
||||
text = result.policyReason,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
// ── Findings ──
|
||||
if (result.findings.isNotEmpty()) {
|
||||
HorizontalDivider()
|
||||
result.findings.forEach { finding ->
|
||||
ScanFindingRow(finding = finding)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ScanFindingRow(finding: SkillScanFinding) {
|
||||
val statusColors = LocalHermesStatusColors.current
|
||||
val severityColor =
|
||||
when (finding.severity) {
|
||||
"critical", "high" -> statusColors.error
|
||||
"medium" -> statusColors.warning
|
||||
else -> statusColors.neutral
|
||||
}
|
||||
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Surface(
|
||||
color = severityColor.copy(alpha = 0.15f),
|
||||
shape = RoundedCornerShape(4.dp),
|
||||
) {
|
||||
Text(
|
||||
text = finding.severity,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = severityColor,
|
||||
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = finding.category,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
val location =
|
||||
buildString {
|
||||
finding.file?.let { append(it) }
|
||||
finding.line?.let { append(":").append(it) }
|
||||
}
|
||||
if (location.isNotEmpty()) {
|
||||
Text(
|
||||
text = location,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
finding.description?.let { description ->
|
||||
Text(
|
||||
text = description,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Hub Skill Card ─────────────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -8,7 +8,9 @@ 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.SkillHubSource
|
||||
import com.m57.hermescontrol.data.model.SkillHubUninstallRequest
|
||||
import com.m57.hermescontrol.data.model.SkillScanResponse
|
||||
import com.m57.hermescontrol.data.model.ToggleSkillRequest
|
||||
import com.m57.hermescontrol.data.remote.ApiClient
|
||||
import com.m57.hermescontrol.data.remote.NetworkResult
|
||||
@@ -58,6 +60,16 @@ data class SkillsUiState(
|
||||
val hubResults: List<HubSkill> = emptyList(),
|
||||
val isHubSearching: Boolean = false,
|
||||
val hubSearchError: String? = null,
|
||||
// Hub sources + featured (landing, before any search)
|
||||
val hubSources: List<SkillHubSource> = emptyList(),
|
||||
val hubFeatured: List<HubSkill> = emptyList(),
|
||||
val isHubSourcesLoading: Boolean = false,
|
||||
val hubSourcesError: String? = null,
|
||||
// Hub security scan
|
||||
val hubScanIdentifier: String? = null,
|
||||
val hubScanResult: SkillScanResponse? = null,
|
||||
val isHubScanning: Boolean = false,
|
||||
val hubScanError: String? = null,
|
||||
// Hub detail preview (full description/content via preview endpoint)
|
||||
val hubPreviewIdentifier: String? = null,
|
||||
val hubPreviewContent: String? = null,
|
||||
@@ -111,6 +123,9 @@ class SkillsViewModel(
|
||||
if (mode == SkillsViewMode.INSTALLED && _uiState.value.skills.isEmpty()) {
|
||||
loadSkills()
|
||||
}
|
||||
if (mode == SkillsViewMode.HUB && _uiState.value.hubSources.isEmpty()) {
|
||||
loadHubSources()
|
||||
}
|
||||
}
|
||||
|
||||
fun setSourceFilter(source: String?) {
|
||||
@@ -161,6 +176,94 @@ class SkillsViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
// ── Hub sources + featured landing ────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Loads the configured hub sources and featured skills (the browse-hub
|
||||
* landing state shown before any search runs).
|
||||
*/
|
||||
fun loadHubSources() {
|
||||
safeLaunchLoad(
|
||||
apiCall = { safeApiCall { ApiClient.hermesApi.getSkillHubSources() } },
|
||||
onStart = {
|
||||
_uiState.update {
|
||||
it.copy(isHubSourcesLoading = true, hubSourcesError = null)
|
||||
}
|
||||
},
|
||||
onSuccess = { data ->
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isHubSourcesLoading = false,
|
||||
hubSources = data.sources,
|
||||
hubFeatured = data.featured,
|
||||
)
|
||||
}
|
||||
},
|
||||
onError = { errorMsg ->
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isHubSourcesLoading = false,
|
||||
hubSourcesError = "Failed to load hub sources: $errorMsg",
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// ── Hub security scan ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Runs the install-time security scan on a hub skill WITHOUT installing
|
||||
* it, so the user sees the safety verdict before deciding.
|
||||
*/
|
||||
fun scanHubSkill(identifier: String) {
|
||||
if (identifier.isBlank()) return
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
hubScanIdentifier = identifier,
|
||||
isHubScanning = true,
|
||||
hubScanResult = null,
|
||||
hubScanError = null,
|
||||
)
|
||||
}
|
||||
viewModelScope.launch {
|
||||
val result =
|
||||
withContext(Dispatchers.IO) {
|
||||
safeApiCall { ApiClient.hermesApi.scanHubSkill(identifier = identifier) }
|
||||
}
|
||||
when (result) {
|
||||
is NetworkResult.Success -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isHubScanning = false,
|
||||
hubScanResult = result.data,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is NetworkResult.Failure -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isHubScanning = false,
|
||||
hubScanError = result.error.message,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clearHubScan() {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
hubScanIdentifier = null,
|
||||
hubScanResult = null,
|
||||
isHubScanning = false,
|
||||
hubScanError = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Hub detail preview ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,291 +0,0 @@
|
||||
package com.m57.hermescontrol.ui.skills.components
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.CloudDownload
|
||||
import androidx.compose.material.icons.filled.Extension
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.data.model.HubSkill
|
||||
import com.m57.hermescontrol.ui.common.DetailDialog
|
||||
import com.m57.hermescontrol.ui.common.DetailRow
|
||||
import com.m57.hermescontrol.ui.common.EmptyState
|
||||
import com.m57.hermescontrol.ui.common.ErrorState
|
||||
import com.m57.hermescontrol.ui.common.SkeletonListState
|
||||
import com.m57.hermescontrol.ui.common.listItemSpacing
|
||||
import com.m57.hermescontrol.ui.skills.SkillsUiState
|
||||
|
||||
@Composable
|
||||
internal fun HubBrowseView(
|
||||
state: SkillsUiState,
|
||||
hubQuery: String,
|
||||
onHubQueryChange: (String) -> Unit,
|
||||
onSearch: (String) -> Unit,
|
||||
onClearSearch: () -> Unit,
|
||||
onInstall: (String) -> Unit,
|
||||
onPreviewHubSkill: (String) -> Unit,
|
||||
isInstalling: Boolean,
|
||||
installingSkillName: String?,
|
||||
) {
|
||||
var showDetail by remember { mutableStateOf<HubSkill?>(null) }
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
OutlinedTextField(
|
||||
value = hubQuery,
|
||||
onValueChange = onHubQueryChange,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = 6.dp),
|
||||
placeholder = { Text(stringResource(R.string.skills_hub_search_placeholder)) },
|
||||
trailingIcon = {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (hubQuery.isNotEmpty()) {
|
||||
IconButton(onClick = {
|
||||
onHubQueryChange("")
|
||||
onClearSearch()
|
||||
}) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Close,
|
||||
contentDescription = stringResource(R.string.action_clear),
|
||||
)
|
||||
}
|
||||
}
|
||||
IconButton(
|
||||
onClick = { onSearch(hubQuery) },
|
||||
enabled = hubQuery.isNotBlank(),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Search,
|
||||
contentDescription = stringResource(R.string.skills_hub_search_button),
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
singleLine = true,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
|
||||
keyboardActions =
|
||||
KeyboardActions(
|
||||
onSearch = {
|
||||
if (hubQuery.isNotBlank()) {
|
||||
onSearch(hubQuery)
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
when {
|
||||
state.isHubSearching -> {
|
||||
SkeletonListState()
|
||||
}
|
||||
|
||||
state.hubSearchError != null -> {
|
||||
ErrorState(
|
||||
message = state.hubSearchError,
|
||||
onRetry = { onSearch(hubQuery) },
|
||||
)
|
||||
}
|
||||
|
||||
state.hubResults.isEmpty() && hubQuery.isNotBlank() && !state.isHubSearching -> {
|
||||
EmptyState(
|
||||
icon = Icons.Filled.Extension,
|
||||
title = stringResource(R.string.skills_hub_empty_results),
|
||||
actionLabel = stringResource(R.string.empty_action_retry_search),
|
||||
onAction = { onSearch(hubQuery) },
|
||||
)
|
||||
}
|
||||
|
||||
state.hubResults.isNotEmpty() -> {
|
||||
LazyColumn(
|
||||
modifier = Modifier.weight(1f),
|
||||
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 8.dp),
|
||||
verticalArrangement = listItemSpacing,
|
||||
) {
|
||||
itemsIndexed(
|
||||
state.hubResults,
|
||||
key = { index, hubSkill -> "${hubSkill.name}:${hubSkill.source ?: "unknown"}:$index" },
|
||||
) { _, hubSkill ->
|
||||
HubSkillCard(
|
||||
hubSkill = hubSkill,
|
||||
onInstall = { onInstall(hubSkill.name) },
|
||||
isInstalling = isInstalling && installingSkillName == hubSkill.name,
|
||||
onClick = {
|
||||
showDetail = hubSkill
|
||||
hubSkill.identifier?.let { onPreviewHubSkill(it) }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
EmptyState(
|
||||
icon = Icons.Filled.Search,
|
||||
title = stringResource(R.string.skills_hub_search_hint),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
showDetail?.let { hubSkill ->
|
||||
val previewReady = state.hubPreviewIdentifier == hubSkill.identifier
|
||||
val fullContent =
|
||||
if (previewReady) {
|
||||
state.hubPreviewContent ?: hubSkill.description
|
||||
} else {
|
||||
hubSkill.description
|
||||
}
|
||||
DetailDialog(
|
||||
title = hubSkill.name,
|
||||
rows =
|
||||
listOf(
|
||||
DetailRow(stringResource(R.string.detail_dialog_category), hubSkill.category),
|
||||
DetailRow(stringResource(R.string.detail_dialog_source), hubSkill.source),
|
||||
DetailRow(stringResource(R.string.detail_dialog_description), fullContent),
|
||||
DetailRow(stringResource(R.string.detail_dialog_tags), hubSkill.tags.orEmpty().joinToString(", ")),
|
||||
DetailRow(stringResource(R.string.detail_dialog_trust_level), hubSkill.trustLevel),
|
||||
),
|
||||
actions =
|
||||
if (previewReady && state.isHubPreviewing) {
|
||||
{ CircularProgressIndicator(modifier = Modifier.padding(top = 8.dp)) }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onDismiss = { showDetail = null },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HubSkillCard(
|
||||
hubSkill: HubSkill,
|
||||
onInstall: () -> Unit,
|
||||
isInstalling: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth().clickable(onClick = onClick),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
),
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 8.dp),
|
||||
) {
|
||||
// ── Top row: name + source badge ──
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = hubSkill.name,
|
||||
style =
|
||||
MaterialTheme.typography.titleMedium.copy(
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
hubSkill.source?.let { source ->
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
SourceBadge(source = source)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Category ──
|
||||
hubSkill.category?.let { cat ->
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
text = cat,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
// ── Description ──
|
||||
hubSkill.description?.let { desc ->
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = desc,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
|
||||
// ── Install button ──
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
) {
|
||||
Button(
|
||||
onClick = onInstall,
|
||||
enabled = !isInstalling,
|
||||
) {
|
||||
if (isInstalling) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(18.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.CloudDownload,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text(stringResource(R.string.skills_hub_install))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -277,6 +277,23 @@
|
||||
<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_hub_sources_title">Connected sources</string>
|
||||
<string name="skills_hub_source_rate_limited">rate limited</string>
|
||||
<string name="skills_hub_source_offline">offline</string>
|
||||
<string name="skills_hub_source_via_index">via index</string>
|
||||
<string name="skills_hub_featured_title">Featured skills</string>
|
||||
<string name="skills_hub_featured_subtitle">from the Hermes index — search above for more</string>
|
||||
<string name="skills_hub_landing_hint">Search the hub above to browse installable skills from the connected sources</string>
|
||||
<string name="skills_hub_scan">Scan</string>
|
||||
<string name="skills_hub_scan_verdict_label">Verdict: %1$s</string>
|
||||
<string name="skills_hub_scan_policy_allow">Install allowed</string>
|
||||
<string name="skills_hub_scan_policy_ask">Needs confirmation</string>
|
||||
<string name="skills_hub_scan_policy_block">Install blocked</string>
|
||||
<string name="skills_hub_scan_no_findings">No risky patterns detected</string>
|
||||
<plurals name="skills_hub_scan_findings_count">
|
||||
<item quantity="one">%1$d finding</item>
|
||||
<item quantity="other">%1$d findings</item>
|
||||
</plurals>
|
||||
<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>
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
package com.m57.hermescontrol.ui.skills
|
||||
|
||||
import android.app.Application
|
||||
import com.m57.hermescontrol.data.model.HubSkill
|
||||
import com.m57.hermescontrol.data.model.SkillHubSource
|
||||
import com.m57.hermescontrol.data.model.SkillHubSourcesResponse
|
||||
import com.m57.hermescontrol.data.model.SkillScanFinding
|
||||
import com.m57.hermescontrol.data.model.SkillScanResponse
|
||||
import com.m57.hermescontrol.data.remote.ApiClient
|
||||
import com.m57.hermescontrol.data.remote.HermesApiService
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkObject
|
||||
import io.mockk.unmockkAll
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import retrofit2.Response
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class SkillsViewModelTest {
|
||||
private val testDispatcher = StandardTestDispatcher()
|
||||
private val mockApi = mockk<HermesApiService>(relaxed = true)
|
||||
private val app = mockk<Application>(relaxed = true)
|
||||
|
||||
/**
|
||||
* Pump the test scheduler while letting the real Dispatchers.IO hops
|
||||
* (safeLaunchLoad / withContext(IO)) land their resumptions.
|
||||
*/
|
||||
private fun settle() {
|
||||
repeat(20) {
|
||||
testDispatcher.scheduler.advanceUntilIdle()
|
||||
Thread.sleep(10)
|
||||
}
|
||||
testDispatcher.scheduler.advanceUntilIdle()
|
||||
}
|
||||
|
||||
private val sourcesResponse =
|
||||
SkillHubSourcesResponse(
|
||||
sources =
|
||||
listOf(
|
||||
SkillHubSource(id = "hermes-index", label = "Hermes Index", available = true),
|
||||
SkillHubSource(id = "github", label = "GitHub", rateLimited = true),
|
||||
),
|
||||
indexAvailable = true,
|
||||
featured =
|
||||
listOf(
|
||||
HubSkill(
|
||||
name = "web-research",
|
||||
description = "Find and verify current web facts",
|
||||
source = "hermes-index",
|
||||
identifier = "hermes-index:web-research",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
private val scanResponse =
|
||||
SkillScanResponse(
|
||||
identifier = "hermes-index:web-research",
|
||||
name = "web-research",
|
||||
source = "hermes-index",
|
||||
trustLevel = "trusted",
|
||||
verdict = "safe",
|
||||
summary = "No issues found",
|
||||
policy = "allow",
|
||||
policyReason = "No dangerous patterns detected.",
|
||||
findings =
|
||||
listOf(
|
||||
SkillScanFinding(
|
||||
severity = "low",
|
||||
category = "network",
|
||||
file = "scripts/fetch.py",
|
||||
line = 12,
|
||||
description = "Uses urllib without a timeout.",
|
||||
),
|
||||
),
|
||||
severityCounts = mapOf("low" to 1),
|
||||
)
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
Dispatchers.setMain(testDispatcher)
|
||||
mockkObject(ApiClient)
|
||||
every { ApiClient.hermesApi } returns mockApi
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
Dispatchers.resetMain()
|
||||
unmockkAll()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `loadHubSources stores sources and featured skills`() {
|
||||
coEvery { mockApi.getSkillHubSources() } returns Response.success(sourcesResponse)
|
||||
val vm = SkillsViewModel(app)
|
||||
vm.loadHubSources()
|
||||
settle()
|
||||
|
||||
assertEquals(sourcesResponse.sources, vm.uiState.value.hubSources)
|
||||
assertEquals(sourcesResponse.featured, vm.uiState.value.hubFeatured)
|
||||
assertEquals(false, vm.uiState.value.isHubSourcesLoading)
|
||||
assertNull(vm.uiState.value.hubSourcesError)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `loadHubSources surfaces an error`() {
|
||||
coEvery { mockApi.getSkillHubSources() } returns
|
||||
Response.error(
|
||||
404,
|
||||
"""{"detail":"Hub sources unavailable"}""".toResponseBody("application/json".toMediaTypeOrNull()),
|
||||
)
|
||||
val vm = SkillsViewModel(app)
|
||||
vm.loadHubSources()
|
||||
settle()
|
||||
|
||||
assertEquals(true, vm.uiState.value.hubSourcesError?.contains("Failed to load hub sources"))
|
||||
assertEquals(false, vm.uiState.value.isHubSourcesLoading)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `setViewMode HUB triggers sources load when landing is empty`() {
|
||||
coEvery { mockApi.getSkillHubSources() } returns Response.success(sourcesResponse)
|
||||
val vm = SkillsViewModel(app)
|
||||
vm.setViewMode(SkillsViewMode.HUB)
|
||||
settle()
|
||||
|
||||
coVerify { mockApi.getSkillHubSources() }
|
||||
assertEquals(sourcesResponse.featured, vm.uiState.value.hubFeatured)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `scanHubSkill stores the scan result for the identifier`() {
|
||||
coEvery { mockApi.scanHubSkill(identifier = "hermes-index:web-research") } returns
|
||||
Response.success(scanResponse)
|
||||
val vm = SkillsViewModel(app)
|
||||
vm.scanHubSkill("hermes-index:web-research")
|
||||
settle()
|
||||
|
||||
assertEquals("hermes-index:web-research", vm.uiState.value.hubScanIdentifier)
|
||||
assertEquals(scanResponse, vm.uiState.value.hubScanResult)
|
||||
assertEquals(false, vm.uiState.value.isHubScanning)
|
||||
assertNull(vm.uiState.value.hubScanError)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `scanHubSkill surfaces an error`() {
|
||||
coEvery { mockApi.scanHubSkill(identifier = "broken:skill") } returns
|
||||
Response.error(
|
||||
404,
|
||||
"""{"detail":"Skill not found"}""".toResponseBody("application/json".toMediaTypeOrNull()),
|
||||
)
|
||||
val vm = SkillsViewModel(app)
|
||||
vm.scanHubSkill("broken:skill")
|
||||
settle()
|
||||
|
||||
assertEquals("broken:skill", vm.uiState.value.hubScanIdentifier)
|
||||
assertNull(vm.uiState.value.hubScanResult)
|
||||
assertEquals(false, vm.uiState.value.isHubScanning)
|
||||
assertEquals("Skill not found", vm.uiState.value.hubScanError)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `scanHubSkill ignores blank identifiers`() {
|
||||
val vm = SkillsViewModel(app)
|
||||
vm.scanHubSkill(" ")
|
||||
settle()
|
||||
|
||||
assertNull(vm.uiState.value.hubScanIdentifier)
|
||||
coVerify(exactly = 0) { mockApi.scanHubSkill(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clearHubScan resets scan state`() {
|
||||
coEvery { mockApi.scanHubSkill(identifier = "hermes-index:web-research") } returns
|
||||
Response.success(scanResponse)
|
||||
val vm = SkillsViewModel(app)
|
||||
vm.scanHubSkill("hermes-index:web-research")
|
||||
settle()
|
||||
vm.clearHubScan()
|
||||
|
||||
assertNull(vm.uiState.value.hubScanIdentifier)
|
||||
assertNull(vm.uiState.value.hubScanResult)
|
||||
assertNull(vm.uiState.value.hubScanError)
|
||||
assertEquals(false, vm.uiState.value.isHubScanning)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user