Compare commits

...
5 changed files with 399 additions and 32 deletions
@@ -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
}
}
@@ -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
}
}
@@ -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
@@ -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)
}
}