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 18505dd..b628f41 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 @@ -779,9 +779,10 @@ class PurchaseLiveAutomation( /** * #335: the order button's own text keeps changing (促销文案、价格文案等),因此不再依赖 * [FINAL_SUBMIT_MARKERS] 文字匹配判定必要条件;改为用户确认的策略——在已识别的规格面板 - * (沿用 #331 的 [PURCHASE_CONFIRMATION_PANEL_TYPES] 判定)内,取最下沿最低、可点击、可用、 - * 尺寸非零的节点。多个节点并列最下沿时取最右侧并在诊断中标记“并列”。节点自身或其子树文字 - * 命中 [PAYMENT_MARKERS] 时明确失败、不返回目标。旧的文字匹配仅作诊断证据,不再是必要条件。 + * (沿用 #331 的 [PURCHASE_CONFIRMATION_PANEL_TYPES] 判定)内,取属于该面板、最下沿最低、 + * 可点击、可用、尺寸非零的节点。多个节点并列最下沿时取最右侧并在诊断中标记“并列”。节点自身 + * 或其子树文字命中 [SUBMIT_TARGET_BLOCKED_MARKERS] 时明确失败、不返回目标。旧的文字匹配 + * ([FINAL_SUBMIT_MARKERS])仅作诊断证据,不再是必要条件。 */ private fun finalSubmitTargets(snapshot: UiSnapshot): List { fun hasArea(node: SnapshotNode) = node.bounds.width > 0 && node.bounds.height > 0 @@ -790,6 +791,11 @@ class PurchaseLiveAutomation( panelDiagnostic("bottomSubmit;outcome=not_panel;type=${screen.specPanelType}") return emptyList() } + val container = panelContainerBounds(snapshot) + if (container == null) { + panelDiagnostic("bottomSubmit;outcome=no_container;type=${screen.specPanelType}") + return emptyList() + } // 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 @@ -797,7 +803,8 @@ class PurchaseLiveAutomation( 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 + node.bounds.height.toLong() * 100 <= screenHeight.toLong() * SUBMIT_ROW_MAX_HEIGHT_PERCENT && + inside(node.bounds, container) }.distinctBy { it.path } if (candidates.isEmpty()) { panelDiagnostic("bottomSubmit;outcome=no_candidates;type=${screen.specPanelType}") @@ -809,7 +816,7 @@ class PurchaseLiveAutomation( 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)) { + if (SUBMIT_TARGET_BLOCKED_MARKERS.any(subtreeLabels::contains)) { panelDiagnostic( "bottomSubmit;outcome=payment_blocked;tie=${tie.diagFlag()};class=${chosen.className};" + "w=${chosen.bounds.width};h=${chosen.bounds.height}", @@ -831,6 +838,48 @@ class PurchaseLiveAutomation( return snapshot.nodes.filter { it.path.startsWith(prefix) } } + /** + * #335 follow-up: the submit click target must belong to the recognized spec panel, + * not just be somewhere on screen (e.g. an underlying goods-detail-page bottom bar + * that is still in the accessibility tree behind the panel). [ParsedPddScreen + * .specPanelContainer] (PddScreenParser's `panelScrollable`) is not usable for this: + * it is the inner *scrollable dimension list* used only for heading/option parsing, + * and in a real PDD sheet the address/payment/submit rows sit OUTSIDE it as + * structural siblings, not descendants. Instead this climbs from the panel's unique + * quantity input — present for every recognized #331 panel type used here — to the + * LARGEST ancestor that still does not cover the whole screen, i.e. the whole + * bottom-sheet container. That is the same "does not cover the whole screen" bounded + * notion #331 already uses for `sharesBoundedPanelContainer`/`boundedScrollables`, + * just walked from the quantity input instead of recomputed from private internals. + * A candidate then belongs to the panel when its center point falls inside that + * container's bounds (geometric containment, not path prefix, since PDD's own + * accessibility tree can place the submit bar as a structural sibling of the panel + * container that is still visually part of the sheet). Returns null (no target) when + * the quantity input is missing or not unique, so the caller fails explicitly instead + * of guessing a container. + */ + private fun panelContainerBounds(snapshot: UiSnapshot): NodeBounds? { + val byPath = snapshot.nodes.associateBy { it.path } + val visible = snapshot.nodes.filter { it.visible } + val screenWidth = visible.maxOfOrNull { it.bounds.right } ?: return null + val screenHeight = visible.maxOfOrNull { it.bounds.bottom } ?: return null + val screenArea = screenWidth.toLong() * screenHeight + val quantity = snapshot.nodes.singleOrNull { node -> + node.visible && node.enabled && node.className == "android.widget.EditText" && + node.label.toIntOrNull()?.let { it > 0 } == true + } ?: return null + var lastBounded: SnapshotNode? = null + var current: SnapshotNode = quantity + while (true) { + val parent = current.parentPath?.let(byPath::get) ?: break + val parentArea = parent.bounds.width.toLong() * parent.bounds.height + if (parentArea <= 0 || parentArea >= screenArea) break + lastBounded = parent + current = parent + } + return lastBounded?.bounds + } + private fun Boolean.diagFlag(): Int = if (this) 1 else 0 private fun orderConfirmationReady(snapshot: UiSnapshot): Boolean { @@ -919,6 +968,13 @@ class PurchaseLiveAutomation( // whole-sheet/whole-body wrapper, never the order button itself. const val SUBMIT_ROW_MAX_HEIGHT_PERCENT = 30 val PAYMENT_MARKERS = listOf("立即支付", "确认支付", "输入支付密码") + // #335: broader than PAYMENT_MARKERS on purpose — this list is used ONLY by + // finalSubmitTargets' bottom-node subtree check, never by pageProblem/order-result + // parsing. "付款" alone must NOT join PAYMENT_MARKERS: UNPAID_MARKERS' "待付款" + // contains "付款" and PAYMENT_MARKERS gates the global pageProblem() payment guard, + // so adding it there would misclassify every ordinary unpaid-order page as a + // forbidden payment page. + val SUBMIT_TARGET_BLOCKED_MARKERS = listOf("立即支付", "确认支付", "去支付", "付款", "输入支付密码") val UNPAID_MARKERS = listOf("待付款", "待支付", "去支付") val ORDER_DETAIL_ENTRY_MARKERS = setOf("查看订单", "订单详情") val ORDER_CONTEXT_MARKERS = listOf("订单编号", "订单号", "下单时间", "创建时间") 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 9916c3c..f2f0fe8 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 @@ -438,6 +438,38 @@ class SpecPanelRecognitionTest { assertTrue(driver.clicked.isEmpty()) } + @Test + fun `a bottom node whose own label is go-pay is never a click target`() { + // "去支付" is not in PAYMENT_MARKERS (so the global pageProblem guard does not + // trip on it — a real 待付款/去支付 order-result page must stay usable), but it + // must still block the submit-target logic via SUBMIT_TARGET_BLOCKED_MARKERS. + 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 + + assertEquals("PURCHASE_SUBMIT_TARGET_AMBIGUOUS", error?.code) + assertTrue(driver.clicked.isEmpty()) + } + + @Test + fun `a bottom node whose own label is pay-fee is never a click target`() { + // "付款" alone is not in PAYMENT_MARKERS either (UNPAID_MARKERS' "待付款" contains + // it, so adding it to PAYMENT_MARKERS would misclassify every unpaid-order page), + // but it must still block the submit-target logic here. + 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 + + assertEquals("PURCHASE_SUBMIT_TARGET_AMBIGUOUS", 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 @@ -538,6 +570,59 @@ class SpecPanelRecognitionTest { assertEquals(1, driver.clicked.size) } + @Test + fun `a clickable node outside the recognized panel container is never chosen even if it is bottom most`() { + // A hand-built #331 REQUIRED_EVIDENCE panel ("r/panel", bounded, not full-screen) + // with its own valid submit row, plus an unrelated clickable bar further down the + // same accessibility tree but OUTSIDE "r/panel" — e.g. an underlying goods-detail + // page's "单独购买" bar still present behind the sheet. It is visually and + // structurally lower on screen (bottom=2400 vs the panel submit's bottom=2100), + // so a naive whole-screen "bottom-most clickable node" scan would wrongly pick it. + val t = SpecPanelFixtures.Tree(2400) + t.add("r/panel", "", NodeBounds(0, 300, 1080, 2100), "android.view.ViewGroup") + t.add("r/panel/close", "", NodeBounds(975, 320, 1050, 390), "android.widget.ImageView", clickable = true, description = "关闭") + t.add("r/panel/addr", "", NodeBounds(0, 340, 1080, 460), "android.view.ViewGroup", clickable = true) + t.add("r/panel/addr/phone", "测试,${SpecPanelFixtures.FAKE_PHONE},示例省示例市", NodeBounds(40, 360, 900, 440)) + t.add("r/panel/pay", "", NodeBounds(0, 500, 1080, 600), "android.view.ViewGroup", clickable = true) + t.add("r/panel/pay/t", "微信支付", NodeBounds(40, 520, 400, 580)) + t.add("r/panel/qty", "1", NodeBounds(400, 650, 600, 720), "android.widget.EditText", clickable = true) + t.add("r/panel/submit", "提交订单", NodeBounds(0, 2020, 1080, 2100), "android.widget.FrameLayout", clickable = true) + // Outside "r/panel": lower on screen, but not part of the recognized panel. + t.add("r/outsideBar", "单独购买", NodeBounds(0, 2200, 1080, 2400), "android.widget.FrameLayout", clickable = true) + val snapshot = t.snapshot() + + val screen = PddScreenParser.parse(snapshot, PurchaseRehearsalExecutor.DEFAULT_COLLECTOR, "", null) + assertEquals(SpecPanelType.REQUIRED_EVIDENCE, screen.specPanelType) + + val driver = StaticDriver(snapshot) + PurchaseLiveAutomation(driver, pause = {}).submitOrderOnce() + + assertEquals(listOf("提交订单"), driver.clicked) + } + + @Test + fun `no determinable panel container fails explicitly instead of guessing`() { + // Recognized (NORMAL_SCROLLABLE, via the headed-scrollable + selection-summary + // branch, which does not require a quantity input at all) but with NO quantity + // input anywhere: panelContainerBounds cannot determine a container, so + // finalSubmitTargets must fail explicitly rather than fall back to a whole-screen + // scan that could pick an unrelated bottom-most clickable node. + val recognizedNoQuantity = SpecPanelFixtures.sheet( + Sheet(quantityInput = false, adjustButtons = false, submit = "none"), + ) + val screen = PddScreenParser.parse(recognizedNoQuantity, PurchaseRehearsalExecutor.DEFAULT_COLLECTOR, "", null) + assertEquals(SpecPanelType.NORMAL_SCROLLABLE, screen.specPanelType) + + val snapshot = recognizedNoQuantity.copy( + nodes = recognizedNoQuantity.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 + + assertEquals("PURCHASE_SUBMIT_TARGET_AMBIGUOUS", error?.code) + assertTrue(driver.clicked.isEmpty()) + } + // --- Helpers ------------------------------------------------------------- private fun readySheet(expected: String): UiSnapshot {