diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/GoAutoAccessibilityService.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/GoAutoAccessibilityService.kt index be6c6f4..4ff2c6f 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/GoAutoAccessibilityService.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/GoAutoAccessibilityService.kt @@ -248,6 +248,8 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto checked = node.isChecked, enabled = node.isEnabled, visible = node.isVisibleToUser, + hintText = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) node.hintText?.toString() else null, + showingHintText = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) node.isShowingHintText else null, ) for (index in 0 until node.childCount) { node.getChild(index)?.let { snapshot(it, "$path/$index", path) } diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PddProductDetailCollector.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PddProductDetailCollector.kt index 4f49eef..d298d72 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PddProductDetailCollector.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PddProductDetailCollector.kt @@ -27,6 +27,9 @@ data class SnapshotNode( val checked: Boolean, val enabled: Boolean, val visible: Boolean, + // In-memory accessibility metadata; not part of result or diagnostic payloads. + val hintText: String? = null, + val showingHintText: Boolean? = null, ) { val label: String get() = text?.trim().takeUnless { it.isNullOrEmpty() } ?: contentDescription?.trim().orEmpty() 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..110c499 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 @@ -42,6 +42,7 @@ class PurchaseLiveAutomation( private val pause: (Long) -> Unit = Thread::sleep, /** Boolean/count-only diagnostics; never receives address text. */ private val panelDiagnostic: (String) -> Unit = {}, + private val monotonicClockMs: () -> Long = { System.nanoTime() / 1_000_000L }, ) { private var submitAttempted = false var lastOrderReadFailure: PurchaseOrderReadFailure? = null @@ -366,48 +367,83 @@ class PurchaseLiveAutomation( var consecutiveEmptySnapshots = 0 var unpaidStartSample: Int? = null var unpaidSwipes = 0 - repeat(ORDER_RESULT_MAX_SAMPLES + ORDER_RESULT_UNPAID_MAX_SAMPLES) { index -> + var paymentBackSucceededAt: Long? = null + var orderContextObserved = false + // Extra observations cannot reopen navigation/scroll budgets. Late unpaid evidence + // still receives its existing independent budget below. + var passiveExtension = false + fun observationRemainingMs(): Long? { + val startedAt = paymentBackSucceededAt ?: return null + if (unpaidStartSample != null || (orderContextObserved && !passiveExtension)) return null + return ORDER_RESULT_POST_PAYMENT_OBSERVATION_MS - (monotonicClockMs() - startedAt) + } + fun observePause(durationMs: Long) { + val remaining = observationRemainingMs() + val duration = if (remaining == null) durationMs else minOf(durationMs, remaining.coerceAtLeast(0)) + if (duration > 0) pause(duration) + } + var nextSample = 0 + while (true) { + val index = nextSample++ // The first unpaid page receives its own bounded budget, even after a long handoff. // Never reset it on repeated labels or navigation back to payment. val unpaidStart = unpaidStartSample - if ((unpaidStart == null && index >= ORDER_RESULT_MAX_SAMPLES) || + if (unpaidStart == null && index >= ORDER_RESULT_MAX_SAMPLES && + paymentBackSucceededAt != null && !orderContextObserved) { + passiveExtension = true + } + if ((observationRemainingMs()?.let { it <= 0 } == true) || + (unpaidStart == null && index >= ORDER_RESULT_MAX_SAMPLES && !passiveExtension) || (unpaidStart != null && index - unpaidStart >= ORDER_RESULT_UNPAID_MAX_SAMPLES)) { val failure = orderEvidenceFailure(labels) return unknown(failure.code, failure.message) } val snapshot = driver.capture() + // A blocking capture can finish after the deadline. Do not act on or accept that frame. + if (observationRemainingMs()?.let { it <= 0 } == true) { + val failure = orderEvidenceFailure(labels) + return unknown(failure.code, failure.message) + } if (snapshot.packageName.isNullOrBlank()) { consecutiveEmptySnapshots++ if (consecutiveEmptySnapshots >= ORDER_RESULT_MAX_EMPTY_SAMPLES) { return unknown("PURCHASE_ORDER_EMPTY_TIMEOUT", "等待订单页面时无障碍窗口持续为空") } - pause(ORDER_RESULT_SAMPLE_INTERVAL_MS) - return@repeat + observePause(ORDER_RESULT_SAMPLE_INTERVAL_MS) + continue } consecutiveEmptySnapshots = 0 val currentLabels = snapshot.nodes.filter { it.visible }.map { it.label } if (isKnownAndroidWechatChooser(snapshot, currentLabels)) { + if (passiveExtension && !backedOutOfChooser) { + val failure = orderEvidenceFailure(labels) + return unknown(failure.code, failure.message) + } if (backedOutOfChooser || !driver.backPurchase()) { return unknown("PURCHASE_ORDER_CHOOSER_BACK_FAILED", "系统应用选择页无法安全返回") } backedOutOfChooser = true - pause(500) - return@repeat + observePause(500) + continue } if (snapshot.packageName == WECHAT_PACKAGE) { if (!restoredFromWechat) { + if (passiveExtension) { + val failure = orderEvidenceFailure(labels) + return unknown(failure.code, failure.message) + } if (!driver.bringPddToForeground()) { return unknown("PURCHASE_ORDER_WECHAT_RESTORE_FAILED", "从微信恢复拼多多的请求失败") } restoredFromWechat = true - pause(500) - return@repeat + observePause(500) + continue } if (pddObservedAfterWechatRestore || ++wechatRestorePendingSamples >= ORDER_RESULT_WECHAT_RESTORE_MAX_SAMPLES) { return unknown("PURCHASE_ORDER_WECHAT_RESTORE_TIMEOUT", "从微信恢复拼多多后未在限定时间到达订单页面") } - pause(ORDER_RESULT_SAMPLE_INTERVAL_MS) - return@repeat + observePause(ORDER_RESULT_SAMPLE_INTERVAL_MS) + continue } if (snapshot.packageName != PDD_PACKAGE) { return unknown("PURCHASE_ORDER_UNEXPECTED_APP", "核单期间出现未授权应用") @@ -426,7 +462,8 @@ class PurchaseLiveAutomation( if (!driver.backPurchase()) { return unknown("PURCHASE_ORDER_PAYMENT_BACK_FAILED", "支付页无法安全返回订单详情") } - pause(500) + paymentBackSucceededAt = monotonicClockMs() + observePause(500) } else { consecutivePaymentSamplesAfterBack++ if (consecutivePaymentSamplesAfterBack >= ORDER_RESULT_PAYMENT_POST_BACK_MAX_SAMPLES) { @@ -437,20 +474,21 @@ class PurchaseLiveAutomation( "consecutivePaymentSamplesAfterBack=$consecutivePaymentSamplesAfterBack]", ) } - pause(ORDER_RESULT_SAMPLE_INTERVAL_MS) + observePause(ORDER_RESULT_SAMPLE_INTERVAL_MS) } - return@repeat + continue } consecutivePaymentSamplesAfterBack = 0 + if (orderContextVisible) orderContextObserved = true if (unpaidContextVisible) { paymentPageObserved = true if (unpaidStartSample == null) unpaidStartSample = index } if (!orderContextVisible && !unpaidContextVisible) { - if (unpaidStartSample != null) { - // After reaching the unpaid page only observe; never click a newly exposed control. - pause(ORDER_RESULT_SAMPLE_INTERVAL_MS) - return@repeat + if (unpaidStartSample != null || passiveExtension) { + // Neither unpaid reading nor the added observation budget permits a new click. + observePause(ORDER_RESULT_SAMPLE_INTERVAL_MS) + continue } val entries = orderDetailEntryTargets(snapshot) if (entries.size > 1) { @@ -466,27 +504,25 @@ class PurchaseLiveAutomation( else -> return unknown("PURCHASE_ORDER_DETAIL_ENTRY_FAILED", "订单详情入口点击失败,已停止只读核单") } orderDetailEntryOpened = true - pause(500) - return@repeat + observePause(500) + continue } - pause(ORDER_RESULT_SAMPLE_INTERVAL_MS) - return@repeat + observePause(ORDER_RESULT_SAMPLE_INTERVAL_MS) + continue } currentLabels.forEach(labels::add) parseOrderEvidence(labels)?.let { return it } if (unpaidStartSample != null && unpaidSwipes < ORDER_RESULT_UNPAID_MAX_SWIPES) { unpaidSwipes++ driver.swipePurchase(SwipeDirection.UP, 400) - pause(ORDER_RESULT_UNPAID_SETTLE_MS) - return@repeat + observePause(ORDER_RESULT_UNPAID_SETTLE_MS) + continue } - if (unpaidStartSample == null && index > 0 && index % ORDER_RESULT_SCROLL_SAMPLE_INTERVAL == 0) { + if (!passiveExtension && unpaidStartSample == null && index > 0 && index % ORDER_RESULT_SCROLL_SAMPLE_INTERVAL == 0) { driver.swipePurchase(SwipeDirection.UP, 400) } - pause(ORDER_RESULT_SAMPLE_INTERVAL_MS) + observePause(ORDER_RESULT_SAMPLE_INTERVAL_MS) } - val failure = orderEvidenceFailure(labels) - return unknown(failure.code, failure.message) } // orderEvidenceFailure 之外的路径(选择器卡住、微信恢复超时等)同样要带上 @@ -509,16 +545,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.text.orEmpty().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 +571,47 @@ 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 { + fun isHint(editor: SnapshotNode): Boolean { + val hint = editor.hintText?.trim().orEmpty() + return editor.showingHintText == true || (hint.isNotEmpty() && editor.text?.trim() == hint) + } + 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.text.isNullOrBlank() && !isHint(it) } + 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" + structural.single().text.isNullOrBlank() -> "editor_empty" + else -> "editor_hint" + } + 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) @@ -1038,6 +1111,7 @@ class PurchaseLiveAutomation( val ANDROID_CHOOSER_TITLES = setOf("选择要使用的应用", "使用以下应用打开", "完成操作时使用") val PDD_PAYMENT_ACTIVITIES = setOf("com.xunmeng.pinduoduo.app_pay.core.PayActivity") const val ORDER_RESULT_MAX_SAMPLES = 60 + const val ORDER_RESULT_POST_PAYMENT_OBSERVATION_MS = 30_000L const val ORDER_RESULT_MAX_EMPTY_SAMPLES = 15 const val ORDER_RESULT_WECHAT_RESTORE_MAX_SAMPLES = 15 const val ORDER_RESULT_PAYMENT_POST_BACK_MAX_SAMPLES = 25 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..d3b55e5 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,354 @@ import org.junit.Assert.assertTrue import org.junit.Test class PurchaseLiveAutomationTest { + @Test + fun `hint delayed address update preserves fresh payment back budget for late order evidence`() { + val observationDriver = OrderObservationDriver { sample -> + observationPage(when { + sample == 1 -> "payment" + sample < 140 -> "loading" + else -> "complete" + }) + }.apply { backDuration = 4_000 } + lateinit var addressDriver: LiveDriver + addressDriver = LiveDriver(initialEditFrame = { snapshot, sample -> + assertEquals(0, addressDriver.inputCount) + assertFalse(addressDriver.clicked.contains("保存")) + if (sample <= 4) observationDriver.elapsed += 10_000 + snapshot.changeAddressEditor { + it.copy( + text = if (sample <= 4) "合成提示占位标记" else "合成测试区域示例路段1号-old", + hintText = "合成提示占位标记", + showingHintText = sample <= 4, + ) + } + }) + var observingOrder = false + val driver = object : PurchaseUiDriver by addressDriver { + override fun capture(): UiSnapshot = + if (observingOrder) observationDriver.capture() else addressDriver.capture() + override fun backPurchase(): Boolean = + if (observingOrder) observationDriver.backPurchase() else addressDriver.backPurchase() + override fun clickFresh(target: SnapshotNode): FreshActionResult = + if (observingOrder) observationDriver.clickFresh(target) else addressDriver.clickFresh(target) + override fun swipePurchase(direction: SwipeDirection, durationMs: Long): Boolean = + if (observingOrder) observationDriver.swipePurchase(direction, durationMs) + else addressDriver.swipePurchase(direction, durationMs) + override fun bringPddToForeground(): Boolean = + if (observingOrder) observationDriver.bringPddToForeground() else addressDriver.bringPddToForeground() + } + val automation = PurchaseLiveAutomation( + driver, + pause = { observationDriver.elapsed += it }, + monotonicClockMs = { observationDriver.elapsed }, + ) + + val address = automation.updateShippingAddress("_cg366") + assertEquals(5, addressDriver.initialEditCaptures) + assertEquals("合成测试区域示例路段1号-old", addressDriver.lastInputTarget?.text) + assertTrue(address.expectedAddress.endsWith("_cg366")) + assertFalse(address.expectedAddress.contains("合成提示占位标记")) + assertAddressEditorActions(addressDriver, saved = true) + assertTrue(observationDriver.elapsed > 40_000) + assertEquals("_cg366", automation.finalConfirmation(input().copy(addressSuffix = "_cg366"), address).addressSuffix) + automation.submitOrderOnce() + val clicksBeforeObservation = addressDriver.clicked.toList() + observingOrder = true + + val order = automation.readOrderResult() + + assertEquals("PDD-DEMO-365", order?.orderNo) + assertEquals("2026-10-08T03:21:43Z", order?.submittedAt) + assertEquals(140, observationDriver.captures) + val elapsedAfterBack = observationDriver.elapsed - observationDriver.backCompletedAt!! + assertTrue(elapsedAfterBack in 28_000L until 30_000L) + assertTrue(elapsedAfterBack + observationDriver.backDuration > 30_000) + assertObservationActions(observationDriver, backs = 1) + assertEquals(1, addressDriver.inputCount) + assertEquals(1, addressDriver.clicked.count { it == "保存" }) + assertEquals(1, addressDriver.submitClicks) + assertEquals(clicksBeforeObservation, addressDriver.clicked) + assertEquals(0, addressDriver.genericSwipes + addressDriver.scopedSwipes) + assertFalse(addressDriver.clicked.any { it.contains("支付") }) + } + + @Test + fun `payment back observes late complete order beyond original sample budget`() { + val driver = OrderObservationDriver { sample -> + when { + sample == 1 -> observationPage("payment") + sample < 100 -> observationPage("loading") + else -> observationPage("complete") + } + } + val order = observationAutomation(driver).readOrderResult() + + assertEquals("PDD-DEMO-365", order?.orderNo) + assertEquals(100, driver.captures) + assertEquals(1, driver.backs) + assertEquals(0, driver.swipes) + assertEquals(0, driver.clicks) + assertEquals(0, driver.foregroundRequests) + } + + @Test + fun `deadline starts at late successful back and never resets on incomplete evidence or jitter`() { + val driver = OrderObservationDriver { sample -> + observationPage(when { + sample < 58 -> "loading" + sample == 58 -> "payment" + sample > 60 && sample % 3 == 0 -> "incomplete" + else -> "loading" + }) + }.apply { backDuration = 4_000; captureDuration = 25 } + val automation = observationAutomation(driver, pauseOverhead = 17) + + assertEquals(null, automation.readOrderResult()) + assertTrue(driver.captures > 100) + assertEquals(driver.backCompletedAt!! + 30_000 + 17, driver.elapsed) + assertTrue(automation.lastOrderReadFailure!!.paymentPageObserved) + assertObservationActions(driver, backs = 1) + } + + @Test + fun `capture that crosses deadline cannot accept late complete evidence or trigger unpaid scroll`() { + for (latePage in listOf("complete", "unpaid", "entry", "chooser", "wechat")) { + lateinit var driver: OrderObservationDriver + driver = OrderObservationDriver { sample -> + if (sample == 1) { + driver.captureDuration = 30_000 + observationPage("payment") + } else observationPage(latePage) + } + val automation = observationAutomation(driver) + + assertEquals(null, automation.readOrderResult()) + assertEquals(2, driver.captures) + assertEquals(30_500L, driver.elapsed) + assertObservationActions(driver, backs = 1) + } + } + + @Test + fun `slow captures exhaust fixed observation deadline before original sixty samples`() { + val driver = OrderObservationDriver { sample -> observationPage(if (sample == 1) "payment" else "loading") } + .apply { captureDuration = 1_000 } + val automation = observationAutomation(driver) + + assertEquals(null, automation.readOrderResult()) + assertTrue(driver.captures < 60) + assertTrue(driver.elapsed >= driver.backCompletedAt!! + 30_000) + assertTrue(driver.elapsed < driver.backCompletedAt!! + 31_000) + assertObservationActions(driver, backs = 1) + } + + @Test + fun `incomplete context first appearing during extension stays passive until late time arrives`() { + val driver = OrderObservationDriver { sample -> observationPage(when { + sample == 1 -> "payment" + sample < 80 -> "loading" + sample < 100 -> "incomplete" + else -> "complete" + }) } + + assertEquals("PDD-DEMO-365", observationAutomation(driver).readOrderResult()?.orderNo) + assertEquals(100, driver.captures) + assertObservationActions(driver, backs = 1) + } + + @Test + fun `late detail entry chooser and WeChat cannot add navigation during extension`() { + for (latePage in listOf("entry", "chooser", "wechat")) { + val driver = OrderObservationDriver { sample -> observationPage(when { + sample == 1 -> "payment" + sample <= 60 -> "loading" + else -> latePage + }) } + val automation = observationAutomation(driver) + assertEquals(null, automation.readOrderResult()) + assertEquals("PURCHASE_ORDER_CONTEXT_NOT_FOUND", automation.lastOrderReadFailure?.code) + assertTrue(driver.captures > 60) + assertObservationActions(driver, backs = 1) + } + } + + @Test + fun `previous chooser and WeChat recovery retain repeat failures during extension`() { + for (kind in listOf("chooser", "wechat")) { + val driver = OrderObservationDriver { sample -> observationPage(when { + sample == 1 || sample > 60 -> kind + sample == 2 -> "payment" + else -> "loading" + }) } + val automation = observationAutomation(driver) + assertEquals(null, automation.readOrderResult()) + assertEquals( + if (kind == "chooser") "PURCHASE_ORDER_CHOOSER_BACK_FAILED" else "PURCHASE_ORDER_WECHAT_RESTORE_TIMEOUT", + automation.lastOrderReadFailure?.code, + ) + assertEquals(61, driver.captures) + assertEquals(if (kind == "chooser") 2 else 1, driver.backs) + assertEquals(if (kind == "wechat") 1 else 0, driver.foregroundRequests) + assertEquals(0, driver.swipes) + assertEquals(0, driver.clicks) + } + } + + @Test + fun `late unpaid context retains its independent thirty samples and four swipes`() { + val driver = OrderObservationDriver { sample -> observationPage(when { + sample == 1 -> "payment" + sample < 140 -> "loading" + else -> "unpaid" + }) } + val automation = observationAutomation(driver) + + assertEquals(null, automation.readOrderResult()) + assertEquals(169, driver.captures) + assertTrue(driver.elapsed > 30_000) + assertObservationActions(driver, backs = 1, swipes = 4) + } + + @Test + fun `unpaid markers alone never activate payment back extension`() { + val driver = OrderObservationDriver { observationPage("unpaid") } + val automation = observationAutomation(driver) + + assertEquals(null, automation.readOrderResult()) + assertEquals(30, driver.captures) + assertTrue(automation.lastOrderReadFailure!!.paymentPageObserved) + assertObservationActions(driver, swipes = 4) + } + + @Test + fun `missing payment back retains original sixty samples and existing context scroll budget`() { + for (kind in listOf("loading", "incomplete")) { + val driver = OrderObservationDriver { observationPage(kind) } + assertEquals(null, observationAutomation(driver).readOrderResult()) + assertEquals(60, driver.captures) + assertObservationActions(driver, swipes = if (kind == "incomplete") 3 else 0) + } + } + + @Test + fun `order context within original budget retains original sampling and gestures after back`() { + val driver = OrderObservationDriver { sample -> observationPage(if (sample == 1) "payment" else "incomplete") } + assertEquals(null, observationAutomation(driver).readOrderResult()) + assertEquals(60, driver.captures) + assertObservationActions(driver, backs = 1, swipes = 3) + } + + @Test + fun `failed payment back stops immediately without activating observation`() { + val driver = OrderObservationDriver { observationPage("payment") }.apply { backSucceeds = false } + val automation = observationAutomation(driver) + assertEquals(null, automation.readOrderResult()) + assertEquals("PURCHASE_ORDER_PAYMENT_BACK_FAILED", automation.lastOrderReadFailure?.code) + assertEquals(1, driver.captures) + assertEquals(0L, driver.elapsed) + assertObservationActions(driver, backs = 1) + } + + @Test + fun `extension preserves repeated payment empty and unexpected app early failures`() { + for ((kind, code, expectedCaptures) in listOf( + Triple("payment", "PURCHASE_ORDER_PAYMENT_REPEATED", 85), + Triple("empty", "PURCHASE_ORDER_EMPTY_TIMEOUT", 75), + Triple("unexpected", "PURCHASE_ORDER_UNEXPECTED_APP", 61), + )) { + val driver = OrderObservationDriver { sample -> observationPage(when { + sample == 1 -> "payment" + sample <= 60 -> "loading" + else -> kind + }) } + val automation = observationAutomation(driver) + assertEquals(null, automation.readOrderResult()) + assertEquals(code, automation.lastOrderReadFailure?.code) + assertEquals(expectedCaptures, driver.captures) + assertObservationActions(driver, backs = 1) + } + } + + private fun observationAutomation(driver: OrderObservationDriver, pauseOverhead: Long = 0) = PurchaseLiveAutomation( + driver, + pause = { driver.elapsed += it + pauseOverhead }, + monotonicClockMs = { driver.elapsed }, + ) + + private fun assertObservationActions(driver: OrderObservationDriver, backs: Int = 0, swipes: Int = 0) { + assertEquals(backs, driver.backs) + assertEquals(swipes, driver.swipes) + assertEquals(0, driver.clicks) + assertEquals(0, driver.foregroundRequests) + } + + private class OrderObservationDriver( + val frame: (Int) -> UiSnapshot, + ) : PurchaseUiDriver by LiveDriver() { + var elapsed = 0L + var captures = 0 + var backs = 0 + var swipes = 0 + var clicks = 0 + var foregroundRequests = 0 + var captureDuration = 0L + var backDuration = 0L + var backCompletedAt: Long? = null + var backSucceeds = true + override fun capture(): UiSnapshot { + elapsed += captureDuration + return frame(++captures) + } + override fun backPurchase(): Boolean { + backs++ + elapsed += backDuration + if (backSucceeds) backCompletedAt = elapsed + return backSucceeds + } + override fun swipePurchase(direction: SwipeDirection, durationMs: Long): Boolean { + swipes++ + return true + } + override fun clickFresh(target: SnapshotNode): FreshActionResult { + clicks++ + return FreshActionResult.SUCCESS + } + override fun bringPddToForeground(): Boolean { + foregroundRequests++ + return true + } + } + + private fun observationPage(kind: String): UiSnapshot { + val labels = when (kind) { + "payment" -> listOf("立即支付") + "complete" -> listOf("订单号:PDD-DEMO-365", "下单时间:2026-10-08 11:21:43", "立即支付") + "incomplete" -> listOf("订单号:PDD-DEMO-365", "立即支付") + "unpaid" -> listOf("待付款") + "entry" -> listOf("查看订单") + "chooser" -> listOf("选择要使用的应用", "微信") + else -> emptyList() + } + return UiSnapshot( + when (kind) { + "empty" -> null + "wechat" -> "com.tencent.mm" + "unexpected" -> "com.example.unexpected" + "chooser" -> "android" + else -> PDD + }, + when (kind) { + "payment" -> "com.xunmeng.pinduoduo.app_pay.core.PayActivity" + "chooser" -> "com.android.internal.app.ChooserActivity" + else -> ACTIVITY + }, + labels.map { text -> + SnapshotNode(text, null, text, null, null, "android.widget.TextView", + NodeBounds(0, 100, 500, 180), true, false, false, false, true, true) + }, + ) + } + @Test fun `two second payment transition and two needed scrolls yield order and payable amount without clicks`() { val driver = ReadOnlyOrderDriver(paymentMs = 2500) @@ -219,6 +567,338 @@ 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 description cannot substitute for empty text`() { + for (text in listOf(null, "", " ")) { + assertAddressEditorTimeout("editor_empty", 1, 0) { snapshot, _ -> + snapshot.changeAddressEditor { it.copy(text = text, contentDescription = "合成说明占位标记") } + } + } + } + + @Test + fun `address editor waits until hint is replaced by real text`() { + assertDelayedAddressEditor { snapshot, sample -> + snapshot.changeAddressEditor { + it.copy( + text = if (sample <= 4) "合成提示占位标记" else it.text, + hintText = "合成提示占位标记", + showingHintText = sample <= 4, + ) + } + } + } + + @Test + fun `address editor persistent hint text fails after the full budget`() { + assertAddressEditorTimeout("editor_hint", 1, 0) { snapshot, _ -> + snapshot.changeAddressEditor { + it.copy(text = "合成提示占位标记", hintText = "合成提示占位标记", showingHintText = true) + } + } + } + + @Test + fun `address editor rejects text equal to trimmed hint despite false or absent showing flag`() { + for (showing in listOf(false, null)) { + assertAddressEditorTimeout("editor_hint", 1, 0) { snapshot, _ -> + snapshot.changeAddressEditor { + it.copy(text = " 合成提示占位标记 ", hintText = " 合成提示占位标记 ", showingHintText = showing) + } + } + } + } + + @Test + fun `address editor showing hint flag blocks nonmatching or missing hint text`() { + for (hint in listOf(null, "", "合成提示占位标记")) { + assertAddressEditorTimeout("editor_hint", 1, 0) { snapshot, _ -> + snapshot.changeAddressEditor { it.copy(hintText = hint, showingHintText = true) } + } + } + } + + @Test + fun `address editor empty text remains empty even when hint and description are present`() { + assertAddressEditorTimeout("editor_empty", 1, 0) { snapshot, _ -> + snapshot.changeAddressEditor { + it.copy(text = " ", contentDescription = "合成说明占位标记", hintText = "合成提示占位标记", showingHintText = true) + } + } + } + + @Test + fun `address editor missing hint metadata remains compatible and uses actual text`() { + val driver = LiveDriver(initialEditFrame = { snapshot, _ -> + snapshot.changeAddressEditor { + assertEquals(null, it.hintText) + assertEquals(null, it.showingHintText) + it.copy(text = " 合成真实地址-old ", contentDescription = "合成说明占位标记") + } + }) + + val proof = PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg366") + + assertEquals("合成真实地址_cg366", proof.expectedAddress) + assertEquals(1, driver.initialEditCaptures) + assertTrue(driver.lastInputTarget === driver.lastInitialEditSnapshot?.nodes?.single { it.path == "editor-address" }) + assertAddressEditorActions(driver, saved = true) + } + + @Test + fun `address editor accepts actual text with absent false or blank hint metadata`() { + for (showing in listOf(null, false)) { + for (hint in listOf(null, "", " ", "合成提示占位标记")) { + val driver = LiveDriver(initialEditFrame = { snapshot, _ -> + snapshot.changeAddressEditor { it.copy(hintText = hint, showingHintText = showing) } + }) + + val proof = PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg366") + + assertTrue(proof.expectedAddress.endsWith("_cg366")) + assertEquals(1, driver.initialEditCaptures) + assertAddressEditorActions(driver, saved = true) + } + } + } + + @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 +1468,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 +1484,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 +1500,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 +1677,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 } diff --git a/docs/03-business-rules-and-glossary.md b/docs/03-business-rules-and-glossary.md index e48c979..7e2132c 100644 --- a/docs/03-business-rules-and-glossary.md +++ b/docs/03-business-rules-and-glossary.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Business-Rules-and-Glossary wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Business-Rules-and-Glossary.- -wiki_revision: 233b72e9e2415a2456bb8dd27c770df881960db4 -synchronized_at: 2026-10-08T01:42:04Z +wiki_revision: 25fdd0c4837882dccb27c7b9e740d6d97065d490 +synchronized_at: 2026-10-08T03:25:26Z @@ -670,10 +670,21 @@ Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态 实现分支 fix/325-order-result,2026-09-19;未部署、未真机验收。下单点击及不可逆边界不改。已知 PayActivity 允许一次原有 Back,随后从3次放宽为25次200ms采样(约5秒等待,不含页面捕获耗时),持续无证据仍为 PURCHASE_ORDER_PAYMENT_REPEATED。微信恢复、系统选择器、空窗口和陌生应用的既有边界保留。 -过渡阶段最多60次采样。首次识别到待付款证据后单独分配30次采样预算,不被之前绕路消耗,不因文字反复出现重置;核单总采样上限90次。缺少唯一订单号或时间时立即向上滑动读取下方内容,最多4次、手势400ms、每次后等500ms;读全立即停止,已有完整字段不滑动。该待付款读取分支不点击任何控件,也不点击付款。纯支付页不使用该滑动分支;出现可读待付款证据优先于复用的 Activity 名称。按采样间隔约18秒加有限手势/额外等待,实际耗时还含capture等调用;未改变2分钟任务租约或5分钟唤醒锁,也不宣称新增了执行器总体墙钟超时机制。 +原 #325 过渡阶段最多60次采样。首次识别到待付款证据后单独分配30次采样预算,不被之前绕路消耗,不因文字反复出现重置;原核单总采样上限90次。#365 工单分支增加下节所述成功支付页返回后的定时观察例外,不能再把90次视为该例外路径的总上限。缺少唯一订单号或时间时立即向上滑动读取下方内容,最多4次、手势400ms、每次后等500ms;读全立即停止,已有完整字段不滑动。该待付款读取分支不点击任何控件,也不点击付款。纯支付页不使用该滑动分支;出现可读待付款证据优先于复用的 Activity 名称。按采样间隔约18秒加有限手势/额外等待,实际耗时还含capture等调用;未改变2分钟任务租约或5分钟唤醒锁,也不宣称新增了执行器总体墙钟超时机制。 应付金额从同一订单的可见标签集合中读取,兼容“应付:,13元”等标点;不跨无关联节点拼接,不读拼单价、优惠或实付。父子重复同金额去重,多值/缺失/无效/溢出省略,不因金额失败丢弃订单号与时间。仅存Admin,不推断支付、不改SYB金额、不修改地址行为。历史回填及界面语义未在本单统一,详见API契约#325补充。 +## 支付页安全返回后的核单观察(#365) + +实现绑定 `37714d39b5ac8ff720cfa1d0d2609f9a8af3fbd9`,工单分支已实现,未合并 main、未安装或发布;自动化验证不代表真机核单成功率已改善。仅 Android 变化,Admin、Server、数据库和上传状态契约不改。 + +- 只有识别到 PDD 支付页且既有一次 Back 成功后,才记录单调时钟起点。在尚未取得订单/待付款上下文时,允许观察至该起点后约30秒,不因反复采样或上下文缺失重置;不能用 paymentPageObserved 代替成功返回事实。 +- 无成功返回,或在原60次内已取得普通订单上下文的路径,保持原预算。返回失败、持续支付页25次、空窗口15次、微信恢复失败和未授权应用等独立失败保持,不保证每种失败等满30秒。 +- 原60次之后的新增观察只采样,不因迟到的详情入口、选择器、微信页面新增点击、返回、拉前台或滑动。延长期出现非待付款不完整订单上下文,可只读等待迟到字段至同一期限;完整证据立即结束。 +- 首次出现待付款证据后,仍使用原独立30次采样及最多4次有界读取滑动;不新增该分支额度。该分支有自己的预算,30秒不是整个核单方法的全局时限。 +- 固定期限检查包含采样耗时与既有暂停,单次 capture 本身不能被此机制中断;不承诺严格墙钟总耗时。不增加采样硬上限来提前替代30秒期限。 +- 仍无完整证据时沿用已有失败原因与 order_result_unknown 处理,不自动补录、重试采购或再次创建订单;永久禁止付款。这只是对加载慢的有界缓解,不是对历史未知原因的根因修复。 + ## 采购订单资料完整性及简化单号回填(#326) 用户2026-09-19确认内部系统简化流程,原型v1通过。实现3a2472d,尚未发布或真实SYB验收。 diff --git a/docs/06-troubleshooting.md b/docs/06-troubleshooting.md index 5490939..caf14c0 100644 --- a/docs/06-troubleshooting.md +++ b/docs/06-troubleshooting.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Troubleshooting wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Troubleshooting -wiki_revision: ac3a0b74704f398cec54149267089c87c8375a3e -synchronized_at: 2026-10-06T09:50:33Z +wiki_revision: fee37efc79b644ecfa3237283dd45f0a09796f44 +synchronized_at: 2026-10-08T03:25:33Z # 故障排查 @@ -114,3 +114,25 @@ adb -s shell run-as cn.ilapage.goauto.agent sqlite3 -readonly databases - 保留现有加载、错误提示、取消和旧请求隔离逻辑,不增加自动重试。慢请求在 10~60 秒间完成时不再被原 10 秒客户端预算提前中止;超过 60 秒仍会超时,上游更短的超时也可能先终止请求。 - 此调整不优化后端执行速度。处理阶段筛选当前先加载候选并计算阶段、后分页;不选店铺且采购类型为全部时,候选可能很大。遇到持续慢查询,应另行分析候选预筛选与数据库执行计划,不能据此认为延长前端预算已解决后端性能问题。 - 只需发布包含该提交的 Web 资源即可生效,不要求数据库迁移、Android 安装或后端参数变更;发布仍需人工授权。 + +## Android 地址编辑页控件就绪等待(#366,环节②) + +实现绑定 `7fdfe04658d16a05ebcb016ee964a7263eb74802`,位于独立工单分支,未合并 main、未装机或发布;合成测试与 APK 构建结果见 #366 工单,不代表真机采购验收。只修改点击“修改”后等待详细地址输入框就绪的环节;地址列表识别(①)仍缺真实单/多地址列表证据,保存退出(③)、返回与最终复核(④)不改。 + +- 标题出现不再立即触发输入框缺失失败。在“详细地址”标签的既有同排区域,统计可见 EditText 的 structural 数量(包括空值/禁用)及其 enabled、实际 text 非空且未判为 hint 的子集 ready 数量。只有 structural=1 且 ready=1 才继续,读取的地址与就绪判定来自同一帧;后续 inputFresh 仍重新定位且只尝试一次。 +- 原地址只读取同帧实际 text.trim(),不使用 contentDescription 补空。API 26+ 捕获内存 hintText / showingHintText;showingHintText=true 或 text.trim() 等于非空 hintText.trim() 时不就绪,继续等待。全局 label 和其他调用方的语义保持不变,这两个新增元数据不落盘、不上报、不记日志。 +- API <26 或应用未正确提供 hint 元数据时,不能完全区分非空 text 是提示还是实际值;仅凭此修复不能保证所有设备/页面的提示文字均被识别,不使用提示词猜测。 +- 沿用单轮最多 50 次采样、原 200ms 暂停及点击后的既有暂停。不再叠加标题等待与控件等待,不设置“三次多候选即失败”;采样与解析另有耗时,不能将其表述为严格 10 秒墙钟超时。既有安全异常仍可提前终止。 +- 等待超时使用现有错误码:从未观察到详细地址标题为 PURCHASE_ADDRESS_EDIT_TIMEOUT;曾见标题但候选未就绪/不唯一为 PURCHASE_ADDRESS_UPDATE_FAILED。titleSeen 跨帧保留,因此最后一帧空树不会抹掉此前的标题证据。 +- 诊断仅含固定 stage=address_editor_ready、reason 分类以及 titleSeen、structural、ready 标量,不含地址、收货人、电话或节点文本。具体原因以实现和测试中的固定分类为准;这不是新增 SQLite 字段或上传接口。 +- 收货人/电话不能作为备用目标;区域重叠造成歧义时不输入、不保存。无额外点击、滑动、返回、保存或下单重试,正常单次动作路径保持。 +- 此修复不能证明历史失败均因慢加载,也不解决保存地址后返回超时。识别①不能仅凭“不是规格面板+无提交按钮”推断已进入地址列表:两项都依赖同一个面板解析结果。 + +## 支付页返回后核单仍缺少证据(#365) + +实现绑定 `37714d39b5ac8ff720cfa1d0d2609f9a8af3fbd9`(工单分支,未合并、安装或发布)。成功执行既有一次支付页安全返回后,在无订单上下文阶段以固定单调时钟起点观察约30秒;原预算后的新增观察不增加页面动作,待付款分支仍沿用独立读取预算。 + +- 先确认运行版本是否包含此提交,再区分失败码:持续支付页、空窗口、微信恢复和未授权应用等独立条件可提前结束,并非所有失败都等待30秒。 +- 完整订单证据出现就结束;超时保留原订单结果未知语义,不能因为等待更久就把任务改为可重试失败或自动重下单。 +- 固定期限从 Back 成功时起算,不是从提交订单或进入核单函数起算,也不会因新样本重置;capture 阻塞不能被该检查中断,待付款分支拥有独立预算。 +- 不因本次延长断言历史问题就是加载慢。延长期出现必须点击才能展开的入口仍不会新增点击;页面识别/挽留弹窗等原因需要真实诊断证据另行分析。#364 的失败现场诊断不属于本单实现。 diff --git a/docs/08-agent-api-contract.md b/docs/08-agent-api-contract.md index 10382cd..f3a27be 100644 --- a/docs/08-agent-api-contract.md +++ b/docs/08-agent-api-contract.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Android-Agent-API-Contract wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Android-Agent-API-Contract.- -wiki_revision: 640da0a86bf1057d418dbeb062c07a84cb5b1d8e -synchronized_at: 2026-10-08T01:42:56Z +wiki_revision: f97ac6b8784fc200ee68e9e2eb4a1201296049f0 +synchronized_at: 2026-10-08T03:25:37Z @@ -641,6 +641,17 @@ Agent 主动提交 `order_result_unknown` 时保持订单号和下单时间为 | `PURCHASE_ORDER_RESULT_UNKNOWN` | 无法确认订单是否创建,请人工检查 | | `PURCHASE_PAYMENT_FORBIDDEN` | 系统禁止自动付款 | +### 地址编辑页初始等待诊断(#366,环节②) + +实现绑定 `7fdfe04658d16a05ebcb016ee964a7263eb74802`(工单分支,尚未合并 main 或发布)。本修订只细化 Android 点击“修改”后的输入框等待及错误说明,不增加 HTTP 字段、数据库字段或错误码,不改变任务结果提交、设备/任务/规则关联和保存后的最终复核。 + +- 一个现有 50 次采样预算内,只有详细地址区域可见 EditText 的结构候选数为 1,且其中 enabled、实际 text 非空并排除明确 hint 的就绪候选数也为 1,才允许后续既有单次输入;结构计数包括空值和禁用候选。 +- ready 和原地址不使用 contentDescription 补空;showingHintText=true 或实际 text.trim() 等于非空 hintText.trim() 时排除。hintText / showingHintText 仅为 API 26+ 的内存节点元数据,低版本为 null,不新增持久化或上传字段,不改变全局 label。应用缺失/错误的 hint 元数据或 API <26 时无法完全区分提示与实际值。 +- 超时从未观察到“详细地址”标题时沿用 `PURCHASE_ADDRESS_EDIT_TIMEOUT`;曾见标题但输入框缺失、为空、仅显示 hint、禁用或存在歧义时沿用 `PURCHASE_ADDRESS_UPDATE_FAILED`。 +- 此等待失败的 `errorMessage` 可附固定诊断 `stage=address_editor_ready;reason=<固定分类>;titleSeen=<布尔>;structural=<数量>;ready=<数量>`。reason 为 `title_missing`、`editor_missing`、`editor_ambiguous`、`editor_disabled`、`editor_empty` 或 `editor_hint`;titleSeen 累计保留,数量取最后采样帧,不包含地址值、收货人、电话及节点文本。 +- 既有页面安全异常优先失败,不必耗尽预算;`inputFresh` 失败仍不重试。其它使用 `PURCHASE_ADDRESS_UPDATE_FAILED` 的路径保持原义,不要求它们附本段诊断。 +- ①地址列表识别仍待真实场景证据,③保存退出、④返回与最终复核不改;本段不代表整单完成或真机验收。 + ## Agent 当前设备任务历史(#90) 四个只读接口统一使用设备注册所得的 Device Token,只返回该 Token 对应设备最近 30 天内的任务;设备 A 查询设备 B 的任务时按不存在处理,不泄露任务是否存在。