Compare commits

..
7 changed files with 375 additions and 429 deletions
@@ -248,8 +248,6 @@ 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) }
@@ -27,9 +27,6 @@ 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()
@@ -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,12 +545,16 @@ class PurchaseLiveAutomation(
val modify = addressModifyTargets(panel)
if (modify.size != 1) fail("PURCHASE_ADDRESS_EDIT_AMBIGUOUS", "没有找到唯一的地址修改按钮,未创建订单")
click(modify.single(), "修改地址")
val editor = waitForInitialAddressEditor()
val current = editor.text.orEmpty().trim()
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 body = current.split(Regex("[-_]"), limit = 2).firstOrNull()?.trim().orEmpty()
if (body.isBlank()) fail("PURCHASE_ADDRESS_UPDATE_FAILED", "当前详细地址无效,未创建订单")
val expected = body + suffix
if (driver.inputFresh(editor, expected) != FreshActionResult.SUCCESS) fail("PURCHASE_ADDRESS_UPDATE_FAILED", "详细地址修改失败,未创建订单")
if (driver.inputFresh(editors.single(), 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
}
@@ -535,47 +575,6 @@ 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<SnapshotNode>()
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)
@@ -1075,6 +1074,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
@@ -18,6 +18,283 @@ import org.junit.Assert.assertTrue
import org.junit.Test
class PurchaseLiveAutomationTest {
@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,338 +496,6 @@ 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<Long>()
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<Long>()
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<String>()
val editPauses = mutableListOf<Long>()
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)
@@ -1120,8 +1065,6 @@ 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<String> = emptyList(),
) : PurchaseUiDriver {
private var page = "confirmation"
@@ -1136,10 +1079,6 @@ 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
@@ -1152,12 +1091,7 @@ class PurchaseLiveAutomationTest {
postSubmitCaptureCount++
if (pendingPostSubmitPages.isNotEmpty()) page = pendingPostSubmitPages.removeAt(0)
}
val snapshot = currentSnapshot()
if (page == "edit" && inputCount == 0) {
initialEditCaptures++
return initialEditFrame(snapshot, initialEditCaptures).also { lastInitialEditSnapshot = it }
}
return snapshot
return currentSnapshot()
}
private fun currentSnapshot(): UiSnapshot = when (page) {
@@ -1329,10 +1263,7 @@ 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
}
+14 -3
View File
@@ -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
<!-- gitea-wiki-mirror:end -->
<!-- gitea-wiki-mirror:start -->
@@ -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验收。
+11 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Troubleshooting
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Troubleshooting
wiki_revision: b096c5a86f3ce0f9dbf3dc0591f7b5cf69f711c7
synchronized_at: 2026-10-08T03:01:08Z
wiki_revision: fee37efc79b644ecfa3237283dd45f0a09796f44
synchronized_at: 2026-10-08T03:25:33Z
<!-- gitea-wiki-mirror:end -->
# 故障排查
@@ -127,3 +127,12 @@ adb -s <serial> shell run-as cn.ilapage.goauto.agent sqlite3 -readonly databases
- 诊断仅含固定 stage=address_editor_ready、reason 分类以及 titleSeen、structural、ready 标量,不含地址、收货人、电话或节点文本。具体原因以实现和测试中的固定分类为准;这不是新增 SQLite 字段或上传接口。
- 收货人/电话不能作为备用目标;区域重叠造成歧义时不输入、不保存。无额外点击、滑动、返回、保存或下单重试,正常单次动作路径保持。
- 此修复不能证明历史失败均因慢加载,也不解决保存地址后返回超时。识别①不能仅凭“不是规格面板+无提交按钮”推断已进入地址列表:两项都依赖同一个面板解析结果。
## 支付页返回后核单仍缺少证据(#365)
实现绑定 `37714d39b5ac8ff720cfa1d0d2609f9a8af3fbd9`(工单分支,未合并、安装或发布)。成功执行既有一次支付页安全返回后,在无订单上下文阶段以固定单调时钟起点观察约30秒;原预算后的新增观察不增加页面动作,待付款分支仍沿用独立读取预算。
- 先确认运行版本是否包含此提交,再区分失败码:持续支付页、空窗口、微信恢复和未授权应用等独立条件可提前结束,并非所有失败都等待30秒。
- 完整订单证据出现就结束;超时保留原订单结果未知语义,不能因为等待更久就把任务改为可重试失败或自动重下单。
- 固定期限从 Back 成功时起算,不是从提交订单或进入核单函数起算,也不会因新样本重置;capture 阻塞不能被该检查中断,待付款分支拥有独立预算。
- 不因本次延长断言历史问题就是加载慢。延长期出现必须点击才能展开的入口仍不会新增点击;页面识别/挽留弹窗等原因需要真实诊断证据另行分析。#364 的失败现场诊断不属于本单实现。
+1 -1
View File
@@ -3,7 +3,7 @@ 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: f97ac6b8784fc200ee68e9e2eb4a1201296049f0
synchronized_at: 2026-10-08T03:01:11Z
synchronized_at: 2026-10-08T03:25:37Z
<!-- gitea-wiki-mirror:end -->
<!-- gitea-wiki-mirror:start -->