Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
15e2a008a7 | ||
|
|
a1a9343bf6 | ||
|
|
bb879be836 | ||
|
|
14459ead62 | ||
|
|
fcb5cf5e78 | ||
|
|
9eb28c225c |
+90
-2
@@ -328,8 +328,19 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
|
||||
) candidates += node
|
||||
}
|
||||
if (candidates.isEmpty()) return FreshActionResult.NOT_FOUND
|
||||
if (candidates.size != 1) return FreshActionResult.AMBIGUOUS
|
||||
val bounds = Rect().also(candidates.single()::getBoundsInScreen)
|
||||
val candidate = when {
|
||||
candidates.size == 1 -> candidates.single()
|
||||
candidates.shareClickableAncestor() || FreshTapDuplicatePolicy.isSingleVisualTarget(candidates.map { node ->
|
||||
val bounds = Rect().also(node::getBoundsInScreen)
|
||||
NodeBounds(bounds.left, bounds.top, bounds.right, bounds.bottom)
|
||||
}) -> candidates.minBy { node ->
|
||||
val bounds = Rect().also(node::getBoundsInScreen)
|
||||
kotlin.math.abs(bounds.centerX() - target.bounds.centerX) +
|
||||
kotlin.math.abs(bounds.centerY() - target.bounds.centerY)
|
||||
}
|
||||
else -> return FreshActionResult.AMBIGUOUS
|
||||
}
|
||||
val bounds = Rect().also(candidate::getBoundsInScreen)
|
||||
if (bounds.width() < 2 || bounds.height() < 2 || Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
|
||||
return FreshActionResult.FAILED
|
||||
}
|
||||
@@ -410,6 +421,42 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
|
||||
return swipeNode(matches.single(), direction, durationMs, preferScrollAction = false)
|
||||
}
|
||||
|
||||
override fun pullDownPurchaseSurface(bounds: NodeBounds, durationMs: Long): Boolean {
|
||||
val root = rootInActiveWindow ?: return false
|
||||
if (root.packageName?.toString() != PDD_PACKAGE || Build.VERSION.SDK_INT < Build.VERSION_CODES.N) return false
|
||||
val rootBounds = Rect().also(root::getBoundsInScreen)
|
||||
val clipped = PurchaseSurfaceGesturePolicy.clippedBounds(
|
||||
bounds,
|
||||
NodeBounds(rootBounds.left, rootBounds.top, rootBounds.right, rootBounds.bottom),
|
||||
) ?: return false
|
||||
val gestureBounds = Rect(clipped.left, clipped.top, clipped.right, clipped.bottom)
|
||||
val centerX = gestureBounds.centerX().toFloat()
|
||||
val startY = (gestureBounds.top + gestureBounds.height() * 25 / 100).toFloat()
|
||||
val endY = (gestureBounds.top + gestureBounds.height() * 75 / 100).toFloat()
|
||||
val path = Path().apply {
|
||||
moveTo(centerX, startY)
|
||||
lineTo(centerX, endY)
|
||||
}
|
||||
val completed = AtomicBoolean(false)
|
||||
val latch = CountDownLatch(1)
|
||||
val queued = dispatchGesture(
|
||||
GestureDescription.Builder().addStroke(GestureDescription.StrokeDescription(path, 0, durationMs)).build(),
|
||||
object : GestureResultCallback() {
|
||||
override fun onCompleted(gestureDescription: GestureDescription?) {
|
||||
completed.set(true)
|
||||
latch.countDown()
|
||||
}
|
||||
|
||||
override fun onCancelled(gestureDescription: GestureDescription?) {
|
||||
latch.countDown()
|
||||
}
|
||||
},
|
||||
null,
|
||||
)
|
||||
if (!queued) return false
|
||||
return latch.await(1_500, TimeUnit.MILLISECONDS) && completed.get()
|
||||
}
|
||||
|
||||
override fun backPurchase(): Boolean = performGlobalAction(GLOBAL_ACTION_BACK)
|
||||
|
||||
override fun bringPddToForeground(): Boolean {
|
||||
@@ -617,6 +664,15 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
|
||||
return latch.await(1500, TimeUnit.MILLISECONDS) && completed.get()
|
||||
}
|
||||
|
||||
private fun List<AccessibilityNodeInfo>.shareClickableAncestor(): Boolean {
|
||||
val ancestors = map { source ->
|
||||
var node: AccessibilityNodeInfo? = source.parent
|
||||
while (node != null && !node.isClickable) node = node.parent
|
||||
node
|
||||
}
|
||||
return ancestors.all { it != null } && ancestors.distinct().size == 1
|
||||
}
|
||||
|
||||
private fun walk(node: AccessibilityNodeInfo, visit: (AccessibilityNodeInfo) -> Unit) {
|
||||
visit(node)
|
||||
for (index in 0 until node.childCount) node.getChild(index)?.let { walk(it, visit) }
|
||||
@@ -665,3 +721,35 @@ internal object SpecSwipeSafety {
|
||||
fun preferAccessibilityScrollAction(direction: SwipeDirection): Boolean =
|
||||
direction == SwipeDirection.UP || direction == SwipeDirection.DOWN
|
||||
}
|
||||
|
||||
internal object FreshTapDuplicatePolicy {
|
||||
fun isSingleVisualTarget(bounds: List<NodeBounds>): Boolean {
|
||||
if (bounds.isEmpty()) return false
|
||||
return bounds.indices.all { first ->
|
||||
((first + 1) until bounds.size).all { second -> nearDuplicate(bounds[first], bounds[second]) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun nearDuplicate(first: NodeBounds, second: NodeBounds): Boolean {
|
||||
val overlapWidth = (minOf(first.right, second.right) - maxOf(first.left, second.left)).coerceAtLeast(0)
|
||||
val overlapHeight = (minOf(first.bottom, second.bottom) - maxOf(first.top, second.top)).coerceAtLeast(0)
|
||||
val overlap = overlapWidth.toLong() * overlapHeight
|
||||
val smaller = minOf(first.width.toLong() * first.height, second.width.toLong() * second.height)
|
||||
return smaller > 0 && overlap * 100 >= smaller * 98
|
||||
}
|
||||
}
|
||||
|
||||
internal object PurchaseSurfaceGesturePolicy {
|
||||
fun clippedBounds(requested: NodeBounds, root: NodeBounds): NodeBounds? {
|
||||
val clipped = NodeBounds(
|
||||
maxOf(requested.left, root.left),
|
||||
maxOf(requested.top, root.top),
|
||||
minOf(requested.right, root.right),
|
||||
minOf(requested.bottom, root.bottom),
|
||||
)
|
||||
if (root.width < 2 || root.height < 2 || clipped.width * 100 < root.width * 60 ||
|
||||
clipped.height * 100 < root.height * 20
|
||||
) return null
|
||||
return clipped
|
||||
}
|
||||
}
|
||||
|
||||
+107
-22
@@ -36,14 +36,31 @@ class PurchaseLiveAutomation(
|
||||
private var submitAttempted = false
|
||||
var lastOrderReadFailure: PurchaseOrderReadFailure? = null
|
||||
private set
|
||||
fun updateShippingAddress(addressSuffix: String): ShippingAddressProof {
|
||||
fun updateShippingAddress(input: PurchaseExecutionInput): ShippingAddressProof =
|
||||
updateShippingAddress(input.addressSuffix, input)
|
||||
|
||||
fun updateShippingAddress(addressSuffix: String): ShippingAddressProof =
|
||||
updateShippingAddress(addressSuffix, null)
|
||||
|
||||
private fun updateShippingAddress(addressSuffix: String, input: PurchaseExecutionInput?): ShippingAddressProof {
|
||||
if (!addressSuffix.matches(Regex("^_cg[1-9][0-9]*$"))) fail("PURCHASE_ADDRESS_UPDATE_FAILED", "采购任务的地址标记无效")
|
||||
var snapshot = driver.capture()
|
||||
var previousSignature: String? = null
|
||||
var unchangedCount = 0
|
||||
var verifiedPanelBounds: NodeBounds? = null
|
||||
var trustedPurchaseSurfaceBounds: NodeBounds? = null
|
||||
for (attempt in 0 until 5) {
|
||||
pageProblem(snapshot)
|
||||
val entries = mergedDirect(snapshot.nodes.filter { it.visible && it.enabled && MASKED_PHONE.containsMatchIn(it.label) })
|
||||
val panel = purchasePanelScrollTargets(snapshot)
|
||||
if (panel.size == 1) verifiedPanelBounds = panel.single().bounds
|
||||
if (trustedPurchaseSurfaceBounds == null && input != null) {
|
||||
trustedPurchaseSurfaceBounds = verifiedPurchaseSurfaceBounds(snapshot, input)
|
||||
}
|
||||
val entries = mergedDirect(
|
||||
listOfNotNull(trustedPurchaseSurfaceBounds, verifiedPanelBounds)
|
||||
.distinct()
|
||||
.flatMap { bounds -> addressEntryTargets(snapshot, bounds) },
|
||||
)
|
||||
if (entries.size == 1) {
|
||||
when (driver.tapPurchaseFresh(entries.single())) {
|
||||
FreshActionResult.SUCCESS -> Unit
|
||||
@@ -56,14 +73,22 @@ class PurchaseLiveAutomation(
|
||||
}
|
||||
return editAndVerifyAddress(snapshot, addressSuffix)
|
||||
}
|
||||
if (entries.size > 1) fail("PURCHASE_ADDRESS_ENTRY_AMBIGUOUS", "收货地址入口不唯一,未创建订单")
|
||||
val panel = purchasePanelScrollTargets(snapshot)
|
||||
if (panel.size != 1) fail("PURCHASE_ADDRESS_ENTRY_AMBIGUOUS", "没有找到唯一的规格面板滚动区域,未创建订单")
|
||||
val signature = viewportSignature(snapshot, panel.single())
|
||||
val fallbackBounds = if (panel.size == 1) panel.single().bounds else trustedPurchaseSurfaceBounds
|
||||
?: input?.let { verifiedPurchaseSurfaceBounds(snapshot, it) }
|
||||
?: fail("PURCHASE_ADDRESS_ENTRY_AMBIGUOUS", "无法确认唯一的采购页面滑动区域,未创建订单")
|
||||
val signature = viewportSignature(snapshot, fallbackBounds)
|
||||
unchangedCount = if (signature == previousSignature) unchangedCount + 1 else 0
|
||||
if (unchangedCount >= 2 || attempt == 4) break
|
||||
if (unchangedCount >= 2 || attempt == 4) {
|
||||
if (entries.size > 1) fail("PURCHASE_ADDRESS_ENTRY_AMBIGUOUS", "收货地址入口不唯一,未创建订单")
|
||||
break
|
||||
}
|
||||
previousSignature = signature
|
||||
if (!driver.swipePurchaseIn(panel.single(), SwipeDirection.DOWN, 350)) {
|
||||
val swiped = if (panel.size == 1) {
|
||||
driver.swipePurchaseIn(panel.single(), SwipeDirection.DOWN, 350)
|
||||
} else {
|
||||
driver.pullDownPurchaseSurface(fallbackBounds, 350)
|
||||
}
|
||||
if (!swiped) {
|
||||
fail("PURCHASE_ADDRESS_PANEL_TIMEOUT", "规格面板无法下拉显示收货地址,未创建订单")
|
||||
}
|
||||
pause(500)
|
||||
@@ -162,24 +187,28 @@ class PurchaseLiveAutomation(
|
||||
return unknown("PURCHASE_ORDER_UNEXPECTED_APP", "核单期间出现未授权应用")
|
||||
}
|
||||
if (restoredFromWechat) pddObservedAfterWechatRestore = true
|
||||
val paymentVisible = isKnownPddPaymentActivity(snapshot) ||
|
||||
currentLabels.any { label -> PAYMENT_MARKERS.any(label::contains) }
|
||||
val paymentVisible = isKnownPddPaymentActivity(snapshot)
|
||||
val orderContextVisible = currentLabels.any { label -> ORDER_CONTEXT_MARKERS.any(label::contains) }
|
||||
if (!paymentVisible && !orderContextVisible) {
|
||||
val unpaidContextVisible = currentLabels.any { label -> UNPAID_MARKERS.any(label::contains) }
|
||||
if (paymentVisible) {
|
||||
if (!backedOutOfPayment) {
|
||||
backedOutOfPayment = true
|
||||
if (!driver.backPurchase()) {
|
||||
return unknown("PURCHASE_ORDER_PAYMENT_BACK_FAILED", "支付页无法安全返回订单详情")
|
||||
}
|
||||
pause(500)
|
||||
} else {
|
||||
return unknown("PURCHASE_ORDER_PAYMENT_REPEATED", "支付页重复出现,已停止自动核单")
|
||||
}
|
||||
return@repeat
|
||||
}
|
||||
if (!orderContextVisible && !unpaidContextVisible) {
|
||||
pause(ORDER_RESULT_SAMPLE_INTERVAL_MS)
|
||||
return@repeat
|
||||
}
|
||||
currentLabels.forEach(labels::add)
|
||||
parseOrderEvidence(labels)?.let { return it }
|
||||
if (paymentVisible && !backedOutOfPayment) {
|
||||
backedOutOfPayment = true
|
||||
if (!driver.backPurchase()) {
|
||||
return unknown("PURCHASE_ORDER_PAYMENT_BACK_FAILED", "支付页无法安全返回订单详情")
|
||||
}
|
||||
pause(500)
|
||||
} else if (paymentVisible) {
|
||||
return unknown("PURCHASE_ORDER_PAYMENT_REPEATED", "支付页重复出现,已停止自动核单")
|
||||
} else if (index > 0 && index % 15 == 0) {
|
||||
if (index > 0 && index % ORDER_RESULT_SCROLL_SAMPLE_INTERVAL == 0) {
|
||||
driver.swipePurchase(SwipeDirection.UP, 400)
|
||||
}
|
||||
pause(ORDER_RESULT_SAMPLE_INTERVAL_MS)
|
||||
@@ -290,8 +319,62 @@ class PurchaseLiveAutomation(
|
||||
}.distinctBy { it.path }
|
||||
}
|
||||
|
||||
private fun viewportSignature(snapshot: UiSnapshot, panel: SnapshotNode): String = snapshot.nodes
|
||||
.filter { node -> node.visible && inside(node.bounds, panel.bounds) }
|
||||
private fun addressEntryTargets(snapshot: UiSnapshot, panelBounds: NodeBounds): List<SnapshotNode> {
|
||||
val byPath = snapshot.nodes.associateBy { it.path }
|
||||
val cards = snapshot.nodes.filter { node ->
|
||||
node.visible && node.enabled && MASKED_PHONE.containsMatchIn(node.label)
|
||||
}.mapNotNull { source ->
|
||||
var target: SnapshotNode? = source
|
||||
while (target != null && !target.clickable) target = target.parentPath?.let(byPath::get)
|
||||
target?.takeIf { card ->
|
||||
card.visible && card.enabled && visiblyIntersects(card.bounds, panelBounds)
|
||||
}
|
||||
}.distinctBy { it.path }
|
||||
return mergedDirect(cards)
|
||||
}
|
||||
|
||||
private fun visiblyIntersects(target: NodeBounds, scope: NodeBounds): Boolean {
|
||||
val overlapWidth = (minOf(target.right, scope.right) - maxOf(target.left, scope.left)).coerceAtLeast(0)
|
||||
val overlapHeight = (minOf(target.bottom, scope.bottom) - maxOf(target.top, scope.top)).coerceAtLeast(0)
|
||||
val comparableWidth = minOf(target.width, scope.width)
|
||||
return comparableWidth > 0 && target.height > 0 &&
|
||||
overlapWidth * 100 >= comparableWidth * 60 &&
|
||||
overlapHeight >= minOf(ADDRESS_CARD_MIN_VISIBLE_HEIGHT, target.height)
|
||||
}
|
||||
|
||||
private fun verifiedPurchaseSurfaceBounds(snapshot: UiSnapshot, input: PurchaseExecutionInput): NodeBounds? {
|
||||
if (snapshot.packageName != PDD_PACKAGE || finalSubmitTargets(snapshot).size != 1) return null
|
||||
val screen = PddScreenParser.parse(snapshot, PurchaseRehearsalExecutor.DEFAULT_COLLECTOR, input.goodsId, null)
|
||||
val selected = listOf(input.mappedColor, input.mappedSize).filter(String::isNotBlank)
|
||||
if (selected.any { value ->
|
||||
screen.selectedSummary?.contains(value) != true && snapshot.nodes.none { it.visible && it.label.contains(value) }
|
||||
}
|
||||
) return null
|
||||
val quantities = snapshot.nodes.filter {
|
||||
it.visible && it.enabled && it.className?.endsWith("EditText") == true
|
||||
}.mapNotNull { it.label.toLongOrNull() }
|
||||
if (quantities.singleOrNull() != input.quantity) return null
|
||||
val price = screen.priceCent ?: return null
|
||||
if (price !in input.minUnitPriceCent..input.maxUnitPriceCent) return null
|
||||
val visibleBounds = snapshot.nodes.filter { it.visible && it.bounds.width > 1 && it.bounds.height > 1 }.map { it.bounds }
|
||||
if (visibleBounds.isEmpty()) return null
|
||||
val screenBounds = NodeBounds(
|
||||
visibleBounds.minOf { it.left },
|
||||
visibleBounds.minOf { it.top },
|
||||
visibleBounds.maxOf { it.right },
|
||||
visibleBounds.maxOf { it.bottom },
|
||||
)
|
||||
if (screenBounds.width < 2 || screenBounds.height < 4) return null
|
||||
return NodeBounds(
|
||||
screenBounds.left,
|
||||
screenBounds.top + screenBounds.height * 20 / 100,
|
||||
screenBounds.right,
|
||||
screenBounds.top + screenBounds.height * 90 / 100,
|
||||
)
|
||||
}
|
||||
|
||||
private fun viewportSignature(snapshot: UiSnapshot, bounds: NodeBounds): String = snapshot.nodes
|
||||
.filter { node -> node.visible && inside(node.bounds, bounds) }
|
||||
.joinToString("|") { node ->
|
||||
listOf(node.className.orEmpty(), node.bounds.left, node.bounds.top, node.bounds.right, node.bounds.bottom, node.clickable, node.scrollable).joinToString(":")
|
||||
}
|
||||
@@ -418,6 +501,7 @@ class PurchaseLiveAutomation(
|
||||
const val PDD_PACKAGE = "com.xunmeng.pinduoduo"
|
||||
const val WECHAT_PACKAGE = "com.tencent.mm"
|
||||
val MASKED_PHONE = Regex("(?<![0-9])[0-9]{3}\\*{4}[0-9]{4}(?![0-9])")
|
||||
const val ADDRESS_CARD_MIN_VISIBLE_HEIGHT = 24
|
||||
val FINAL_SUBMIT_MARKERS = listOf("提交订单", "现在买,仅", "确认购买")
|
||||
val PAYMENT_MARKERS = listOf("立即支付", "确认支付", "输入支付密码")
|
||||
val UNPAID_MARKERS = listOf("待付款", "待支付", "去支付")
|
||||
@@ -435,6 +519,7 @@ class PurchaseLiveAutomation(
|
||||
const val ORDER_RESULT_MAX_SAMPLES = 60
|
||||
const val ORDER_RESULT_MAX_EMPTY_SAMPLES = 15
|
||||
const val ORDER_RESULT_WECHAT_RESTORE_MAX_SAMPLES = 15
|
||||
const val ORDER_RESULT_SCROLL_SAMPLE_INTERVAL = 15
|
||||
const val ORDER_RESULT_SAMPLE_INTERVAL_MS = 200L
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -14,6 +14,7 @@ interface PurchaseUiDriver {
|
||||
fun inputFresh(target: SnapshotNode, value: String): FreshActionResult
|
||||
fun swipePurchase(direction: SwipeDirection, durationMs: Long): Boolean
|
||||
fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long): Boolean
|
||||
fun pullDownPurchaseSurface(bounds: NodeBounds, durationMs: Long): Boolean = false
|
||||
fun pullDownGoodsPage(): Boolean = swipePurchase(SwipeDirection.DOWN, 550)
|
||||
fun backPurchase(): Boolean
|
||||
/** Requests the existing PDD task stack in the foreground; callers must verify the observed package afterwards. */
|
||||
@@ -79,7 +80,7 @@ class PurchaseRehearsalExecutor(
|
||||
null
|
||||
}
|
||||
PurchaseActionType.UPDATE_SHIPPING_ADDRESS -> try {
|
||||
addressProof = live.updateShippingAddress(input.addressSuffix)
|
||||
addressProof = live.updateShippingAddress(input)
|
||||
null
|
||||
} catch (error: PurchaseLiveException) {
|
||||
failure(error.code, error.message ?: "收货地址修改失败,未创建订单")
|
||||
|
||||
@@ -120,6 +120,37 @@ class PurchaseLiveAutomationTest {
|
||||
assertFalse(driver.clicked.any { it.startsWith("微信") || it.contains("支付") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `payment page returns to folded unpaid order detail and scrolls to read evidence`() {
|
||||
val driver = LiveDriver(chooserAfterSubmit = true, orderEvidenceBelowFold = true)
|
||||
val automation = PurchaseLiveAutomation(driver, pause = {})
|
||||
val address = automation.updateShippingAddress("_cg54")
|
||||
automation.finalConfirmation(input().copy(addressSuffix = "_cg54"), address)
|
||||
automation.submitOrderOnce()
|
||||
|
||||
val order = automation.readOrderResult()
|
||||
|
||||
assertEquals("PDD-202608210001", order?.orderNo)
|
||||
assertEquals(2, driver.postSubmitBackCount)
|
||||
assertEquals(1, driver.genericSwipes)
|
||||
assertFalse(driver.clicked.any { it.contains("支付") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `repeated known payment activity still stops without scrolling or payment clicks`() {
|
||||
val driver = LiveDriver(postSubmitCaptureSequence = listOf("payment", "payment"))
|
||||
val automation = PurchaseLiveAutomation(driver, pause = {})
|
||||
val address = automation.updateShippingAddress("_cg55")
|
||||
automation.finalConfirmation(input().copy(addressSuffix = "_cg55"), address)
|
||||
automation.submitOrderOnce()
|
||||
|
||||
assertEquals(null, automation.readOrderResult())
|
||||
assertEquals("PURCHASE_ORDER_PAYMENT_REPEATED", automation.lastOrderReadFailure?.code)
|
||||
assertEquals(1, driver.postSubmitBackCount)
|
||||
assertEquals(0, driver.genericSwipes)
|
||||
assertFalse(driver.clicked.any { it.contains("支付") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `WeChat login is not touched and PDD is restored once before reading order detail`() {
|
||||
val driver = LiveDriver(wechatLoginAfterSubmit = true)
|
||||
@@ -272,6 +303,94 @@ class PurchaseLiveAutomationTest {
|
||||
assertEquals(0, driver.genericSwipes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `duplicate partial address hints are scrolled before choosing the complete entry`() {
|
||||
val driver = LiveDriver(addressClipped = true, duplicateAddressHintsBeforeReveal = true)
|
||||
val address = PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg38")
|
||||
|
||||
assertEquals("广东省广州市天园街道骏景花园骏晖轩1202_cg38", address.expectedAddress)
|
||||
assertEquals(1, driver.scopedSwipes)
|
||||
assertEquals(1, driver.addressTaps)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `revealed address is used when panel stops reporting scrollable after swipe`() {
|
||||
val driver = LiveDriver(addressClipped = true, panelScrollableAfterReveal = false)
|
||||
val address = PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg41")
|
||||
|
||||
assertEquals("广东省广州市天园街道骏景花园骏晖轩1202_cg41", address.expectedAddress)
|
||||
assertEquals(1, driver.scopedSwipes)
|
||||
assertEquals(1, driver.addressTaps)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `verified purchase surface pulls down when PDD exposes no scrollable panel`() {
|
||||
val driver = LiveDriver(addressClipped = true, panelInitiallyScrollable = false, addressAtTopAfterReveal = true)
|
||||
val address = PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress(input().copy(addressSuffix = "_cg56"))
|
||||
|
||||
assertEquals("广东省广州市天园街道骏景花园骏晖轩1202_cg56", address.expectedAddress)
|
||||
assertEquals(0, driver.scopedSwipes)
|
||||
assertEquals(1, driver.surfacePullDowns)
|
||||
assertEquals(1, driver.addressTaps)
|
||||
assertEquals("address-card", driver.lastAddressTapPath)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `verified purchase surface finds address above a separate scrollable spec panel`() {
|
||||
val driver = LiveDriver(addressClipped = true, addressAboveScrollablePanel = true)
|
||||
val address = PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress(input().copy(addressSuffix = "_cg58"))
|
||||
|
||||
assertEquals("广东省广州市天园街道骏景花园骏晖轩1202_cg58", address.expectedAddress)
|
||||
assertEquals(1, driver.scopedSwipes)
|
||||
assertEquals(0, driver.surfacePullDowns)
|
||||
assertEquals(1, driver.addressTaps)
|
||||
assertEquals("address-card", driver.lastAddressTapPath)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `verified purchase surface pulls down when PDD exposes multiple scrollable panels`() {
|
||||
val driver = LiveDriver(addressClipped = true, duplicatePanels = true)
|
||||
val address = PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress(input().copy(addressSuffix = "_cg57"))
|
||||
|
||||
assertEquals("广东省广州市天园街道骏景花园骏晖轩1202_cg57", address.expectedAddress)
|
||||
assertEquals(0, driver.scopedSwipes)
|
||||
assertEquals(1, driver.surfacePullDowns)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `missing exact purchase evidence blocks surface gesture`() {
|
||||
val driver = LiveDriver(addressClipped = true, panelInitiallyScrollable = false, hideSelectedSummary = true)
|
||||
val error = runCatching {
|
||||
PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress(input().copy(addressSuffix = "_cg58"))
|
||||
}.exceptionOrNull() as PurchaseLiveException
|
||||
|
||||
assertEquals("PURCHASE_ADDRESS_ENTRY_AMBIGUOUS", error.code)
|
||||
assertEquals(0, driver.scopedSwipes)
|
||||
assertEquals(0, driver.surfacePullDowns)
|
||||
assertEquals(0, driver.addressTaps)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `duplicate phone nodes in one clickable address card are treated as one entry`() {
|
||||
val driver = LiveDriver(duplicateAddressNodesSameCard = true)
|
||||
val address = PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg39")
|
||||
|
||||
assertEquals("广东省广州市天园街道骏景花园骏晖轩1202_cg39", address.expectedAddress)
|
||||
assertEquals(0, driver.scopedSwipes)
|
||||
assertEquals(1, driver.addressTaps)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `two distinct address cards remain ambiguous after bounded panel review`() {
|
||||
val driver = LiveDriver(distinctAddressCards = true)
|
||||
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg40") }
|
||||
.exceptionOrNull() as PurchaseLiveException
|
||||
|
||||
assertEquals("PURCHASE_ADDRESS_ENTRY_AMBIGUOUS", error.code)
|
||||
assertEquals(2, driver.scopedSwipes)
|
||||
assertEquals(0, driver.addressTaps)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ambiguous purchase panels fail without swiping or clicking`() {
|
||||
val driver = LiveDriver(addressClipped = true, duplicatePanels = true)
|
||||
@@ -300,6 +419,14 @@ class PurchaseLiveAutomationTest {
|
||||
private class LiveDriver(
|
||||
private val addressClipped: Boolean = false,
|
||||
private val duplicatePanels: Boolean = false,
|
||||
private val duplicateAddressHintsBeforeReveal: Boolean = false,
|
||||
private val duplicateAddressNodesSameCard: Boolean = false,
|
||||
private val distinctAddressCards: Boolean = false,
|
||||
private val panelInitiallyScrollable: Boolean = true,
|
||||
private val panelScrollableAfterReveal: Boolean = true,
|
||||
private val hideSelectedSummary: Boolean = false,
|
||||
private val addressAtTopAfterReveal: Boolean = false,
|
||||
private val addressAboveScrollablePanel: Boolean = false,
|
||||
private val chooserAfterSubmit: Boolean = false,
|
||||
private val trustedChooser: Boolean = true,
|
||||
private val splitConfirmationAddress: Boolean = false,
|
||||
@@ -310,6 +437,7 @@ class PurchaseLiveAutomationTest {
|
||||
private val savedTransitionHidesSuffix: Boolean = false,
|
||||
private val wechatLoginAfterSubmit: Boolean = false,
|
||||
private val wechatRestoreStuck: Boolean = false,
|
||||
private val orderEvidenceBelowFold: Boolean = false,
|
||||
postSubmitCaptureSequence: List<String> = emptyList(),
|
||||
) : PurchaseUiDriver {
|
||||
private var page = "confirmation"
|
||||
@@ -321,7 +449,9 @@ class PurchaseLiveAutomationTest {
|
||||
var submitClicks = 0
|
||||
var scopedSwipes = 0
|
||||
var genericSwipes = 0
|
||||
var surfacePullDowns = 0
|
||||
var addressTaps = 0
|
||||
var lastAddressTapPath: String? = null
|
||||
var lastInputTargetPath: String? = null
|
||||
var backCount = 0
|
||||
var postSubmitBackCount = 0
|
||||
@@ -359,6 +489,7 @@ class PurchaseLiveAutomationTest {
|
||||
node("address-summary", if (savedTransitionHidesSuffix) "已保存的收货信息" else address.substring(address.lastIndexOf("_cg"))),
|
||||
))
|
||||
"order" -> snapshot(listOf(node("status", "待付款"), node("order", "订单号:PDD-202608210001"), node("time", "下单时间:2026-08-21 10:30:00"), node("pay", "立即支付", clickable = true)))
|
||||
"order-folded" -> snapshot(listOf(node("status", "待付款"), node("pay", "立即支付", clickable = true)))
|
||||
"order-no-time" -> snapshot(listOf(node("status", "待付款"), node("order", "订单号:PDD-202608210001"), node("pay", "去支付", clickable = true)))
|
||||
"chooser" -> UiSnapshot(if (trustedChooser) "android" else "example.untrusted", "com.android.internal.app.ChooserActivity", listOf(
|
||||
node("chooser-title", "选择要使用的应用"), node("wechat-1", "微信"), node("wechat-2", "微信分身"),
|
||||
@@ -371,14 +502,42 @@ class PurchaseLiveAutomationTest {
|
||||
else -> {
|
||||
val nodes = mutableListOf(
|
||||
node("root", "", bounds = NodeBounds(0, 0, 1080, 2200)),
|
||||
node("panel", "", scrollable = true, bounds = NodeBounds(0, 400, 1080, 2100)),
|
||||
node("price", "¥20.00"), node("selected", "已选 黑色 XL"),
|
||||
node(
|
||||
"panel",
|
||||
"",
|
||||
scrollable = if (addressVisible) panelScrollableAfterReveal else panelInitiallyScrollable,
|
||||
bounds = if (addressAboveScrollablePanel) NodeBounds(0, 900, 1080, 2079) else NodeBounds(0, 400, 1080, 2100),
|
||||
),
|
||||
node("price", "¥20.00"),
|
||||
node("quantity", "2", className = "android.widget.EditText"),
|
||||
node("submit-parent", "", clickable = true), node("submit", "提交订单", parentPath = "submit-parent"),
|
||||
)
|
||||
if (!hideSelectedSummary) nodes += node("selected", "已选 黑色 XL")
|
||||
if (duplicatePanels) nodes += node("panel2", "", scrollable = true, bounds = NodeBounds(0, 500, 1080, 2000))
|
||||
if (addressVisible) {
|
||||
nodes += node("phone", "138****5678")
|
||||
when {
|
||||
distinctAddressCards -> {
|
||||
nodes += node("address-card-1", "", clickable = true, bounds = NodeBounds(0, 620, 1080, 820))
|
||||
nodes += node("phone-1", "138****5678", bounds = NodeBounds(20, 650, 300, 710), parentPath = "address-card-1")
|
||||
nodes += node("address-card-2", "", clickable = true, bounds = NodeBounds(0, 850, 1080, 1050))
|
||||
nodes += node("phone-2", "139****5678", bounds = NodeBounds(20, 880, 300, 940), parentPath = "address-card-2")
|
||||
}
|
||||
duplicateAddressNodesSameCard -> {
|
||||
nodes += node("address-card", "", clickable = true, bounds = NodeBounds(0, 620, 1080, 850))
|
||||
nodes += node("phone", "138****5678", bounds = NodeBounds(20, 650, 300, 710), parentPath = "address-card")
|
||||
nodes += node("phone-duplicate", "138****5678", bounds = NodeBounds(20, 735, 300, 795), parentPath = "address-card")
|
||||
}
|
||||
addressAtTopAfterReveal || addressAboveScrollablePanel -> {
|
||||
val cardBounds = if (addressAboveScrollablePanel) NodeBounds(0, 366, 1080, 520) else NodeBounds(0, 300, 1080, 520)
|
||||
val phoneBounds = if (addressAboveScrollablePanel) NodeBounds(412, 382, 993, 431) else NodeBounds(300, 330, 800, 380)
|
||||
nodes += node("address-card", "", clickable = true, bounds = cardBounds)
|
||||
nodes += node("phone", "138****5678", bounds = phoneBounds, parentPath = "address-card")
|
||||
}
|
||||
else -> {
|
||||
nodes += node("address-card", "", clickable = true, bounds = NodeBounds(0, 620, 1080, 850))
|
||||
nodes += node("phone", "138****5678", bounds = NodeBounds(20, 650, 300, 710), parentPath = "address-card")
|
||||
}
|
||||
}
|
||||
val suffixStart = address.lastIndexOf("_cg")
|
||||
val addressBody = if (suffixStart >= 0) address.substring(0, suffixStart) else address
|
||||
val addressSuffix = if (suffixStart >= 0) address.substring(suffixStart) else ""
|
||||
@@ -391,8 +550,11 @@ class PurchaseLiveAutomationTest {
|
||||
nodes += node("address-suffix-2", addressSuffix, bounds = NodeBounds(500, 780, 780, 840))
|
||||
}
|
||||
}
|
||||
else -> nodes += node("address", address)
|
||||
else -> nodes += node("address", address, bounds = NodeBounds(20, 720, 900, 800))
|
||||
}
|
||||
} else if (duplicateAddressHintsBeforeReveal) {
|
||||
nodes += node("phone-hint-1", "138****5678", bounds = NodeBounds(20, 420, 300, 480))
|
||||
nodes += node("phone-hint-2", "138****5678", bounds = NodeBounds(20, 510, 300, 570))
|
||||
}
|
||||
snapshot(nodes)
|
||||
}
|
||||
@@ -418,8 +580,9 @@ class PurchaseLiveAutomationTest {
|
||||
|
||||
override fun tapPurchaseFresh(target: SnapshotNode): FreshActionResult {
|
||||
addressTaps++
|
||||
lastAddressTapPath = target.path
|
||||
clicked += target.label
|
||||
if (target.label == "138****5678") page = "panel"
|
||||
if (target.path.startsWith("address-card")) page = "panel"
|
||||
return FreshActionResult.SUCCESS
|
||||
}
|
||||
|
||||
@@ -428,18 +591,27 @@ class PurchaseLiveAutomationTest {
|
||||
address = value
|
||||
return FreshActionResult.SUCCESS
|
||||
}
|
||||
override fun swipePurchase(direction: SwipeDirection, durationMs: Long): Boolean { genericSwipes++; return true }
|
||||
override fun swipePurchase(direction: SwipeDirection, durationMs: Long): Boolean {
|
||||
genericSwipes++
|
||||
if (page == "order-folded" && direction == SwipeDirection.UP) page = "order"
|
||||
return true
|
||||
}
|
||||
override fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long): Boolean {
|
||||
scopedSwipes++
|
||||
if (target.path == "panel" && direction == SwipeDirection.DOWN) addressVisible = true
|
||||
return true
|
||||
}
|
||||
override fun pullDownPurchaseSurface(bounds: NodeBounds, durationMs: Long): Boolean {
|
||||
surfacePullDowns++
|
||||
addressVisible = true
|
||||
return true
|
||||
}
|
||||
override fun backPurchase(): Boolean {
|
||||
backCount++
|
||||
if (page == "chooser" || page == "payment") postSubmitBackCount++
|
||||
page = when (page) {
|
||||
"chooser" -> "payment"
|
||||
"payment" -> "order"
|
||||
"payment" -> if (orderEvidenceBelowFold) "order-folded" else "order"
|
||||
else -> "confirmation"
|
||||
}
|
||||
return true
|
||||
|
||||
+21
@@ -18,4 +18,25 @@ class GoAutoAccessibilityServicePolicyTest {
|
||||
assertTrue(SpecSwipeSafety.preferAccessibilityScrollAction(SwipeDirection.UP))
|
||||
assertTrue(SpecSwipeSafety.preferAccessibilityScrollAction(SwipeDirection.DOWN))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun freshTapCollapsesOnlyNodesOccupyingTheSameVisualTarget() {
|
||||
assertTrue(FreshTapDuplicatePolicy.isSingleVisualTarget(listOf(
|
||||
NodeBounds(20, 650, 300, 710),
|
||||
NodeBounds(20, 650, 300, 710),
|
||||
)))
|
||||
assertFalse(FreshTapDuplicatePolicy.isSingleVisualTarget(listOf(
|
||||
NodeBounds(20, 650, 300, 710),
|
||||
NodeBounds(20, 850, 300, 910),
|
||||
)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun purchaseSurfaceGestureRequiresABroadRegionInsideTheCurrentRoot() {
|
||||
val root = NodeBounds(0, 0, 1080, 2376)
|
||||
assertTrue(PurchaseSurfaceGesturePolicy.clippedBounds(NodeBounds(0, 400, 1080, 2100), root) != null)
|
||||
assertTrue(PurchaseSurfaceGesturePolicy.clippedBounds(NodeBounds(-100, 400, 1180, 2100), root) != null)
|
||||
assertTrue(PurchaseSurfaceGesturePolicy.clippedBounds(NodeBounds(0, 0, 400, 2376), root) == null)
|
||||
assertTrue(PurchaseSurfaceGesturePolicy.clippedBounds(NodeBounds(0, 2300, 1080, 2500), root) == null)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user