From e6d439989c5732f26d3e1e291fc6f32c64bc69e9 Mon Sep 17 00:00:00 2001 From: Mohd Aquib Date: Tue, 5 May 2026 15:59:07 +0800 Subject: [PATCH 1/8] Implement TouchTargetRule for accessibility scanning --- .../rules/TouchTargetRule.kt | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 scanner-rules/src/main/java/com/composea11yscanner/rules/TouchTargetRule.kt diff --git a/scanner-rules/src/main/java/com/composea11yscanner/rules/TouchTargetRule.kt b/scanner-rules/src/main/java/com/composea11yscanner/rules/TouchTargetRule.kt new file mode 100644 index 0000000..d3a3ca0 --- /dev/null +++ b/scanner-rules/src/main/java/com/composea11yscanner/rules/TouchTargetRule.kt @@ -0,0 +1,31 @@ +package com.composea11yscanner.rules + +import com.composea11yscanner.core.model.A11yIssue +import com.composea11yscanner.core.model.A11yNode +import com.composea11yscanner.core.model.A11ySeverity +import com.composea11yscanner.core.rule.BaseA11yRule + +class TouchTargetRule( + private val minTouchTargetDp: Int = 48, +) : BaseA11yRule() { + override val ruleId = "touch-target-size" + override val ruleName = "Touch Target Size" + override val severity = A11ySeverity.Error + override val wcagReference = "WCAG 2.5.5 Target Size (Level AA)" + + override fun check(node: A11yNode): A11yIssue? { + if (!node.isTouchTarget) return null + + val w = node.touchTargetSize.width + val h = node.touchTargetSize.height + if (w >= minTouchTargetDp && h >= minTouchTargetDp) return null + + return issue( + node = node, + message = "Touch target is ${"%.0f".format(w)}x${"%.0f".format(h)}dp. " + + "Minimum required is ${minTouchTargetDp}x${minTouchTargetDp}dp.", + howToFix = "Apply Modifier.minimumInteractiveComponentSize() or add padding so the " + + "composable reaches at least ${minTouchTargetDp}dp in both dimensions.", + ) + } +} From 6aff014536b7483bb39852ae9a4fb73fb801490a Mon Sep 17 00:00:00 2001 From: Mohd Aquib Date: Tue, 5 May 2026 16:04:00 +0800 Subject: [PATCH 2/8] Implement MissingContentDescriptionRule for accessibility scanning --- .../rules/MissingContentDescriptionRule.kt | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 scanner-rules/src/main/java/com/composea11yscanner/rules/MissingContentDescriptionRule.kt diff --git a/scanner-rules/src/main/java/com/composea11yscanner/rules/MissingContentDescriptionRule.kt b/scanner-rules/src/main/java/com/composea11yscanner/rules/MissingContentDescriptionRule.kt new file mode 100644 index 0000000..9762a56 --- /dev/null +++ b/scanner-rules/src/main/java/com/composea11yscanner/rules/MissingContentDescriptionRule.kt @@ -0,0 +1,34 @@ +package com.composea11yscanner.rules + +import com.composea11yscanner.core.model.A11yIssue +import com.composea11yscanner.core.model.A11yNode +import com.composea11yscanner.core.model.A11ySeverity +import com.composea11yscanner.core.rule.BaseA11yRule + +class MissingContentDescriptionRule : BaseA11yRule() { + + override val ruleId = "missing-content-description" + override val ruleName = "Missing Content Description" + override val severity = A11ySeverity.Error + override val wcagReference = "WCAG 1.1.1 Non-text Content (Level A)" + + override fun check(node: A11yNode): A11yIssue? { + // Merged descendants are announced via their parent — skip them. + if (node.isMergedDescendant) return null + + val isInteractive = node.isTouchTarget + // Covers Image, AsyncImage, SubcomposeAsyncImage, etc. + val isImage = node.composableName.contains("Image", ignoreCase = true) + + if (!isInteractive && !isImage) return null + if (!node.contentDescription.isNullOrBlank()) return null + + return issue( + node = node, + message = "Interactive element has no content description. " + + "Screen readers cannot announce this.", + howToFix = "Add a meaningful contentDescription via semantics: " + + "Modifier.semantics { contentDescription = \"Describe the action or content here\" }", + ) + } +} From fed72c870bd748f8466bbbfc8b9353047a3b042f Mon Sep 17 00:00:00 2001 From: Mohd Aquib Date: Tue, 5 May 2026 16:11:38 +0800 Subject: [PATCH 3/8] Implement DuplicateContentDescriptionRule and enhance rule evaluation logic --- .../composea11yscanner/core/rule/A11yRule.kt | 26 +++++++++++++++ .../rules/DuplicateContentDescriptionRule.kt | 32 +++++++++++++++++++ 2 files changed, 58 insertions(+) create mode 100644 scanner-rules/src/main/java/com/composea11yscanner/rules/DuplicateContentDescriptionRule.kt diff --git a/scanner-core/src/main/java/com/composea11yscanner/core/rule/A11yRule.kt b/scanner-core/src/main/java/com/composea11yscanner/core/rule/A11yRule.kt index da92121..9f6f2b9 100644 --- a/scanner-core/src/main/java/com/composea11yscanner/core/rule/A11yRule.kt +++ b/scanner-core/src/main/java/com/composea11yscanner/core/rule/A11yRule.kt @@ -11,6 +11,32 @@ interface A11yRule { val wcagReference: String? fun evaluate(node: A11yNode): A11yIssue? + + /** Evaluates an entire node list. Per-node rules are handled by the default implementation. */ + fun evaluateAll(nodes: List): List = nodes.mapNotNull { evaluate(it) } +} + +/** + * Base for rules that must inspect all nodes together (e.g. duplicate detection). + * [evaluate] is sealed to a no-op; subclasses implement [evaluateAll] instead. + */ +abstract class BaseScanRule : A11yRule { + + final override fun evaluate(node: A11yNode): A11yIssue? = null + + abstract override fun evaluateAll(nodes: List): List + + protected fun issue(node: A11yNode, message: String, howToFix: String): A11yIssue = + A11yIssue( + issueId = "${ruleId}_${node.nodeId}", + severity = severity, + ruleId = ruleId, + ruleName = ruleName, + affectedNode = node, + message = message, + howToFix = howToFix, + wcagReference = wcagReference, + ) } abstract class BaseA11yRule : A11yRule { diff --git a/scanner-rules/src/main/java/com/composea11yscanner/rules/DuplicateContentDescriptionRule.kt b/scanner-rules/src/main/java/com/composea11yscanner/rules/DuplicateContentDescriptionRule.kt new file mode 100644 index 0000000..05521c2 --- /dev/null +++ b/scanner-rules/src/main/java/com/composea11yscanner/rules/DuplicateContentDescriptionRule.kt @@ -0,0 +1,32 @@ +package com.composea11yscanner.rules + +import com.composea11yscanner.core.model.A11yIssue +import com.composea11yscanner.core.model.A11yNode +import com.composea11yscanner.core.model.A11ySeverity +import com.composea11yscanner.core.rule.BaseScanRule + +class DuplicateContentDescriptionRule : BaseScanRule() { + + override val ruleId = "duplicate-content-description" + override val ruleName = "Duplicate Content Description" + override val severity = A11ySeverity.Warning + override val wcagReference = "WCAG 2.4.6 Headings and Labels (Level AA)" + + override fun evaluateAll(nodes: List): List = + nodes + .filter { !it.contentDescription.isNullOrBlank() && !it.isMergedDescendant } + .groupBy { it.depth to it.contentDescription } + .filter { (_, group) -> group.size > 1 } + .flatMap { (key, group) -> + val text = key.second + group.map { node -> + issue( + node = node, + message = "Two elements share the same content description: '$text'. " + + "Screen readers may confuse users.", + howToFix = "Give each element a unique content description that " + + "identifies its specific action or content.", + ) + } + } +} From 30c0419f3818cc9668ad55a7353f9122d147d2d6 Mon Sep 17 00:00:00 2001 From: Mohd Aquib Date: Tue, 5 May 2026 17:45:03 +0800 Subject: [PATCH 4/8] Implement FocusOrderRule for accessibility scanning --- .../rules/FocusOrderRule.kt | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 scanner-rules/src/main/java/com/composea11yscanner/rules/FocusOrderRule.kt diff --git a/scanner-rules/src/main/java/com/composea11yscanner/rules/FocusOrderRule.kt b/scanner-rules/src/main/java/com/composea11yscanner/rules/FocusOrderRule.kt new file mode 100644 index 0000000..e932848 --- /dev/null +++ b/scanner-rules/src/main/java/com/composea11yscanner/rules/FocusOrderRule.kt @@ -0,0 +1,46 @@ +package com.composea11yscanner.rules + +import com.composea11yscanner.core.model.A11yIssue +import com.composea11yscanner.core.model.A11yNode +import com.composea11yscanner.core.model.A11ySeverity +import com.composea11yscanner.core.rule.BaseScanRule +import kotlin.math.roundToInt + +/** + * @param screenDensity Display density from DisplayMetrics.density. + * Used to convert [jumpThresholdDp] to pixels for comparison against [A11yNode.bounds]. + * @param jumpThresholdDp Upward movement that triggers a violation (default 8dp). + */ +class FocusOrderRule( + private val screenDensity: Float, + private val jumpThresholdDp: Float = 8f, +) : BaseScanRule() { + + override val ruleId = "focus-order" + override val ruleName = "Focus Order" + override val severity = A11ySeverity.Error + override val wcagReference = "WCAG 2.4.3 Focus Order (Level A)" + + private val jumpThresholdPx: Int = (jumpThresholdDp * screenDensity).roundToInt() + + override fun evaluateAll(nodes: List): List = + nodes + .filter { it.isFocusable } + .zipWithNext { prev, curr -> + val jumpedUpward = curr.bounds.top < prev.bounds.top - jumpThresholdPx + if (!jumpedUpward) return@zipWithNext null + + val prevTopDp = (prev.bounds.top / screenDensity).roundToInt() + val currTopDp = (curr.bounds.top / screenDensity).roundToInt() + + issue( + node = curr, + message = "Focus jumps upward from ${prevTopDp}dp to ${currTopDp}dp. " + + "Screen readers will announce this element out of visual reading order.", + howToFix = "Reorder composables in the source so focus flows top-to-bottom, " + + "left-to-right, or apply Modifier.semantics { traversalIndex = n } " + + "to explicitly control the focus traversal sequence.", + ) + } + .filterNotNull() +} From b4fab48eac583ffaf6c0154705e7f477c5adbfbc Mon Sep 17 00:00:00 2001 From: Mohd Aquib Date: Tue, 5 May 2026 18:05:26 +0800 Subject: [PATCH 5/8] Implement FocusOrderRule for accessibility scanning --- .../rules/TextScalingRule.kt | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 scanner-rules/src/main/java/com/composea11yscanner/rules/TextScalingRule.kt diff --git a/scanner-rules/src/main/java/com/composea11yscanner/rules/TextScalingRule.kt b/scanner-rules/src/main/java/com/composea11yscanner/rules/TextScalingRule.kt new file mode 100644 index 0000000..cc13759 --- /dev/null +++ b/scanner-rules/src/main/java/com/composea11yscanner/rules/TextScalingRule.kt @@ -0,0 +1,59 @@ +package com.composea11yscanner.rules + +import com.composea11yscanner.core.model.A11yIssue +import com.composea11yscanner.core.model.A11yNode +import com.composea11yscanner.core.model.A11ySeverity +import com.composea11yscanner.core.rule.BaseScanRule +import kotlin.math.roundToInt + +/** + * @param screenDensity Display density from DisplayMetrics.density. + * @param scaleFactor Font scale to simulate (default 1.3×). + */ +class TextScalingRule( + private val screenDensity: Float, + private val scaleFactor: Float = 1.3f, +) : BaseScanRule() { + + override val ruleId = "text-scaling" + override val ruleName = "Text Scaling" + override val severity = A11ySeverity.Warning + override val wcagReference = "WCAG 1.4.4 Resize Text (Level AA)" + + override fun evaluateAll(nodes: List): List = + nodes + .filter { it.composableName.contains("Text", ignoreCase = true) } + .mapNotNull { textNode -> + val parent = findParent(textNode, nodes) ?: return@mapNotNull null + + val scaledHeight = textNode.bounds.height * scaleFactor + val overflowPx = (textNode.bounds.top + scaledHeight) - parent.bounds.bottom + if (overflowPx <= 0f) return@mapNotNull null + + val originalDp = (textNode.bounds.height / screenDensity).roundToInt() + val scaledDp = (scaledHeight / screenDensity).roundToInt() + val overflowDp = (overflowPx / screenDensity).roundToInt() + + issue( + node = textNode, + message = "'${textNode.composableName}' may clip at ${scaleFactor}× font scale: " + + "height grows from ${originalDp}dp to ${scaledDp}dp, " + + "overflowing its container by ${overflowDp}dp.", + howToFix = "Remove fixed heights from the parent container, use " + + "wrapContentHeight(), or wrap the content in a verticalScroll " + + "so text can reflow without clipping.", + ) + } + + // Tightest enclosing node at depth - 1 (smallest area that still fully contains the text node). + private fun findParent(node: A11yNode, allNodes: List): A11yNode? = + allNodes + .filter { candidate -> + candidate.depth == node.depth - 1 && + candidate.bounds.left <= node.bounds.left && + candidate.bounds.top <= node.bounds.top && + candidate.bounds.right >= node.bounds.right && + candidate.bounds.bottom >= node.bounds.bottom + } + .minByOrNull { it.bounds.width * it.bounds.height } +} From f353c80f7f93e7d56ba6e8e065cbdbf0533fc550 Mon Sep 17 00:00:00 2001 From: Mohd Aquib Date: Tue, 5 May 2026 18:31:52 +0800 Subject: [PATCH 6/8] Implement ImageWithTextOverlayRule for accessibility scanning --- .../rules/ImageWithTextOverlayRule.kt | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 scanner-rules/src/main/java/com/composea11yscanner/rules/ImageWithTextOverlayRule.kt diff --git a/scanner-rules/src/main/java/com/composea11yscanner/rules/ImageWithTextOverlayRule.kt b/scanner-rules/src/main/java/com/composea11yscanner/rules/ImageWithTextOverlayRule.kt new file mode 100644 index 0000000..d3f92b4 --- /dev/null +++ b/scanner-rules/src/main/java/com/composea11yscanner/rules/ImageWithTextOverlayRule.kt @@ -0,0 +1,57 @@ +package com.composea11yscanner.rules + +import com.composea11yscanner.core.model.A11yIssue +import com.composea11yscanner.core.model.A11yNode +import com.composea11yscanner.core.model.A11ySeverity +import com.composea11yscanner.core.rule.BaseScanRule + +/** + * @param overlapThreshold Fraction of the text node's area that must intersect an image + * node before the pair is flagged (default 0.5 = 50%). + */ +class ImageWithTextOverlayRule( + private val overlapThreshold: Float = 0.5f, +) : BaseScanRule() { + + override val ruleId = "image-text-overlay" + override val ruleName = "Image With Text Overlay" + override val severity = A11ySeverity.Warning + override val wcagReference = "WCAG 1.4.3 Contrast Minimum (Level AA)" + + override fun evaluateAll(nodes: List): List { + val textNodes = nodes.filter { it.composableName.contains("Text", ignoreCase = true) } + val imageNodes = nodes.filter { it.composableName.contains("Image", ignoreCase = true) } + + return textNodes.mapNotNull { textNode -> + val overlaps = imageNodes.any { imageNode -> + overlapRatio(textNode.bounds, imageNode.bounds) > overlapThreshold + } + if (!overlaps) return@mapNotNull null + + issue( + node = textNode, + message = "Text rendered over image may fail contrast requirements on different images.", + howToFix = "Add a semi-transparent scrim or solid background behind the text " + + "(e.g. Modifier.background(Color.Black.copy(alpha = 0.5f))), or verify " + + "the image always provides sufficient contrast (4.5:1 normal, 3:1 large text).", + ) + } + } + + private fun overlapRatio(text: Rect, image: Rect): Float { + val intLeft = maxOf(text.left, image.left) + val intTop = maxOf(text.top, image.top) + val intRight = minOf(text.right, image.right) + val intBottom = minOf(text.bottom, image.bottom) + + if (intRight <= intLeft || intBottom <= intTop) return 0f + + val textArea = text.width * text.height + if (textArea == 0) return 0f + + val intersectionArea = (intRight - intLeft) * (intBottom - intTop) + return intersectionArea.toFloat() / textArea + } +} + +private typealias Rect = com.composea11yscanner.core.model.Rect From bfa8723d85bed1a8dccb64a16f555ba74183f457 Mon Sep 17 00:00:00 2001 From: Mohd Aquib Date: Tue, 5 May 2026 18:37:29 +0800 Subject: [PATCH 7/8] Implement ClickableRoleRule and add role property to A11yNode --- .../composea11yscanner/core/model/A11yNode.kt | 1 + .../composea11yscanner/core/model/A11yRole.kt | 15 ++++++++ .../rules/ClickableRoleRule.kt | 34 +++++++++++++++++++ 3 files changed, 50 insertions(+) create mode 100644 scanner-core/src/main/java/com/composea11yscanner/core/model/A11yRole.kt create mode 100644 scanner-rules/src/main/java/com/composea11yscanner/rules/ClickableRoleRule.kt diff --git a/scanner-core/src/main/java/com/composea11yscanner/core/model/A11yNode.kt b/scanner-core/src/main/java/com/composea11yscanner/core/model/A11yNode.kt index cf84970..ca54044 100644 --- a/scanner-core/src/main/java/com/composea11yscanner/core/model/A11yNode.kt +++ b/scanner-core/src/main/java/com/composea11yscanner/core/model/A11yNode.kt @@ -12,4 +12,5 @@ data class A11yNode( val isFocusable: Boolean, val isMergedDescendant: Boolean, val depth: Int, + val role: A11yRole? = null, ) diff --git a/scanner-core/src/main/java/com/composea11yscanner/core/model/A11yRole.kt b/scanner-core/src/main/java/com/composea11yscanner/core/model/A11yRole.kt new file mode 100644 index 0000000..0fbb925 --- /dev/null +++ b/scanner-core/src/main/java/com/composea11yscanner/core/model/A11yRole.kt @@ -0,0 +1,15 @@ +package com.composea11yscanner.core.model + +/** + * Mirrors [androidx.compose.ui.semantics.Role] without a Compose dependency. + * Conversion in :scanner-ui: A11yRole.from(semanticsNode.config[SemanticsProperties.Role]) + */ +enum class A11yRole { + Button, + Checkbox, + DropdownList, + Image, + RadioButton, + Switch, + Tab, +} diff --git a/scanner-rules/src/main/java/com/composea11yscanner/rules/ClickableRoleRule.kt b/scanner-rules/src/main/java/com/composea11yscanner/rules/ClickableRoleRule.kt new file mode 100644 index 0000000..a5e8ec0 --- /dev/null +++ b/scanner-rules/src/main/java/com/composea11yscanner/rules/ClickableRoleRule.kt @@ -0,0 +1,34 @@ +package com.composea11yscanner.rules + +import com.composea11yscanner.core.model.A11yIssue +import com.composea11yscanner.core.model.A11yNode +import com.composea11yscanner.core.model.A11yRole +import com.composea11yscanner.core.model.A11ySeverity +import com.composea11yscanner.core.rule.BaseA11yRule + +class ClickableRoleRule : BaseA11yRule() { + + override val ruleId = "clickable-role" + override val ruleName = "Clickable Role" + override val severity = A11ySeverity.Error + override val wcagReference = "WCAG 4.1.2 Name, Role, Value (Level A)" + + override fun check(node: A11yNode): A11yIssue? { + if (!node.isTouchTarget) return null + + val missingRole = node.role == null + val imageWithoutDescription = + node.role == A11yRole.Image && node.contentDescription.isNullOrBlank() + + if (!missingRole && !imageWithoutDescription) return null + + return issue( + node = node, + message = "Clickable element has no semantic role. " + + "Add Modifier.semantics { role = Role.Button }", + howToFix = "Apply the appropriate role: Modifier.semantics { role = Role.Button } " + + "for buttons, Role.Checkbox for toggles, Role.Image for images. " + + "Clickable images also require a non-empty contentDescription.", + ) + } +} From 76b5b62c8d76e10fcf2ceaf796858afa50761b87 Mon Sep 17 00:00:00 2001 From: Mohd Aquib Date: Tue, 5 May 2026 18:47:32 +0800 Subject: [PATCH 8/8] Add comprehensive unit tests for accessibility rules in `scanner-rules --- .../rules/ClickableRoleRuleTest.kt | 100 ++++++++++++++ .../DuplicateContentDescriptionRuleTest.kt | 125 +++++++++++++++++ .../rules/FakeNodeBuilder.kt | 38 ++++++ .../rules/FocusOrderRuleTest.kt | 127 ++++++++++++++++++ .../rules/ImageWithTextOverlayRuleTest.kt | 117 ++++++++++++++++ .../MissingContentDescriptionRuleTest.kt | 92 +++++++++++++ .../rules/TextScalingRuleTest.kt | 114 ++++++++++++++++ .../rules/TouchTargetRuleTest.kt | 94 +++++++++++++ 8 files changed, 807 insertions(+) create mode 100644 scanner-rules/src/test/java/com/composea11yscanner/rules/ClickableRoleRuleTest.kt create mode 100644 scanner-rules/src/test/java/com/composea11yscanner/rules/DuplicateContentDescriptionRuleTest.kt create mode 100644 scanner-rules/src/test/java/com/composea11yscanner/rules/FakeNodeBuilder.kt create mode 100644 scanner-rules/src/test/java/com/composea11yscanner/rules/FocusOrderRuleTest.kt create mode 100644 scanner-rules/src/test/java/com/composea11yscanner/rules/ImageWithTextOverlayRuleTest.kt create mode 100644 scanner-rules/src/test/java/com/composea11yscanner/rules/MissingContentDescriptionRuleTest.kt create mode 100644 scanner-rules/src/test/java/com/composea11yscanner/rules/TextScalingRuleTest.kt create mode 100644 scanner-rules/src/test/java/com/composea11yscanner/rules/TouchTargetRuleTest.kt diff --git a/scanner-rules/src/test/java/com/composea11yscanner/rules/ClickableRoleRuleTest.kt b/scanner-rules/src/test/java/com/composea11yscanner/rules/ClickableRoleRuleTest.kt new file mode 100644 index 0000000..e9a77a1 --- /dev/null +++ b/scanner-rules/src/test/java/com/composea11yscanner/rules/ClickableRoleRuleTest.kt @@ -0,0 +1,100 @@ +package com.composea11yscanner.rules + +import com.composea11yscanner.core.model.A11yRole +import com.composea11yscanner.core.model.A11ySeverity +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Test + +class ClickableRoleRuleTest { + + private val rule = ClickableRoleRule() + + // --- passing cases --- + + @Test + fun `non-touch-target is not evaluated`() { + assertNull(rule.evaluate(createNode(isTouchTarget = false, role = null))) + } + + @Test + fun `touch target with Button role passes`() { + assertNull(rule.evaluate(createNode(isTouchTarget = true, role = A11yRole.Button))) + } + + @Test + fun `touch target with Checkbox role passes`() { + assertNull(rule.evaluate(createNode(isTouchTarget = true, role = A11yRole.Checkbox))) + } + + @Test + fun `touch target with Switch role passes`() { + assertNull(rule.evaluate(createNode(isTouchTarget = true, role = A11yRole.Switch))) + } + + @Test + fun `touch target with Image role and non-empty description passes`() { + assertNull( + rule.evaluate( + createNode(isTouchTarget = true, role = A11yRole.Image, contentDescription = "Photo") + ) + ) + } + + // --- failing cases --- + + @Test + fun `touch target with null role fails`() { + assertNotNull(rule.evaluate(createNode(isTouchTarget = true, role = null))) + } + + @Test + fun `touch target with Image role and null description fails`() { + assertNotNull( + rule.evaluate(createNode(isTouchTarget = true, role = A11yRole.Image, contentDescription = null)) + ) + } + + @Test + fun `touch target with Image role and blank description fails`() { + assertNotNull( + rule.evaluate(createNode(isTouchTarget = true, role = A11yRole.Image, contentDescription = " ")) + ) + } + + @Test + fun `touch target with Image role and empty description fails`() { + assertNotNull( + rule.evaluate(createNode(isTouchTarget = true, role = A11yRole.Image, contentDescription = "")) + ) + } + + // --- edge cases --- + + @Test + fun `merged descendant that is a touch target with no role is still flagged`() { + // ClickableRoleRule does not exempt merged descendants — role is a per-node concern + assertNotNull( + rule.evaluate(createNode(isTouchTarget = true, role = null, isMergedDescendant = true)) + ) + } + + @Test + fun `evaluateAll aggregates per-node results`() { + val nodes = listOf( + createNode(isTouchTarget = false), + createNode(isTouchTarget = true, role = A11yRole.Button), + createNode(isTouchTarget = true, role = null), + ) + assertEquals(1, rule.evaluateAll(nodes).size) + } + + @Test + fun `issue carries correct rule metadata`() { + val issue = rule.evaluate(createNode(isTouchTarget = true, role = null))!! + assertEquals("clickable-role", issue.ruleId) + assertEquals(A11ySeverity.Error, issue.severity) + assertEquals("WCAG 4.1.2 Name, Role, Value (Level A)", issue.wcagReference) + } +} diff --git a/scanner-rules/src/test/java/com/composea11yscanner/rules/DuplicateContentDescriptionRuleTest.kt b/scanner-rules/src/test/java/com/composea11yscanner/rules/DuplicateContentDescriptionRuleTest.kt new file mode 100644 index 0000000..0bcb514 --- /dev/null +++ b/scanner-rules/src/test/java/com/composea11yscanner/rules/DuplicateContentDescriptionRuleTest.kt @@ -0,0 +1,125 @@ +package com.composea11yscanner.rules + +import com.composea11yscanner.core.model.A11ySeverity +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class DuplicateContentDescriptionRuleTest { + + private val rule = DuplicateContentDescriptionRule() + + // --- passing cases --- + + @Test + fun `empty node list returns no issues`() { + assertTrue(rule.evaluateAll(emptyList()).isEmpty()) + } + + @Test + fun `unique descriptions at same depth return no issues`() { + val nodes = listOf( + createNode(depth = 1, contentDescription = "Button A"), + createNode(depth = 1, contentDescription = "Button B"), + ) + assertTrue(rule.evaluateAll(nodes).isEmpty()) + } + + @Test + fun `same description at different depths is not a duplicate`() { + val nodes = listOf( + createNode(depth = 1, contentDescription = "Submit"), + createNode(depth = 2, contentDescription = "Submit"), + ) + assertTrue(rule.evaluateAll(nodes).isEmpty()) + } + + @Test + fun `null descriptions are not compared`() { + val nodes = listOf( + createNode(depth = 1, contentDescription = null), + createNode(depth = 1, contentDescription = null), + ) + assertTrue(rule.evaluateAll(nodes).isEmpty()) + } + + @Test + fun `blank descriptions are not compared`() { + val nodes = listOf( + createNode(depth = 1, contentDescription = " "), + createNode(depth = 1, contentDescription = " "), + ) + assertTrue(rule.evaluateAll(nodes).isEmpty()) + } + + @Test + fun `merged descendants are excluded from the check`() { + val nodes = listOf( + createNode(depth = 1, contentDescription = "Submit", isMergedDescendant = true), + createNode(depth = 1, contentDescription = "Submit", isMergedDescendant = true), + ) + assertTrue(rule.evaluateAll(nodes).isEmpty()) + } + + // --- failing cases --- + + @Test + fun `two nodes with same description at same depth produce two issues`() { + val nodes = listOf( + createNode(depth = 1, contentDescription = "Submit"), + createNode(depth = 1, contentDescription = "Submit"), + ) + assertEquals(2, rule.evaluateAll(nodes).size) + } + + @Test + fun `three nodes with same description at same depth produce three issues`() { + val nodes = listOf( + createNode(depth = 1, contentDescription = "Delete"), + createNode(depth = 1, contentDescription = "Delete"), + createNode(depth = 1, contentDescription = "Delete"), + ) + assertEquals(3, rule.evaluateAll(nodes).size) + } + + // --- edge cases --- + + @Test + fun `only the duplicate group is flagged, non-duplicates are clean`() { + val nodes = listOf( + createNode(depth = 1, contentDescription = "Submit"), + createNode(depth = 1, contentDescription = "Submit"), + createNode(depth = 1, contentDescription = "Cancel"), + ) + val issues = rule.evaluateAll(nodes) + assertEquals(2, issues.size) + assertTrue(issues.all { it.message.contains("'Submit'") }) + } + + @Test + fun `issue message contains the duplicated text`() { + val nodes = listOf( + createNode(depth = 1, contentDescription = "Close dialog"), + createNode(depth = 1, contentDescription = "Close dialog"), + ) + val issue = rule.evaluateAll(nodes).first() + assertTrue(issue.message.contains("'Close dialog'")) + } + + @Test + fun `evaluate returns null for scan-level rule`() { + assertNull(rule.evaluate(createNode(depth = 1, contentDescription = "Anything"))) + } + + @Test + fun `issue carries correct rule metadata`() { + val nodes = listOf( + createNode(depth = 1, contentDescription = "X"), + createNode(depth = 1, contentDescription = "X"), + ) + val issue = rule.evaluateAll(nodes).first() + assertEquals("duplicate-content-description", issue.ruleId) + assertEquals(A11ySeverity.Warning, issue.severity) + assertEquals("WCAG 2.4.6 Headings and Labels (Level AA)", issue.wcagReference) + } +} diff --git a/scanner-rules/src/test/java/com/composea11yscanner/rules/FakeNodeBuilder.kt b/scanner-rules/src/test/java/com/composea11yscanner/rules/FakeNodeBuilder.kt new file mode 100644 index 0000000..2b2bc40 --- /dev/null +++ b/scanner-rules/src/test/java/com/composea11yscanner/rules/FakeNodeBuilder.kt @@ -0,0 +1,38 @@ +package com.composea11yscanner.rules + +import com.composea11yscanner.core.model.A11yNode +import com.composea11yscanner.core.model.A11yRole +import com.composea11yscanner.core.model.Color +import com.composea11yscanner.core.model.DpSize +import com.composea11yscanner.core.model.Rect +import java.util.concurrent.atomic.AtomicInteger + +private val nodeIdSeq = AtomicInteger(0) + +fun createNode( + composableName: String = "Box", + bounds: Rect = Rect(0, 0, 100, 100), + contentDescription: String? = null, + isTouchTarget: Boolean = false, + touchTargetSize: DpSize = DpSize(48f, 48f), + textColor: Color? = null, + backgroundColors: List = emptyList(), + isFocusable: Boolean = false, + isMergedDescendant: Boolean = false, + depth: Int = 0, + role: A11yRole? = null, + nodeId: String = "node-${nodeIdSeq.incrementAndGet()}", +): A11yNode = A11yNode( + nodeId = nodeId, + composableName = composableName, + bounds = bounds, + contentDescription = contentDescription, + isTouchTarget = isTouchTarget, + touchTargetSize = touchTargetSize, + textColor = textColor, + backgroundColors = backgroundColors, + isFocusable = isFocusable, + isMergedDescendant = isMergedDescendant, + depth = depth, + role = role, +) diff --git a/scanner-rules/src/test/java/com/composea11yscanner/rules/FocusOrderRuleTest.kt b/scanner-rules/src/test/java/com/composea11yscanner/rules/FocusOrderRuleTest.kt new file mode 100644 index 0000000..d85e0b3 --- /dev/null +++ b/scanner-rules/src/test/java/com/composea11yscanner/rules/FocusOrderRuleTest.kt @@ -0,0 +1,127 @@ +package com.composea11yscanner.rules + +import com.composea11yscanner.core.model.A11ySeverity +import com.composea11yscanner.core.model.Rect +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +// screenDensity=1f makes 1dp == 1px, simplifying expected values. +class FocusOrderRuleTest { + + private val rule = FocusOrderRule(screenDensity = 1f) + + // --- passing cases --- + + @Test + fun `empty node list returns no issues`() { + assertTrue(rule.evaluateAll(emptyList()).isEmpty()) + } + + @Test + fun `single focusable node returns no issues`() { + assertTrue(rule.evaluateAll(listOf(createNode(isFocusable = true))).isEmpty()) + } + + @Test + fun `focusable nodes in correct top-to-bottom order pass`() { + val nodes = listOf( + createNode(isFocusable = true, bounds = Rect(0, 0, 100, 50)), + createNode(isFocusable = true, bounds = Rect(0, 60, 100, 110)), + ) + assertTrue(rule.evaluateAll(nodes).isEmpty()) + } + + @Test + fun `upward jump of exactly the threshold is not flagged`() { + // threshold=8px; 100-92=8 → condition is strict less-than, so NOT flagged + val nodes = listOf( + createNode(isFocusable = true, bounds = Rect(0, 100, 100, 150)), + createNode(isFocusable = true, bounds = Rect(0, 92, 100, 142)), + ) + assertTrue(rule.evaluateAll(nodes).isEmpty()) + } + + @Test + fun `no focusable nodes returns no issues`() { + val nodes = listOf( + createNode(isFocusable = false, bounds = Rect(0, 100, 100, 150)), + createNode(isFocusable = false, bounds = Rect(0, 0, 100, 50)), + ) + assertTrue(rule.evaluateAll(nodes).isEmpty()) + } + + // --- failing cases --- + + @Test + fun `upward jump one pixel beyond threshold is flagged`() { + // 100-91=9 > 8 → flagged + val nodes = listOf( + createNode(isFocusable = true, bounds = Rect(0, 100, 100, 150)), + createNode(isFocusable = true, bounds = Rect(0, 91, 100, 141)), + ) + assertEquals(1, rule.evaluateAll(nodes).size) + } + + @Test + fun `multiple upward jumps produce multiple issues`() { + val nodes = listOf( + createNode(isFocusable = true, bounds = Rect(0, 200, 100, 250)), + createNode(isFocusable = true, bounds = Rect(0, 100, 100, 150)), + createNode(isFocusable = true, bounds = Rect(0, 0, 100, 50)), + ) + assertEquals(2, rule.evaluateAll(nodes).size) + } + + // --- edge cases --- + + @Test + fun `non-focusable nodes between focusable ones are ignored`() { + val nodes = listOf( + createNode(isFocusable = true, bounds = Rect(0, 100, 100, 150)), + createNode(isFocusable = false, bounds = Rect(0, 0, 100, 50)), // would be a jump if focusable + createNode(isFocusable = true, bounds = Rect(0, 200, 100, 250)), + ) + assertTrue(rule.evaluateAll(nodes).isEmpty()) + } + + @Test + fun `issue message contains both position values in dp`() { + val nodes = listOf( + createNode(isFocusable = true, bounds = Rect(0, 100, 100, 150)), + createNode(isFocusable = true, bounds = Rect(0, 50, 100, 100)), + ) + val issue = rule.evaluateAll(nodes).first() + assertTrue(issue.message.contains("100dp")) + assertTrue(issue.message.contains("50dp")) + } + + @Test + fun `custom threshold increases detection sensitivity`() { + val strictRule = FocusOrderRule(screenDensity = 1f, jumpThresholdDp = 1f) + // 50-48=2px > 1dp threshold → flagged + val nodes = listOf( + createNode(isFocusable = true, bounds = Rect(0, 50, 100, 100)), + createNode(isFocusable = true, bounds = Rect(0, 48, 100, 98)), + ) + assertEquals(1, strictRule.evaluateAll(nodes).size) + } + + @Test + fun `evaluate returns null for scan-level rule`() { + assertNull(rule.evaluate(createNode(isFocusable = true))) + } + + @Test + fun `issue carries correct rule metadata`() { + val nodes = listOf( + createNode(isFocusable = true, bounds = Rect(0, 100, 100, 150)), + createNode(isFocusable = true, bounds = Rect(0, 50, 100, 100)), + ) + val issue = rule.evaluateAll(nodes).first() + assertEquals("focus-order", issue.ruleId) + assertEquals(A11ySeverity.Error, issue.severity) + assertEquals("WCAG 2.4.3 Focus Order (Level A)", issue.wcagReference) + } +} diff --git a/scanner-rules/src/test/java/com/composea11yscanner/rules/ImageWithTextOverlayRuleTest.kt b/scanner-rules/src/test/java/com/composea11yscanner/rules/ImageWithTextOverlayRuleTest.kt new file mode 100644 index 0000000..2878438 --- /dev/null +++ b/scanner-rules/src/test/java/com/composea11yscanner/rules/ImageWithTextOverlayRuleTest.kt @@ -0,0 +1,117 @@ +package com.composea11yscanner.rules + +import com.composea11yscanner.core.model.A11ySeverity +import com.composea11yscanner.core.model.Rect +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class ImageWithTextOverlayRuleTest { + + private val rule = ImageWithTextOverlayRule() + + // --- passing cases --- + + @Test + fun `no image nodes returns no issues`() { + val text = createNode(composableName = "Text", bounds = Rect(0, 0, 100, 50)) + assertTrue(rule.evaluateAll(listOf(text)).isEmpty()) + } + + @Test + fun `no text nodes returns no issues`() { + val image = createNode(composableName = "Image", bounds = Rect(0, 0, 200, 200)) + assertTrue(rule.evaluateAll(listOf(image)).isEmpty()) + } + + @Test + fun `non-overlapping text and image pass`() { + val text = createNode(composableName = "Text", bounds = Rect(0, 0, 100, 50)) + val image = createNode(composableName = "Image", bounds = Rect(150, 0, 300, 200)) + assertTrue(rule.evaluateAll(listOf(text, image)).isEmpty()) + } + + @Test + fun `overlap of exactly 50 percent is not flagged`() { + // text area=10000; intersection=50*100=5000; ratio=0.5; threshold is strictly greater-than + val text = createNode(composableName = "Text", bounds = Rect(0, 0, 100, 100)) + val image = createNode(composableName = "Image", bounds = Rect(50, 0, 200, 100)) + assertTrue(rule.evaluateAll(listOf(text, image)).isEmpty()) + } + + @Test + fun `partial overlap below threshold passes`() { + // intersection=20*100=2000; ratio=0.2 < 0.5 + val text = createNode(composableName = "Text", bounds = Rect(0, 0, 100, 100)) + val image = createNode(composableName = "Image", bounds = Rect(80, 0, 200, 100)) + assertTrue(rule.evaluateAll(listOf(text, image)).isEmpty()) + } + + // --- failing cases --- + + @Test + fun `overlap above 50 percent is flagged`() { + // intersection=60*100=6000; ratio=0.6 > 0.5 + val text = createNode(composableName = "Text", bounds = Rect(0, 0, 100, 100)) + val image = createNode(composableName = "Image", bounds = Rect(40, 0, 200, 100)) + assertEquals(1, rule.evaluateAll(listOf(text, image)).size) + } + + @Test + fun `text completely inside image is flagged`() { + val text = createNode(composableName = "Text", bounds = Rect(10, 10, 90, 90)) + val image = createNode(composableName = "Image", bounds = Rect(0, 0, 100, 100)) + assertEquals(1, rule.evaluateAll(listOf(text, image)).size) + } + + // --- edge cases --- + + @Test + fun `only one issue per text node even when multiple images overlap it`() { + val text = createNode(composableName = "Text", bounds = Rect(0, 0, 100, 100)) + val image1 = createNode(composableName = "Image", bounds = Rect(0, 0, 100, 100)) + val image2 = createNode(composableName = "Image", bounds = Rect(0, 0, 100, 100)) + assertEquals(1, rule.evaluateAll(listOf(text, image1, image2)).size) + } + + @Test + fun `zero-area text node does not produce a false positive`() { + // textArea == 0 → overlapRatio returns 0f → not flagged + val text = createNode(composableName = "Text", bounds = Rect(50, 50, 50, 50)) + val image = createNode(composableName = "Image", bounds = Rect(0, 0, 100, 100)) + assertTrue(rule.evaluateAll(listOf(text, image)).isEmpty()) + } + + @Test + fun `custom threshold lowers the overlap required to flag`() { + // intersection=25*100=2500; ratio=0.25 > 0.1 threshold + val strictRule = ImageWithTextOverlayRule(overlapThreshold = 0.1f) + val text = createNode(composableName = "Text", bounds = Rect(0, 0, 100, 100)) + val image = createNode(composableName = "Image", bounds = Rect(75, 0, 200, 100)) + assertEquals(1, strictRule.evaluateAll(listOf(text, image)).size) + } + + @Test + fun `adjacent bounds with no intersection do not overlap`() { + // text right edge == image left edge → intRight==intLeft → no intersection + val text = createNode(composableName = "Text", bounds = Rect(0, 0, 100, 100)) + val image = createNode(composableName = "Image", bounds = Rect(100, 0, 200, 100)) + assertTrue(rule.evaluateAll(listOf(text, image)).isEmpty()) + } + + @Test + fun `evaluate returns null for scan-level rule`() { + assertNull(rule.evaluate(createNode())) + } + + @Test + fun `issue carries correct rule metadata`() { + val text = createNode(composableName = "Text", bounds = Rect(0, 0, 100, 100)) + val image = createNode(composableName = "Image", bounds = Rect(0, 0, 100, 100)) + val issue = rule.evaluateAll(listOf(text, image)).first() + assertEquals("image-text-overlay", issue.ruleId) + assertEquals(A11ySeverity.Warning, issue.severity) + assertEquals("WCAG 1.4.3 Contrast Minimum (Level AA)", issue.wcagReference) + } +} diff --git a/scanner-rules/src/test/java/com/composea11yscanner/rules/MissingContentDescriptionRuleTest.kt b/scanner-rules/src/test/java/com/composea11yscanner/rules/MissingContentDescriptionRuleTest.kt new file mode 100644 index 0000000..77f2954 --- /dev/null +++ b/scanner-rules/src/test/java/com/composea11yscanner/rules/MissingContentDescriptionRuleTest.kt @@ -0,0 +1,92 @@ +package com.composea11yscanner.rules + +import com.composea11yscanner.core.model.A11ySeverity +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Test + +class MissingContentDescriptionRuleTest { + + private val rule = MissingContentDescriptionRule() + + // --- passing cases --- + + @Test + fun `non-interactive non-image node passes`() { + assertNull(rule.evaluate(createNode(composableName = "Box", isTouchTarget = false))) + } + + @Test + fun `interactive node with description passes`() { + assertNull(rule.evaluate(createNode(isTouchTarget = true, contentDescription = "Submit"))) + } + + @Test + fun `Image composable with description passes`() { + assertNull(rule.evaluate(createNode(composableName = "Image", contentDescription = "Profile photo"))) + } + + @Test + fun `merged descendant with no description is skipped`() { + assertNull( + rule.evaluate( + createNode(isTouchTarget = true, contentDescription = null, isMergedDescendant = true) + ) + ) + } + + // --- failing cases --- + + @Test + fun `interactive node with null description fails`() { + assertNotNull(rule.evaluate(createNode(isTouchTarget = true, contentDescription = null))) + } + + @Test + fun `interactive node with blank description fails`() { + assertNotNull(rule.evaluate(createNode(isTouchTarget = true, contentDescription = " "))) + } + + @Test + fun `interactive node with empty description fails`() { + assertNotNull(rule.evaluate(createNode(isTouchTarget = true, contentDescription = ""))) + } + + @Test + fun `Image composable with no description fails`() { + assertNotNull(rule.evaluate(createNode(composableName = "Image", contentDescription = null))) + } + + // --- edge cases --- + + @Test + fun `AsyncImage is treated as an image composable`() { + assertNotNull(rule.evaluate(createNode(composableName = "AsyncImage", contentDescription = null))) + } + + @Test + fun `SubcomposeAsyncImage is treated as an image composable`() { + assertNotNull( + rule.evaluate(createNode(composableName = "SubcomposeAsyncImage", contentDescription = null)) + ) + } + + @Test + fun `issue carries correct rule metadata`() { + val issue = rule.evaluate(createNode(isTouchTarget = true))!! + assertEquals("missing-content-description", issue.ruleId) + assertEquals(A11ySeverity.Error, issue.severity) + assertEquals("WCAG 1.1.1 Non-text Content (Level A)", issue.wcagReference) + } + + @Test + fun `evaluateAll aggregates per-node results`() { + val nodes = listOf( + createNode(composableName = "Box"), + createNode(isTouchTarget = true, contentDescription = null), + createNode(isTouchTarget = true, contentDescription = "OK"), + ) + assertEquals(1, rule.evaluateAll(nodes).size) + } +} diff --git a/scanner-rules/src/test/java/com/composea11yscanner/rules/TextScalingRuleTest.kt b/scanner-rules/src/test/java/com/composea11yscanner/rules/TextScalingRuleTest.kt new file mode 100644 index 0000000..2dcf735 --- /dev/null +++ b/scanner-rules/src/test/java/com/composea11yscanner/rules/TextScalingRuleTest.kt @@ -0,0 +1,114 @@ +package com.composea11yscanner.rules + +import com.composea11yscanner.core.model.A11ySeverity +import com.composea11yscanner.core.model.Rect +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +// screenDensity=1f makes 1dp == 1px, simplifying expected overflow arithmetic. +class TextScalingRuleTest { + + private val rule = TextScalingRule(screenDensity = 1f) + + // --- passing cases --- + + @Test + fun `non-text composable is not evaluated`() { + val parent = createNode(depth = 0, bounds = Rect(0, 0, 200, 100)) + val button = createNode(composableName = "Button", depth = 1, bounds = Rect(0, 0, 200, 80)) + assertTrue(rule.evaluateAll(listOf(parent, button)).isEmpty()) + } + + @Test + fun `text node at depth 0 has no parent and is skipped`() { + val text = createNode(composableName = "Text", depth = 0, bounds = Rect(0, 0, 100, 80)) + assertTrue(rule.evaluateAll(listOf(text)).isEmpty()) + } + + @Test + fun `text node with sufficient room in parent passes`() { + // scaledHeight = 100 * 1.3 = 130 < parent.bottom 200 → no overflow + val parent = createNode(depth = 0, bounds = Rect(0, 0, 200, 200)) + val text = createNode(composableName = "Text", depth = 1, bounds = Rect(0, 0, 200, 100)) + assertTrue(rule.evaluateAll(listOf(parent, text)).isEmpty()) + } + + @Test + fun `scaled height landing exactly on parent bottom passes`() { + // 80 * 1.3 = 104.0; scaledBottom = 0 + 104 = 104 == parent.bottom 104 → overflowPx = 0 + val parent = createNode(depth = 0, bounds = Rect(0, 0, 200, 104)) + val text = createNode(composableName = "Text", depth = 1, bounds = Rect(0, 0, 200, 80)) + assertTrue(rule.evaluateAll(listOf(parent, text)).isEmpty()) + } + + @Test + fun `text node with zero height never overflows`() { + val parent = createNode(depth = 0, bounds = Rect(0, 0, 200, 100)) + val text = createNode(composableName = "Text", depth = 1, bounds = Rect(0, 0, 200, 0)) + assertTrue(rule.evaluateAll(listOf(parent, text)).isEmpty()) + } + + // --- failing cases --- + + @Test + fun `text node that overflows parent at 1_3x scale fails`() { + // scaledHeight = 80 * 1.3 = 104; scaledBottom = 104 > parent.bottom 100 → overflow 4px + val parent = createNode(depth = 0, bounds = Rect(0, 0, 200, 100)) + val text = createNode(composableName = "Text", depth = 1, bounds = Rect(0, 0, 200, 80)) + assertEquals(1, rule.evaluateAll(listOf(parent, text)).size) + } + + @Test + fun `BasicText composable name is recognised`() { + val parent = createNode(depth = 0, bounds = Rect(0, 0, 200, 100)) + val text = createNode(composableName = "BasicText", depth = 1, bounds = Rect(0, 0, 200, 80)) + assertEquals(1, rule.evaluateAll(listOf(parent, text)).size) + } + + // --- edge cases --- + + @Test + fun `tightest parent is selected when multiple candidates at depth minus 1`() { + // tightParent area=20000 vs looseParent area=160000; tightParent is chosen + // tightParent.bottom=100 < scaledBottom=104 → overflow flagged + val tightParent = createNode(depth = 0, bounds = Rect(0, 0, 200, 100)) + val looseParent = createNode(depth = 0, bounds = Rect(0, 0, 400, 400)) + val text = createNode(composableName = "Text", depth = 1, bounds = Rect(0, 0, 200, 80)) + assertEquals(1, rule.evaluateAll(listOf(tightParent, looseParent, text)).size) + } + + @Test + fun `custom scale factor changes overflow threshold`() { + // At 2.0x: scaledHeight = 60 * 2 = 120 > parent.bottom 100 → overflow + val rule2x = TextScalingRule(screenDensity = 1f, scaleFactor = 2.0f) + val parent = createNode(depth = 0, bounds = Rect(0, 0, 200, 100)) + val text = createNode(composableName = "Text", depth = 1, bounds = Rect(0, 0, 200, 60)) + assertEquals(1, rule2x.evaluateAll(listOf(parent, text)).size) + } + + @Test + fun `issue message contains original and scaled height in dp`() { + val parent = createNode(depth = 0, bounds = Rect(0, 0, 200, 100)) + val text = createNode(composableName = "Text", depth = 1, bounds = Rect(0, 0, 200, 80)) + val issue = rule.evaluateAll(listOf(parent, text)).first() + assertTrue(issue.message.contains("80dp")) // original + assertTrue(issue.message.contains("104dp")) // 80 * 1.3 = 104 + } + + @Test + fun `evaluate returns null for scan-level rule`() { + assertNull(rule.evaluate(createNode())) + } + + @Test + fun `issue carries correct rule metadata`() { + val parent = createNode(depth = 0, bounds = Rect(0, 0, 200, 100)) + val text = createNode(composableName = "Text", depth = 1, bounds = Rect(0, 0, 200, 80)) + val issue = rule.evaluateAll(listOf(parent, text)).first() + assertEquals("text-scaling", issue.ruleId) + assertEquals(A11ySeverity.Warning, issue.severity) + assertEquals("WCAG 1.4.4 Resize Text (Level AA)", issue.wcagReference) + } +} diff --git a/scanner-rules/src/test/java/com/composea11yscanner/rules/TouchTargetRuleTest.kt b/scanner-rules/src/test/java/com/composea11yscanner/rules/TouchTargetRuleTest.kt new file mode 100644 index 0000000..f8ec116 --- /dev/null +++ b/scanner-rules/src/test/java/com/composea11yscanner/rules/TouchTargetRuleTest.kt @@ -0,0 +1,94 @@ +package com.composea11yscanner.rules + +import com.composea11yscanner.core.model.A11ySeverity +import com.composea11yscanner.core.model.DpSize +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class TouchTargetRuleTest { + + private val rule = TouchTargetRule() + + // --- passing cases --- + + @Test + fun `non-interactive node is not evaluated`() { + assertNull(rule.evaluate(createNode(isTouchTarget = false, touchTargetSize = DpSize(40f, 40f)))) + } + + @Test + fun `touch target at exactly minimum size passes`() { + assertNull(rule.evaluate(createNode(isTouchTarget = true, touchTargetSize = DpSize(48f, 48f)))) + } + + @Test + fun `touch target larger than minimum passes`() { + assertNull(rule.evaluate(createNode(isTouchTarget = true, touchTargetSize = DpSize(56f, 64f)))) + } + + // --- failing cases --- + + @Test + fun `width below minimum fails`() { + assertNotNull(rule.evaluate(createNode(isTouchTarget = true, touchTargetSize = DpSize(44f, 48f)))) + } + + @Test + fun `height below minimum fails`() { + assertNotNull(rule.evaluate(createNode(isTouchTarget = true, touchTargetSize = DpSize(48f, 44f)))) + } + + @Test + fun `both dimensions below minimum fail`() { + assertNotNull(rule.evaluate(createNode(isTouchTarget = true, touchTargetSize = DpSize(32f, 32f)))) + } + + // --- edge cases --- + + @Test + fun `zero-size touch target fails with correct dimensions in message`() { + val issue = rule.evaluate(createNode(isTouchTarget = true, touchTargetSize = DpSize(0f, 0f))) + assertNotNull(issue) + assertTrue(issue!!.message.contains("0x0dp")) + } + + @Test + fun `message contains actual dimensions`() { + val issue = rule.evaluate(createNode(isTouchTarget = true, touchTargetSize = DpSize(32f, 40f))) + assertNotNull(issue) + assertTrue(issue!!.message.contains("32x40dp")) + } + + @Test + fun `custom threshold accepts larger node`() { + val rule36 = TouchTargetRule(minTouchTargetDp = 36) + assertNull(rule36.evaluate(createNode(isTouchTarget = true, touchTargetSize = DpSize(40f, 40f)))) + } + + @Test + fun `custom threshold rejects node below it`() { + val rule36 = TouchTargetRule(minTouchTargetDp = 36) + assertNotNull(rule36.evaluate(createNode(isTouchTarget = true, touchTargetSize = DpSize(30f, 30f)))) + } + + @Test + fun `issue carries correct rule metadata`() { + val issue = rule.evaluate(createNode(isTouchTarget = true, touchTargetSize = DpSize(40f, 40f)))!! + assertEquals("touch-target-size", issue.ruleId) + assertEquals(A11ySeverity.Error, issue.severity) + assertEquals("WCAG 2.5.5 Target Size (Level AA)", issue.wcagReference) + } + + @Test + fun `evaluateAll aggregates per-node results`() { + val nodes = listOf( + createNode(isTouchTarget = false), + createNode(isTouchTarget = true, touchTargetSize = DpSize(40f, 40f)), + createNode(isTouchTarget = true, touchTargetSize = DpSize(48f, 48f)), + ) + assertEquals(1, rule.evaluateAll(nodes).size) + } +}