fix(#65): restore address entry within purchase panel
This commit is contained in:
+15
@@ -184,6 +184,21 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
|
||||
return swipeNode(target, direction, durationMs, preferScrollAction = false)
|
||||
}
|
||||
|
||||
override fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long): Boolean {
|
||||
val root = rootInActiveWindow ?: return false
|
||||
val matches = mutableListOf<AccessibilityNodeInfo>()
|
||||
walk(root) { node ->
|
||||
val bounds = Rect().also(node::getBoundsInScreen)
|
||||
if (node.isVisibleToUser && node.isEnabled && node.isScrollable &&
|
||||
node.className?.toString() == target.className &&
|
||||
kotlin.math.abs(bounds.centerX() - target.bounds.centerX) <= 32 &&
|
||||
kotlin.math.abs(bounds.centerY() - target.bounds.centerY) <= 32
|
||||
) matches += node
|
||||
}
|
||||
if (matches.size != 1) return false
|
||||
return swipeNode(matches.single(), direction, durationMs, preferScrollAction = false)
|
||||
}
|
||||
|
||||
override fun backPurchase(): Boolean = performGlobalAction(GLOBAL_ACTION_BACK)
|
||||
|
||||
override fun swipeSpec(direction: SwipeDirection, anchor: SnapshotNode?): Boolean {
|
||||
|
||||
+65
-14
@@ -36,22 +36,32 @@ class PurchaseLiveAutomation(
|
||||
fun updateShippingAddress(addressSuffix: String): ShippingAddressProof {
|
||||
if (!addressSuffix.matches(Regex("^_cg[1-9][0-9]*$"))) fail("PURCHASE_ADDRESS_UPDATE_FAILED", "采购任务的地址标记无效")
|
||||
var snapshot = driver.capture()
|
||||
repeat(4) { attempt ->
|
||||
var previousSignature: String? = null
|
||||
var unchangedCount = 0
|
||||
for (attempt in 0 until 5) {
|
||||
pageProblem(snapshot)
|
||||
val entries = uniqueClickable(snapshot, snapshot.nodes.filter { it.visible && MASKED_PHONE.containsMatchIn(it.label) })
|
||||
val entries = mergedClickable(snapshot, snapshot.nodes.filter { it.visible && MASKED_PHONE.containsMatchIn(it.label) })
|
||||
if (entries.size == 1) {
|
||||
click(entries.single(), "收货地址入口")
|
||||
snapshot = waitFor { it.nodes.any { node -> node.visible && node.label.replace(" ", "") == "收货地址" } }
|
||||
snapshot = waitFor("PURCHASE_ADDRESS_PANEL_TIMEOUT", "收货地址页面打开超时,未创建订单") {
|
||||
it.nodes.any { node -> node.visible && node.label.replace(" ", "") == "收货地址" }
|
||||
}
|
||||
return editAndVerifyAddress(snapshot, addressSuffix)
|
||||
}
|
||||
if (entries.size > 1) fail("PURCHASE_ADDRESS_UPDATE_FAILED", "收货地址入口不唯一,未创建订单")
|
||||
if (attempt < 3 && !driver.swipePurchase(SwipeDirection.DOWN, 350)) {
|
||||
fail("PURCHASE_ADDRESS_UPDATE_FAILED", "没有找到收货地址入口,未创建订单")
|
||||
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())
|
||||
unchangedCount = if (signature == previousSignature) unchangedCount + 1 else 0
|
||||
if (unchangedCount >= 2 || attempt == 4) break
|
||||
previousSignature = signature
|
||||
if (!driver.swipePurchaseIn(panel.single(), SwipeDirection.DOWN, 350)) {
|
||||
fail("PURCHASE_ADDRESS_PANEL_TIMEOUT", "规格面板无法下拉显示收货地址,未创建订单")
|
||||
}
|
||||
pause(500)
|
||||
snapshot = driver.capture()
|
||||
}
|
||||
fail("PURCHASE_ADDRESS_UPDATE_FAILED", "没有找到收货地址入口,未创建订单")
|
||||
fail("PURCHASE_ADDRESS_PANEL_TIMEOUT", "规格面板下拉后仍未找到收货地址,未创建订单")
|
||||
}
|
||||
|
||||
fun finalConfirmation(input: PurchaseExecutionInput, address: ShippingAddressProof): FinalConfirmationEvidence {
|
||||
@@ -114,9 +124,11 @@ class PurchaseLiveAutomation(
|
||||
|
||||
private fun editAndVerifyAddress(panel: UiSnapshot, suffix: String): ShippingAddressProof {
|
||||
val modify = addressModifyTargets(panel)
|
||||
if (modify.size != 1) fail("PURCHASE_ADDRESS_UPDATE_FAILED", "没有找到唯一的地址修改按钮,未创建订单")
|
||||
if (modify.size != 1) fail("PURCHASE_ADDRESS_EDIT_AMBIGUOUS", "没有找到唯一的地址修改按钮,未创建订单")
|
||||
click(modify.single(), "修改地址")
|
||||
val edit = waitFor { snapshot -> snapshot.nodes.any { it.visible && it.label.replace(" ", "").contains("详细地址") } }
|
||||
val edit = waitFor("PURCHASE_ADDRESS_EDIT_TIMEOUT", "地址编辑页面打开超时,未创建订单") { snapshot ->
|
||||
snapshot.nodes.any { it.visible && it.label.replace(" ", "").contains("详细地址") }
|
||||
}
|
||||
val editors = edit.nodes.filter { it.visible && it.enabled && it.className?.endsWith("EditText") == true && it.label.isNotBlank() }
|
||||
if (editors.size != 1) fail("PURCHASE_ADDRESS_UPDATE_FAILED", "没有找到唯一的详细地址输入框,未创建订单")
|
||||
val current = editors.single().label.trim()
|
||||
@@ -124,31 +136,53 @@ class PurchaseLiveAutomation(
|
||||
if (body.isBlank()) fail("PURCHASE_ADDRESS_UPDATE_FAILED", "当前详细地址无效,未创建订单")
|
||||
val expected = body + suffix
|
||||
if (driver.inputFresh(editors.single(), expected) != FreshActionResult.SUCCESS) fail("PURCHASE_ADDRESS_UPDATE_FAILED", "详细地址修改失败,未创建订单")
|
||||
val stable = waitFor { snapshot -> snapshot.nodes.count { it.visible && it.enabled && it.className?.endsWith("EditText") == true && it.label == expected } == 1 }
|
||||
val stable = waitFor("PURCHASE_ADDRESS_INPUT_MISMATCH", "详细地址修改后回读不一致,未创建订单") { snapshot ->
|
||||
snapshot.nodes.count { it.visible && it.enabled && it.className?.endsWith("EditText") == true && it.label == expected } == 1
|
||||
}
|
||||
val save = uniqueClickable(stable, stable.nodes.filter { it.visible && it.enabled && it.label == "保存" })
|
||||
if (save.size != 1) fail("PURCHASE_ADDRESS_UPDATE_FAILED", "地址保存按钮不唯一,未创建订单")
|
||||
click(save.single(), "保存地址")
|
||||
var saved = waitFor { snapshot ->
|
||||
var saved = waitFor("PURCHASE_ADDRESS_SAVE_TIMEOUT", "地址保存超时,未创建订单") { snapshot ->
|
||||
snapshot.nodes.any { it.visible && it.label.contains(expected) } || finalSubmitTargets(snapshot).size == 1
|
||||
}
|
||||
if (finalSubmitTargets(saved).size != 1) {
|
||||
if (!driver.backPurchase()) fail("PURCHASE_ADDRESS_UPDATE_FAILED", "地址保存后无法返回订单页面,未创建订单")
|
||||
saved = waitFor { finalSubmitTargets(it).size == 1 }
|
||||
saved = waitFor("PURCHASE_ADDRESS_SAVE_TIMEOUT", "地址保存后无法返回订单页面,未创建订单") { finalSubmitTargets(it).size == 1 }
|
||||
}
|
||||
if (saved.nodes.none { it.visible && it.label.contains(expected) }) fail("PURCHASE_ADDRESS_UPDATE_FAILED", "地址保存后回读不一致,未创建订单")
|
||||
return ShippingAddressProof(expected, suffix)
|
||||
}
|
||||
|
||||
private fun waitFor(predicate: (UiSnapshot) -> Boolean): UiSnapshot {
|
||||
private fun waitFor(code: String, message: String, predicate: (UiSnapshot) -> Boolean): UiSnapshot {
|
||||
repeat(50) {
|
||||
val snapshot = driver.capture()
|
||||
pageProblem(snapshot)
|
||||
if (predicate(snapshot)) return snapshot
|
||||
pause(200)
|
||||
}
|
||||
fail("PURCHASE_ADDRESS_UPDATE_FAILED", "页面切换超时,未创建订单")
|
||||
fail(code, message)
|
||||
}
|
||||
|
||||
private fun purchasePanelScrollTargets(snapshot: UiSnapshot): List<SnapshotNode> {
|
||||
val screenWidth = snapshot.nodes.maxOfOrNull { it.bounds.right }?.coerceAtLeast(1) ?: 1
|
||||
val screenHeight = snapshot.nodes.maxOfOrNull { it.bounds.bottom }?.coerceAtLeast(1) ?: 1
|
||||
return snapshot.nodes.filter { node ->
|
||||
node.visible && node.enabled && node.scrollable &&
|
||||
node.bounds.height >= node.bounds.width &&
|
||||
node.bounds.width * 100 >= screenWidth * 60 &&
|
||||
node.bounds.height * 100 >= screenHeight * 20
|
||||
}.distinctBy { it.path }
|
||||
}
|
||||
|
||||
private fun viewportSignature(snapshot: UiSnapshot, panel: SnapshotNode): String = snapshot.nodes
|
||||
.filter { node -> node.visible && inside(node.bounds, panel.bounds) }
|
||||
.joinToString("|") { node ->
|
||||
listOf(node.className.orEmpty(), node.bounds.left, node.bounds.top, node.bounds.right, node.bounds.bottom, node.clickable, node.scrollable).joinToString(":")
|
||||
}
|
||||
|
||||
private fun inside(child: NodeBounds, parent: NodeBounds): Boolean =
|
||||
child.centerX in parent.left..parent.right && child.centerY in parent.top..parent.bottom
|
||||
|
||||
private fun uniqueClickable(snapshot: UiSnapshot, nodes: List<SnapshotNode>): List<SnapshotNode> {
|
||||
val byPath = snapshot.nodes.associateBy { it.path }
|
||||
return nodes.mapNotNull { source ->
|
||||
@@ -158,6 +192,23 @@ class PurchaseLiveAutomation(
|
||||
}.distinctBy { it.path }
|
||||
}
|
||||
|
||||
private fun mergedClickable(snapshot: UiSnapshot, nodes: List<SnapshotNode>): List<SnapshotNode> {
|
||||
val candidates = uniqueClickable(snapshot, nodes)
|
||||
val merged = mutableListOf<SnapshotNode>()
|
||||
candidates.sortedBy { it.bounds.width.toLong() * it.bounds.height }.forEach { candidate ->
|
||||
if (merged.none { existing -> nearDuplicate(existing.bounds, candidate.bounds) }) merged += candidate
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
private fun addressModifyTargets(snapshot: UiSnapshot): List<SnapshotNode> {
|
||||
val all = uniqueClickable(snapshot, snapshot.nodes.filter { it.visible && it.label == "修改" })
|
||||
if (all.size <= 1) return all
|
||||
|
||||
+1
@@ -8,6 +8,7 @@ interface PurchaseUiDriver {
|
||||
fun clickFresh(target: SnapshotNode): FreshActionResult
|
||||
fun inputFresh(target: SnapshotNode, value: String): FreshActionResult
|
||||
fun swipePurchase(direction: SwipeDirection, durationMs: Long): Boolean
|
||||
fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long): Boolean
|
||||
fun backPurchase(): Boolean
|
||||
}
|
||||
|
||||
|
||||
@@ -47,6 +47,27 @@ class PurchaseLiveAutomationTest {
|
||||
assertTrue(runCatching { PurchaseRuleParser.parse(LIVE_RULE.replace("readOrderResult", "pay")) }.isFailure)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clipped address is revealed by swiping the unique purchase panel`() {
|
||||
val driver = LiveDriver(addressClipped = true)
|
||||
val address = PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg12")
|
||||
|
||||
assertEquals("广东省广州市天园街道骏景花园骏晖轩1202_cg12", address.expectedAddress)
|
||||
assertEquals(1, driver.scopedSwipes)
|
||||
assertEquals(0, driver.genericSwipes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ambiguous purchase panels fail without swiping or clicking`() {
|
||||
val driver = LiveDriver(addressClipped = true, duplicatePanels = true)
|
||||
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg13") }
|
||||
.exceptionOrNull() as PurchaseLiveException
|
||||
|
||||
assertEquals("PURCHASE_ADDRESS_ENTRY_AMBIGUOUS", error.code)
|
||||
assertEquals(0, driver.scopedSwipes)
|
||||
assertTrue(driver.clicked.isEmpty())
|
||||
}
|
||||
|
||||
private fun input() = PurchaseExecutionInput(
|
||||
taskId = 11,
|
||||
executionMode = "live",
|
||||
@@ -61,21 +82,36 @@ class PurchaseLiveAutomationTest {
|
||||
addressSuffix = "_cg11",
|
||||
)
|
||||
|
||||
private class LiveDriver : PurchaseUiDriver {
|
||||
private class LiveDriver(
|
||||
private val addressClipped: Boolean = false,
|
||||
private val duplicatePanels: Boolean = false,
|
||||
) : PurchaseUiDriver {
|
||||
private var page = "confirmation"
|
||||
private var addressVisible = !addressClipped
|
||||
private var address = "广东省广州市天园街道骏景花园骏晖轩1202-old"
|
||||
val clicked = mutableListOf<String>()
|
||||
var submitClicks = 0
|
||||
var scopedSwipes = 0
|
||||
var genericSwipes = 0
|
||||
|
||||
override fun capture(): UiSnapshot = when (page) {
|
||||
"panel" -> snapshot(listOf(node("title", "收货地址"), node("modify", "修改", clickable = true), node("address", address)))
|
||||
"edit" -> snapshot(listOf(node("title", "修改收货地址"), node("detail", "详细地址"), node("editor", address, className = "android.widget.EditText"), node("save", "保存", clickable = true)))
|
||||
"order" -> snapshot(listOf(node("status", "待付款"), node("order", "订单号:PDD-202608210001"), node("time", "下单时间:2026-08-21 10:30:00"), node("pay", "立即支付", clickable = true)))
|
||||
else -> snapshot(listOf(
|
||||
node("phone", "138****5678", clickable = true), node("address", address),
|
||||
node("price", "¥20.00"), node("selected", "已选 黑色 XL"),
|
||||
node("quantity", "2", className = "android.widget.EditText"), node("submit", "提交订单", clickable = true),
|
||||
))
|
||||
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("quantity", "2", className = "android.widget.EditText"), node("submit", "提交订单", clickable = true),
|
||||
)
|
||||
if (duplicatePanels) nodes += node("panel2", "", scrollable = true, bounds = NodeBounds(0, 500, 1080, 2000))
|
||||
if (addressVisible) {
|
||||
nodes += node("phone", "138****5678", clickable = true)
|
||||
nodes += node("address", address)
|
||||
}
|
||||
snapshot(nodes)
|
||||
}
|
||||
}
|
||||
|
||||
override fun clickFresh(target: SnapshotNode): FreshActionResult {
|
||||
@@ -90,12 +126,23 @@ class PurchaseLiveAutomationTest {
|
||||
}
|
||||
|
||||
override fun inputFresh(target: SnapshotNode, value: String): FreshActionResult { address = value; return FreshActionResult.SUCCESS }
|
||||
override fun swipePurchase(direction: SwipeDirection, durationMs: Long): Boolean = true
|
||||
override fun swipePurchase(direction: SwipeDirection, durationMs: Long): Boolean { genericSwipes++; 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 backPurchase(): Boolean { page = "confirmation"; return true }
|
||||
|
||||
private fun snapshot(nodes: List<SnapshotNode>) = UiSnapshot(PDD, ACTIVITY, nodes)
|
||||
private fun node(path: String, text: String, clickable: Boolean = false, className: String = "android.widget.TextView") =
|
||||
SnapshotNode(path, null, text, null, null, className, NodeBounds(0, 0, 500, 100), clickable, false, false, false, true, true)
|
||||
private fun node(
|
||||
path: String,
|
||||
text: String,
|
||||
clickable: Boolean = false,
|
||||
scrollable: Boolean = false,
|
||||
className: String = "android.widget.TextView",
|
||||
bounds: NodeBounds = NodeBounds(0, 0, 500, 100),
|
||||
) = SnapshotNode(path, null, text, null, null, className, bounds, clickable, scrollable, false, false, true, true)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
|
||||
@@ -298,6 +298,9 @@ class PurchaseRehearsalExecutorTest {
|
||||
return true
|
||||
}
|
||||
|
||||
override fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long): Boolean =
|
||||
swipePurchase(direction, durationMs)
|
||||
|
||||
override fun backPurchase(): Boolean = true
|
||||
|
||||
private fun node(
|
||||
|
||||
Reference in New Issue
Block a user