diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 6e8b4ac..8187763 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 = 55 - versionName = "0.9.42" + versionCode = 56 + versionName = "0.9.43" 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 b77992e..05ac1e3 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 @@ -36,6 +36,49 @@ class PurchaseLiveAutomation( private var submitAttempted = false var lastOrderReadFailure: PurchaseOrderReadFailure? = null private set + + /** + * Advances an already verified spec selector to the order confirmation + * page. Only the selector's unique exact confirm button is clickable; an + * order-submit or payment control can never satisfy this transition. + */ + fun advanceToOrderConfirmation() { + var snapshot = driver.capture() + pageProblem(snapshot) + if (orderConfirmationReady(snapshot)) return + if (snapshot.packageName != PDD_PACKAGE) { + fail("PURCHASE_SPEC_CONFIRMATION_NOT_READY", "当前不是拼多多规格页面,未创建订单") + } + val screen = PddScreenParser.parse(snapshot, PurchaseRehearsalExecutor.DEFAULT_COLLECTOR, "", null) + if (screen.specPanelType !in setOf(SpecPanelType.NORMAL_SCROLLABLE, SpecPanelType.NON_SCROLLABLE_CONFIRMATION)) { + fail("PURCHASE_SPEC_CONFIRMATION_NOT_READY", "当前规格面板不能安全确认,未创建订单") + } + val aliases = PurchaseRehearsalExecutor.DEFAULT_COLLECTOR.textAliases.specPanel.confirmAliases + .map { it.replace(" ", "") } + .toSet() + val targets = snapshot.nodes.filter { node -> + node.visible && node.enabled && node.clickable && node.label.replace(" ", "") in aliases + }.distinctBy { it.path } + if (targets.isEmpty()) { + fail("PURCHASE_SPEC_CONFIRM_TARGET_MISSING", "没有找到唯一的规格确认按钮,未创建订单") + } + if (targets.size > 1) { + fail("PURCHASE_SPEC_CONFIRM_TARGET_AMBIGUOUS", "规格确认按钮不唯一,未创建订单") + } + when (driver.clickFresh(targets.single())) { + FreshActionResult.SUCCESS -> Unit + FreshActionResult.AMBIGUOUS -> fail("PURCHASE_SPEC_CONFIRM_TARGET_AMBIGUOUS", "规格确认按钮不唯一,未创建订单") + else -> fail("PURCHASE_SPEC_CONFIRM_CLICK_FAILED", "规格确认按钮点击失败,未创建订单") + } + repeat(SPEC_CONFIRMATION_MAX_SAMPLES) { + pause(SPEC_CONFIRMATION_SAMPLE_INTERVAL_MS) + snapshot = driver.capture() + pageProblem(snapshot) + if (orderConfirmationReady(snapshot)) return + } + fail("PURCHASE_SPEC_CONFIRMATION_UNCONFIRMED", "规格确认后没有进入订单确认页,未创建订单") + } + fun updateShippingAddress(addressSuffix: String): ShippingAddressProof { if (!addressSuffix.matches(Regex("^_cg[1-9][0-9]*$"))) fail("PURCHASE_ADDRESS_UPDATE_FAILED", "采购任务的地址标记无效") var snapshot = driver.capture() @@ -407,6 +450,13 @@ class PurchaseLiveAutomation( snapshot.nodes.filter { node -> node.visible && node.enabled && FINAL_SUBMIT_MARKERS.any { node.label == it || node.label.startsWith(it) } }, ) + private fun orderConfirmationReady(snapshot: UiSnapshot): Boolean { + if (snapshot.packageName != PDD_PACKAGE) return false + if (snapshot.nodes.any { it.visible && it.enabled && MASKED_PHONE.containsMatchIn(it.label) }) return true + return PddScreenParser.parse(snapshot, PurchaseRehearsalExecutor.DEFAULT_COLLECTOR, "", null).specPanelType == + SpecPanelType.ORDER_CONFIRMATION + } + /** A post-submit navigation target is allowed only when it is one exact, non-payment PDD order-detail entry. */ private fun orderDetailEntryTargets(snapshot: UiSnapshot): List = uniqueClickable( snapshot, @@ -483,5 +533,7 @@ class PurchaseLiveAutomation( const val ORDER_RESULT_PAYMENT_POST_BACK_MAX_SAMPLES = 3 const val ORDER_RESULT_SCROLL_SAMPLE_INTERVAL = 15 const val ORDER_RESULT_SAMPLE_INTERVAL_MS = 200L + const val SPEC_CONFIRMATION_MAX_SAMPLES = 20 + const val SPEC_CONFIRMATION_SAMPLE_INTERVAL_MS = 100L } } 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 0ee551d..778e382 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 @@ -105,6 +105,7 @@ class PurchaseRehearsalExecutor( null } PurchaseActionType.UPDATE_SHIPPING_ADDRESS -> try { + live.advanceToOrderConfirmation() addressProof = live.updateShippingAddress(input.addressSuffix) null } catch (error: PurchaseLiveException) { 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 b67a08e..890a9c9 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 @@ -16,6 +16,46 @@ import org.junit.Assert.assertTrue import org.junit.Test class PurchaseLiveAutomationTest { + @Test + fun `verified spec panel advances through one exact confirm target`() { + val driver = SpecConfirmationDriver() + + PurchaseLiveAutomation(driver, pause = {}).advanceToOrderConfirmation() + + assertEquals(listOf("确定"), driver.clicked) + assertEquals("order", driver.page) + } + + @Test + fun `ambiguous missing and unchanged spec confirmation stop safely`() { + val ambiguous = SpecConfirmationDriver(confirmLabels = listOf("确定", "确认")) + val ambiguousError = runCatching { PurchaseLiveAutomation(ambiguous, pause = {}).advanceToOrderConfirmation() } + .exceptionOrNull() as PurchaseLiveException + assertEquals("PURCHASE_SPEC_CONFIRM_TARGET_AMBIGUOUS", ambiguousError.code) + assertTrue(ambiguous.clicked.isEmpty()) + + val missing = SpecConfirmationDriver(confirmLabels = emptyList(), specPanelScrollable = true) + val missingError = runCatching { PurchaseLiveAutomation(missing, pause = {}).advanceToOrderConfirmation() } + .exceptionOrNull() as PurchaseLiveException + assertEquals("PURCHASE_SPEC_CONFIRM_TARGET_MISSING", missingError.code) + assertTrue(missing.clicked.isEmpty()) + + val unchanged = SpecConfirmationDriver(advanceAfterClick = false) + val unchangedError = runCatching { PurchaseLiveAutomation(unchanged, pause = {}).advanceToOrderConfirmation() } + .exceptionOrNull() as PurchaseLiveException + assertEquals("PURCHASE_SPEC_CONFIRMATION_UNCONFIRMED", unchangedError.code) + assertEquals(listOf("确定"), unchanged.clicked) + } + + @Test + fun `existing order confirmation does not click submit or payment controls`() { + val driver = SpecConfirmationDriver(startOnOrderPage = true) + + PurchaseLiveAutomation(driver, pause = {}).advanceToOrderConfirmation() + + assertTrue(driver.clicked.isEmpty()) + } + @Test fun `address is retagged verified and final order button can only be clicked once`() { val driver = LiveDriver() @@ -413,6 +453,68 @@ class PurchaseLiveAutomationTest { addressSuffix = "_cg11", ) + private class SpecConfirmationDriver( + private val confirmLabels: List = listOf("确定"), + private val advanceAfterClick: Boolean = true, + private val specPanelScrollable: Boolean = false, + startOnOrderPage: Boolean = false, + ) : PurchaseUiDriver { + var page = if (startOnOrderPage) "order" else "spec" + val clicked = mutableListOf() + + override fun capture(): UiSnapshot = if (page == "order") { + snapshot(listOf( + node("root", "", bounds = NodeBounds(0, 0, 1080, 2200)), + node("scroll", "", scrollable = true, bounds = NodeBounds(0, 400, 1080, 2100)), + node("summary", "已选 黑色 均码"), + node("quantity", "1", className = "android.widget.EditText"), + node("phone", "138****5678"), + node("submit", "提交订单", clickable = true), + node("payment", "微信支付"), + )) + } else { + snapshot(buildList { + add(node("root", "", bounds = NodeBounds(0, 0, 1080, 2200))) + if (specPanelScrollable) { + add(node("scroll", "", scrollable = true, bounds = NodeBounds(0, 400, 1080, 1500))) + } + add(node("title", "确认款式")) + add(node("summary", "已选 黑色 均码")) + val parent = "scroll".takeIf { specPanelScrollable } + val prefix = if (specPanelScrollable) "scroll/" else "" + add(node("${prefix}color-heading", "颜色分类", parentPath = parent)) + add(node("${prefix}color", "黑色", clickable = true, parentPath = parent)) + add(node("${prefix}size-heading", "尺码", parentPath = parent)) + add(node("${prefix}size", "均码", clickable = true, parentPath = parent)) + add(node("quantity", "1", className = "android.widget.EditText")) + confirmLabels.forEachIndexed { index, label -> add(node("confirm-$index", label, clickable = true)) } + }) + } + + override fun clickFresh(target: SnapshotNode): FreshActionResult { + clicked += target.label + if (advanceAfterClick && target.label in confirmLabels) page = "order" + return FreshActionResult.SUCCESS + } + + override fun tapPurchaseFresh(target: SnapshotNode) = FreshActionResult.FAILED + override fun inputFresh(target: SnapshotNode, value: String) = FreshActionResult.FAILED + override fun swipePurchase(direction: SwipeDirection, durationMs: Long) = false + override fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long) = false + override fun backPurchase() = false + + private fun snapshot(nodes: List) = UiSnapshot(PDD, ACTIVITY, nodes) + private fun node( + path: String, + text: String, + clickable: Boolean = false, + scrollable: Boolean = false, + className: String = "android.widget.TextView", + bounds: NodeBounds = NodeBounds(20, 100, 900, 180), + parentPath: String? = null, + ) = SnapshotNode(path, parentPath, text, null, null, className, bounds, clickable, scrollable, false, false, true, true) + } + private class LiveDriver( private val addressClipped: Boolean = false, private val duplicatePanels: Boolean = false,