fix(agent): deduplicate address entry nodes (#229)

This commit is contained in:
QiuSW
2026-09-05 18:17:54 +08:00
parent 5ee3b62906
commit c9aaade6e9
3 changed files with 166 additions and 9 deletions
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId = "cn.ilapage.goauto.agent"
minSdk = 23
targetSdk = 34
versionCode = 63
versionName = "0.9.50"
versionCode = 64
versionName = "0.9.51"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -95,12 +95,31 @@ class PurchaseLiveAutomation(
var unchangedCount = 0
for (attempt in 0 until 5) {
pageProblem(snapshot)
val entries = mergedDirect(snapshot.nodes.filter { it.visible && it.enabled && MASKED_PHONE.containsMatchIn(it.label) })
if (entries.size == 1) {
when (driver.tapPurchaseFresh(entries.single())) {
val resolution = resolveAddressEntries(snapshot)
if (resolution.addressCardCount > 1 || resolution.tapTargetCount > 1) {
fail("PURCHASE_ADDRESS_ENTRY_AMBIGUOUS", "收货地址入口不唯一,未创建订单 [${resolution.diagnostic()}]")
}
if (resolution.target != null) {
pause(ADDRESS_ENTRY_STABLE_INTERVAL_MS)
val stableSnapshot = driver.capture()
pageProblem(stableSnapshot)
val stableResolution = resolveAddressEntries(stableSnapshot)
if (stableResolution.addressCardCount > 1 || stableResolution.tapTargetCount > 1) {
fail("PURCHASE_ADDRESS_ENTRY_AMBIGUOUS", "收货地址入口不唯一,未创建订单 [${stableResolution.diagnostic()}]")
}
if (stableResolution.target == null || stableResolution.fingerprint() != resolution.fingerprint()) {
snapshot = stableSnapshot
continue
}
val target = stableResolution.target
val result = when (target.activation) {
AddressEntryActivation.CLICK -> driver.clickFreshDetailed(target.node).result
AddressEntryActivation.TAP -> driver.tapPurchaseFresh(target.node)
}
when (result) {
FreshActionResult.SUCCESS -> Unit
FreshActionResult.AMBIGUOUS -> fail("PURCHASE_ADDRESS_ENTRY_AMBIGUOUS", "收货地址入口不唯一,未创建订单")
else -> fail("PURCHASE_ADDRESS_ENTRY_NOT_READY", "收货地址入口点击失败,未创建订单")
FreshActionResult.AMBIGUOUS -> fail("PURCHASE_ADDRESS_ENTRY_AMBIGUOUS", "收货地址入口不唯一,未创建订单 [${stableResolution.diagnostic()}]")
else -> fail("PURCHASE_ADDRESS_ENTRY_NOT_READY", "收货地址入口点击失败,未创建订单 [${stableResolution.diagnostic()}]")
}
pause(1_000)
snapshot = waitFor("PURCHASE_ADDRESS_PANEL_TIMEOUT", "收货地址页面打开超时,未创建订单") {
@@ -108,7 +127,9 @@ class PurchaseLiveAutomation(
}
return editAndVerifyAddress(snapshot, addressSuffix)
}
if (entries.size > 1) fail("PURCHASE_ADDRESS_ENTRY_AMBIGUOUS", "收货地址入口不唯一,未创建订单")
if (resolution.phoneNodeCount > 0) {
fail("PURCHASE_ADDRESS_ENTRY_NOT_READY", "收货地址入口无法安全定位,未创建订单 [${resolution.diagnostic()}]")
}
val panel = purchasePanelScrollTargets(snapshot)
if (panel.size != 1) fail("PURCHASE_ADDRESS_ENTRY_AMBIGUOUS", "没有找到唯一的规格面板滚动区域,未创建订单")
val signature = viewportSignature(snapshot, panel.single())
@@ -124,6 +145,103 @@ class PurchaseLiveAutomation(
fail("PURCHASE_ADDRESS_PANEL_TIMEOUT", "规格面板下拉后仍未找到收货地址,未创建订单")
}
private enum class AddressEntryActivation { CLICK, TAP }
private data class AddressEntryTarget(
val node: SnapshotNode,
val activation: AddressEntryActivation,
val cardKey: String,
)
private data class AddressEntryResolution(
val phoneNodeCount: Int,
val addressCardCount: Int,
val tapTargetCount: Int,
val target: AddressEntryTarget?,
) {
fun diagnostic(): String =
"phoneNodes=$phoneNodeCount;addressCards=$addressCardCount;tapTargets=$tapTargetCount"
fun fingerprint(): String? = target?.let {
listOf(
it.activation.name,
it.cardKey,
it.node.label,
it.node.className.orEmpty(),
it.node.bounds.left,
it.node.bounds.top,
it.node.bounds.right,
it.node.bounds.bottom,
).joinToString(":")
}
}
private fun resolveAddressEntries(snapshot: UiSnapshot): AddressEntryResolution {
val phones = snapshot.nodes.filter {
it.visible && it.enabled && maskedPhone(it.label) != null
}.distinctBy { it.path }
if (phones.isEmpty()) return AddressEntryResolution(0, 0, 0, null)
val byPath = snapshot.nodes.associateBy(SnapshotNode::path)
val withClickableCard = phones.mapNotNull { phone ->
nearestClickableAncestor(phone, byPath)?.let { ancestor -> ancestor.path to phone }
}
val clickableGroups = withClickableCard.groupBy({ it.first }, { it.second })
val assignedPaths = withClickableCard.map { it.second.path }.toSet()
val targets = clickableGroups.map { (cardPath, nodes) ->
AddressEntryTarget(nodes.minBy(::nodeArea), AddressEntryActivation.CLICK, "click:$cardPath")
}.toMutableList()
val spatialGroups = mutableListOf<MutableList<SnapshotNode>>()
phones.filterNot { it.path in assignedPaths }.forEach { phone ->
val group = spatialGroups.firstOrNull { existing -> existing.any { sameAddressRegion(it, phone) } }
if (group == null) spatialGroups += mutableListOf(phone) else group += phone
}
spatialGroups.forEachIndexed { index, nodes ->
targets += AddressEntryTarget(nodes.minBy(::nodeArea), AddressEntryActivation.TAP, "tap:$index")
}
val actionable = targets.filter { it.node.bounds.width >= 2 && it.node.bounds.height >= 2 }
return AddressEntryResolution(
phoneNodeCount = phones.size,
addressCardCount = targets.size,
tapTargetCount = actionable.size,
target = actionable.singleOrNull(),
)
}
private fun nearestClickableAncestor(
source: SnapshotNode,
byPath: Map<String, SnapshotNode>,
): SnapshotNode? {
var current: SnapshotNode? = source
while (current != null) {
if (current.clickable && current.visible && current.enabled) return current
current = current.parentPath?.let(byPath::get)
}
return null
}
private fun sameAddressRegion(first: SnapshotNode, second: SnapshotNode): Boolean {
if (maskedPhone(first.label) != maskedPhone(second.label)) return false
val overlapWidth = (minOf(first.bounds.right, second.bounds.right) - maxOf(first.bounds.left, second.bounds.left)).coerceAtLeast(0)
val overlapHeight = (minOf(first.bounds.bottom, second.bounds.bottom) - maxOf(first.bounds.top, second.bounds.top)).coerceAtLeast(0)
val overlap = overlapWidth.toLong() * overlapHeight
val smaller = minOf(nodeArea(first), nodeArea(second))
val containsCenter = (
first.bounds.centerX in second.bounds.left..second.bounds.right &&
first.bounds.centerY in second.bounds.top..second.bounds.bottom
) || (
second.bounds.centerX in first.bounds.left..first.bounds.right &&
second.bounds.centerY in first.bounds.top..first.bounds.bottom
)
return (smaller > 0 && overlap * 100 >= smaller * 50) || containsCenter
}
private fun maskedPhone(value: String): String? = MASKED_PHONE.find(value)?.value
private fun nodeArea(node: SnapshotNode): Long = node.bounds.width.toLong() * node.bounds.height
fun finalConfirmation(input: PurchaseExecutionInput, address: ShippingAddressProof): FinalConfirmationEvidence {
val snapshot = driver.capture()
pageProblem(snapshot)
@@ -592,6 +710,7 @@ class PurchaseLiveAutomation(
const val ORDER_RESULT_SAMPLE_INTERVAL_MS = 200L
const val SPEC_CONFIRMATION_MAX_SAMPLES = 20
const val SPEC_CONFIRMATION_SAMPLE_INTERVAL_MS = 100L
const val ADDRESS_ENTRY_STABLE_INTERVAL_MS = 200L
const val ADDRESS_CONFIRMATION_SCROLL_LIMIT = 5
const val ADDRESS_CONFIRMATION_SCROLL_INTERVAL_MS = 500L
}
@@ -499,6 +499,29 @@ class PurchaseLiveAutomationTest {
assertTrue(driver.clicked.isEmpty())
}
@Test
fun `duplicate masked phone nodes in one clickable address card are activated once`() {
val driver = LiveDriver(duplicatePhoneNodesSameCard = true)
PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg94")
assertEquals(1, driver.clicked.count { it == "138****5678" })
assertEquals(0, driver.addressTaps)
}
@Test
fun `two independent address cards remain ambiguous without any click`() {
val driver = LiveDriver(duplicateAddressCards = true)
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg95") }
.exceptionOrNull() as PurchaseLiveException
assertEquals("PURCHASE_ADDRESS_ENTRY_AMBIGUOUS", error.code)
assertTrue(error.message.orEmpty().contains("phoneNodes=2;addressCards=2;tapTargets=2"))
assertFalse(error.message.orEmpty().contains("138"))
assertTrue(driver.clicked.isEmpty())
assertEquals(0, driver.addressTaps)
}
private fun input() = PurchaseExecutionInput(
taskId = 11,
executionMode = "live",
@@ -598,6 +621,8 @@ class PurchaseLiveAutomationTest {
private val savedTransitionHidesSuffix: Boolean = false,
private val saveReturnsToSpecPanel: Boolean = false,
private val savedSpecPanelRecoveryStuck: Boolean = false,
private val duplicatePhoneNodesSameCard: Boolean = false,
private val duplicateAddressCards: Boolean = false,
private val wechatLoginAfterSubmit: Boolean = false,
private val wechatRestoreStuck: Boolean = false,
private val orderEvidenceBelowFold: Boolean = false,
@@ -701,7 +726,20 @@ class PurchaseLiveAutomationTest {
)
if (duplicatePanels) nodes += node("panel2", "", scrollable = true, bounds = NodeBounds(0, 500, 1080, 2000))
if (addressVisible) {
nodes += node("phone", "138****5678")
when {
duplicateAddressCards -> {
nodes += node("address-card-a", "", clickable = true, bounds = NodeBounds(0, 620, 1080, 820))
nodes += node("address-card-a/phone", "138****5678", parentPath = "address-card-a", bounds = NodeBounds(20, 650, 400, 710))
nodes += node("address-card-b", "", clickable = true, bounds = NodeBounds(0, 840, 1080, 1040))
nodes += node("address-card-b/phone", "138****5678", parentPath = "address-card-b", bounds = NodeBounds(20, 870, 400, 930))
}
duplicatePhoneNodesSameCard -> {
nodes += node("address-card", "", clickable = true, bounds = NodeBounds(0, 620, 1080, 900))
nodes += node("address-card/phone-a", "138****5678", parentPath = "address-card", bounds = NodeBounds(20, 650, 400, 710))
nodes += node("address-card/phone-b", "138****5678", parentPath = "address-card", bounds = NodeBounds(20, 720, 440, 790))
}
else -> nodes += node("phone", "138****5678")
}
val suffixStart = address.lastIndexOf("_cg")
val addressBody = if (suffixStart >= 0) address.substring(0, suffixStart) else address
val addressSuffix = if (suffixStart >= 0) address.substring(suffixStart) else ""