fix(android): scope submit target to panel container, widen blocked words (#335)

Review follow-up on 2bc624f:

1. finalSubmitTargets' payment-word block only covered PAYMENT_MARKERS
   (立即支付/确认支付/输入支付密码). The ticket also requires blocking 去支付
   and 付款. These cannot join the shared PAYMENT_MARKERS list: it also
   gates the global pageProblem() payment guard, and UNPAID_MARKERS' "待付款"
   contains "付款", so adding it there would misclassify every ordinary
   unpaid-order page as a forbidden payment page. Added a separate
   SUBMIT_TARGET_BLOCKED_MARKERS list (立即支付/确认支付/去支付/付款/输入支付密码)
   used only by finalSubmitTargets' bottom-node subtree check.

2. Candidates were previously scanned across the whole snapshot, so an
   unrelated clickable node outside the recognized panel (e.g. an
   underlying goods-detail-page bottom bar still in the accessibility tree
   behind the sheet) could out-rank the real button by sitting lower on
   screen. Added panelContainerBounds(): climbs from the panel's unique
   quantity input to the largest ancestor that still does not cover the
   whole screen (the same "does not cover the whole screen" bounded notion
   #331 already uses for sharesBoundedPanelContainer/boundedScrollables),
   giving the whole bottom-sheet container. ParsedPddScreen.specPanelContainer
   (PddScreenParser's `panelScrollable`) was considered but is the wrong
   notion here: it is only the inner *scrollable dimension list* used for
   heading/option parsing, and in a real PDD sheet the address/payment/
   submit rows sit outside it as structural siblings, not descendants -
   using it would incorrectly exclude the real submit row in most panels.
   A candidate belongs to the panel when its center point falls inside the
   container's bounds (geometric containment, matching the existing
   `inside()` convention in this file, not path prefix, since PDD's own
   tree can place the submit bar as a structural sibling that is still
   visually part of the sheet). When the quantity input is missing or not
   unique, the container cannot be determined and finalSubmitTargets fails
   explicitly (no target) instead of guessing.

Tests added to SpecPanelRecognitionTest: bottom node labeled "去支付" and
"付款" are blocked (distinct from the existing PAYMENT_MARKERS-triggered
PURCHASE_PAYMENT_FORBIDDEN cases, since neither word reaches the global
guard); a clickable node outside the recognized panel container is never
chosen even though it is bottom-most on screen; a recognized panel with no
determinable quantity input/container fails explicitly. All existing
readOrderResult tests (which legitimately show "待付款"/"去支付" on the
order-result page, unrelated to finalSubmitTargets) stay green unchanged,
confirming SUBMIT_TARGET_BLOCKED_MARKERS did not leak into that path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
This commit is contained in:
QiuSW
2026-09-22 15:27:44 +08:00
co-authored by Claude Opus 5
parent 2bc624f629
commit 4c6106f000
2 changed files with 146 additions and 5 deletions
@@ -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<SnapshotNode> {
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("订单编号", "订单号", "下单时间", "创建时间")
@@ -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 {