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 =
|
val categories =
|
||||||
remember(state.skills) {
|
remember(state.skills) {
|
||||||
state.skills
|
SkillListFilter.extractCategories(state.skills)
|
||||||
.mapNotNull { it.category }
|
|
||||||
.distinct()
|
|
||||||
.sorted()
|
|
||||||
}
|
}
|
||||||
val sources =
|
val sources =
|
||||||
remember(state.skills) {
|
remember(state.skills) {
|
||||||
state.skills
|
SkillListFilter.extractSources(state.skills)
|
||||||
.mapNotNull { it.source }
|
|
||||||
.distinct()
|
|
||||||
.sorted()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val filteredSkills =
|
val filteredSkills =
|
||||||
remember(state.skills, query, selectedStatus, selectedCategory, sourceFilter) {
|
remember(state.skills, query, selectedStatus, selectedCategory, sourceFilter) {
|
||||||
state.skills.filter { skill ->
|
SkillListFilter.filterSkills(
|
||||||
(
|
skills = state.skills,
|
||||||
query.isBlank() || skill.name.contains(query, ignoreCase = true) ||
|
query = query,
|
||||||
skill.description?.contains(query, ignoreCase = true) == true
|
selectedStatus = selectedStatus,
|
||||||
) &&
|
selectedCategory = selectedCategory,
|
||||||
(
|
sourceFilter = sourceFilter,
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
@@ -891,26 +879,11 @@ private fun SkillScanSection(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun ScanResultCard(result: SkillScanResponse) {
|
private fun ScanResultCard(result: SkillScanResponse) {
|
||||||
val statusColors = LocalHermesStatusColors.current
|
val statusColors = LocalHermesStatusColors.current
|
||||||
val verdictColor =
|
val verdictColor = SkillScanPresentation.resolveVerdictColor(result.verdict, statusColors)
|
||||||
when (result.verdict) {
|
|
||||||
"safe" -> statusColors.success
|
|
||||||
"dangerous" -> statusColors.error
|
|
||||||
else -> statusColors.warning
|
|
||||||
}
|
|
||||||
val policyLabel =
|
val policyLabel =
|
||||||
when (result.policy) {
|
SkillScanPresentation.resolvePolicyLabelRes(result.policy)?.let { stringResource(it) }
|
||||||
"allow" -> stringResource(R.string.skills_hub_scan_policy_allow)
|
?: result.policy.orEmpty()
|
||||||
"ask" -> stringResource(R.string.skills_hub_scan_policy_ask)
|
val policyColor = SkillScanPresentation.resolvePolicyColor(result.policy, statusColors)
|
||||||
"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(
|
Card(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
@@ -979,13 +952,6 @@ private fun ScanResultCard(result: SkillScanResponse) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Severity tally ──
|
// ── Severity tally ──
|
||||||
val severityColors =
|
|
||||||
mapOf(
|
|
||||||
"critical" to statusColors.error,
|
|
||||||
"high" to statusColors.error,
|
|
||||||
"medium" to statusColors.warning,
|
|
||||||
"low" to statusColors.neutral,
|
|
||||||
)
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.horizontalScroll(rememberScrollState()),
|
modifier = Modifier.horizontalScroll(rememberScrollState()),
|
||||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
@@ -993,7 +959,7 @@ private fun ScanResultCard(result: SkillScanResponse) {
|
|||||||
listOf("critical", "high", "medium", "low").forEach { sev ->
|
listOf("critical", "high", "medium", "low").forEach { sev ->
|
||||||
val count = result.severityCounts[sev] ?: 0
|
val count = result.severityCounts[sev] ?: 0
|
||||||
if (count > 0) {
|
if (count > 0) {
|
||||||
val sevColor = severityColors[sev] ?: statusColors.neutral
|
val sevColor = SkillScanPresentation.resolveSeverityColor(sev, statusColors)
|
||||||
Surface(
|
Surface(
|
||||||
color = sevColor.copy(alpha = 0.15f),
|
color = sevColor.copy(alpha = 0.15f),
|
||||||
shape = RoundedCornerShape(50),
|
shape = RoundedCornerShape(50),
|
||||||
@@ -1039,12 +1005,7 @@ private fun ScanResultCard(result: SkillScanResponse) {
|
|||||||
@Composable
|
@Composable
|
||||||
private fun ScanFindingRow(finding: SkillScanFinding) {
|
private fun ScanFindingRow(finding: SkillScanFinding) {
|
||||||
val statusColors = LocalHermesStatusColors.current
|
val statusColors = LocalHermesStatusColors.current
|
||||||
val severityColor =
|
val severityColor = SkillScanPresentation.resolveSeverityColor(finding.severity, statusColors)
|
||||||
when (finding.severity) {
|
|
||||||
"critical", "high" -> statusColors.error
|
|
||||||
"medium" -> statusColors.warning
|
|
||||||
else -> statusColors.neutral
|
|
||||||
}
|
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
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