fix(android): submit target = bottom-most clickable node in spec panel (#335)
Task 570 (goods 8580) timed out because the order button's text kept changing (促销价文案 instead of "提交订单"), so FINAL_SUBMIT_MARKERS never matched and hasFinalSavedAddressEvidence stayed false forever. Per the user's confirmed strategy, finalSubmitTargets now ignores button text and instead picks the bottom-most clickable/enabled/non-zero-size node of an already-recognized spec panel (reusing #331's PddScreenParser recognition, including the REQUIRED_EVIDENCE fallback). Ties on the bottom edge pick the rightmost node and are flagged in diagnostics. A node whose own label or subtree contains a payment word (PAYMENT_MARKERS) is never a click target. A candidate taller than 30% of screen height is excluded so a full-sheet/ full-body wrapper container can never win the tie against the real bottom bar. The old FINAL_SUBMIT_MARKERS text match is kept only as non-required diagnostic evidence. hasFinalSavedAddressEvidence, finalConfirmation and submitOrderOnce all consume the same finalSubmitTargets, so the address-save evidence check and the final click use one consistent rule. Tests: PurchaseLiveAutomationTest's LiveDriver confirmation-page fixture now carries a full #331 REQUIRED_EVIDENCE structure (address/payment rows nested under the shared panel container) since finalSubmitTargets depends on panel recognition; one assertion that encoded the old bare-tap address activation is updated to reflect the now-wrapped clickable row. SpecPanelRecognitionTest replaces the old text-matching submit-target test with cases for the 570 promo-price bottom row, legacy "提交订单" panels, side-by-side tie/rightmost, payment-word blocking (visible and invisible-subtree), zero-size exclusion, and unrecognized pages. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
This commit is contained in:
+56
-14
@@ -776,26 +776,63 @@ class PurchaseLiveAutomation(
|
||||
pause(500)
|
||||
}
|
||||
|
||||
/**
|
||||
* #335: the order button's own text keeps changing (促销文案、价格文案等),因此不再依赖
|
||||
* [FINAL_SUBMIT_MARKERS] 文字匹配判定必要条件;改为用户确认的策略——在已识别的规格面板
|
||||
* (沿用 #331 的 [PURCHASE_CONFIRMATION_PANEL_TYPES] 判定)内,取最下沿最低、可点击、可用、
|
||||
* 尺寸非零的节点。多个节点并列最下沿时取最右侧并在诊断中标记“并列”。节点自身或其子树文字
|
||||
* 命中 [PAYMENT_MARKERS] 时明确失败、不返回目标。旧的文字匹配仅作诊断证据,不再是必要条件。
|
||||
*/
|
||||
private fun finalSubmitTargets(snapshot: UiSnapshot): List<SnapshotNode> {
|
||||
val byPath = snapshot.nodes.associateBy { it.path }
|
||||
fun hasArea(node: SnapshotNode) = node.bounds.width > 0 && node.bounds.height > 0
|
||||
// #331: recognition may treat the submit action as auxiliary, but the
|
||||
// final click target stays strict. A zero-size label or a zero-size
|
||||
// clickable container is never a click target.
|
||||
fun clickableAncestorHasArea(node: SnapshotNode): Boolean {
|
||||
var current: SnapshotNode? = node
|
||||
while (current != null && !current.clickable) current = current.parentPath?.let(byPath::get)
|
||||
return current != null && hasArea(current)
|
||||
val screen = PddScreenParser.parse(snapshot, PurchaseRehearsalExecutor.DEFAULT_COLLECTOR, "", null)
|
||||
if (screen.specPanelType !in PURCHASE_CONFIRMATION_PANEL_TYPES) {
|
||||
panelDiagnostic("bottomSubmit;outcome=not_panel;type=${screen.specPanelType}")
|
||||
return emptyList()
|
||||
}
|
||||
return uniqueClickable(
|
||||
snapshot,
|
||||
snapshot.nodes.filter { node ->
|
||||
node.visible && node.enabled && hasArea(node) && clickableAncestorHasArea(node) &&
|
||||
FINAL_SUBMIT_MARKERS.any { node.label == it || node.label.startsWith(it) }
|
||||
},
|
||||
// A full-sheet or full-body wrapper is sometimes clickable too (it can intercept
|
||||
// touches); it is never the order button itself, so it must not win a bottom-edge
|
||||
// tie against the real, compact bottom bar. Same guard family as the #331
|
||||
// ENTRY_ROW_MAX_HEIGHT_PERCENT check used for address/payment rows.
|
||||
val screenHeight = snapshot.nodes.filter { it.visible }.maxOfOrNull { it.bounds.bottom }?.coerceAtLeast(1) ?: 1
|
||||
val candidates = snapshot.nodes.filter { node ->
|
||||
node.visible && node.enabled && node.clickable && hasArea(node) &&
|
||||
node.bounds.height.toLong() * 100 <= screenHeight.toLong() * SUBMIT_ROW_MAX_HEIGHT_PERCENT
|
||||
}.distinctBy { it.path }
|
||||
if (candidates.isEmpty()) {
|
||||
panelDiagnostic("bottomSubmit;outcome=no_candidates;type=${screen.specPanelType}")
|
||||
return emptyList()
|
||||
}
|
||||
val maxBottom = candidates.maxOf { it.bounds.bottom }
|
||||
val bottomRow = candidates.filter { it.bounds.bottom == maxBottom }
|
||||
val tie = bottomRow.size > 1
|
||||
val chosen = bottomRow.maxBy { it.bounds.right }
|
||||
val subtreeLabels = (listOf(chosen.label) + subtreeDescendants(chosen, snapshot).map(SnapshotNode::label))
|
||||
.joinToString("")
|
||||
if (PAYMENT_MARKERS.any(subtreeLabels::contains)) {
|
||||
panelDiagnostic(
|
||||
"bottomSubmit;outcome=payment_blocked;tie=${tie.diagFlag()};class=${chosen.className};" +
|
||||
"w=${chosen.bounds.width};h=${chosen.bounds.height}",
|
||||
)
|
||||
return emptyList()
|
||||
}
|
||||
val hasPrice = PRICE_PRESENCE.containsMatchIn(subtreeLabels)
|
||||
val legacyMarker = FINAL_SUBMIT_MARKERS.any { subtreeLabels == it || subtreeLabels.startsWith(it) }
|
||||
panelDiagnostic(
|
||||
"bottomSubmit;outcome=ok;tie=${tie.diagFlag()};class=${chosen.className};" +
|
||||
"w=${chosen.bounds.width};h=${chosen.bounds.height};price=${hasPrice.diagFlag()};" +
|
||||
"legacyMarker=${legacyMarker.diagFlag()};type=${screen.specPanelType}",
|
||||
)
|
||||
return listOf(chosen)
|
||||
}
|
||||
|
||||
private fun subtreeDescendants(node: SnapshotNode, snapshot: UiSnapshot): List<SnapshotNode> {
|
||||
val prefix = "${node.path}/"
|
||||
return snapshot.nodes.filter { it.path.startsWith(prefix) }
|
||||
}
|
||||
|
||||
private fun Boolean.diagFlag(): Int = if (this) 1 else 0
|
||||
|
||||
private fun orderConfirmationReady(snapshot: UiSnapshot): Boolean {
|
||||
if (snapshot.packageName != PDD_PACKAGE) return false
|
||||
if (snapshot.nodes.any { it.visible && it.enabled && MASKED_PHONE.containsMatchIn(it.label) }) return true
|
||||
@@ -876,6 +913,11 @@ class PurchaseLiveAutomation(
|
||||
)
|
||||
val PURCHASE_CONFIRMATION_PANEL_TYPES = STRONG_PURCHASE_PANEL_TYPES + SpecPanelType.REQUIRED_EVIDENCE
|
||||
val FINAL_SUBMIT_MARKERS = listOf("提交订单", "现在买,仅", "确认购买")
|
||||
// #335: presence-only signal for diagnostics; never records the actual price digits.
|
||||
val PRICE_PRESENCE = Regex("[¥¥][0-9]")
|
||||
// #335: a bottom-edge candidate taller than this share of screen height is a
|
||||
// whole-sheet/whole-body wrapper, never the order button itself.
|
||||
const val SUBMIT_ROW_MAX_HEIGHT_PERCENT = 30
|
||||
val PAYMENT_MARKERS = listOf("立即支付", "确认支付", "输入支付密码")
|
||||
val UNPAID_MARKERS = listOf("待付款", "待支付", "去支付")
|
||||
val ORDER_DETAIL_ENTRY_MARKERS = setOf("查看订单", "订单详情")
|
||||
|
||||
@@ -197,7 +197,12 @@ class PurchaseLiveAutomationTest {
|
||||
val automation = PurchaseLiveAutomation(driver, pause = {})
|
||||
val address = automation.updateShippingAddress("_cg11")
|
||||
assertEquals("广东省广州市天园街道骏景花园骏晖轩1202_cg11", address.expectedAddress)
|
||||
assertEquals(1, driver.addressTaps)
|
||||
// #335: the confirmation-page fixture now wraps the masked phone in a clickable
|
||||
// row (required so the page satisfies #331 REQUIRED_EVIDENCE panel recognition,
|
||||
// which finalSubmitTargets depends on). That changes address entry activation
|
||||
// from a bare TAP to a CLICK through the row's path, same as production PDD rows.
|
||||
assertEquals(0, driver.addressTaps)
|
||||
assertEquals(1, driver.addressPathClicks)
|
||||
assertEquals("editor-address", driver.lastInputTargetPath)
|
||||
val final = automation.finalConfirmation(input(), address)
|
||||
assertEquals("_cg11", final.addressSuffix)
|
||||
@@ -872,38 +877,50 @@ class PurchaseLiveAutomationTest {
|
||||
"unknown" -> UiSnapshot("example.untrusted", "example.untrusted.UnknownActivity", emptyList())
|
||||
"pdd-home" -> UiSnapshot(PDD, "com.xunmeng.pinduoduo.ui.activity.MainFrameActivity", listOf(node("home", "拼多多首页")))
|
||||
else -> {
|
||||
// #335: the confirmation page must satisfy PddScreenParser's spec-panel
|
||||
// recognition (#331 REQUIRED_EVIDENCE fallback: address entry + payment
|
||||
// entry + one quantity input, all sharing the "panel" container) so the
|
||||
// new bottom-most-clickable-node submit rule has a recognized panel to
|
||||
// operate on. "panel/submit" keeps the legacy exact "提交订单" label so
|
||||
// existing click-driven state transitions below stay unchanged.
|
||||
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-parent", "", clickable = true), node("submit", "提交订单", parentPath = "submit-parent"),
|
||||
node("panel", "", scrollable = true, parentPath = "root", bounds = NodeBounds(0, 400, 1080, 2100)),
|
||||
node("panel/price", "¥20.00", parentPath = "panel", bounds = NodeBounds(20, 420, 300, 470)),
|
||||
node("panel/selected", "已选 黑色 XL", parentPath = "panel", bounds = NodeBounds(20, 480, 700, 530)),
|
||||
node("panel/quantity", "2", className = "android.widget.EditText", parentPath = "panel", bounds = NodeBounds(400, 560, 600, 620)),
|
||||
node("panel/payment-row", "", clickable = true, parentPath = "panel", bounds = NodeBounds(20, 640, 1060, 710)),
|
||||
node("panel/payment-row/label", "微信支付", parentPath = "panel/payment-row", bounds = NodeBounds(40, 650, 300, 700)),
|
||||
node("panel/submit", "提交订单", clickable = true, parentPath = "panel", bounds = NodeBounds(20, 1900, 1060, 2080)),
|
||||
)
|
||||
if (duplicatePanels) nodes += node("panel2", "", scrollable = true, bounds = NodeBounds(0, 500, 1080, 2000))
|
||||
if (addressVisible) {
|
||||
when {
|
||||
duplicateSemanticAddressCards -> {
|
||||
nodes += node("address-layer-a", "", clickable = true, bounds = NodeBounds(0, 620, 1080, 840))
|
||||
nodes += node("address-layer-a/phone", "138****5678", parentPath = "address-layer-a", bounds = NodeBounds(20, 650, 400, 710))
|
||||
nodes += node("address-layer-a/detail", "广东省广州市天园街道骏景花园", parentPath = "address-layer-a", bounds = NodeBounds(20, 720, 900, 790))
|
||||
nodes += node("address-layer-b", "", clickable = true, bounds = NodeBounds(0, 900, 1080, 1120))
|
||||
nodes += node("address-layer-b/phone", "138****5678", parentPath = "address-layer-b", bounds = NodeBounds(20, 930, 400, 990))
|
||||
nodes += node("address-layer-b/detail", "广东省广州市天园街道骏景花园", parentPath = "address-layer-b", bounds = NodeBounds(20, 1000, 900, 1070))
|
||||
nodes += node("panel/address-layer-a", "", clickable = true, parentPath = "panel", bounds = NodeBounds(0, 820, 1080, 900))
|
||||
nodes += node("panel/address-layer-a/phone", "138****5678", parentPath = "panel/address-layer-a", bounds = NodeBounds(20, 830, 400, 860))
|
||||
nodes += node("panel/address-layer-a/detail", "广东省广州市天园街道骏景花园", parentPath = "panel/address-layer-a", bounds = NodeBounds(20, 862, 900, 898))
|
||||
nodes += node("panel/address-layer-b", "", clickable = true, parentPath = "panel", bounds = NodeBounds(0, 910, 1080, 990))
|
||||
nodes += node("panel/address-layer-b/phone", "138****5678", parentPath = "panel/address-layer-b", bounds = NodeBounds(20, 920, 400, 950))
|
||||
nodes += node("panel/address-layer-b/detail", "广东省广州市天园街道骏景花园", parentPath = "panel/address-layer-b", bounds = NodeBounds(20, 952, 900, 988))
|
||||
}
|
||||
duplicateAddressCards -> {
|
||||
nodes += node("address-card-a", "", clickable = true, bounds = NodeBounds(0, 620, 1080, 820))
|
||||
nodes += node("address-card-a/phone", "138****5678", parentPath = "address-card-a", bounds = NodeBounds(20, 650, 400, 710))
|
||||
nodes += node("address-card-a/detail", "广东省广州市天园街道一号", parentPath = "address-card-a", bounds = NodeBounds(20, 720, 900, 780))
|
||||
nodes += node("address-card-b", "", clickable = true, bounds = NodeBounds(0, 840, 1080, 1040))
|
||||
nodes += node("address-card-b/phone", "138****5678", parentPath = "address-card-b", bounds = NodeBounds(20, 870, 400, 930))
|
||||
nodes += node("address-card-b/detail", "广东省广州市天园街道二号", parentPath = "address-card-b", bounds = NodeBounds(20, 940, 900, 1000))
|
||||
nodes += node("panel/address-card-a", "", clickable = true, parentPath = "panel", bounds = NodeBounds(0, 820, 1080, 900))
|
||||
nodes += node("panel/address-card-a/phone", "138****5678", parentPath = "panel/address-card-a", bounds = NodeBounds(20, 830, 400, 860))
|
||||
nodes += node("panel/address-card-a/detail", "广东省广州市天园街道一号", parentPath = "panel/address-card-a", bounds = NodeBounds(20, 862, 900, 898))
|
||||
nodes += node("panel/address-card-b", "", clickable = true, parentPath = "panel", bounds = NodeBounds(0, 910, 1080, 990))
|
||||
nodes += node("panel/address-card-b/phone", "138****5678", parentPath = "panel/address-card-b", bounds = NodeBounds(20, 920, 400, 950))
|
||||
nodes += node("panel/address-card-b/detail", "广东省广州市天园街道二号", parentPath = "panel/address-card-b", bounds = NodeBounds(20, 952, 900, 988))
|
||||
}
|
||||
duplicatePhoneNodesSameCard -> {
|
||||
nodes += node("address-card", "", clickable = true, bounds = NodeBounds(0, 620, 1080, 900))
|
||||
nodes += node("address-card/phone-a", "138****5678", parentPath = "address-card", bounds = NodeBounds(20, 650, 400, 710))
|
||||
nodes += node("address-card/phone-b", "138****5678", parentPath = "address-card", bounds = NodeBounds(20, 720, 440, 790))
|
||||
nodes += node("panel/address-card", "", clickable = true, parentPath = "panel", bounds = NodeBounds(0, 820, 1080, 900))
|
||||
nodes += node("panel/address-card/phone-a", "138****5678", parentPath = "panel/address-card", bounds = NodeBounds(20, 830, 400, 860))
|
||||
nodes += node("panel/address-card/phone-b", "138****5678", parentPath = "panel/address-card", bounds = NodeBounds(20, 862, 440, 892))
|
||||
}
|
||||
else -> {
|
||||
nodes += node("panel/address-row", "", clickable = true, parentPath = "panel", bounds = NodeBounds(0, 820, 1080, 900))
|
||||
nodes += node("panel/address-row/phone", "138****5678", parentPath = "panel/address-row", bounds = NodeBounds(20, 830, 400, 890))
|
||||
}
|
||||
else -> nodes += node("phone", "138****5678")
|
||||
}
|
||||
val suffixStart = address.lastIndexOf("_cg")
|
||||
val addressBody = if (suffixStart >= 0) address.substring(0, suffixStart) else address
|
||||
@@ -911,10 +928,10 @@ class PurchaseLiveAutomationTest {
|
||||
when {
|
||||
hideConfirmationSuffix -> nodes += node("address", addressBody)
|
||||
splitConfirmationAddress -> {
|
||||
nodes += node("address-body", addressBody, bounds = NodeBounds(20, 700, 900, 780))
|
||||
nodes += node("address-suffix", addressSuffix, bounds = NodeBounds(20, 780, 300, 840))
|
||||
nodes += node("address-body", addressBody, bounds = NodeBounds(20, 1000, 900, 1080))
|
||||
nodes += node("address-suffix", addressSuffix, bounds = NodeBounds(20, 1090, 300, 1150))
|
||||
if (duplicateConfirmationSuffix) {
|
||||
nodes += node("address-suffix-2", addressSuffix, bounds = NodeBounds(500, 780, 780, 840))
|
||||
nodes += node("address-suffix-2", addressSuffix, bounds = NodeBounds(500, 1090, 780, 1150))
|
||||
}
|
||||
}
|
||||
else -> nodes += node("address", address)
|
||||
|
||||
@@ -388,50 +388,156 @@ class SpecPanelRecognitionTest {
|
||||
|
||||
// --- Strict final submit click -------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `submit click fails for zero size hidden unmatched or duplicated buttons`() {
|
||||
fun page(build: SpecPanelFixtures.Tree.() -> Unit) = SpecPanelFixtures.Tree(2216).apply(build).snapshot()
|
||||
val cases = mapOf(
|
||||
"hiddenZeroLabel" to SpecPanelFixtures.sheet(Sheet(submit = "hidden")),
|
||||
"zeroSizeVisibleLabel" to page {
|
||||
add("r/submit", "", NodeBounds(0, 2185, 1080, 2216), "android.widget.FrameLayout", clickable = true)
|
||||
add("r/submit/t", "提交订单", NodeBounds(0, 0, 0, 0))
|
||||
},
|
||||
"zeroSizeContainer" to page {
|
||||
add("r/submit", "", NodeBounds(0, 0, 0, 0), "android.widget.FrameLayout", clickable = true)
|
||||
add("r/submit/t", "提交订单", NodeBounds(157, 2179, 922, 2216))
|
||||
},
|
||||
"labelMismatch" to SpecPanelFixtures.sheet(),
|
||||
"duplicated" to page {
|
||||
add("r/a", "", NodeBounds(0, 2000, 540, 2216), "android.widget.FrameLayout", clickable = true)
|
||||
add("r/a/t", "提交订单", NodeBounds(20, 2050, 520, 2150))
|
||||
add("r/b", "", NodeBounds(540, 2000, 1080, 2216), "android.widget.FrameLayout", clickable = true)
|
||||
add("r/b/t", "提交订单", NodeBounds(560, 2050, 1060, 2150))
|
||||
},
|
||||
)
|
||||
cases.forEach { (name, snapshot) ->
|
||||
val driver = StaticDriver(snapshot)
|
||||
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}).submitOrderOnce() }.exceptionOrNull() as? PurchaseLiveException
|
||||
// #335: the order button's text keeps changing across PDD builds (促销文案/价格文案),
|
||||
// so the click target is now the bottom-most clickable, enabled, non-zero-size node of
|
||||
// a *recognized* spec panel, not a text match. These fixtures build on
|
||||
// [SpecPanelFixtures.sheet] (address + payment + quantity present, so #331 recognizes
|
||||
// the panel) with `submit = "none"` and then splice in the bottom-row scenario under
|
||||
// test, mirroring how [readySheet] already extends a base fixture elsewhere in this file.
|
||||
|
||||
assertEquals(name, "PURCHASE_SUBMIT_TARGET_AMBIGUOUS", error?.code)
|
||||
assertTrue(name, driver.clicked.isEmpty())
|
||||
}
|
||||
@Test
|
||||
fun `an unrecognized page never yields a submit target`() {
|
||||
// #335: the button's own text no longer has to match any known marker; this used
|
||||
// to fail as "labelMismatch" and must now succeed instead (see the dedicated
|
||||
// wording tests below). Only "the page is not a recognized spec panel" still blocks.
|
||||
val driver = StaticDriver(SpecPanelFixtures.addressListPage())
|
||||
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}).submitOrderOnce() }.exceptionOrNull() as? PurchaseLiveException
|
||||
|
||||
assertEquals("PURCHASE_SUBMIT_TARGET_AMBIGUOUS", error?.code)
|
||||
assertTrue(driver.clicked.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unique visible submit button is still clickable exactly once`() {
|
||||
val driver = StaticDriver(
|
||||
SpecPanelFixtures.Tree(2216).apply {
|
||||
add("r/submit", "", NodeBounds(0, 2135, 1080, 2216), "android.widget.FrameLayout", clickable = true)
|
||||
add("r/submit/t", "提交订单", NodeBounds(157, 2150, 922, 2210))
|
||||
}.snapshot(),
|
||||
fun `a bottom node whose own label carries a payment word is never a click target`() {
|
||||
val recognizedBase = SpecPanelFixtures.sheet(Sheet(submit = "none"))
|
||||
val snapshot = recognizedBase.copy(
|
||||
nodes = recognizedBase.nodes + node("r/submit", "立即支付", NodeBounds(0, 2135, 1080, 2216), clickable = true, parent = "r"),
|
||||
)
|
||||
val driver = StaticDriver(snapshot)
|
||||
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}).submitOrderOnce() }.exceptionOrNull() as? PurchaseLiveException
|
||||
|
||||
// The project-wide page-problem guard (pageProblem) rejects any visible payment
|
||||
// wording before the submit-target logic even runs; either way, nothing is clicked.
|
||||
assertEquals("PURCHASE_PAYMENT_FORBIDDEN", error?.code)
|
||||
assertTrue(driver.clicked.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a bottom node whose subtree carries a payment word is never a click target`() {
|
||||
val recognizedBase = SpecPanelFixtures.sheet(Sheet(submit = "none"))
|
||||
val snapshot = recognizedBase.copy(
|
||||
nodes = recognizedBase.nodes + listOf(
|
||||
node("r/submit", "", NodeBounds(0, 2135, 1080, 2216), clickable = true, parent = "r"),
|
||||
node("r/submit/t", "确认支付 ¥23.99", NodeBounds(157, 2150, 922, 2210), parent = "r/submit"),
|
||||
),
|
||||
)
|
||||
val driver = StaticDriver(snapshot)
|
||||
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}).submitOrderOnce() }.exceptionOrNull() as? PurchaseLiveException
|
||||
|
||||
assertEquals("PURCHASE_PAYMENT_FORBIDDEN", error?.code)
|
||||
assertTrue(driver.clicked.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an invisible payment word in the bottom node subtree still blocks the click`() {
|
||||
// The project-wide pageProblem guard only scans *visible* labels; an invisible
|
||||
// descendant would slip past it, so the submit-target logic must catch it on its
|
||||
// own (#335 safety condition 3 covers "自身或子树文字" regardless of visibility).
|
||||
val recognizedBase = SpecPanelFixtures.sheet(Sheet(submit = "none"))
|
||||
val hiddenPaymentText = SnapshotNode(
|
||||
"r/submit/hidden", "r/submit", "输入支付密码", null, null, "android.widget.TextView",
|
||||
NodeBounds(0, 0, 0, 0), false, false, false, false, true, false,
|
||||
)
|
||||
val snapshot = recognizedBase.copy(
|
||||
nodes = recognizedBase.nodes + listOf(
|
||||
node("r/submit", "", NodeBounds(0, 2135, 1080, 2216), clickable = true, parent = "r"),
|
||||
node("r/submit/t", "提交订单", NodeBounds(157, 2150, 922, 2210), parent = "r/submit"),
|
||||
hiddenPaymentText,
|
||||
),
|
||||
)
|
||||
val driver = StaticDriver(snapshot)
|
||||
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}).submitOrderOnce() }.exceptionOrNull() as? PurchaseLiveException
|
||||
|
||||
assertEquals("PURCHASE_SUBMIT_TARGET_AMBIGUOUS", error?.code)
|
||||
assertTrue(driver.clicked.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `zero size bottom node is ignored in favor of the next non-zero clickable node`() {
|
||||
// #335: the click target is the CLICKABLE node itself; its own accessibility text
|
||||
// is what a real driver taps, so the button carries its own label directly here
|
||||
// (no separate text child) to make the assertion check the real click target.
|
||||
val base = SpecPanelFixtures.sheet(Sheet(submit = "none"))
|
||||
val snapshot = base.copy(
|
||||
nodes = base.nodes + listOf(
|
||||
node("r/real-submit", "选择颜色分类及尺码后,提交订单", NodeBounds(0, 2135, 1080, 2200), clickable = true, parent = "r"),
|
||||
// A taller, zero-*area* node below it (right==left) must never win: it has no clickable area.
|
||||
node("r/decoy", "", NodeBounds(500, 2200, 500, 2216), clickable = true, parent = "r"),
|
||||
),
|
||||
)
|
||||
val driver = StaticDriver(snapshot)
|
||||
|
||||
PurchaseLiveAutomation(driver, pause = {}).submitOrderOnce()
|
||||
|
||||
assertEquals(listOf("选择颜色分类及尺码后,提交订单"), driver.clicked)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `two side by side bottom buttons pick the rightmost and are reported as a tie`() {
|
||||
val base = SpecPanelFixtures.sheet(Sheet(submit = "none"))
|
||||
val diagnostics = mutableListOf<String>()
|
||||
val snapshot = base.copy(
|
||||
nodes = base.nodes + listOf(
|
||||
// Avoid quickBuyAliases ("现在买") in either label: it would flip specPanelType
|
||||
// to QUICK_CONFIRMATION, which is intentionally outside the recognized set here.
|
||||
node("r/a", "限时优惠 ¥19.6", NodeBounds(0, 2000, 540, 2216), clickable = true, parent = "r"),
|
||||
node("r/b", "大促价,¥16.9", NodeBounds(540, 2000, 1080, 2216), clickable = true, parent = "r"),
|
||||
),
|
||||
)
|
||||
val driver = StaticDriver(snapshot)
|
||||
|
||||
PurchaseLiveAutomation(driver, pause = {}, panelDiagnostic = diagnostics::add).submitOrderOnce()
|
||||
|
||||
// r/b is the rightmost of the tied bottom row, so it is the one clicked.
|
||||
assertEquals(listOf("大促价,¥16.9"), driver.clicked)
|
||||
assertTrue(diagnostics.any { it.contains("outcome=ok") && it.contains("tie=1") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unique visible submit button is still clickable exactly once regardless of its wording`() {
|
||||
val base = SpecPanelFixtures.sheet(Sheet(submit = "none"))
|
||||
val snapshot = base.copy(
|
||||
nodes = base.nodes + node("r/submit", "提交订单", NodeBounds(0, 2135, 1080, 2216), clickable = true, parent = "r"),
|
||||
)
|
||||
val driver = StaticDriver(snapshot)
|
||||
|
||||
PurchaseLiveAutomation(driver, pause = {}).submitOrderOnce()
|
||||
|
||||
assertEquals(listOf("提交订单"), driver.clicked)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `task 570 promo priced bottom row is the unique submit target`() {
|
||||
// #335 origin: goods 8580 task 570, the bottom button's text was split across
|
||||
// three sibling text nodes ("大促价," / "仅 ¥30.8" / "¥39.9") instead of one
|
||||
// "提交订单"-prefixed label. This is the exact case the strategy targets.
|
||||
val base = SpecPanelFixtures.sheet(Sheet(submit = "none"))
|
||||
val snapshot = base.copy(
|
||||
nodes = base.nodes + listOf(
|
||||
node("r/submit", "", NodeBounds(0, 2181, 1080, 2216), clickable = true, parent = "r"),
|
||||
node("r/submit/l1", "大促价,", NodeBounds(40, 2190, 300, 2210), parent = "r/submit"),
|
||||
node("r/submit/l2", "仅 ¥30.8", NodeBounds(320, 2190, 600, 2210), parent = "r/submit"),
|
||||
node("r/submit/l3", "¥39.9", NodeBounds(620, 2190, 780, 2210), parent = "r/submit"),
|
||||
),
|
||||
)
|
||||
val driver = StaticDriver(snapshot)
|
||||
|
||||
PurchaseLiveAutomation(driver, pause = {}).submitOrderOnce()
|
||||
|
||||
assertEquals(listOf(""), driver.clicked)
|
||||
assertEquals(1, driver.clicked.size)
|
||||
}
|
||||
|
||||
// --- Helpers -------------------------------------------------------------
|
||||
|
||||
private fun readySheet(expected: String): UiSnapshot {
|
||||
|
||||
Reference in New Issue
Block a user