|
| 1 | +package com.composea11yscanner.core |
| 2 | + |
| 3 | +import com.composea11yscanner.core.model.A11yIssue |
| 4 | +import com.composea11yscanner.core.model.A11yNode |
| 5 | +import com.composea11yscanner.core.model.ScanResult |
| 6 | +import com.composea11yscanner.core.model.ScannerConfig |
| 7 | +import com.composea11yscanner.core.model.ScannerState |
| 8 | +import com.composea11yscanner.core.rule.A11yRule |
| 9 | +import kotlinx.coroutines.CancellationException |
| 10 | +import kotlinx.coroutines.Dispatchers |
| 11 | +import kotlinx.coroutines.flow.Flow |
| 12 | +import kotlinx.coroutines.flow.flow |
| 13 | +import kotlinx.coroutines.flow.flowOn |
| 14 | +import java.util.UUID |
| 15 | + |
| 16 | +/** |
| 17 | + * Core orchestrator: runs a fixed set of accessibility rules over a list of nodes and |
| 18 | + * streams scan progress as a [Flow] of [ScannerState]. |
| 19 | + * |
| 20 | + * Emission sequence: |
| 21 | + * Scanning(0f) → Scanning(1/n) → … → Scanning(1f) → Complete(result) |
| 22 | + * or Complete(emptyResult) when no enabled rules / no nodes. |
| 23 | + * or Error(message) if a rule throws an unexpected exception. |
| 24 | + * |
| 25 | + * The entire flow body runs on [Dispatchers.Default] via [flowOn]; collectors receive |
| 26 | + * emissions on their own dispatcher. |
| 27 | + * |
| 28 | + * @param rules All candidate rules. Only those whose [A11yRule.ruleId] appears in |
| 29 | + * [config.enabledRules] will be evaluated — defensive against callers that pass the |
| 30 | + * full rule set regardless of config. |
| 31 | + */ |
| 32 | +class A11yScanEngine( |
| 33 | + rules: List<A11yRule>, |
| 34 | + private val config: ScannerConfig, |
| 35 | +) { |
| 36 | + private val enabledRules: List<A11yRule> = rules.filter { it.ruleId in config.enabledRules } |
| 37 | + |
| 38 | + fun scan(nodes: List<A11yNode>): Flow<ScannerState> = flow { |
| 39 | + // Fast path: nothing to evaluate. |
| 40 | + if (enabledRules.isEmpty() || nodes.isEmpty()) { |
| 41 | + emit(ScannerState.Complete(buildResult(nodes.size, emptyList(), emptySet()))) |
| 42 | + return@flow |
| 43 | + } |
| 44 | + |
| 45 | + emit(ScannerState.Scanning(0f)) |
| 46 | + |
| 47 | + val allIssues = mutableListOf<A11yIssue>() |
| 48 | + val failedRuleIds = mutableSetOf<String>() |
| 49 | + |
| 50 | + try { |
| 51 | + enabledRules.forEachIndexed { index, rule -> |
| 52 | + val issues = rule.evaluateAll(nodes) |
| 53 | + allIssues += issues |
| 54 | + if (issues.isNotEmpty()) failedRuleIds += rule.ruleId |
| 55 | + // Progress advances to 1f after the last rule. |
| 56 | + emit(ScannerState.Scanning((index + 1f) / enabledRules.size)) |
| 57 | + } |
| 58 | + emit(ScannerState.Complete(buildResult(nodes.size, allIssues, failedRuleIds))) |
| 59 | + } catch (e: CancellationException) { |
| 60 | + throw e // never swallow cancellation |
| 61 | + } catch (e: Exception) { |
| 62 | + emit(ScannerState.Error(e.message ?: "Scan failed")) |
| 63 | + } |
| 64 | + }.flowOn(Dispatchers.Default) |
| 65 | + |
| 66 | + // --- helpers --- |
| 67 | + |
| 68 | + private fun buildResult( |
| 69 | + totalNodes: Int, |
| 70 | + issues: List<A11yIssue>, |
| 71 | + failedRuleIds: Set<String>, |
| 72 | + ): ScanResult = ScanResult( |
| 73 | + scanId = UUID.randomUUID().toString(), |
| 74 | + timestamp = System.currentTimeMillis(), |
| 75 | + totalNodes = totalNodes, |
| 76 | + issues = issues.sortedWith(compareBy({ it.severity.sortOrder }, { it.ruleId })), |
| 77 | + passedRules = enabledRules.size - failedRuleIds.size, |
| 78 | + failedRules = failedRuleIds.size, |
| 79 | + ) |
| 80 | +} |
0 commit comments