Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d2083e71fb | ||
|
|
5206a6fd7d | ||
|
|
c55455d35a | ||
|
|
37714d39b5 |
+64
-27
@@ -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 之外的路径(选择器卡住、微信恢复超时等)同样要带上
|
||||
@@ -1075,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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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验收。
|
||||
|
||||
@@ -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 的失败现场诊断不属于本单实现。
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
Reference in New Issue
Block a user