refactor(skills): extract SkillListFilter and SkillScanPresentation
Separate pure skill filtering and scan presentation helpers. Wire into SkillsScreen with unit test coverage.
This commit is contained in:
1 parent
6876db0393
commit
9eaf9663f3
5 files changed
+323
-54
No files matched your search
@@ -0,0 +1,39 @@
|
||||
package com.m57.hermescontrol.ui.skills
|
||||
|
||||
import com.m57.hermescontrol.data.model.Skill
|
||||
|
||||
object SkillListFilter {
|
||||
/**
|
||||
* Filters skills by search query, enabled/disabled status, category, and source.
|
||||
*/
|
||||
fun filterSkills(
|
||||
skills: List<Skill>,
|
||||
query: String,
|
||||
selectedStatus: SkillFilter,
|
||||
selectedCategory: String?,
|
||||
sourceFilter: String?,
|
||||
): List<Skill> =
|
||||
skills.filter { skill ->
|
||||
val matchesQuery =
|
||||
query.isBlank() ||
|
||||
skill.name.contains(query, ignoreCase = true) ||
|
||||
skill.description?.contains(query, ignoreCase = true) == true
|
||||
val matchesStatus =
|
||||
when (selectedStatus) {
|
||||
SkillFilter.ALL_STATUSES -> true
|
||||
SkillFilter.ENABLED -> skill.enabled
|
||||
SkillFilter.DISABLED -> !skill.enabled
|
||||
}
|
||||
val matchesCategory =
|
||||
selectedCategory == null ||
|
||||
selectedCategory == CATEGORY_ALL ||
|
||||
skill.category == selectedCategory
|
||||
val matchesSource = sourceFilter == null || skill.source == sourceFilter
|
||||
|
||||
matchesQuery && matchesStatus && matchesCategory && matchesSource
|
||||
}
|
||||
|
||||
fun extractCategories(skills: List<Skill>): List<String> = skills.mapNotNull { it.category }.distinct().sorted()
|
||||
|
||||
fun extractSources(skills: List<Skill>): List<String> = skills.mapNotNull { it.source }.distinct().sorted()
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package com.m57.hermescontrol.ui.skills
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.theme.HermesStatusColors
|
||||
|
||||
enum class ScanVerdictKind {
|
||||
SAFE,
|
||||
DANGEROUS,
|
||||
WARNING,
|
||||
}
|
||||
|
||||
enum class ScanPolicyKind {
|
||||
ALLOW,
|
||||
ASK,
|
||||
BLOCK,
|
||||
OTHER,
|
||||
}
|
||||
|
||||
object SkillScanPresentation {
|
||||
fun resolveVerdictKind(verdict: String?): ScanVerdictKind =
|
||||
when (verdict) {
|
||||
"safe" -> ScanVerdictKind.SAFE
|
||||
"dangerous" -> ScanVerdictKind.DANGEROUS
|
||||
else -> ScanVerdictKind.WARNING
|
||||
}
|
||||
|
||||
fun resolveVerdictColor(
|
||||
verdict: String?,
|
||||
statusColors: HermesStatusColors,
|
||||
): Color =
|
||||
when (resolveVerdictKind(verdict)) {
|
||||
ScanVerdictKind.SAFE -> statusColors.success
|
||||
ScanVerdictKind.DANGEROUS -> statusColors.error
|
||||
ScanVerdictKind.WARNING -> statusColors.warning
|
||||
}
|
||||
|
||||
fun resolvePolicyKind(policy: String?): ScanPolicyKind =
|
||||
when (policy) {
|
||||
"allow" -> ScanPolicyKind.ALLOW
|
||||
"ask" -> ScanPolicyKind.ASK
|
||||
"block" -> ScanPolicyKind.BLOCK
|
||||
else -> ScanPolicyKind.OTHER
|
||||
}
|
||||
|
||||
fun resolvePolicyLabelRes(policy: String?): Int? =
|
||||
when (policy) {
|
||||
"allow" -> R.string.skills_hub_scan_policy_allow
|
||||
"ask" -> R.string.skills_hub_scan_policy_ask
|
||||
"block" -> R.string.skills_hub_scan_policy_block
|
||||
else -> null
|
||||
}
|
||||
|
||||
fun resolvePolicyColor(
|
||||
policy: String?,
|
||||
statusColors: HermesStatusColors,
|
||||
): Color =
|
||||
when (policy) {
|
||||
"allow" -> statusColors.success
|
||||
"ask" -> statusColors.warning
|
||||
"block" -> statusColors.error
|
||||
else -> statusColors.neutral
|
||||
}
|
||||
|
||||
fun resolveSeverityColor(
|
||||
severity: String?,
|
||||
statusColors: HermesStatusColors,
|
||||
): Color =
|
||||
when (severity) {
|
||||
"critical", "high" -> statusColors.error
|
||||
"medium" -> statusColors.warning
|
||||
"low" -> statusColors.neutral
|
||||
else -> statusColors.neutral
|
||||
}
|
||||
}
|
||||
@@ -256,34 +256,22 @@ private fun InstalledSkillsView(
|
||||
|
||||
val categories =
|
||||
remember(state.skills) {
|
||||
state.skills
|
||||
.mapNotNull { it.category }
|
||||
.distinct()
|
||||
.sorted()
|
||||
SkillListFilter.extractCategories(state.skills)
|
||||
}
|
||||
val sources =
|
||||
remember(state.skills) {
|
||||
state.skills
|
||||
.mapNotNull { it.source }
|
||||
.distinct()
|
||||
.sorted()
|
||||
SkillListFilter.extractSources(state.skills)
|
||||
}
|
||||
|
||||
val filteredSkills =
|
||||
remember(state.skills, query, selectedStatus, selectedCategory, sourceFilter) {
|
||||
state.skills.filter { skill ->
|
||||
(
|
||||
query.isBlank() || skill.name.contains(query, ignoreCase = true) ||
|
||||
skill.description?.contains(query, ignoreCase = true) == true
|
||||
) &&
|
||||
(
|
||||
selectedStatus == SkillFilter.ALL_STATUSES ||
|
||||
(selectedStatus == SkillFilter.ENABLED && skill.enabled) ||
|
||||
(selectedStatus == SkillFilter.DISABLED && !skill.enabled)
|
||||
) &&
|
||||
(selectedCategory == CATEGORY_ALL || skill.category == selectedCategory) &&
|
||||
(sourceFilter == null || skill.source == sourceFilter)
|
||||
}
|
||||
SkillListFilter.filterSkills(
|
||||
skills = state.skills,
|
||||
query = query,
|
||||
selectedStatus = selectedStatus,
|
||||
selectedCategory = selectedCategory,
|
||||
sourceFilter = sourceFilter,
|
||||
)
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
@@ -891,26 +879,11 @@ private fun SkillScanSection(
|
||||
@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 verdictColor = SkillScanPresentation.resolveVerdictColor(result.verdict, statusColors)
|
||||
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
|
||||
}
|
||||
SkillScanPresentation.resolvePolicyLabelRes(result.policy)?.let { stringResource(it) }
|
||||
?: result.policy.orEmpty()
|
||||
val policyColor = SkillScanPresentation.resolvePolicyColor(result.policy, statusColors)
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@@ -979,13 +952,6 @@ private fun ScanResultCard(result: SkillScanResponse) {
|
||||
}
|
||||
|
||||
// ── 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),
|
||||
@@ -993,7 +959,7 @@ private fun ScanResultCard(result: SkillScanResponse) {
|
||||
listOf("critical", "high", "medium", "low").forEach { sev ->
|
||||
val count = result.severityCounts[sev] ?: 0
|
||||
if (count > 0) {
|
||||
val sevColor = severityColors[sev] ?: statusColors.neutral
|
||||
val sevColor = SkillScanPresentation.resolveSeverityColor(sev, statusColors)
|
||||
Surface(
|
||||
color = sevColor.copy(alpha = 0.15f),
|
||||
shape = RoundedCornerShape(50),
|
||||
@@ -1039,12 +1005,7 @@ private fun ScanResultCard(result: SkillScanResponse) {
|
||||
@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
|
||||
}
|
||||
val severityColor = SkillScanPresentation.resolveSeverityColor(finding.severity, statusColors)
|
||||
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
package com.m57.hermescontrol.ui.skills
|
||||
|
||||
import com.m57.hermescontrol.data.model.Skill
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class SkillListFilterTest {
|
||||
private val sampleSkills =
|
||||
listOf(
|
||||
Skill(
|
||||
name = "code-review",
|
||||
category = "dev",
|
||||
description = "Automated code review assistant",
|
||||
enabled = true,
|
||||
source = "built-in",
|
||||
),
|
||||
Skill(
|
||||
name = "deploy-bot",
|
||||
category = "ops",
|
||||
description = "Deploy to production environments",
|
||||
enabled = false,
|
||||
source = "hub",
|
||||
),
|
||||
Skill(
|
||||
name = "image-gen",
|
||||
category = "creative",
|
||||
description = "Generate creative assets",
|
||||
enabled = true,
|
||||
source = "hub",
|
||||
),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `filterSkills empty query and all statuses returns all`() {
|
||||
val result =
|
||||
SkillListFilter.filterSkills(
|
||||
skills = sampleSkills,
|
||||
query = "",
|
||||
selectedStatus = SkillFilter.ALL_STATUSES,
|
||||
selectedCategory = CATEGORY_ALL,
|
||||
sourceFilter = null,
|
||||
)
|
||||
assertEquals(3, result.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `filterSkills filters by search query on name and description`() {
|
||||
val nameResult =
|
||||
SkillListFilter.filterSkills(
|
||||
skills = sampleSkills,
|
||||
query = "code",
|
||||
selectedStatus = SkillFilter.ALL_STATUSES,
|
||||
selectedCategory = CATEGORY_ALL,
|
||||
sourceFilter = null,
|
||||
)
|
||||
assertEquals(1, nameResult.size)
|
||||
assertEquals("code-review", nameResult[0].name)
|
||||
|
||||
val descResult =
|
||||
SkillListFilter.filterSkills(
|
||||
skills = sampleSkills,
|
||||
query = "production",
|
||||
selectedStatus = SkillFilter.ALL_STATUSES,
|
||||
selectedCategory = CATEGORY_ALL,
|
||||
sourceFilter = null,
|
||||
)
|
||||
assertEquals(1, descResult.size)
|
||||
assertEquals("deploy-bot", descResult[0].name)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `filterSkills filters by enabled and disabled status`() {
|
||||
val enabledResult =
|
||||
SkillListFilter.filterSkills(
|
||||
skills = sampleSkills,
|
||||
query = "",
|
||||
selectedStatus = SkillFilter.ENABLED,
|
||||
selectedCategory = CATEGORY_ALL,
|
||||
sourceFilter = null,
|
||||
)
|
||||
assertEquals(2, enabledResult.size)
|
||||
assertTrue(enabledResult.all { it.enabled })
|
||||
|
||||
val disabledResult =
|
||||
SkillListFilter.filterSkills(
|
||||
skills = sampleSkills,
|
||||
query = "",
|
||||
selectedStatus = SkillFilter.DISABLED,
|
||||
selectedCategory = CATEGORY_ALL,
|
||||
sourceFilter = null,
|
||||
)
|
||||
assertEquals(1, disabledResult.size)
|
||||
assertEquals("deploy-bot", disabledResult[0].name)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `filterSkills filters by category and source`() {
|
||||
val catResult =
|
||||
SkillListFilter.filterSkills(
|
||||
skills = sampleSkills,
|
||||
query = "",
|
||||
selectedStatus = SkillFilter.ALL_STATUSES,
|
||||
selectedCategory = "ops",
|
||||
sourceFilter = null,
|
||||
)
|
||||
assertEquals(1, catResult.size)
|
||||
assertEquals("deploy-bot", catResult[0].name)
|
||||
|
||||
val sourceResult =
|
||||
SkillListFilter.filterSkills(
|
||||
skills = sampleSkills,
|
||||
query = "",
|
||||
selectedStatus = SkillFilter.ALL_STATUSES,
|
||||
selectedCategory = CATEGORY_ALL,
|
||||
sourceFilter = "built-in",
|
||||
)
|
||||
assertEquals(1, sourceResult.size)
|
||||
assertEquals("code-review", sourceResult[0].name)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `extractCategories and extractSources return distinct sorted values`() {
|
||||
val categories = SkillListFilter.extractCategories(sampleSkills)
|
||||
assertEquals(listOf("creative", "dev", "ops"), categories)
|
||||
|
||||
val sources = SkillListFilter.extractSources(sampleSkills)
|
||||
assertEquals(listOf("built-in", "hub"), sources)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package com.m57.hermescontrol.ui.skills
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.theme.HermesStatusColors
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class SkillScanPresentationTest {
|
||||
private val dummyColors =
|
||||
HermesStatusColors(
|
||||
success = Color(0xFF00FF00),
|
||||
successContainer = Color(0xFF003300),
|
||||
onSuccess = Color.White,
|
||||
warning = Color(0xFFFFFF00),
|
||||
warningContainer = Color(0xFF333300),
|
||||
onWarning = Color.Black,
|
||||
error = Color(0xFFFF0000),
|
||||
errorContainer = Color(0xFF330000),
|
||||
onError = Color.White,
|
||||
info = Color(0xFF0000FF),
|
||||
infoContainer = Color(0xFF000033),
|
||||
onInfo = Color.White,
|
||||
neutral = Color.Gray,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `resolveVerdictKind maps safe dangerous and fallback`() {
|
||||
assertEquals(ScanVerdictKind.SAFE, SkillScanPresentation.resolveVerdictKind("safe"))
|
||||
assertEquals(ScanVerdictKind.DANGEROUS, SkillScanPresentation.resolveVerdictKind("dangerous"))
|
||||
assertEquals(ScanVerdictKind.WARNING, SkillScanPresentation.resolveVerdictKind("unknown"))
|
||||
assertEquals(ScanVerdictKind.WARNING, SkillScanPresentation.resolveVerdictKind(null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `resolveVerdictColor returns corresponding semantic color`() {
|
||||
assertEquals(dummyColors.success, SkillScanPresentation.resolveVerdictColor("safe", dummyColors))
|
||||
assertEquals(dummyColors.error, SkillScanPresentation.resolveVerdictColor("dangerous", dummyColors))
|
||||
assertEquals(dummyColors.warning, SkillScanPresentation.resolveVerdictColor("suspicious", dummyColors))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `resolvePolicyKind and labels map allow ask block and fallback`() {
|
||||
assertEquals(ScanPolicyKind.ALLOW, SkillScanPresentation.resolvePolicyKind("allow"))
|
||||
assertEquals(ScanPolicyKind.ASK, SkillScanPresentation.resolvePolicyKind("ask"))
|
||||
assertEquals(ScanPolicyKind.BLOCK, SkillScanPresentation.resolvePolicyKind("block"))
|
||||
assertEquals(ScanPolicyKind.OTHER, SkillScanPresentation.resolvePolicyKind("custom"))
|
||||
|
||||
assertEquals(R.string.skills_hub_scan_policy_allow, SkillScanPresentation.resolvePolicyLabelRes("allow"))
|
||||
assertEquals(R.string.skills_hub_scan_policy_ask, SkillScanPresentation.resolvePolicyLabelRes("ask"))
|
||||
assertEquals(R.string.skills_hub_scan_policy_block, SkillScanPresentation.resolvePolicyLabelRes("block"))
|
||||
assertNull(SkillScanPresentation.resolvePolicyLabelRes("other"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `resolveSeverityColor maps critical high medium low and fallback`() {
|
||||
assertEquals(dummyColors.error, SkillScanPresentation.resolveSeverityColor("critical", dummyColors))
|
||||
assertEquals(dummyColors.error, SkillScanPresentation.resolveSeverityColor("high", dummyColors))
|
||||
assertEquals(dummyColors.warning, SkillScanPresentation.resolveSeverityColor("medium", dummyColors))
|
||||
assertEquals(dummyColors.neutral, SkillScanPresentation.resolveSeverityColor("low", dummyColors))
|
||||
assertEquals(dummyColors.neutral, SkillScanPresentation.resolveSeverityColor("info", dummyColors))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user