diff --git a/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/data/source/YoloModelSource.kt b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/data/source/YoloModelSource.kt index 45d4a20119..6c305eb79a 100644 --- a/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/data/source/YoloModelSource.kt +++ b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/data/source/YoloModelSource.kt @@ -25,7 +25,7 @@ class YoloModelSource { companion object { private const val MODEL_INPUT_WIDTH = 640 private const val MODEL_INPUT_HEIGHT = 640 - private const val CONFIDENCE_THRESHOLD = 0.3f + private const val CONFIDENCE_THRESHOLD = 0.2f private const val NMS_THRESHOLD = 0.45f } diff --git a/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/AndroidXmlGenerator.kt b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/AndroidXmlGenerator.kt deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/DetectionMerger.kt b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/DetectionMerger.kt index a7c69be43c..1878eac88d 100644 --- a/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/DetectionMerger.kt +++ b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/DetectionMerger.kt @@ -3,6 +3,7 @@ package org.appdevforall.codeonthego.computervision.domain import android.graphics.RectF import com.google.mlkit.vision.text.Text import org.appdevforall.codeonthego.computervision.domain.model.DetectionResult +import org.appdevforall.codeonthego.computervision.utils.OcrTextAssembler.joinElementsWithTolerance class DetectionMerger( private val enrichedComponents: List, @@ -39,20 +40,22 @@ class DetectionMerger( } } - val orphanText = fullImageTextBlocks.filter { it !in usedTextBlocks } - for (textBlock in orphanText) { - textBlock.boundingBox?.let { - finalDetections.add( + val orphanDetections = fullImageTextBlocks + .filter { it !in usedTextBlocks } + .flatMap { it.lines } + .mapNotNull { line -> + line.boundingBox?.let { box -> DetectionResult( - boundingBox = RectF(it), + boundingBox = RectF(box), label = "text", score = 0.99f, - text = textBlock.text.replace("\n", " "), + text = joinElementsWithTolerance(line), isYolo = false ) - ) + } } - } + + finalDetections.addAll(orphanDetections) return finalDetections } diff --git a/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/FuzzyAttributeParser.kt b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/FuzzyAttributeParser.kt index 94871dc4a5..46ba667498 100644 --- a/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/FuzzyAttributeParser.kt +++ b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/FuzzyAttributeParser.kt @@ -7,6 +7,9 @@ object FuzzyAttributeParser { private val grammarValidator = UiGrammarValidator() private const val FUZZY_VALUE_THRESHOLD = 75 + private const val FUZZY_DIMENSION_THRESHOLD = 60 + private val DIMENSION_CONSTANTS = listOf("wrap_content", "match_parent") + private val ID_VOCABULARY = listOf("cb", "rb", "group", "checkbox", "radio", "btn", "button", "text", "view", "img", "image", "input") private fun fuzzyKeyThreshold(keyLength: Int): Int = when { keyLength <= 3 -> 65 @@ -415,6 +418,11 @@ object FuzzyAttributeParser { if (matchKeywords.any { it in normalized }) return "match_parent" if (wrapKeywords.any { it in normalized }) return "wrap_content" + val fuzzyResult = FuzzySearch.extractOne(normalized, DIMENSION_CONSTANTS) + if (fuzzyResult.score >= FUZZY_DIMENSION_THRESHOLD) { + return fuzzyResult.string + } + val fixedUnit = normalized .replace(Regex("0p$"), "dp") .replace(Regex("op$"), "dp") @@ -458,11 +466,68 @@ object FuzzyAttributeParser { } private fun cleanId(value: String): String { - return denoiseOcrIdentifier(value.lowercase()) + val cleaned = denoiseOcrIdentifier(value.lowercase()) .replace(nonAlphanumericRegex, "_") .replace(multipleUnderscoresRegex, "_") .trimEnd('_') .trimStart('_') + + return normalizeKnownIdVocabulary(cleaned) + } + + private fun normalizeKnownIdVocabulary(identifier: String): String { + if (identifier.isBlank()) return identifier + + return identifier + .split('_') + .filter { it.isNotBlank() } + .flatMap(::normalizeIdToken) + .joinToString("_") + } + + private fun normalizeIdToken(token: String): List { + if (token.isBlank()) return emptyList() + if (token.all(Char::isDigit)) return listOf(token) + + val wholeMatch = fuzzyMatchIdVocabulary(token) + if (wholeMatch != null) { + return listOf(wholeMatch) + } + + val compositeMatch = normalizeCompositeIdToken(token) + if (compositeMatch != null) { + return compositeMatch + } + + return listOf(token) + } + + private fun normalizeCompositeIdToken(token: String): List? { + val match = Regex("^([a-z]+?)(\\d+)?$").matchEntire(token) ?: return null + val alphaPart = match.groupValues[1] + val trailingDigits = match.groupValues[2].takeIf { it.isNotEmpty() } + + val prefix = ID_VOCABULARY + .filter { alphaPart.length > it.length } + .sortedByDescending { it.length } + .firstOrNull { alphaPart.startsWith(it) } + ?: return null + + val remainder = alphaPart.removePrefix(prefix) + val normalizedRemainder = fuzzyMatchIdVocabulary(remainder) ?: return null + + val result = mutableListOf(prefix, normalizedRemainder) + if (trailingDigits != null) result += trailingDigits + return result + } + + private fun fuzzyMatchIdVocabulary(token: String): String? { + if (token.length < 3) return null + if (token in ID_VOCABULARY) return token + + val result = FuzzySearch.extractOne(token, ID_VOCABULARY) + val lengthDelta = kotlin.math.abs(token.length - result.string.length) + return result.string.takeIf { result.score >= 80 && lengthDelta <= 2 } } private fun denoiseOcrIdentifier(value: String): String { diff --git a/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/LayoutGeometryProcessor.kt b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/LayoutGeometryProcessor.kt index 9448bd50a3..79a0ca6d39 100644 --- a/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/LayoutGeometryProcessor.kt +++ b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/LayoutGeometryProcessor.kt @@ -4,6 +4,8 @@ import android.graphics.Rect import org.appdevforall.codeonthego.computervision.domain.model.DetectionResult import org.appdevforall.codeonthego.computervision.domain.model.LayoutItem import org.appdevforall.codeonthego.computervision.domain.model.ScaledBox +import org.appdevforall.codeonthego.computervision.utils.TextCleaner.cleanTextPreservingLeadingO +import org.appdevforall.codeonthego.computervision.utils.TextCleaner.cleanTextStrippingLeadingO import kotlin.math.abs import kotlin.math.max import kotlin.math.roundToInt @@ -19,6 +21,9 @@ class LayoutGeometryProcessor { private fun isRadioButton(box: ScaledBox): Boolean = box.label == "radio_button_unchecked" || box.label == "radio_button_checked" + private fun isCheckbox(box: ScaledBox): Boolean = + box.label == "checkbox_unchecked" || box.label == "checkbox_checked" + private fun isLabelableWidget(box: ScaledBox): Boolean { return box.label in setOf( "radio_button_unchecked", "radio_button_checked", @@ -134,23 +139,33 @@ class LayoutGeometryProcessor { val rows = groupIntoRows(boxes) val items = mutableListOf() val verticalRadioRun = mutableListOf() + val verticalCheckboxRun = mutableListOf() - fun flushVerticalRadioRun() { + fun flushRuns() { if (verticalRadioRun.isNotEmpty()) { items.add(LayoutItem.RadioGroup(verticalRadioRun.toList(), "vertical")) verticalRadioRun.clear() } + if (verticalCheckboxRun.isNotEmpty()) { + items.add(LayoutItem.CheckboxGroup(verticalCheckboxRun.toList(), "vertical")) + verticalCheckboxRun.clear() + } } rows.forEach { row -> + val isRadioRow = row.all { isRadioButton(it) } + val isCheckboxRow = row.all { isCheckbox(it) } + + if (!isRadioRow && verticalRadioRun.isNotEmpty()) flushRuns() + if (!isCheckboxRow && verticalCheckboxRun.isNotEmpty()) flushRuns() + when { - row.all { isRadioButton(it) } && row.size == 1 -> verticalRadioRun.add(row.first()) - row.all { isRadioButton(it) } -> { - flushVerticalRadioRun() - items.add(LayoutItem.RadioGroup(row, "horizontal")) - } + isRadioRow && row.size == 1 -> verticalRadioRun.add(row.first()) + isRadioRow -> items.add(LayoutItem.RadioGroup(row, "horizontal")) + isCheckboxRow && row.size == 1 -> verticalCheckboxRun.add(row.first()) + isCheckboxRow -> items.add(LayoutItem.CheckboxGroup(row, "horizontal")) else -> { - flushVerticalRadioRun() + flushRuns() if (row.size == 1) { items.add(LayoutItem.SimpleView(row.first())) } else { @@ -159,7 +174,7 @@ class LayoutGeometryProcessor { } } } - flushVerticalRadioRun() + flushRuns() return items } @@ -169,25 +184,31 @@ class LayoutGeometryProcessor { val updatedWidgets = mutableMapOf() val labelableWidgets = boxes.filter { isLabelableWidget(it) } + .sortedWith(compareBy({ it.y }, { it.x })) for (widget in labelableWidgets) { val nearbyText = availableTexts .asSequence() .filter { !consumedTexts.contains(it) } .filter { text -> - val dx = maxOf(0, widget.rect.left - text.rect.right, text.rect.left - widget.rect.right) - val dy = maxOf(0, widget.rect.top - text.rect.bottom, text.rect.top - widget.rect.bottom) + val isToTheRight = text.rect.centerX() > widget.rect.centerX() + val verticalDistance = abs(widget.centerY - text.centerY) - dx < widget.w * 2 && dy < widget.h * 2 + isToTheRight && verticalDistance < maxOf(widget.h * 2.5, 40.0) } .minByOrNull { text -> - val dx = maxOf(0, widget.rect.left - text.rect.right, text.rect.left - widget.rect.right).toDouble() - val dy = maxOf(0, widget.rect.top - text.rect.bottom, text.rect.top - widget.rect.bottom).toDouble() - (dx * dx) + (dy * dy) + val dx = maxOf(0, text.rect.left - widget.rect.right).toDouble() + val dy = abs(widget.centerY - text.centerY).toDouble() + (dx * dx) + (dy * dy * 5) } if (nearbyText != null) { - updatedWidgets[widget] = widget.copy(text = nearbyText.text) + val finalText = if (widget.label.contains("radio", ignoreCase = true)) { + cleanTextStrippingLeadingO(nearbyText.text) + } else { + cleanTextPreservingLeadingO(nearbyText.text) + } + updatedWidgets[widget] = widget.copy(text = finalText) consumedTexts.add(nearbyText) } } diff --git a/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/RegionOcrProcessor.kt b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/RegionOcrProcessor.kt index 513ce1d035..72653ba6ef 100644 --- a/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/RegionOcrProcessor.kt +++ b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/RegionOcrProcessor.kt @@ -18,8 +18,6 @@ class RegionOcrProcessor( private val interactiveLabels = setOf( "button", - "checkbox_checked", - "checkbox_unchecked", "switch_on", "switch_off", "text_entry_box", diff --git a/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/model/LayoutItem.kt b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/model/LayoutItem.kt index 168173cc9e..5234ef0711 100644 --- a/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/model/LayoutItem.kt +++ b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/model/LayoutItem.kt @@ -4,4 +4,5 @@ sealed interface LayoutItem { data class SimpleView(val box: ScaledBox) : LayoutItem data class HorizontalRow(val row: List) : LayoutItem data class RadioGroup(val boxes: List, val orientation: String) : LayoutItem + data class CheckboxGroup(val boxes: List, val orientation: String) : LayoutItem } diff --git a/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/xml/AndroidWidget.kt b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/xml/AndroidWidget.kt index ef49d3847f..73641c6bb4 100644 --- a/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/xml/AndroidWidget.kt +++ b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/xml/AndroidWidget.kt @@ -46,9 +46,9 @@ sealed class AndroidWidget( companion object { fun create(box: ScaledBox, parsedAttrs: Map): AndroidWidget { return when (box.label) { - "text", "button", "checkbox_unchecked", "checkbox_checked", - "radio_button_unchecked", "radio_button_checked" -> + "text", "button", "radio_button_unchecked", "radio_button_checked" -> TextBasedWidget(box, parsedAttrs, getTagFor(box.label)) + "checkbox_unchecked", "checkbox_checked" -> CheckBoxWidget(box, parsedAttrs) "switch_off", "switch_on" -> SwitchWidget(box, parsedAttrs) "text_entry_box" -> InputWidget(box, parsedAttrs) "image_placeholder", "icon" -> ImageWidget(box, parsedAttrs) @@ -95,6 +95,28 @@ class TextBasedWidget( } } +class CheckBoxWidget( + box: ScaledBox, parsedAttrs: Map +) : AndroidWidget(box, parsedAttrs) { + override val tag = "CheckBox" + + override fun specificAttributes(): Map { + val attrs = mutableMapOf() + + val rawViewText = box.text.takeIf { it.isNotEmpty() && it != box.label } + ?: parsedAttrs["android:text"] + ?: "CheckBox" + + attrs["android:text"] = rawViewText + attrs["tools:ignore"] = "HardcodedText" + + if (box.label.contains("_checked")) { + attrs["android:checked"] = parsedAttrs["android:checked"] ?: "true" + } + return attrs + } +} + class SwitchWidget( box: ScaledBox, parsedAttrs: Map diff --git a/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/xml/LayoutRenderer.kt b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/xml/LayoutRenderer.kt index 47d8164824..300698e64a 100644 --- a/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/xml/LayoutRenderer.kt +++ b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/xml/LayoutRenderer.kt @@ -8,11 +8,18 @@ class LayoutRenderer( private val context: XmlContext, private val annotations: Map ) { + private val checkboxGroupIdPattern = Regex("^cb_group_\\d+$") + private val radioChildGroupIdPatterns = listOf( + Regex("^rb_group_\\d+(?:_|$).*"), + Regex("^radio_group_\\d+(?:_|$).*") + ) + fun render(item: LayoutItem, indent: String = " ") { when (item) { is LayoutItem.SimpleView -> renderSimpleView(item.box, indent) is LayoutItem.HorizontalRow -> renderHorizontalRow(item.row, indent) is LayoutItem.RadioGroup -> renderRadioGroup(item.boxes, item.orientation, indent) + is LayoutItem.CheckboxGroup -> renderCheckboxGroup(item.boxes, item.orientation, indent) } context.appendLine() } @@ -62,7 +69,11 @@ class LayoutRenderer( val parsedAttrs = FuzzyAttributeParser.parse(annotations[box], "RadioButton") val requestedId = parsedAttrs["android:id"]?.substringAfterLast('/') - val id = context.resolveId(requestedId, box.label) + val id = if (requestedId != null && radioChildGroupIdPatterns.any { it.matches(requestedId) }) { + context.nextId(box.label) + } else { + context.resolveId(requestedId, box.label) + } val extraAttrs = if (orientation == "horizontal" && index < boxes.lastIndex) { val gap = maxOf(0, boxes[index + 1].x - (box.x + box.w)) @@ -106,4 +117,38 @@ class LayoutRenderer( } context.append("$indent") } + + private fun renderCheckboxGroup(boxes: List, orientation: String, indent: String) { + val groupAnnotation = boxes.firstNotNullOfOrNull { annotations[it] } + val parsedAttrs = FuzzyAttributeParser.parse(groupAnnotation, "CheckBox") + + val requestedId = parsedAttrs["android:id"]?.substringAfterLast('/') + val baseId = if (requestedId != null && checkboxGroupIdPattern.matches(requestedId)) { + context.resolveId(requestedId, "cb_group") + } else { + context.nextId("cb_group", initialIndex = 1) + } + + boxes.forEachIndexed { index, box -> + val suffix = ('a' + index).toString() + val childId = "${baseId}_$suffix" + + val safeAttrs = parsedAttrs.toMutableMap() + safeAttrs.remove("android:id") + + val extraAttrs = if (orientation == "horizontal" && index < boxes.lastIndex) { + val gap = maxOf(0, boxes[index + 1].x - (box.x + box.w)) + mapOf("android:layout_marginEnd" to "${gap}dp") + } else emptyMap() + + renderSimpleView( + box = box, + indent = indent, + extraAttrs = extraAttrs, + idOverride = childId, + parsedAttrsOverride = safeAttrs + ) + context.appendLine() + } + } } diff --git a/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/xml/XmlContext.kt b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/xml/XmlContext.kt index aa28393d80..c339625ee9 100644 --- a/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/xml/XmlContext.kt +++ b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/domain/xml/XmlContext.kt @@ -6,10 +6,10 @@ class XmlContext( ) { private val usedIds = mutableSetOf() - fun nextId(label: String): String { + fun nextId(label: String, initialIndex: Int = 0): String { val safeLabel = label.replace(Regex("[^a-zA-Z0-9_]"), "_") - var count = counters.getOrDefault(safeLabel, -1) + var count = counters.getOrDefault(safeLabel, initialIndex - 1) var newId: String do { diff --git a/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/utils/TextCleaner.kt b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/utils/TextCleaner.kt index 6cd38cc22e..7f00a7635a 100644 --- a/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/utils/TextCleaner.kt +++ b/cv-image-to-xml/src/main/java/org/appdevforall/codeonthego/computervision/utils/TextCleaner.kt @@ -9,4 +9,22 @@ object TextCleaner { .replace(nonAlphanumericRegex, "") .trim() } -} \ No newline at end of file + + fun cleanTextStrippingLeadingO(text: String): String { + val cleanedText = text.trim() + .replace(Regex("^[O0o\\[\\]()●○□☑✓-]+\\s*"), "") + + return cleanedText.ifEmpty { text } + } + + fun cleanTextPreservingLeadingO(text: String): String { + var cleanedText = text.trim() + .replace(Regex("^[\\[\\]()●○□☑✓-]+\\s*"), "") + + cleanedText = cleanedText.replace(Regex("^[DT]?opti[oa]n", RegexOption.IGNORE_CASE), "Option") + cleanedText = cleanedText.replace(Regex("^pti[oa]n", RegexOption.IGNORE_CASE), "Option") + cleanedText = cleanedText.replace(Regex("^optton", RegexOption.IGNORE_CASE), "Option") + + return cleanedText.ifEmpty { text } + } +} diff --git a/cv-image-to-xml/src/test/java/org/appdevforall/codeonthego/computervision/domain/FuzzyAttributeParserTest.kt b/cv-image-to-xml/src/test/java/org/appdevforall/codeonthego/computervision/domain/FuzzyAttributeParserTest.kt index dd3329d24d..cd53dd41f1 100644 --- a/cv-image-to-xml/src/test/java/org/appdevforall/codeonthego/computervision/domain/FuzzyAttributeParserTest.kt +++ b/cv-image-to-xml/src/test/java/org/appdevforall/codeonthego/computervision/domain/FuzzyAttributeParserTest.kt @@ -175,6 +175,15 @@ class FuzzyAttributeParserTest { assertEquals("radius_slider", result["android:id"]) } + @Test + fun `OCR garbled checkbox group id is normalized without changing custom ids`() { + val checkboxResult = FuzzyAttributeParser.parse("id: cbgraup2 | width: 100dp", "CheckBox") + val customResult = FuzzyAttributeParser.parse("id: radius_slider | width: 100dp", "ImageView") + + assertEquals("cb_group_2", checkboxResult["android:id"]) + assertEquals("radius_slider", customResult["android:id"]) + } + @Test fun `hex color values pass through unchanged`() { val annotation = "background: #FF5733 | width: 100dp" diff --git a/cv-image-to-xml/src/test/java/org/appdevforall/codeonthego/computervision/domain/xml/LayoutRendererTest.kt b/cv-image-to-xml/src/test/java/org/appdevforall/codeonthego/computervision/domain/xml/LayoutRendererTest.kt new file mode 100644 index 0000000000..0443c730e2 --- /dev/null +++ b/cv-image-to-xml/src/test/java/org/appdevforall/codeonthego/computervision/domain/xml/LayoutRendererTest.kt @@ -0,0 +1,105 @@ +package org.appdevforall.codeonthego.computervision.domain.xml + +import android.graphics.Rect +import org.appdevforall.codeonthego.computervision.domain.model.LayoutItem +import org.appdevforall.codeonthego.computervision.domain.model.ScaledBox +import org.junit.Assert.assertTrue +import org.junit.Test + +class LayoutRendererTest { + + @Test + fun `checkbox group uses canonical ids when OCR annotation id is noisy`() { + val first = checkboxBox(y = 0, text = "Option A") + val second = checkboxBox(y = 20, text = "Option B", checked = true) + val context = XmlContext() + + val renderer = LayoutRenderer( + context = context, + annotations = mapOf( + first to "id: cbgraup2 | textColor: gray" + ) + ) + + renderer.render(LayoutItem.CheckboxGroup(listOf(first, second), "vertical")) + + val xml = context.toString() + + assertTrue(xml.contains("""android:id="@+id/cb_group_2_a"""")) + assertTrue(xml.contains("""android:id="@+id/cb_group_2_b"""")) + assertTrue(!xml.contains("cbgraup2")) + } + + @Test + fun `radio group ignores group style ids on child radios`() { + val first = radioBox(y = 0, text = "choice A") + val second = radioBox(y = 20, text = "choice B", checked = true) + val context = XmlContext() + + val renderer = LayoutRenderer( + context = context, + annotations = mapOf( + first to "id: rb_group_1 | textSize: 16sp" + ) + ) + + renderer.render(LayoutItem.RadioGroup(listOf(first, second), "vertical")) + + val xml = context.toString() + + assertTrue(xml.contains("""android:id="@+id/radio_button_unchecked_0"""")) + assertTrue(xml.contains("""android:id="@+id/radio_button_checked_0"""")) + assertTrue(!xml.contains("""android:id="@+id/rb_group_1"""")) + } + + @Test + fun `radio group ignores group style ids even when parser leaks trailing tokens`() { + val first = radioBox(y = 0, text = "choice A") + val second = radioBox(y = 20, text = "choice B", checked = true) + val context = XmlContext() + + val renderer = LayoutRenderer( + context = context, + annotations = mapOf( + first to "id: rb_group_1_text_site_16sp | textColor: black" + ) + ) + + renderer.render(LayoutItem.RadioGroup(listOf(first, second), "vertical")) + + val xml = context.toString() + + assertTrue(xml.contains("""android:id="@+id/radio_button_unchecked_0"""")) + assertTrue(!xml.contains("""android:id="@+id/rb_group_1_text_site_16sp"""")) + } + + private fun checkboxBox(y: Int, text: String, checked: Boolean = false): ScaledBox { + val label = if (checked) "checkbox_checked" else "checkbox_unchecked" + return ScaledBox( + label = label, + text = text, + x = 0, + y = y, + w = 20, + h = 20, + centerX = 10, + centerY = y + 10, + rect = Rect(0, y, 20, y + 20) + ) + } + + private fun radioBox(y: Int, text: String, checked: Boolean = false): ScaledBox { + val label = if (checked) "radio_button_checked" else "radio_button_unchecked" + return ScaledBox( + label = label, + text = text, + x = 0, + y = y, + w = 20, + h = 20, + centerX = 10, + centerY = y + 10, + rect = Rect(0, y, 20, y + 20) + ) + } +}