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 2fdc8d5..72e9495 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 @@ -777,26 +777,37 @@ class PurchaseLiveAutomation( } /** - * #335 (2nd revision, task 594 real-device failure): the returned target must be a - * node with a NON-BLANK OWN LABEL, not the (often label-less) clickable container. - * [PurchaseUiDriver.clickFresh] re-finds its target live by - * `preferredOrDescendantLabel() == target.label && className && center±32` and only - * THEN climbs to the nearest clickable ancestor and clicks it (see - * `GoAutoAccessibilityService.clickFreshDetailed`). A label-less clickable - * FrameLayout's [SnapshotNode.label] is `""`, but its live `preferredOrDescendantLabel()` - * digs into a child and returns real text — so the re-find never matches and the click - * silently fails as `TARGET_NOT_FOUND` right after the irreversible boundary (task 594: - * "大促价," bottom bar selected correctly, click never happened). Aiming at the - * labelled leaf instead — the same convention already used by - * `ImageSearchCandidatePolicy.clickTargetInside` — lets the existing climb-to-ancestor - * logic land the tap on the right container. + * #335 (3rd revision, reviewer cross-check on real dumps — Samsung sample): a + * two-step "row, then leaf" pick, not a single whole-panel leaf scan. A whole-panel + * scan can pick a labelled leaf that lives in a DIFFERENT clickable row than the real + * bottom bar — on the Samsung sample the bottom bar is a label-less 31px FrameLayout + * whose only text node is zero-size, so a naive scan falls through to the next + * lowest labelled leaf, which sits in the PAYMENT-METHOD row above it, and the climb + * from there lands the click on "change payment method" instead of the order button. * - * Rule: within the recognized spec panel's container (#331 judgment, [PURCHASE_CONFIRMATION_PANEL_TYPES]), - * among visible/enabled/non-zero-size nodes with a non-blank own label that have a - * clickable ancestor (or are clickable themselves) of non-zero size, pick the one with - * the lowest bottom edge; ties go to the rightmost. The nearest clickable ancestor's - * subtree must not contain [SUBMIT_TARGET_BLOCKED_MARKERS]. [FINAL_SUBMIT_MARKERS] text - * match is diagnostic only, never required. + * Step 1 — row: among visible/enabled/non-zero-size CLICKABLE nodes inside the + * recognized panel's container (#331 judgment) with height ≤ [SUBMIT_ROW_MAX_HEIGHT_PERCENT] + * of screen height (same guard as 2bc624f, keeps a full-sheet/full-body wrapper from + * ever being "the row"), pick the one with the lowest bottom edge; ties go to the + * rightmost. This row — and only this row — may hold the order button. If its own + * subtree matches a payment-method alias (`textAliases.specPanel.paymentAreaAliases`, + * e.g. 微信支付/先用后付/支付方式) or [SUBMIT_TARGET_BLOCKED_MARKERS], fail explicitly; + * never fall back to a higher row — a higher row is never the order button either. + * + * Step 2 — leaf: within that row's own subtree (or the row itself), among + * visible/non-zero-size nodes with a NON-BLANK OWN LABEL whose nearest clickable + * ancestor is EXACTLY that row (not some nested sub-button, and not a higher row), + * pick the bottom-right-most one. [PurchaseUiDriver.clickFresh] re-finds its target + * live by `preferredOrDescendantLabel() == target.label && className && center±32` + * and only then climbs to the nearest clickable ancestor and clicks it (see + * `GoAutoAccessibilityService.clickFreshDetailed`); a label-less container's + * [SnapshotNode.label] is `""` but its live `preferredOrDescendantLabel()` digs into a + * child, so the re-find would always miss (task 594: TARGET_NOT_FOUND right after the + * irreversible boundary). Aiming at the labelled leaf — the same convention already + * used by `ImageSearchCandidatePolicy.clickTargetInside` — lets clickFresh's own + * climb-to-ancestor logic land the tap back on the row. If the row has no such leaf + * (Samsung: only a zero-size text node), fail explicitly (`bottom_row_unlabelled`) — + * never fall back to a higher row. */ private fun finalSubmitTargets(snapshot: UiSnapshot): List { fun hasArea(node: SnapshotNode) = node.bounds.width > 0 && node.bounds.height > 0 @@ -816,45 +827,55 @@ class PurchaseLiveAutomation( panelDiagnostic("bottomSubmit;outcome=no_container;type=${screen.specPanelType}") return emptyList() } - // A full-sheet or full-body wrapper container is sometimes clickable too (it can - // intercept touches); this bounds a *candidate leaf's own* size only as extra - // defense — a label-less wrapper is already excluded by requiring a non-blank own - // label below, but a stray, unrealistically large labelled node should not win either. val screenHeight = snapshot.nodes.filter { it.visible }.maxOfOrNull { it.bounds.bottom }?.coerceAtLeast(1) ?: 1 - val candidates = snapshot.nodes.filter { node -> - node.visible && node.enabled && hasArea(node) && node.label.isNotEmpty() && + + // Step 1: the bottom-most clickable row. + val rowCandidates = snapshot.nodes.filter { node -> + node.visible && node.enabled && node.clickable && hasArea(node) && node.bounds.height.toLong() * 100 <= screenHeight.toLong() * SUBMIT_ROW_MAX_HEIGHT_PERCENT && - inside(node.bounds, container) && - clickableAncestor(node)?.let(::hasArea) == true + inside(node.bounds, container) }.distinctBy { it.path } - if (candidates.isEmpty()) { - panelDiagnostic("bottomSubmit;outcome=no_candidates;type=${screen.specPanelType}") + if (rowCandidates.isEmpty()) { + panelDiagnostic("bottomSubmit;outcome=no_rows;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 ancestor = clickableAncestor(chosen) - if (ancestor == null) { - // Unreachable given the candidate filter above, kept as an explicit safety net. - panelDiagnostic("bottomSubmit;outcome=no_clickable_ancestor;tie=${tie.diagFlag()}") - return emptyList() - } - val subtreeLabels = (listOf(ancestor.label) + subtreeDescendants(ancestor, snapshot).map(SnapshotNode::label)) - .joinToString("") - if (SUBMIT_TARGET_BLOCKED_MARKERS.any(subtreeLabels::contains)) { + val rowMaxBottom = rowCandidates.maxOf { it.bounds.bottom } + val rowBottomTied = rowCandidates.filter { it.bounds.bottom == rowMaxBottom } + val rowTie = rowBottomTied.size > 1 + val row = rowBottomTied.maxBy { it.bounds.right } + + val rowSubtreeLabels = (listOf(row.label) + subtreeDescendants(row, snapshot).map(SnapshotNode::label)).joinToString("") + val paymentAreaAliases = PurchaseRehearsalExecutor.DEFAULT_COLLECTOR.textAliases.specPanel.paymentAreaAliases + if (paymentAreaAliases.any(rowSubtreeLabels::contains) || SUBMIT_TARGET_BLOCKED_MARKERS.any(rowSubtreeLabels::contains)) { panelDiagnostic( - "bottomSubmit;outcome=payment_blocked;tie=${tie.diagFlag()};class=${ancestor.className};" + - "w=${ancestor.bounds.width};h=${ancestor.bounds.height}", + "bottomSubmit;outcome=row_blocked;tie=${rowTie.diagFlag()};class=${row.className};" + + "w=${row.bounds.width};h=${row.bounds.height}", ) return emptyList() } - val hasPrice = PRICE_PRESENCE.containsMatchIn(subtreeLabels) - val legacyMarker = FINAL_SUBMIT_MARKERS.any { subtreeLabels == it || subtreeLabels.startsWith(it) } + + // Step 2: the bottom-right-most labelled leaf whose nearest clickable ancestor is exactly this row. + val leafCandidates = (listOf(row) + subtreeDescendants(row, snapshot)).filter { node -> + node.visible && node.enabled && hasArea(node) && node.label.isNotEmpty() && + clickableAncestor(node)?.path == row.path + }.distinctBy { it.path } + if (leafCandidates.isEmpty()) { + panelDiagnostic( + "bottomSubmit;outcome=bottom_row_unlabelled;tie=${rowTie.diagFlag()};class=${row.className};" + + "w=${row.bounds.width};h=${row.bounds.height}", + ) + return emptyList() + } + val leafMaxBottom = leafCandidates.maxOf { it.bounds.bottom } + val leafBottomTied = leafCandidates.filter { it.bounds.bottom == leafMaxBottom } + val leafTie = leafBottomTied.size > 1 + val chosen = leafBottomTied.maxBy { it.bounds.right } + + val hasPrice = PRICE_PRESENCE.containsMatchIn(rowSubtreeLabels) + val legacyMarker = FINAL_SUBMIT_MARKERS.any { rowSubtreeLabels == it || rowSubtreeLabels.startsWith(it) } panelDiagnostic( - "bottomSubmit;outcome=ok;tie=${tie.diagFlag()};leafClass=${chosen.className};" + - "ancestorClass=${ancestor.className};w=${ancestor.bounds.width};h=${ancestor.bounds.height};" + + "bottomSubmit;outcome=ok;rowTie=${rowTie.diagFlag()};leafTie=${leafTie.diagFlag()};" + + "leafClass=${chosen.className};rowClass=${row.className};w=${row.bounds.width};h=${row.bounds.height};" + "price=${hasPrice.diagFlag()};legacyMarker=${legacyMarker.diagFlag()};type=${screen.specPanelType}", ) return listOf(chosen) 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 3b2aa3e..58f5506 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 @@ -533,7 +533,7 @@ class SpecPanelRecognitionTest { // 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") }) + assertTrue(diagnostics.any { it.contains("outcome=ok") && it.contains("rowTie=1") }) } @Test @@ -603,6 +603,44 @@ class SpecPanelRecognitionTest { assertEquals("r/sheet/submit", driver.clickedPath) } + @Test + fun `Samsung 31px bottom bar with only a zero-size label fails explicitly instead of clicking the payment row above it`() { + // #335 (3rd revision): reviewer cross-check on real dumps found that on this + // Samsung sample the bottom-most row is a 31px FrameLayout whose only text node + // is [0,0][0,0] (invisible), while the payment-method row directly above it has a + // real, visible label ("使用#微信支付,更换先用后付可0元下单"). A naive whole-panel + // leaf scan falls through to that payment row's text and the climb lands the click + // on "change payment method" — which must never happen. SpecPanelFixtures' + // `submit = "hidden"` branch already models exactly this shape. + val snapshot = SpecPanelFixtures.sheet(Sheet(submit = "hidden")) + val diagnostics = mutableListOf() + val driver = StaticDriver(snapshot) + val error = runCatching { + PurchaseLiveAutomation(driver, pause = {}, panelDiagnostic = diagnostics::add).submitOrderOnce() + }.exceptionOrNull() as? PurchaseLiveException + + assertEquals("PURCHASE_SUBMIT_TARGET_AMBIGUOUS", error?.code) + assertTrue(driver.clicked.isEmpty()) + assertTrue(diagnostics.any { it.contains("outcome=bottom_row_unlabelled") }) + } + + @Test + fun `a payment-method row as the bottom-most clickable row is never a submit target`() { + // Without a submit region at all, the payment-method row ("使用#微信支付,更换先用 + // 后付可0元下单") is itself the bottom-most clickable row. It must be rejected by + // its own alias match, not merely skipped in favor of something else. + val snapshot = SpecPanelFixtures.sheet(Sheet(submit = "none")) + val diagnostics = mutableListOf() + val driver = StaticDriver(snapshot) + val error = runCatching { + PurchaseLiveAutomation(driver, pause = {}, panelDiagnostic = diagnostics::add).submitOrderOnce() + }.exceptionOrNull() as? PurchaseLiveException + + assertEquals("PURCHASE_SUBMIT_TARGET_AMBIGUOUS", error?.code) + assertTrue(driver.clicked.isEmpty()) + assertTrue(diagnostics.any { it.contains("outcome=row_blocked") }) + } + @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) @@ -659,7 +697,15 @@ class SpecPanelRecognitionTest { // --- Helpers ------------------------------------------------------------- private fun readySheet(expected: String): UiSnapshot { - val base = SpecPanelFixtures.liveShapedSheet() + // #335 (3rd revision): [SpecPanelFixtures.liveShapedSheet] hardcodes a "hidden" + // 31px submit placeholder (its own separate `submit`-shape fixture concern, + // unrelated to this address-save-wait test). With the row-then-leaf submit-target + // rule that placeholder would tie with "r/final" on the bottom edge and can win + // the rightmost tie-break, so this helper builds the same shape directly with + // `submit = "none"` instead, leaving "r/final" as the only, unambiguous bottom row. + val base = SpecPanelFixtures.sheet( + Sheet(summary = false, listScrollable = false, sizeDimension = false, adjustButtons = false, submit = "none"), + ) val extra = listOf( node("r/sheet/body/addr/a/saved", expected, NodeBounds(132, 460, 937, 480), parent = "r/sheet/body/addr/a"), node("r/final", "", NodeBounds(600, 2150, 1060, 2216), clickable = true, parent = "r"),