diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 420255f..b396214 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 = 35 - versionName = "0.9.22" + versionCode = 36 + versionName = "0.9.23" 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 605a596..1d04859 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 @@ -73,7 +73,7 @@ class PurchaseLiveAutomation( val snapshot = driver.capture() pageProblem(snapshot) if (snapshot.packageName != PDD_PACKAGE) fail("PURCHASE_CONFIRMATION_LOST", "最终提交前页面已经变化,禁止创建订单") - if (snapshot.nodes.none { it.visible && it.label.contains(address.expectedAddress) }) { + if (!hasSavedAddressEvidence(snapshot, address.expectedAddress, address.suffix)) { fail("PURCHASE_ADDRESS_UPDATE_FAILED", "收货地址保存后复核失败,未创建订单") } val submit = finalSubmitTargets(snapshot) @@ -171,17 +171,40 @@ class PurchaseLiveAutomation( val save = uniqueClickable(stable, stable.nodes.filter { it.visible && it.enabled && it.label == "保存" }) if (save.size != 1) fail("PURCHASE_ADDRESS_UPDATE_FAILED", "地址保存按钮不唯一,未创建订单") click(save.single(), "保存地址") - var saved = waitFor("PURCHASE_ADDRESS_SAVE_TIMEOUT", "地址保存超时,未创建订单") { snapshot -> - snapshot.nodes.any { it.visible && it.label.contains(expected) } || finalSubmitTargets(snapshot).size == 1 + val savedEvidence = waitFor("PURCHASE_ADDRESS_SAVE_TIMEOUT", "地址保存超时,未创建订单") { snapshot -> + hasSavedAddressEvidence(snapshot, expected, suffix) } - if (finalSubmitTargets(saved).size != 1) { + if (finalSubmitTargets(savedEvidence).size != 1) { if (!driver.backPurchase()) fail("PURCHASE_ADDRESS_UPDATE_FAILED", "地址保存后无法返回订单页面,未创建订单") - saved = waitFor("PURCHASE_ADDRESS_SAVE_TIMEOUT", "地址保存后无法返回订单页面,未创建订单") { finalSubmitTargets(it).size == 1 } + waitFor("PURCHASE_ADDRESS_SAVE_TIMEOUT", "地址保存后无法返回订单页面,未创建订单") { finalSubmitTargets(it).size == 1 } } - if (saved.nodes.none { it.visible && it.label.contains(expected) }) fail("PURCHASE_ADDRESS_UPDATE_FAILED", "地址保存后回读不一致,未创建订单") return ShippingAddressProof(expected, suffix) } + private fun hasSavedAddressEvidence(snapshot: UiSnapshot, expected: String, suffix: String): Boolean { + if (snapshot.packageName != PDD_PACKAGE || shippingAddressEditors(snapshot).isNotEmpty()) return false + val visible = snapshot.nodes.filter { it.visible } + val addressContext = visible.count { normalizeAddressText(it.label) == "收货地址" } == 1 || + (mergedDirect(visible.filter { it.enabled && MASKED_PHONE.containsMatchIn(it.label) }).size == 1 && + finalSubmitTargets(snapshot).size == 1) + if (!addressContext) return false + + val normalizedExpected = normalizeAddressText(expected) + val fullMatches = mergedDirect(visible.filter { + normalizeAddressText(it.label).contains(normalizedExpected) && hasExactTaskSuffix(it.label, suffix) + }) + if (fullMatches.size == 1) return true + if (fullMatches.size > 1) return false + + val suffixMatches = mergedDirect(visible.filter { hasExactTaskSuffix(it.label, suffix) }) + return suffixMatches.size == 1 + } + + private fun normalizeAddressText(value: String): String = value.filterNot(Char::isWhitespace) + + private fun hasExactTaskSuffix(value: String, suffix: String): Boolean = + Regex("${Regex.escape(suffix)}(?![0-9])").containsMatchIn(normalizeAddressText(value)) + private fun waitFor(code: String, message: String, predicate: (UiSnapshot) -> Boolean): UiSnapshot { repeat(50) { val snapshot = driver.capture() 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 d4a5310..3b829d1 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 @@ -39,6 +39,52 @@ class PurchaseLiveAutomationTest { assertFalse(driver.clicked.any { it.contains("支付") }) } + @Test + fun `split confirmation address keeps the unique saved task suffix proof`() { + val driver = LiveDriver(splitConfirmationAddress = true) + val automation = PurchaseLiveAutomation(driver, pause = {}) + val address = automation.updateShippingAddress("_cg17") + + val final = automation.finalConfirmation(input().copy(addressSuffix = "_cg17"), address) + + assertEquals("_cg17", final.addressSuffix) + assertEquals(0, driver.submitClicks) + } + + @Test + fun `edit field containing suffix cannot impersonate post save evidence`() { + val driver = LiveDriver(saveStaysInEdit = true) + val error = runCatching { PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg18") } + .exceptionOrNull() as PurchaseLiveException + + assertEquals("PURCHASE_ADDRESS_SAVE_TIMEOUT", error.code) + assertEquals(0, driver.submitClicks) + } + + @Test + fun `missing confirmation suffix fails before order creation`() { + val driver = LiveDriver(hideConfirmationSuffix = true) + val automation = PurchaseLiveAutomation(driver, pause = {}) + val address = automation.updateShippingAddress("_cg19") + val error = runCatching { automation.finalConfirmation(input().copy(addressSuffix = "_cg19"), address) } + .exceptionOrNull() as PurchaseLiveException + + assertEquals("PURCHASE_ADDRESS_UPDATE_FAILED", error.code) + assertEquals(0, driver.submitClicks) + } + + @Test + fun `duplicate independent confirmation suffixes fail before order creation`() { + val driver = LiveDriver(splitConfirmationAddress = true, duplicateConfirmationSuffix = true) + val automation = PurchaseLiveAutomation(driver, pause = {}) + val address = automation.updateShippingAddress("_cg20") + val error = runCatching { automation.finalConfirmation(input().copy(addressSuffix = "_cg20"), address) } + .exceptionOrNull() as PurchaseLiveException + + assertEquals("PURCHASE_ADDRESS_UPDATE_FAILED", error.code) + assertEquals(0, driver.submitClicks) + } + @Test fun `live rule requires reviewed capabilities and payment stays forbidden`() { val live = PurchaseRuleParser.parse(LIVE_RULE) @@ -116,6 +162,10 @@ class PurchaseLiveAutomationTest { private val duplicatePanels: Boolean = false, private val chooserAfterSubmit: Boolean = false, private val trustedChooser: Boolean = true, + private val splitConfirmationAddress: Boolean = false, + private val hideConfirmationSuffix: Boolean = false, + private val duplicateConfirmationSuffix: Boolean = false, + private val saveStaysInEdit: Boolean = false, ) : PurchaseUiDriver { private var page = "confirmation" private var addressVisible = !addressClipped @@ -162,7 +212,20 @@ class PurchaseLiveAutomationTest { if (duplicatePanels) nodes += node("panel2", "", scrollable = true, bounds = NodeBounds(0, 500, 1080, 2000)) if (addressVisible) { nodes += node("phone", "138****5678") - nodes += node("address", address) + 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 "" + when { + hideConfirmationSuffix -> nodes += node("address", addressBody) + splitConfirmationAddress -> { + nodes += node("address-body", addressBody, bounds = NodeBounds(20, 700, 900, 780)) + nodes += node("address-suffix", addressSuffix, bounds = NodeBounds(20, 780, 300, 840)) + if (duplicateConfirmationSuffix) { + nodes += node("address-suffix-2", addressSuffix, bounds = NodeBounds(500, 780, 780, 840)) + } + } + else -> nodes += node("address", address) + } } snapshot(nodes) } @@ -173,7 +236,7 @@ class PurchaseLiveAutomationTest { when (target.label) { "138****5678" -> page = "panel" "修改" -> page = "edit" - "保存" -> page = "panel" + "保存" -> if (!saveStaysInEdit) page = "panel" "提交订单" -> { submitClicks++; page = if (chooserAfterSubmit) "chooser" else "order" } } return FreshActionResult.SUCCESS