fix(android): wait for stable address editor with transition buffers #261

This commit is contained in:
QiuSW
2026-09-10 16:27:30 +08:00
parent 7de4318001
commit 5ea07f10ad
4 changed files with 63 additions and 11 deletions
@@ -32,6 +32,7 @@ class PurchaseLiveException(val code: String, message: String, val actualUnitPri
class PurchaseLiveAutomation(
private val driver: PurchaseUiDriver,
private val pause: (Long) -> Unit = Thread::sleep,
private val diagnostic: (String) -> Unit = {},
) {
private var submitAttempted = false
private var savedAddressProof: ShippingAddressProof? = null
@@ -126,7 +127,9 @@ class PurchaseLiveAutomation(
}
pause(1_000)
snapshot = waitFor("PURCHASE_ADDRESS_PANEL_TIMEOUT", "收货地址页面打开超时,未创建订单") {
it.nodes.any { node -> node.visible && node.label.replace(" ", "") == "收货地址" }
it.packageName == PDD_PACKAGE &&
it.nodes.any { node -> node.visible && node.label.replace(" ", "") == "收货地址" } &&
addressModifyTargets(it).size == 1
}
return editAndVerifyAddress(snapshot, addressSuffix)
}
@@ -488,10 +491,8 @@ class PurchaseLiveAutomation(
structuralSubmitAnchor = null
val modify = addressModifyTargets(panel)
if (modify.size != 1) fail("PURCHASE_ADDRESS_EDIT_AMBIGUOUS", "没有找到唯一的地址修改按钮,未创建订单")
click(modify.single(), "修改地址")
val edit = waitFor("PURCHASE_ADDRESS_EDIT_TIMEOUT", "地址编辑页面打开超时,未创建订单") { snapshot ->
snapshot.nodes.any { it.visible && it.label.replace(" ", "").contains("详细地址") }
}
click(modify.single(), "修改地址", 1_000)
val edit = waitForAddressEditorReady()
val editors = shippingAddressEditors(edit)
if (editors.size != 1) fail("PURCHASE_ADDRESS_UPDATE_FAILED", "没有找到唯一的详细地址输入框,未创建订单")
val current = editors.single().label.trim()
@@ -504,7 +505,7 @@ class PurchaseLiveAutomation(
}
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(), "保存地址")
click(save.single(), "保存地址", 1_000)
var savedEvidence = waitForStableAddressEditorExit()
if (!savedAddressPageReady(savedEvidence, expected, suffix)) {
if (isPurchaseConfirmationPanel(savedEvidence)) {
@@ -715,13 +716,33 @@ class PurchaseLiveAutomation(
}.distinctBy { it.path }
}
private fun click(node: SnapshotNode, label: String) {
private fun waitForAddressEditorReady(): UiSnapshot {
var previous: SnapshotNode? = null
var previousActivity: String? = null
var count = 0
repeat(50) { poll ->
val snapshot = driver.capture()
pageProblem(snapshot)
val editors = if (snapshot.packageName == PDD_PACKAGE) shippingAddressEditors(snapshot) else emptyList()
count = editors.size
diagnostic("addressEditorCandidates=$count;addressEditorPolls=${poll + 1}")
val editor = editors.singleOrNull()
if (editor != null && previous != null && snapshot.activityName == previousActivity &&
editor.path == previous!!.path && editor.bounds == previous!!.bounds && editor.label == previous!!.label) return snapshot
previous = editor
previousActivity = snapshot.activityName
pause(200)
}
fail("PURCHASE_ADDRESS_UPDATE_FAILED", "详细地址输入框未稳定就绪,未创建订单 [candidates=$count]")
}
private fun click(node: SnapshotNode, label: String, settleMillis: Long = 500) {
when (driver.clickFresh(node)) {
FreshActionResult.SUCCESS -> Unit
FreshActionResult.AMBIGUOUS -> fail("RULE_AMBIGUOUS", "$label 不唯一")
else -> fail("RULE_ACTION_FAILED", "$label 点击失败")
}
pause(500)
pause(settleMillis)
}
private fun legacySubmitTargets(snapshot: UiSnapshot): List<SnapshotNode> = uniqueClickable(
@@ -107,7 +107,7 @@ class PurchaseRehearsalExecutor(
var addressProof: ShippingAddressProof? = null
var irreversibleStarted = false
val specSelectionProofs = mutableMapOf<String, ExactSpecSelectionProof>()
val live = PurchaseLiveAutomation(driver, pause)
val live = PurchaseLiveAutomation(driver, pause, panelDiagnostic)
for (action in rule.actions) {
// The immediate phase-two handoff can reuse the PDD page retained by
// spec_probe. A later manual retry may start from Agent (or another
@@ -58,7 +58,8 @@ class PurchaseDiagnosticStore(private val directory: File, private val now: () -
private val VERSION = Regex("[0-9]+\\.[0-9]+\\.[0-9]+")
private val FILE_NAME = Regex("[1-9][0-9]*_[A-Za-z0-9-]{1,80}\\.jsonl")
private val numbers = setOf("specEntryCandidates", "explicit", "nested", "bottomPurchase", "entryReadyWaitPolls",
"entryReadyWaitMillis", "scrollables", "headings", "options", "entryWaitPolls", "entryWaitMillis")
"entryReadyWaitMillis", "scrollables", "headings", "options", "entryWaitPolls", "entryWaitMillis",
"addressEditorCandidates", "addressEditorPolls")
private val booleans = setOf("panelAlreadyOpen", "reviewPage", "pageEvidence", "pageChanged", "summary", "quantity",
"orderAction", "closeControl", "paymentArea")
private val enums = mapOf(
@@ -18,6 +18,35 @@ import org.junit.Assert.assertTrue
import org.junit.Test
class PurchaseLiveAutomationTest {
@Test fun `address editor waits for loaded unique stable input after one second transitions`() {
val driver = LiveDriver()
var polls = 0
driver.editTransform = { snapshot ->
polls++
when (polls) {
1 -> snapshot.copy(nodes = snapshot.nodes.filterNot { it.path == "editor-address" })
2 -> snapshot.copy(nodes = snapshot.nodes.map { if (it.path == "editor-address") it.copy(text = "") else it })
else -> snapshot
}
}
val pauses = mutableListOf<Long>()
PurchaseLiveAutomation(driver, pause = { pauses += it }).updateShippingAddress("_cg81")
assertTrue(polls >= 4)
assertEquals("editor-address", driver.lastInputTargetPath)
assertEquals(3, pauses.count { it == 1_000L })
assertEquals(0, driver.submitClicks)
}
@Test fun `persistently ambiguous address editors never receive input`() {
val driver = LiveDriver()
driver.editTransform = { snapshot -> snapshot.copy(nodes = snapshot.nodes + snapshot.nodes.single { it.path == "editor-address" }.copy(path = "duplicate-editor")) }
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg81") }.exceptionOrNull() as PurchaseLiveException
assertEquals("PURCHASE_ADDRESS_UPDATE_FAILED", error.code)
assertTrue(error.message!!.contains("candidates=2"))
assertEquals(null, driver.lastInputTargetPath)
assertFalse(driver.clicked.contains("保存地址"))
}
@Test
fun `saved address recognizes split dynamic footer outside spec scroller without a swipe`() {
for (label in listOf("复购价,", "专享优惠,")) {
@@ -902,13 +931,14 @@ class PurchaseLiveAutomationTest {
var postSubmitCaptureCount = 0
val currentPage: String get() = page
var structuredTransform: (UiSnapshot) -> UiSnapshot = { it }
var editTransform: (UiSnapshot) -> UiSnapshot = { it }
override fun capture(): UiSnapshot {
if (submitClicks > 0) {
postSubmitCaptureCount++
if (pendingPostSubmitPages.isNotEmpty()) page = pendingPostSubmitPages.removeAt(0)
}
return currentSnapshot().let { if (page == "structured") structuredTransform(it) else it }
return currentSnapshot().let { if (page == "structured") structuredTransform(it) else if (page == "edit") editTransform(it) else it }
}
private fun currentSnapshot(): UiSnapshot = when (page) {