diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 9db325e..459f99b 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -11,8 +11,8 @@ android { applicationId = "cn.ilapage.goauto.agent" minSdk = 23 targetSdk = 34 - versionCode = 74 - versionName = "0.9.61" + versionCode = 75 + versionName = "0.9.62" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseRehearsalExecutor.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseRehearsalExecutor.kt index b28c729..b69897d 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseRehearsalExecutor.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseRehearsalExecutor.kt @@ -656,7 +656,9 @@ class PurchaseRehearsalExecutor( private fun isExactSpecSelected(screen: ParsedPddScreen, dimension: String, target: String): Boolean { val candidates = screen.dimensions.filter { it.key == dimension }.flatMap { it.values } - if (candidates.any { it.text != target && (it.node.selected || it.node.checked) }) return false + if (candidates.any { it.text != target && (it.node.selected || it.node.checked) }) { + return dimension == "color" && SelectedColorCard.confirms(target, candidates, screen.sourceNodes) + } if (candidates.any { it.text == target && (it.node.selected || it.node.checked) }) return true if (screen.specPanelOpen && fullSummaryTargetMatches(screen.selectedSummary, target)) return true return summarySelectionMatches(screen.selectedSummary, dimension, target, candidates) diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/SelectedColorCard.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/SelectedColorCard.kt new file mode 100644 index 0000000..ea7fed2 --- /dev/null +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/SelectedColorCard.kt @@ -0,0 +1,43 @@ +package cn.ilapage.goauto.agent.automation + +/** A narrow exception for one selected text option duplicated by its card/image. */ +internal object SelectedColorCard { + fun confirms(target: String, values: List, source: List): Boolean { + val exact = values.filter { it.text == target }.singleOrNull() ?: return false + if (!exact.node.selected && !exact.node.checked) return false + val byPath = source.associateBy { it.path } + fun contains(outer: NodeBounds, inner: NodeBounds) = + inner.width > 0 && inner.height > 0 && inner.left >= outer.left && inner.top >= outer.top && + inner.right <= outer.right && inner.bottom <= outer.bottom + fun cardFor(node: SnapshotNode): SnapshotNode? { + var current = node + val seen = mutableSetOf() + while (seen.add(current.path)) { + if (current.scrollable || !current.visible || !current.enabled) return null + val parent = current.parentPath?.let(byPath::get) ?: return null + if (parent.scrollable) { + return current.takeIf { + it.clickable && it.className == "android.view.ViewGroup" && + contains(parent.bounds, it.bounds) && contains(it.bounds, node.bounds) + } + } + current = parent + } + return null + } + val card = cardFor(exact.node) ?: return false + // The parser may have deduplicated identical labels across cards. Check + // the source as well before treating a selected duplicate as harmless. + if (source.any { node -> + node.visible && node.enabled && node.clickable && + SpecValueNormalizer.normalizeColor(node.label) == target && + cardFor(node)?.let { it.path != card.path } == true + }) return false + return values.filter { it.text != target && (it.node.selected || it.node.checked) }.all { other -> + val node = other.node + // Never merge another textual option, adjacent card, or scroll container. + (node.path == card.path || node.className == "android.widget.ImageView") && + cardFor(node)?.path == card.path && contains(card.bounds, node.bounds) + } + } +} diff --git a/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseRehearsalExecutorTest.kt b/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseRehearsalExecutorTest.kt index b98f7f7..7efa7c5 100644 --- a/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseRehearsalExecutorTest.kt +++ b/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseRehearsalExecutorTest.kt @@ -883,6 +883,15 @@ class PurchaseRehearsalExecutorTest { assertTrue(driver.panel) } + @Test + fun `selected color card duplicate does not trigger another selection tap`() { + val driver = FakePurchaseDriver(duplicateColorCardEvidence = true) + val outcome = PurchaseRehearsalExecutor(driver, { true }, { null }, pause = {}) + .execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported) + assertEquals(outcome.message, "rehearsal_completed", outcome.resultType) + assertEquals(0, driver.specTapCount) + } + @Test fun `dynamic product page recovers ineffective and failed entry clicks once`() { for (reason in listOf(null, FreshClickReason.ACTION_CLICK_FALSE, FreshClickReason.TARGET_NOT_FOUND)) { @@ -1355,6 +1364,7 @@ class PurchaseRehearsalExecutorTest { private val forcedEntryClickReason: FreshClickReason? = null, private val initialSize: String? = null, private val entryActionHasEffect: Boolean = true, + private val duplicateColorCardEvidence: Boolean = false, private val dynamicProduct: Boolean = false, private val productOverlay: Boolean = false, private val afterEntryMutation: String = "", @@ -1532,6 +1542,16 @@ class PurchaseRehearsalExecutorTest { nodes += node(colorParent, "", 0, 460, 1080, 550, scrollable = true, parentPath = "scroll") } visibleColors.forEachIndexed { index, value -> + if (duplicateColorCardEvidence && selectedColor == value) { + val cardPath = "scroll/card-$index" + nodes += node(cardPath, "$value 199", 20 + index * 220, 460, 200 + index * 220, 550, + clickable = true, selected = true, parentPath = "scroll", className = "android.view.ViewGroup") + nodes += node("$cardPath/image", "$value 199", 20 + index * 220, 460, 200 + index * 220, 500, + clickable = true, selected = true, parentPath = cardPath, className = "android.widget.ImageView") + nodes += node("$cardPath/text", value, 20 + index * 220, 500, 200 + index * 220, 540, + clickable = true, selected = true, parentPath = cardPath, className = "android.widget.TextView") + return@forEachIndexed + } nodes += node( "scroll/color-$index", value, diff --git a/android/app/src/test/java/cn/ilapage/goauto/agent/SelectedColorCardTest.kt b/android/app/src/test/java/cn/ilapage/goauto/agent/SelectedColorCardTest.kt new file mode 100644 index 0000000..f370b83 --- /dev/null +++ b/android/app/src/test/java/cn/ilapage/goauto/agent/SelectedColorCardTest.kt @@ -0,0 +1,47 @@ +package cn.ilapage.goauto.agent + +import cn.ilapage.goauto.agent.automation.* +import org.junit.Assert.* +import org.junit.Test + +class SelectedColorCardTest { + private val target = "黑色两件套 19" + private fun node(path: String, parent: String?, label: String, kind: String, selected: Boolean = true, + scrollable: Boolean = false) = SnapshotNode(path, parent, label, null, null, kind, + NodeBounds(0, 0, 300, 400), !scrollable, scrollable, selected, false, true, true) + private val scroll = node("s", null, "", "androidx.recyclerview.widget.RecyclerView", false, true) + private val card = node("s/c", "s", "黑色两件套 199", "android.view.ViewGroup") + private val image = node("s/c/i", "s/c", "黑色两件套 199", "android.widget.ImageView") + private val text = node("s/c/t", "s/c", target, "android.widget.TextView") + private fun check(nodes: List, candidates: List = nodes.filter { it != scroll }) = + SelectedColorCard.confirms(target, candidates.map { VisibleSpecValue(it.label, true, it) }, nodes) + + @Test fun `selected target and own card image duplicates are one selection`() { + assertTrue(check(listOf(scroll, card, image, text))) + } + @Test fun `another selected card remains conflict`() { + val other = card.copy(path = "s/other") + assertFalse(check(listOf(scroll, card, image, text, other))) + } + @Test fun `two exact targets are not merged`() { + val otherCard = card.copy(path = "s/other", selected = false) + val otherText = text.copy(path = "s/other/t", parentPath = otherCard.path) + assertFalse(check(listOf(scroll, card, image, text, otherCard, otherText))) + assertFalse(check(listOf(scroll, card, image, text, otherCard, otherText), listOf(card, image, text))) + } + @Test fun `unselected target cannot borrow selection from its container`() { + assertFalse(check(listOf(scroll, card, image, text.copy(selected = false)))) + } + @Test fun `scrollable or missing card boundary cannot merge`() { + assertFalse(check(listOf(scroll, card.copy(scrollable = true), image, text))) + assertFalse(check(listOf(card, image, text))) + assertFalse(check(listOf(scroll, card.copy(clickable = false), image, text))) + } + @Test fun `different text sibling is not treated as image duplicate`() { + assertFalse(check(listOf(scroll, card, text, image.copy(className = "android.widget.TextView")))) + } + @Test fun `out of card bounds and parent cycle are rejected`() { + assertFalse(check(listOf(scroll, card, image.copy(bounds = NodeBounds(0, 0, 301, 400)), text))) + assertFalse(check(listOf(scroll, card.copy(parentPath = text.path), image, text))) + } +}