fix(android): ignore selected duplicates within exact color card (#244)
This commit is contained in:
@@ -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"
|
||||
|
||||
|
||||
+3
-1
@@ -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)
|
||||
|
||||
@@ -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<VisibleSpecValue>, source: List<SnapshotNode>): 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<String>()
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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<SnapshotNode>, candidates: List<SnapshotNode> = 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)))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user