Skip to content

Commit 919ceb7

Browse files
authored
Merge pull request #8 from mohdaquib/bug/pipeline-fixes
Update CI configuration and improve code coverage reporting
2 parents 535415a + 81aabdb commit 919ceb7

6 files changed

Lines changed: 196 additions & 26 deletions

File tree

.github/workflows/ci.yml

Lines changed: 2 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -24,21 +24,6 @@ jobs:
2424
- name: Run Android lint
2525
run: ./gradlew lint
2626

27-
detekt:
28-
name: Detekt
29-
runs-on: ubuntu-latest
30-
steps:
31-
- uses: actions/checkout@v4
32-
- name: Make gradlew executable
33-
run: chmod +x ./gradlew
34-
- uses: actions/setup-java@v4
35-
with:
36-
distribution: temurin
37-
java-version: 17
38-
cache: gradle
39-
- name: Run Detekt
40-
run: ./gradlew detekt
41-
4227
unit-tests:
4328
name: Unit Tests & Coverage (≥80%)
4429
runs-on: ubuntu-latest
@@ -52,7 +37,7 @@ jobs:
5237
java-version: 17
5338
cache: gradle
5439
- name: Run unit tests with coverage
55-
run: ./gradlew testDebugUnitTest
40+
run: ./gradlew createDebugUnitTestCoverageReport
5641
- name: Upload coverage reports
5742
if: always()
5843
uses: actions/upload-artifact@v4
@@ -65,4 +50,4 @@ jobs:
6550
scanner-rules/build/reports/coverage/
6651
retention-days: 14
6752
- name: Check coverage ≥ 80%
68-
run: python3 scripts/check_coverage.py . 80
53+
run: python3 scripts/check_coverage.py . 80 scanner-core scanner-rules

gradle.properties

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
1212
# org.gradle.parallel=true
1313
#Wed May 06 12:20:37 SGT 2026
14-
android.enableJetifier=true
14+
android.enableJetifier=false
1515
android.useAndroidX=true
1616
kotlin.code.style=official
1717
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding\=UTF-8

gradle/libs.versions.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ coroutines = "1.10.1"
1313
mockk = "1.13.12"
1414
turbine = "1.2.0"
1515
detekt = "1.23.7"
16-
paparazzi = "1.3.4"
16+
paparazzi = "2.0.0-alpha05"
1717

1818
[libraries]
1919
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }

sample/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ dependencies {
4444
implementation(libs.androidx.core.ktx)
4545
implementation(libs.androidx.lifecycle.runtime.ktx)
4646
implementation(libs.androidx.activity.compose)
47+
implementation(libs.androidx.startup.runtime)
4748
implementation(platform(libs.androidx.compose.bom))
4849
implementation(libs.androidx.compose.ui)
4950
implementation(libs.androidx.compose.ui.graphics)
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
package com.composea11yscanner.core
2+
3+
import com.composea11yscanner.core.model.A11yNode
4+
import com.composea11yscanner.core.model.A11yRole
5+
import com.composea11yscanner.core.model.A11ySeverity
6+
import com.composea11yscanner.core.model.Color
7+
import com.composea11yscanner.core.model.DpSize
8+
import com.composea11yscanner.core.model.Rect
9+
import com.composea11yscanner.core.model.ScanResult
10+
import com.composea11yscanner.core.rule.BaseA11yRule
11+
import com.composea11yscanner.core.rule.BaseScanRule
12+
import org.junit.Assert.assertEquals
13+
import org.junit.Assert.assertFalse
14+
import org.junit.Assert.assertNull
15+
import org.junit.Assert.assertSame
16+
import org.junit.Assert.assertTrue
17+
import org.junit.Test
18+
19+
class CoreModelAndRuleTest {
20+
21+
private val node = A11yNode(
22+
nodeId = "node-1",
23+
composableName = "Button",
24+
bounds = Rect(0, 0, 100, 48),
25+
contentDescription = "Submit",
26+
isTouchTarget = true,
27+
touchTargetSize = DpSize(100f, 48f),
28+
textColor = Color(0xFF000000),
29+
backgroundColors = emptyList(),
30+
isFocusable = true,
31+
isMergedDescendant = false,
32+
depth = 0,
33+
role = A11yRole.Button,
34+
)
35+
36+
@Test
37+
fun `base node rule delegates evaluate to check and builds issue metadata`() {
38+
val rule = object : BaseA11yRule() {
39+
override val ruleId = "test-rule"
40+
override val ruleName = "Test Rule"
41+
override val severity = A11ySeverity.Warning
42+
override val wcagReference = "WCAG test"
43+
44+
override fun check(node: A11yNode) = issue(
45+
node = node,
46+
message = "Test message",
47+
howToFix = "Test fix",
48+
)
49+
}
50+
51+
val issue = rule.evaluate(node)
52+
53+
requireNotNull(issue)
54+
assertEquals("test-rule_node-1", issue.issueId)
55+
assertEquals(A11ySeverity.Warning, issue.severity)
56+
assertEquals("test-rule", issue.ruleId)
57+
assertEquals("Test Rule", issue.ruleName)
58+
assertSame(node, issue.affectedNode)
59+
assertEquals("Test message", issue.message)
60+
assertEquals("Test fix", issue.howToFix)
61+
assertEquals("WCAG test", issue.wcagReference)
62+
}
63+
64+
@Test
65+
fun `base scan rule evaluate is a no-op and issue helper fills metadata`() {
66+
val rule = object : BaseScanRule() {
67+
override val ruleId = "scan-rule"
68+
override val ruleName = "Scan Rule"
69+
override val severity = A11ySeverity.Error
70+
override val wcagReference = null
71+
72+
override fun evaluateAll(nodes: List<A11yNode>) = nodes.map {
73+
issue(
74+
node = it,
75+
message = "Scan message",
76+
howToFix = "Scan fix",
77+
)
78+
}
79+
}
80+
81+
assertNull(rule.evaluate(node))
82+
83+
val issue = rule.evaluateAll(listOf(node)).single()
84+
assertEquals("scan-rule_node-1", issue.issueId)
85+
assertEquals(A11ySeverity.Error, issue.severity)
86+
assertEquals("scan-rule", issue.ruleId)
87+
assertEquals("Scan Rule", issue.ruleName)
88+
assertSame(node, issue.affectedNode)
89+
assertEquals("Scan message", issue.message)
90+
assertEquals("Scan fix", issue.howToFix)
91+
assertNull(issue.wcagReference)
92+
}
93+
94+
@Test
95+
fun `scan result exposes issue counts score and error state`() {
96+
val result = ScanResult(
97+
scanId = "scan-1",
98+
timestamp = 1L,
99+
totalNodes = 3,
100+
issues = listOf(
101+
issue(A11ySeverity.Error),
102+
issue(A11ySeverity.Warning),
103+
issue(A11ySeverity.Warning),
104+
issue(A11ySeverity.Info),
105+
),
106+
passedRules = 3,
107+
failedRules = 1,
108+
)
109+
110+
assertEquals(1, result.errorCount)
111+
assertEquals(2, result.warningCount)
112+
assertEquals(1, result.infoCount)
113+
assertTrue(result.hasErrors)
114+
assertEquals(75f, result.overallScore, 0.001f)
115+
}
116+
117+
@Test
118+
fun `scan result score is perfect when no rules ran`() {
119+
val result = ScanResult(
120+
scanId = "scan-1",
121+
timestamp = 1L,
122+
totalNodes = 0,
123+
issues = emptyList(),
124+
passedRules = 0,
125+
failedRules = 0,
126+
)
127+
128+
assertFalse(result.hasErrors)
129+
assertEquals(100f, result.overallScore, 0.001f)
130+
}
131+
132+
@Test
133+
fun `severity ordering and rect helpers expose expected values`() {
134+
assertTrue(A11ySeverity.Error < A11ySeverity.Warning)
135+
assertTrue(A11ySeverity.Warning < A11ySeverity.Info)
136+
assertEquals(Rect(0, 0, 0, 0), Rect.Zero)
137+
assertFalse(Rect(0, 0, 10, 10).isEmpty())
138+
assertTrue(Rect(0, 0, 0, 10).isEmpty())
139+
assertTrue(Rect(0, 0, 10, 0).isEmpty())
140+
}
141+
142+
@Test
143+
fun `roles list includes all scanner role values`() {
144+
assertEquals(
145+
listOf(
146+
A11yRole.Button,
147+
A11yRole.Checkbox,
148+
A11yRole.DropdownList,
149+
A11yRole.Image,
150+
A11yRole.RadioButton,
151+
A11yRole.Switch,
152+
A11yRole.Tab,
153+
A11yRole.TextField,
154+
),
155+
A11yRole.entries,
156+
)
157+
}
158+
159+
private fun issue(severity: A11ySeverity) = com.composea11yscanner.core.model.A11yIssue(
160+
issueId = "issue-${severity.sortOrder}",
161+
severity = severity,
162+
ruleId = "rule-${severity.sortOrder}",
163+
ruleName = "Rule",
164+
affectedNode = node,
165+
message = "Message",
166+
howToFix = "Fix",
167+
wcagReference = null,
168+
)
169+
}

scripts/check_coverage.py

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,50 +1,65 @@
11
#!/usr/bin/env python3
22
"""
3-
Usage: python3 scripts/check_coverage.py <reports_dir> <threshold_percent>
3+
Usage:
4+
python3 scripts/check_coverage.py <reports_dir> <threshold_percent> [module ...]
45
56
Finds all JaCoCo report.xml files under <reports_dir>, aggregates LINE coverage
6-
across all modules, and exits non-zero if coverage < <threshold_percent>.
7+
across matching modules, and exits non-zero if coverage < <threshold_percent>.
78
"""
89

910
import sys
1011
import xml.etree.ElementTree as ET
1112
from pathlib import Path
1213

1314

14-
def collect_coverage(reports_dir: str) -> tuple[int, int]:
15+
def module_name(root: Path, report_xml: Path) -> str:
16+
try:
17+
return report_xml.relative_to(root).parts[0]
18+
except ValueError:
19+
return report_xml.parent.parent.parent.parent.name
20+
21+
22+
def collect_coverage(reports_dir: str, included_modules: set[str]) -> tuple[int, int]:
1523
root = Path(reports_dir)
1624
total_covered = 0
1725
total_missed = 0
1826
found = False
1927

2028
for report_xml in root.rglob("report.xml"):
29+
module = module_name(root, report_xml)
30+
if included_modules and module not in included_modules:
31+
continue
2132
found = True
2233
tree = ET.parse(report_xml)
2334
for counter in tree.getroot().findall("counter"):
2435
if counter.get("type") == "LINE":
2536
total_covered += int(counter.get("covered", 0))
2637
total_missed += int(counter.get("missed", 0))
27-
print(f" {report_xml.parent.parent.parent.parent.name}: "
28-
f"covered={counter.get('covered')}, missed={counter.get('missed')}")
38+
print(f" {module}: covered={counter.get('covered')}, missed={counter.get('missed')}")
2939

3040
if not found:
3141
print("ERROR: No JaCoCo report.xml files found.")
42+
if included_modules:
43+
print(f"Included modules: {', '.join(sorted(included_modules))}")
3244
print("Ensure tests ran with enableUnitTestCoverage = true in the debug buildType.")
3345
sys.exit(1)
3446

3547
return total_covered, total_missed
3648

3749

3850
def main() -> None:
39-
if len(sys.argv) != 3:
51+
if len(sys.argv) < 3:
4052
print(f"Usage: {sys.argv[0]} <reports_dir> <threshold_percent>")
4153
sys.exit(1)
4254

4355
reports_dir = sys.argv[1]
4456
threshold = float(sys.argv[2])
57+
included_modules = set(sys.argv[3:])
4558

4659
print("Coverage by module:")
47-
covered, missed = collect_coverage(reports_dir)
60+
if included_modules:
61+
print(f"Included modules: {', '.join(sorted(included_modules))}")
62+
covered, missed = collect_coverage(reports_dir, included_modules)
4863
total = covered + missed
4964

5065
if total == 0:

0 commit comments

Comments
 (0)