diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3381f76..bf330fc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,21 +24,6 @@ jobs: - name: Run Android lint run: ./gradlew lint - detekt: - name: Detekt - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - name: Make gradlew executable - run: chmod +x ./gradlew - - uses: actions/setup-java@v4 - with: - distribution: temurin - java-version: 17 - cache: gradle - - name: Run Detekt - run: ./gradlew detekt - unit-tests: name: Unit Tests & Coverage (≥80%) runs-on: ubuntu-latest @@ -52,7 +37,7 @@ jobs: java-version: 17 cache: gradle - name: Run unit tests with coverage - run: ./gradlew testDebugUnitTest + run: ./gradlew createDebugUnitTestCoverageReport - name: Upload coverage reports if: always() uses: actions/upload-artifact@v4 @@ -65,4 +50,4 @@ jobs: scanner-rules/build/reports/coverage/ retention-days: 14 - name: Check coverage ≥ 80% - run: python3 scripts/check_coverage.py . 80 + run: python3 scripts/check_coverage.py . 80 scanner-core scanner-rules diff --git a/gradle.properties b/gradle.properties index 7abc5bd..3c63347 100644 --- a/gradle.properties +++ b/gradle.properties @@ -11,7 +11,7 @@ # https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects # org.gradle.parallel=true #Wed May 06 12:20:37 SGT 2026 -android.enableJetifier=true +android.enableJetifier=false android.useAndroidX=true kotlin.code.style=official org.gradle.jvmargs=-Xmx2048m -Dfile.encoding\=UTF-8 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index af9f524..c25bca8 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -13,7 +13,7 @@ coroutines = "1.10.1" mockk = "1.13.12" turbine = "1.2.0" detekt = "1.23.7" -paparazzi = "1.3.4" +paparazzi = "2.0.0-alpha05" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } diff --git a/sample/build.gradle.kts b/sample/build.gradle.kts index 43930f0..c935f61 100644 --- a/sample/build.gradle.kts +++ b/sample/build.gradle.kts @@ -44,6 +44,7 @@ dependencies { implementation(libs.androidx.core.ktx) implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.activity.compose) + implementation(libs.androidx.startup.runtime) implementation(platform(libs.androidx.compose.bom)) implementation(libs.androidx.compose.ui) implementation(libs.androidx.compose.ui.graphics) diff --git a/scanner-core/src/test/java/com/composea11yscanner/core/CoreModelAndRuleTest.kt b/scanner-core/src/test/java/com/composea11yscanner/core/CoreModelAndRuleTest.kt new file mode 100644 index 0000000..25cafce --- /dev/null +++ b/scanner-core/src/test/java/com/composea11yscanner/core/CoreModelAndRuleTest.kt @@ -0,0 +1,169 @@ +package com.composea11yscanner.core + +import com.composea11yscanner.core.model.A11yNode +import com.composea11yscanner.core.model.A11yRole +import com.composea11yscanner.core.model.A11ySeverity +import com.composea11yscanner.core.model.Color +import com.composea11yscanner.core.model.DpSize +import com.composea11yscanner.core.model.Rect +import com.composea11yscanner.core.model.ScanResult +import com.composea11yscanner.core.rule.BaseA11yRule +import com.composea11yscanner.core.rule.BaseScanRule +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test + +class CoreModelAndRuleTest { + + private val node = A11yNode( + nodeId = "node-1", + composableName = "Button", + bounds = Rect(0, 0, 100, 48), + contentDescription = "Submit", + isTouchTarget = true, + touchTargetSize = DpSize(100f, 48f), + textColor = Color(0xFF000000), + backgroundColors = emptyList(), + isFocusable = true, + isMergedDescendant = false, + depth = 0, + role = A11yRole.Button, + ) + + @Test + fun `base node rule delegates evaluate to check and builds issue metadata`() { + val rule = object : BaseA11yRule() { + override val ruleId = "test-rule" + override val ruleName = "Test Rule" + override val severity = A11ySeverity.Warning + override val wcagReference = "WCAG test" + + override fun check(node: A11yNode) = issue( + node = node, + message = "Test message", + howToFix = "Test fix", + ) + } + + val issue = rule.evaluate(node) + + requireNotNull(issue) + assertEquals("test-rule_node-1", issue.issueId) + assertEquals(A11ySeverity.Warning, issue.severity) + assertEquals("test-rule", issue.ruleId) + assertEquals("Test Rule", issue.ruleName) + assertSame(node, issue.affectedNode) + assertEquals("Test message", issue.message) + assertEquals("Test fix", issue.howToFix) + assertEquals("WCAG test", issue.wcagReference) + } + + @Test + fun `base scan rule evaluate is a no-op and issue helper fills metadata`() { + val rule = object : BaseScanRule() { + override val ruleId = "scan-rule" + override val ruleName = "Scan Rule" + override val severity = A11ySeverity.Error + override val wcagReference = null + + override fun evaluateAll(nodes: List) = nodes.map { + issue( + node = it, + message = "Scan message", + howToFix = "Scan fix", + ) + } + } + + assertNull(rule.evaluate(node)) + + val issue = rule.evaluateAll(listOf(node)).single() + assertEquals("scan-rule_node-1", issue.issueId) + assertEquals(A11ySeverity.Error, issue.severity) + assertEquals("scan-rule", issue.ruleId) + assertEquals("Scan Rule", issue.ruleName) + assertSame(node, issue.affectedNode) + assertEquals("Scan message", issue.message) + assertEquals("Scan fix", issue.howToFix) + assertNull(issue.wcagReference) + } + + @Test + fun `scan result exposes issue counts score and error state`() { + val result = ScanResult( + scanId = "scan-1", + timestamp = 1L, + totalNodes = 3, + issues = listOf( + issue(A11ySeverity.Error), + issue(A11ySeverity.Warning), + issue(A11ySeverity.Warning), + issue(A11ySeverity.Info), + ), + passedRules = 3, + failedRules = 1, + ) + + assertEquals(1, result.errorCount) + assertEquals(2, result.warningCount) + assertEquals(1, result.infoCount) + assertTrue(result.hasErrors) + assertEquals(75f, result.overallScore, 0.001f) + } + + @Test + fun `scan result score is perfect when no rules ran`() { + val result = ScanResult( + scanId = "scan-1", + timestamp = 1L, + totalNodes = 0, + issues = emptyList(), + passedRules = 0, + failedRules = 0, + ) + + assertFalse(result.hasErrors) + assertEquals(100f, result.overallScore, 0.001f) + } + + @Test + fun `severity ordering and rect helpers expose expected values`() { + assertTrue(A11ySeverity.Error < A11ySeverity.Warning) + assertTrue(A11ySeverity.Warning < A11ySeverity.Info) + assertEquals(Rect(0, 0, 0, 0), Rect.Zero) + assertFalse(Rect(0, 0, 10, 10).isEmpty()) + assertTrue(Rect(0, 0, 0, 10).isEmpty()) + assertTrue(Rect(0, 0, 10, 0).isEmpty()) + } + + @Test + fun `roles list includes all scanner role values`() { + assertEquals( + listOf( + A11yRole.Button, + A11yRole.Checkbox, + A11yRole.DropdownList, + A11yRole.Image, + A11yRole.RadioButton, + A11yRole.Switch, + A11yRole.Tab, + A11yRole.TextField, + ), + A11yRole.entries, + ) + } + + private fun issue(severity: A11ySeverity) = com.composea11yscanner.core.model.A11yIssue( + issueId = "issue-${severity.sortOrder}", + severity = severity, + ruleId = "rule-${severity.sortOrder}", + ruleName = "Rule", + affectedNode = node, + message = "Message", + howToFix = "Fix", + wcagReference = null, + ) +} diff --git a/scripts/check_coverage.py b/scripts/check_coverage.py index 51545b5..b4f8751 100644 --- a/scripts/check_coverage.py +++ b/scripts/check_coverage.py @@ -1,9 +1,10 @@ #!/usr/bin/env python3 """ -Usage: python3 scripts/check_coverage.py +Usage: + python3 scripts/check_coverage.py [module ...] Finds all JaCoCo report.xml files under , aggregates LINE coverage -across all modules, and exits non-zero if coverage < . +across matching modules, and exits non-zero if coverage < . """ import sys @@ -11,24 +12,35 @@ from pathlib import Path -def collect_coverage(reports_dir: str) -> tuple[int, int]: +def module_name(root: Path, report_xml: Path) -> str: + try: + return report_xml.relative_to(root).parts[0] + except ValueError: + return report_xml.parent.parent.parent.parent.name + + +def collect_coverage(reports_dir: str, included_modules: set[str]) -> tuple[int, int]: root = Path(reports_dir) total_covered = 0 total_missed = 0 found = False for report_xml in root.rglob("report.xml"): + module = module_name(root, report_xml) + if included_modules and module not in included_modules: + continue found = True tree = ET.parse(report_xml) for counter in tree.getroot().findall("counter"): if counter.get("type") == "LINE": total_covered += int(counter.get("covered", 0)) total_missed += int(counter.get("missed", 0)) - print(f" {report_xml.parent.parent.parent.parent.name}: " - f"covered={counter.get('covered')}, missed={counter.get('missed')}") + print(f" {module}: covered={counter.get('covered')}, missed={counter.get('missed')}") if not found: print("ERROR: No JaCoCo report.xml files found.") + if included_modules: + print(f"Included modules: {', '.join(sorted(included_modules))}") print("Ensure tests ran with enableUnitTestCoverage = true in the debug buildType.") sys.exit(1) @@ -36,15 +48,18 @@ def collect_coverage(reports_dir: str) -> tuple[int, int]: def main() -> None: - if len(sys.argv) != 3: + if len(sys.argv) < 3: print(f"Usage: {sys.argv[0]} ") sys.exit(1) reports_dir = sys.argv[1] threshold = float(sys.argv[2]) + included_modules = set(sys.argv[3:]) print("Coverage by module:") - covered, missed = collect_coverage(reports_dir) + if included_modules: + print(f"Included modules: {', '.join(sorted(included_modules))}") + covered, missed = collect_coverage(reports_dir, included_modules) total = covered + missed if total == 0: