From 650f3a6ba115bd22cdefef705eeab4535487cccd Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Thu, 8 Oct 2026 10:34:57 +0800 Subject: [PATCH] fix(android): wait for unique ready address editor (#366) --- .../automation/PurchaseLiveAutomation.kt | 46 +++- .../agent/PurchaseLiveAutomationTest.kt | 253 +++++++++++++++++- 2 files changed, 291 insertions(+), 8 deletions(-) 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 72e9495..ff7fc60 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 @@ -509,16 +509,12 @@ class PurchaseLiveAutomation( 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("详细地址") } - } - val editors = shippingAddressEditors(edit) - if (editors.size != 1) fail("PURCHASE_ADDRESS_UPDATE_FAILED", "没有找到唯一的详细地址输入框,未创建订单") - val current = editors.single().label.trim() + val editor = waitForInitialAddressEditor() + val current = editor.label.trim() val body = current.split(Regex("[-_]"), limit = 2).firstOrNull()?.trim().orEmpty() if (body.isBlank()) fail("PURCHASE_ADDRESS_UPDATE_FAILED", "当前详细地址无效,未创建订单") val expected = body + suffix - if (driver.inputFresh(editors.single(), expected) != FreshActionResult.SUCCESS) fail("PURCHASE_ADDRESS_UPDATE_FAILED", "详细地址修改失败,未创建订单") + if (driver.inputFresh(editor, expected) != FreshActionResult.SUCCESS) fail("PURCHASE_ADDRESS_UPDATE_FAILED", "详细地址修改失败,未创建订单") val stable = waitFor("PURCHASE_ADDRESS_INPUT_MISMATCH", "详细地址修改后回读不一致,未创建订单") { snapshot -> snapshot.nodes.count { it.visible && it.enabled && it.className?.endsWith("EditText") == true && it.label == expected } == 1 } @@ -539,6 +535,42 @@ class PurchaseLiveAutomation( return ShippingAddressProof(expected, suffix) } + /** Initial readiness only; return the same sampled node for the original value and fresh input anchor. */ + private fun waitForInitialAddressEditor(): SnapshotNode { + var titleSeen = false + var structural = emptyList() + var readyCount = 0 + repeat(50) { + val snapshot = driver.capture() + pageProblem(snapshot) + val labels = snapshot.nodes.filter { it.visible && it.label.replace(" ", "").contains("详细地址") } + titleSeen = titleSeen || labels.isNotEmpty() + // Keep the existing same-row/rightward region, but count empty and disabled inputs too. + // Their presence is structural ambiguity, even when just one input is ready. + structural = snapshot.nodes.filter { editor -> + editor.visible && editor.className?.endsWith("EditText") == true && + labels.any { label -> + editor.bounds.top <= label.bounds.bottom && + editor.bounds.bottom >= label.bounds.top && + editor.bounds.left >= label.bounds.left + } + }.distinctBy { it.path } + readyCount = structural.count { it.enabled && it.label.isNotBlank() } + if (structural.size == 1 && readyCount == 1) return structural.single() + pause(200) + } + val reason = when { + !titleSeen -> "title_missing" + structural.isEmpty() -> "editor_missing" + structural.size > 1 -> "editor_ambiguous" + !structural.single().enabled -> "editor_disabled" + else -> "editor_empty" + } + val diagnostic = "stage=address_editor_ready;reason=$reason;titleSeen=$titleSeen;structural=${structural.size};ready=$readyCount" + if (!titleSeen) fail("PURCHASE_ADDRESS_EDIT_TIMEOUT", "地址编辑页面打开超时,未创建订单 [$diagnostic]") + fail("PURCHASE_ADDRESS_UPDATE_FAILED", "详细地址输入框未就绪,未创建订单 [$diagnostic]") + } + private fun isPurchaseConfirmationPanel(snapshot: UiSnapshot): Boolean { if (snapshot.packageName != PDD_PACKAGE || shippingAddressEditors(snapshot).isNotEmpty()) return false val screen = PddScreenParser.parse(snapshot, PurchaseRehearsalExecutor.DEFAULT_COLLECTOR, "", null) 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 a1521d9..45da654 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 @@ -219,6 +219,243 @@ class PurchaseLiveAutomationTest { assertFalse(driver.clicked.any { it.contains("支付") }) } + @Test + fun `address editor waits for an input arriving after its title`() { + assertDelayedAddressEditor { snapshot, sample -> + if (sample <= 4) snapshot.copy(nodes = snapshot.nodes.filterNot { it.path == "editor-address" }) else snapshot + } + } + + @Test + fun `address editor waits for a populated value`() { + assertDelayedAddressEditor { snapshot, sample -> + snapshot.changeAddressEditor { if (sample <= 4) it.copy(text = " ") else it } + } + } + + @Test + fun `address editor waits for an enabled input`() { + assertDelayedAddressEditor { snapshot, sample -> + snapshot.changeAddressEditor { if (sample <= 4) it.copy(enabled = false) else it } + } + } + + @Test + fun `address editor tolerates more than three ambiguous frames before unique readiness`() { + assertDelayedAddressEditor { snapshot, sample -> + if (sample <= 4) snapshot.withSecondAddressEditor() else snapshot + } + } + + @Test + fun `address editor uses the value and node from the successful readiness frame`() { + val driver = LiveDriver(initialEditFrame = { snapshot, sample -> + snapshot.changeAddressEditor { it.copy(text = if (sample == 1) "" else "合成地址第${sample}帧-old") } + }) + + val proof = PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg366") + + assertEquals("合成地址第2帧_cg366", proof.expectedAddress) + assertEquals(2, driver.initialEditCaptures) + assertTrue(driver.lastInputTarget === driver.lastInitialEditSnapshot?.nodes?.single { it.path == "editor-address" }) + assertAddressEditorActions(driver, saved = true) + } + + @Test + fun `address editor structural ambiguity waits full budget even with only one ready input`() { + for (second in listOf<(SnapshotNode) -> SnapshotNode>( + { it.copy(text = "") }, { it.copy(enabled = false) }, + )) { + assertAddressEditorTimeout("editor_ambiguous", structural = 2, ready = 1) { snapshot, _ -> + snapshot.withSecondAddressEditor(second) + } + } + } + + @Test + fun `address editor final zero inputs reports scalar missing reason`() { + assertAddressEditorTimeout("editor_missing", 0, 0) { snapshot, _ -> + snapshot.copy(nodes = snapshot.nodes.filterNot { it.path == "editor-address" }) + } + } + + @Test + fun `address editor final empty input reports scalar empty reason`() { + assertAddressEditorTimeout("editor_empty", 1, 0) { snapshot, _ -> + snapshot.changeAddressEditor { it.copy(text = " ") } + } + } + + @Test + fun `address editor final disabled input reports scalar disabled reason`() { + assertAddressEditorTimeout("editor_disabled", 1, 0) { snapshot, _ -> + snapshot.changeAddressEditor { it.copy(enabled = false) } + } + } + + @Test + fun `address editor final multiple inputs reports scalar ambiguity reason`() { + assertAddressEditorTimeout("editor_ambiguous", 2, 2) { snapshot, _ -> snapshot.withSecondAddressEditor() } + } + + @Test + fun `address editor never seeing a title keeps edit timeout and one shared budget`() { + assertAddressEditorTimeout("title_missing", 0, 0, titleSeen = false) { snapshot, _ -> + snapshot.copy(nodes = snapshot.nodes.filterNot { it.path == "detail" }) + } + } + + @Test + fun `address editor remembers a title even when the final snapshot is empty`() { + assertAddressEditorTimeout("editor_missing", 0, 0) { snapshot, sample -> + if (sample == 1) snapshot.copy(nodes = snapshot.nodes.filterNot { it.path == "editor-address" }) + else snapshot.copy(nodes = emptyList()) + } + } + + @Test + fun `address editor late title does not start a second wait budget`() { + assertAddressEditorTimeout("editor_empty", 1, 0) { snapshot, sample -> + val emptyEditor = snapshot.changeAddressEditor { it.copy(text = "") } + if (sample < 50) emptyEditor.copy(nodes = emptyEditor.nodes.filterNot { it.path == "detail" }) else emptyEditor + } + } + + @Test + fun `address editor can become ready on the final sample of its shared budget`() { + val driver = LiveDriver(initialEditFrame = { snapshot, sample -> + if (sample < 50) snapshot.copy(nodes = emptyList()) else snapshot + }) + val editPauses = mutableListOf() + + PurchaseLiveAutomation(driver, pause = { + if (driver.currentPage == "edit" && driver.inputCount == 0) editPauses += it + }).updateShippingAddress("_cg366") + + assertEquals(50, driver.initialEditCaptures) + assertEquals(listOf(500L) + List(49) { 200L }, editPauses) + assertAddressEditorActions(driver, saved = true) + } + + @Test + fun `address editor excludes recipient phone invisible and leftward inputs and deduplicates paths`() { + val driver = LiveDriver(initialEditFrame = { snapshot, _ -> + val editor = snapshot.nodes.single { it.path == "editor-address" } + snapshot.copy(nodes = snapshot.nodes + listOf( + editor, + editor.copy(path = "hidden-input", visible = false), + editor.copy(path = "left-input", bounds = NodeBounds(0, 390, 10, 480)), + )) + }) + + PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg366") + + assertEquals(1, driver.initialEditCaptures) + assertEquals("editor-address", driver.lastInputTargetPath) + assertAddressEditorActions(driver, saved = true) + } + + @Test + fun `address editor small screen row overlap remains ambiguous`() { + assertAddressEditorTimeout("editor_ambiguous", 2, 2) { snapshot, _ -> + snapshot.copy(nodes = snapshot.nodes.map { + if (it.path == "editor-phone") it.copy(bounds = NodeBounds(180, 360, 900, 410)) else it + }) + } + } + + @Test + fun `address editor fresh input failures never retry or save`() { + for (result in FreshActionResult.values().filter { it != FreshActionResult.SUCCESS }) { + val driver = LiveDriver(inputResult = result) + + val error = runCatching { PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg366") } + .exceptionOrNull() as PurchaseLiveException + + assertEquals("PURCHASE_ADDRESS_UPDATE_FAILED", error.code) + assertEquals(1, driver.inputCount) + assertEquals(1, driver.initialEditCaptures) + assertEquals(listOf("138****5678", "修改"), driver.clicked) + assertEquals(0, driver.backCount) + assertEquals(0, driver.scopedSwipes + driver.genericSwipes + driver.submitClicks) + } + } + + @Test + fun `address editor page safety problem interrupts readiness without input`() { + val driver = LiveDriver(initialEditFrame = { snapshot, sample -> + if (sample == 1) snapshot.changeAddressEditor { it.copy(text = "") } + else snapshot.copy(nodes = snapshot.nodes.map { if (it.path == "title") it.copy(text = "立即支付") else it }) + }) + + val error = runCatching { PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg366") } + .exceptionOrNull() as PurchaseLiveException + + assertEquals("PURCHASE_PAYMENT_FORBIDDEN", error.code) + assertEquals(2, driver.initialEditCaptures) + assertAddressEditorActions(driver, saved = false) + } + + private fun assertDelayedAddressEditor(frame: (UiSnapshot, Int) -> UiSnapshot) { + val driver = LiveDriver(initialEditFrame = frame) + val editPauses = mutableListOf() + val automation = PurchaseLiveAutomation(driver, pause = { + if (driver.currentPage == "edit" && driver.inputCount == 0) editPauses += it + }) + + val proof = automation.updateShippingAddress("_cg366") + + assertTrue(proof.expectedAddress.endsWith("_cg366")) + assertEquals(5, driver.initialEditCaptures) + assertEquals(listOf(500L, 200L, 200L, 200L, 200L), editPauses) + assertEquals("editor-address", driver.lastInputTargetPath) + assertAddressEditorActions(driver, saved = true) + } + + private fun assertAddressEditorTimeout( + reason: String, + structural: Int, + ready: Int, + titleSeen: Boolean = true, + frame: (UiSnapshot, Int) -> UiSnapshot, + ) { + val driver = LiveDriver(initialEditFrame = frame) + val diagnostics = mutableListOf() + val editPauses = mutableListOf() + val automation = PurchaseLiveAutomation(driver, pause = { + if (driver.currentPage == "edit" && driver.inputCount == 0) editPauses += it + }, panelDiagnostic = diagnostics::add) + + val failure = runCatching { automation.updateShippingAddress("_cg366") }.exceptionOrNull() + assertTrue("Readiness must fail without changing the address", failure is PurchaseLiveException) + val error = failure as PurchaseLiveException + + assertEquals(if (titleSeen) "PURCHASE_ADDRESS_UPDATE_FAILED" else "PURCHASE_ADDRESS_EDIT_TIMEOUT", error.code) + val diagnostic = "stage=address_editor_ready;reason=$reason;titleSeen=$titleSeen;structural=$structural;ready=$ready" + assertTrue(error.message.orEmpty(), error.message.orEmpty().endsWith("[$diagnostic]")) + assertEquals(50, driver.initialEditCaptures) + assertEquals(listOf(500L) + List(50) { 200L }, editPauses) + val emitted = error.message.orEmpty() + diagnostics.joinToString() + listOf("广东", "骏景", "测试收货人", "13800000000", "editor-address", "_cg366").forEach { + assertFalse("Sensitive fixture value in readiness diagnostics", emitted.contains(it)) + } + assertAddressEditorActions(driver, saved = false) + } + + private fun assertAddressEditorActions(driver: LiveDriver, saved: Boolean) { + assertEquals(if (saved) 1 else 0, driver.inputCount) + assertEquals(listOf("138****5678", "修改") + if (saved) listOf("保存") else emptyList(), driver.clicked) + assertEquals(if (saved) 1 else 0, driver.backCount) + assertEquals(1, driver.addressPathClicks) + assertEquals(0, driver.addressTaps + driver.genericSwipes + driver.scopedSwipes + driver.submitClicks) + } + + private fun UiSnapshot.changeAddressEditor(change: (SnapshotNode) -> SnapshotNode): UiSnapshot = + copy(nodes = nodes.map { if (it.path == "editor-address") change(it) else it }) + + private fun UiSnapshot.withSecondAddressEditor(change: (SnapshotNode) -> SnapshotNode = { it }): UiSnapshot = + copy(nodes = nodes + change(nodes.single { it.path == "editor-address" }.copy(path = "editor-address-2"))) + @Test fun `split confirmation address keeps the unique saved task suffix proof`() { val driver = LiveDriver(splitConfirmationAddress = true) @@ -788,6 +1025,8 @@ class PurchaseLiveAutomationTest { private val orderDetailEntryAfterSubmit: Boolean = false, private val orderDetailEvidenceBelowFold: Boolean = false, private val duplicateOrderDetailEntry: Boolean = false, + private val initialEditFrame: (UiSnapshot, Int) -> UiSnapshot = { snapshot, _ -> snapshot }, + private val inputResult: FreshActionResult = FreshActionResult.SUCCESS, postSubmitCaptureSequence: List = emptyList(), ) : PurchaseUiDriver { private var page = "confirmation" @@ -802,6 +1041,10 @@ class PurchaseLiveAutomationTest { var addressTaps = 0 var addressPathClicks = 0 var lastInputTargetPath: String? = null + var lastInputTarget: SnapshotNode? = null + var lastInitialEditSnapshot: UiSnapshot? = null + var initialEditCaptures = 0 + var inputCount = 0 var backCount = 0 var postSubmitBackCount = 0 var pddRestoreCount = 0 @@ -814,7 +1057,12 @@ class PurchaseLiveAutomationTest { postSubmitCaptureCount++ if (pendingPostSubmitPages.isNotEmpty()) page = pendingPostSubmitPages.removeAt(0) } - return currentSnapshot() + val snapshot = currentSnapshot() + if (page == "edit" && inputCount == 0) { + initialEditCaptures++ + return initialEditFrame(snapshot, initialEditCaptures).also { lastInitialEditSnapshot = it } + } + return snapshot } private fun currentSnapshot(): UiSnapshot = when (page) { @@ -986,7 +1234,10 @@ class PurchaseLiveAutomationTest { } override fun inputFresh(target: SnapshotNode, value: String): FreshActionResult { + inputCount++ + lastInputTarget = target lastInputTargetPath = target.path + if (inputResult != FreshActionResult.SUCCESS) return inputResult address = value return FreshActionResult.SUCCESS }