Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 2 additions & 17 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
2 changes: 1 addition & 1 deletion gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down
1 change: 1 addition & 0 deletions sample/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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<A11yNode>) = 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,
)
}
29 changes: 22 additions & 7 deletions scripts/check_coverage.py
Original file line number Diff line number Diff line change
@@ -1,50 +1,65 @@
#!/usr/bin/env python3
"""
Usage: python3 scripts/check_coverage.py <reports_dir> <threshold_percent>
Usage:
python3 scripts/check_coverage.py <reports_dir> <threshold_percent> [module ...]

Finds all JaCoCo report.xml files under <reports_dir>, aggregates LINE coverage
across all modules, and exits non-zero if coverage < <threshold_percent>.
across matching modules, and exits non-zero if coverage < <threshold_percent>.
"""

import sys
import xml.etree.ElementTree as ET
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)

return total_covered, total_missed


def main() -> None:
if len(sys.argv) != 3:
if len(sys.argv) < 3:
print(f"Usage: {sys.argv[0]} <reports_dir> <threshold_percent>")
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:
Expand Down
Loading