From 2bc624f62962f45b5ff7ef5ff1cb5842a1fdc639 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Tue, 22 Sep 2026 15:04:28 +0800 Subject: [PATCH] fix(android): submit target = bottom-most clickable node in spec panel (#335) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F --- .../automation/PurchaseLiveAutomation.kt | 70 +++++-- .../agent/PurchaseLiveAutomationTest.kt | 65 ++++--- .../goauto/agent/SpecPanelRecognitionTest.kt | 172 ++++++++++++++---- 3 files changed, 236 insertions(+), 71 deletions(-) diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseLiveAutomation.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseLiveAutomation.kt index 7e3144c..18505dd 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseLiveAutomation.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseLiveAutomation.kt @@ -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 { - 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 { + 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("查看订单", "订单详情") diff --git a/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseLiveAutomationTest.kt b/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseLiveAutomationTest.kt index f464187..a1521d9 100644 --- a/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseLiveAutomationTest.kt +++ b/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseLiveAutomationTest.kt @@ -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) diff --git a/android/app/src/test/java/cn/ilapage/goauto/agent/SpecPanelRecognitionTest.kt b/android/app/src/test/java/cn/ilapage/goauto/agent/SpecPanelRecognitionTest.kt index b6360b0..9916c3c 100644 --- a/android/app/src/test/java/cn/ilapage/goauto/agent/SpecPanelRecognitionTest.kt +++ b/android/app/src/test/java/cn/ilapage/goauto/agent/SpecPanelRecognitionTest.kt @@ -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() + 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 {