diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index f14bc76..a3ef51f 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 = 63 - versionName = "0.9.50" + versionCode = 64 + versionName = "0.9.51" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseLiveAutomation.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseLiveAutomation.kt index 0b7742d..8cd35db 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseLiveAutomation.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseLiveAutomation.kt @@ -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>() + 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, + ): 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 } diff --git a/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseLiveAutomationTest.kt b/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseLiveAutomationTest.kt index f213c70..26b6470 100644 --- a/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseLiveAutomationTest.kt +++ b/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseLiveAutomationTest.kt @@ -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 ""