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 1ee7877..51259d5 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 @@ -32,6 +32,7 @@ class PurchaseLiveException(val code: String, message: String, val actualUnitPri class PurchaseLiveAutomation( private val driver: PurchaseUiDriver, private val pause: (Long) -> Unit = Thread::sleep, + private val diagnostic: (String) -> Unit = {}, ) { private var submitAttempted = false private var savedAddressProof: ShippingAddressProof? = null @@ -126,7 +127,9 @@ class PurchaseLiveAutomation( } pause(1_000) snapshot = waitFor("PURCHASE_ADDRESS_PANEL_TIMEOUT", "收货地址页面打开超时,未创建订单") { - it.nodes.any { node -> node.visible && node.label.replace(" ", "") == "收货地址" } + it.packageName == PDD_PACKAGE && + it.nodes.any { node -> node.visible && node.label.replace(" ", "") == "收货地址" } && + addressModifyTargets(it).size == 1 } return editAndVerifyAddress(snapshot, addressSuffix) } @@ -488,10 +491,8 @@ class PurchaseLiveAutomation( structuralSubmitAnchor = null val modify = addressModifyTargets(panel) if (modify.size != 1) fail("PURCHASE_ADDRESS_EDIT_AMBIGUOUS", "没有找到唯一的地址修改按钮,未创建订单") - click(modify.single(), "修改地址") - val edit = waitFor("PURCHASE_ADDRESS_EDIT_TIMEOUT", "地址编辑页面打开超时,未创建订单") { snapshot -> - snapshot.nodes.any { it.visible && it.label.replace(" ", "").contains("详细地址") } - } + click(modify.single(), "修改地址", 1_000) + val edit = waitForAddressEditorReady() val editors = shippingAddressEditors(edit) if (editors.size != 1) fail("PURCHASE_ADDRESS_UPDATE_FAILED", "没有找到唯一的详细地址输入框,未创建订单") val current = editors.single().label.trim() @@ -504,7 +505,7 @@ 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(), "保存地址") + click(save.single(), "保存地址", 1_000) var savedEvidence = waitForStableAddressEditorExit() if (!savedAddressPageReady(savedEvidence, expected, suffix)) { if (isPurchaseConfirmationPanel(savedEvidence)) { @@ -715,13 +716,33 @@ class PurchaseLiveAutomation( }.distinctBy { it.path } } - private fun click(node: SnapshotNode, label: String) { + private fun waitForAddressEditorReady(): UiSnapshot { + var previous: SnapshotNode? = null + var previousActivity: String? = null + var count = 0 + repeat(50) { poll -> + val snapshot = driver.capture() + pageProblem(snapshot) + val editors = if (snapshot.packageName == PDD_PACKAGE) shippingAddressEditors(snapshot) else emptyList() + count = editors.size + diagnostic("addressEditorCandidates=$count;addressEditorPolls=${poll + 1}") + val editor = editors.singleOrNull() + if (editor != null && previous != null && snapshot.activityName == previousActivity && + editor.path == previous!!.path && editor.bounds == previous!!.bounds && editor.label == previous!!.label) return snapshot + previous = editor + previousActivity = snapshot.activityName + pause(200) + } + fail("PURCHASE_ADDRESS_UPDATE_FAILED", "详细地址输入框未稳定就绪,未创建订单 [candidates=$count]") + } + + private fun click(node: SnapshotNode, label: String, settleMillis: Long = 500) { when (driver.clickFresh(node)) { FreshActionResult.SUCCESS -> Unit FreshActionResult.AMBIGUOUS -> fail("RULE_AMBIGUOUS", "$label 不唯一") else -> fail("RULE_ACTION_FAILED", "$label 点击失败") } - pause(500) + pause(settleMillis) } private fun legacySubmitTargets(snapshot: UiSnapshot): List = uniqueClickable( 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 a640082..3a0363e 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 @@ -107,7 +107,7 @@ class PurchaseRehearsalExecutor( var addressProof: ShippingAddressProof? = null var irreversibleStarted = false val specSelectionProofs = mutableMapOf() - val live = PurchaseLiveAutomation(driver, pause) + val live = PurchaseLiveAutomation(driver, pause, panelDiagnostic) for (action in rule.actions) { // The immediate phase-two handoff can reuse the PDD page retained by // spec_probe. A later manual retry may start from Agent (or another diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/persistence/PurchaseDiagnosticStore.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/persistence/PurchaseDiagnosticStore.kt index 2b7a7f4..226e87a 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/persistence/PurchaseDiagnosticStore.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/persistence/PurchaseDiagnosticStore.kt @@ -58,7 +58,8 @@ class PurchaseDiagnosticStore(private val directory: File, private val now: () - private val VERSION = Regex("[0-9]+\\.[0-9]+\\.[0-9]+") private val FILE_NAME = Regex("[1-9][0-9]*_[A-Za-z0-9-]{1,80}\\.jsonl") private val numbers = setOf("specEntryCandidates", "explicit", "nested", "bottomPurchase", "entryReadyWaitPolls", - "entryReadyWaitMillis", "scrollables", "headings", "options", "entryWaitPolls", "entryWaitMillis") + "entryReadyWaitMillis", "scrollables", "headings", "options", "entryWaitPolls", "entryWaitMillis", + "addressEditorCandidates", "addressEditorPolls") private val booleans = setOf("panelAlreadyOpen", "reviewPage", "pageEvidence", "pageChanged", "summary", "quantity", "orderAction", "closeControl", "paymentArea") private val enums = mapOf( 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 c47dde3..5550b33 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 @@ -18,6 +18,35 @@ import org.junit.Assert.assertTrue import org.junit.Test class PurchaseLiveAutomationTest { + @Test fun `address editor waits for loaded unique stable input after one second transitions`() { + val driver = LiveDriver() + var polls = 0 + driver.editTransform = { snapshot -> + polls++ + when (polls) { + 1 -> snapshot.copy(nodes = snapshot.nodes.filterNot { it.path == "editor-address" }) + 2 -> snapshot.copy(nodes = snapshot.nodes.map { if (it.path == "editor-address") it.copy(text = "") else it }) + else -> snapshot + } + } + val pauses = mutableListOf() + PurchaseLiveAutomation(driver, pause = { pauses += it }).updateShippingAddress("_cg81") + assertTrue(polls >= 4) + assertEquals("editor-address", driver.lastInputTargetPath) + assertEquals(3, pauses.count { it == 1_000L }) + assertEquals(0, driver.submitClicks) + } + + @Test fun `persistently ambiguous address editors never receive input`() { + val driver = LiveDriver() + driver.editTransform = { snapshot -> snapshot.copy(nodes = snapshot.nodes + snapshot.nodes.single { it.path == "editor-address" }.copy(path = "duplicate-editor")) } + val error = runCatching { PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg81") }.exceptionOrNull() as PurchaseLiveException + assertEquals("PURCHASE_ADDRESS_UPDATE_FAILED", error.code) + assertTrue(error.message!!.contains("candidates=2")) + assertEquals(null, driver.lastInputTargetPath) + assertFalse(driver.clicked.contains("保存地址")) + } + @Test fun `saved address recognizes split dynamic footer outside spec scroller without a swipe`() { for (label in listOf("复购价,", "专享优惠,")) { @@ -902,13 +931,14 @@ class PurchaseLiveAutomationTest { var postSubmitCaptureCount = 0 val currentPage: String get() = page var structuredTransform: (UiSnapshot) -> UiSnapshot = { it } + var editTransform: (UiSnapshot) -> UiSnapshot = { it } override fun capture(): UiSnapshot { if (submitClicks > 0) { postSubmitCaptureCount++ if (pendingPostSubmitPages.isNotEmpty()) page = pendingPostSubmitPages.removeAt(0) } - return currentSnapshot().let { if (page == "structured") structuredTransform(it) else it } + return currentSnapshot().let { if (page == "structured") structuredTransform(it) else if (page == "edit") editTransform(it) else it } } private fun currentSnapshot(): UiSnapshot = when (page) {