diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PddProductDetailCollector.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PddProductDetailCollector.kt index e00c891..4b60270 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PddProductDetailCollector.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PddProductDetailCollector.kt @@ -337,7 +337,7 @@ object PddScreenParser { val nestedSpecEntry = nestedSpecEntries.singleOrNull() val bottomSpecEntries = if (panelOpen || explicitSpecEntry != null || nestedSpecEntry != null) emptyList() else safeBottomSpecEntries(visibleNodes, visible, config) - val bottomSpecEntry = bottomSpecEntries.singleOrNull() + val bottomSpecEntry = bottomSpecEntries.firstOrNull() val candidateSpecEntry = explicitSpecEntry ?: nestedSpecEntry?.anchor ?: bottomSpecEntry?.anchor val candidateClickTarget = explicitSpecEntry ?: nestedSpecEntry?.clickTarget ?: bottomSpecEntry?.clickTarget val reviewPageOpen = isReviewPage(visibleNodes, visible, screenHeight, candidateSpecEntry, config) @@ -347,7 +347,8 @@ object PddScreenParser { reviewPageOpen -> null explicitSpecEntry != null -> "explicit_selection" nestedSpecEntry != null -> "nested_selection" - bottomSpecEntry != null -> "bottom_purchase" + bottomSpecEntries.size == 1 -> "bottom_purchase" + bottomSpecEntry != null -> "bottom_purchase_rightmost" else -> null } val quickConfirmationEntry = if (quickConfirmationEvidence) quickConfirmationSpecEntry(visibleNodes, visible, config) else null @@ -539,7 +540,7 @@ object PddScreenParser { val normalizedTarget = normalizedByPath[clickTarget.path] ?: return@mapNotNull null SafeSpecEntry(normalizedAnchor, normalizedTarget) to clickTarget.bounds.width.toLong() * clickTarget.bounds.height } - .sortedWith(compareBy> { it.second }.thenByDescending { it.first.clickTarget.bounds.centerX }) + .sortedWith(compareByDescending> { it.first.clickTarget.bounds.centerX }.thenBy { it.second }) .map { it.first } .distinctBy { it.clickTarget.path } .toList() diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseRehearsalExecutor.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseRehearsalExecutor.kt index 5f2c5f0..e408a8f 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseRehearsalExecutor.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseRehearsalExecutor.kt @@ -281,15 +281,18 @@ class PurchaseRehearsalExecutor( } ?: safeCandidates if (candidates.size > 1) { panelDiagnostic(specEntryEvidence(screen, candidates.size)) - return failure(SPEC_ENTRY_TARGET_AMBIGUOUS, "规格入口候选不唯一") + return failure(SPEC_ENTRY_TARGET_AMBIGUOUS, "规格入口候选不唯一 [${specEntryEvidence(screen, candidates.size)}]") } val target = candidates.singleOrNull()?.second ?: run { panelDiagnostic(specEntryEvidence(screen, 0)) - return failure(SPEC_ENTRY_NOT_FOUND, "没有找到安全的商品规格入口") + return failure(SPEC_ENTRY_NOT_FOUND, "没有找到安全的商品规格入口 [${specEntryEvidence(screen, 0)}]") } val click = driver.clickFreshDetailed(target) when (click.result) { - FreshActionResult.AMBIGUOUS -> return failure(SPEC_ENTRY_TARGET_AMBIGUOUS, "规格入口候选不唯一") + FreshActionResult.AMBIGUOUS -> return failure( + SPEC_ENTRY_TARGET_AMBIGUOUS, + "规格入口候选不唯一 [${specEntryEvidence(screen, candidates.size)}]", + ) FreshActionResult.SUCCESS -> Unit else -> return failure(SPEC_ENTRY_CLICK_FAILED, click.reason.specEntrySubreason()) } diff --git a/android/app/src/test/java/cn/ilapage/goauto/agent/PddProductDetailCollectorTest.kt b/android/app/src/test/java/cn/ilapage/goauto/agent/PddProductDetailCollectorTest.kt index f1d5fda..e36e63d 100644 --- a/android/app/src/test/java/cn/ilapage/goauto/agent/PddProductDetailCollectorTest.kt +++ b/android/app/src/test/java/cn/ilapage/goauto/agent/PddProductDetailCollectorTest.kt @@ -367,6 +367,64 @@ class PddProductDetailCollectorTest { assertEquals("bottom_purchase", parsed.specEntrySource) } + @Test + fun bottomPurchaseEntriesPreferRightmostCandidate() { + val snapshot = UiSnapshot( + PDD_PACKAGE, + ACTIVITY, + listOf( + node("content", "", 0, 0, 1080, 2200, resourceId = "android:id/content", className = "android.widget.FrameLayout"), + node("buy-left", "单独购买", 446, 2000, 685, 2160, clickable = true), + node("buy-right", "发起拼单", 685, 2000, 1080, 2160, clickable = true), + ), + ) + + val parsed = PddScreenParser.parse(snapshot, config(), GOODS_ID, evidence()) + + assertEquals("buy-right", parsed.specEntry?.path) + assertEquals("buy-right", parsed.specEntryClickTarget?.path) + assertEquals("bottom_purchase_rightmost", parsed.specEntrySource) + assertEquals(2, parsed.bottomPurchaseEntryCount) + } + + @Test + fun equallyRightmostBottomPurchaseEntriesPreferSmallerArea() { + val snapshot = UiSnapshot( + PDD_PACKAGE, + ACTIVITY, + listOf( + node("content", "", 0, 0, 1080, 2200, resourceId = "android:id/content", className = "android.widget.FrameLayout"), + node("buy-large", "发起拼单", 685, 2000, 1080, 2160, clickable = true), + node("buy-small", "立即购买", 785, 2000, 980, 2140, clickable = true), + ), + ) + + val parsed = PddScreenParser.parse(snapshot, config(), GOODS_ID, evidence()) + + assertEquals("buy-small", parsed.specEntry?.path) + assertEquals("bottom_purchase_rightmost", parsed.specEntrySource) + } + + @Test + fun bottomPurchaseCandidatesStillExcludeReviewOrderAndPaymentContexts() { + val snapshot = UiSnapshot( + PDD_PACKAGE, + ACTIVITY, + listOf( + node("content", "", 0, 0, 1080, 2200, resourceId = "android:id/content", className = "android.widget.FrameLayout"), + node("reviews", "商品评价", 446, 1900, 685, 2160, clickable = true), + node("reviews/buy", "购买", 480, 2000, 650, 2100, parentPath = "reviews"), + node("order", "购买并提交订单", 685, 2000, 880, 2160, clickable = true), + node("payment", "购买后立即支付", 880, 2000, 1080, 2160, clickable = true), + ), + ) + + val parsed = PddScreenParser.parse(snapshot, config(), GOODS_ID, evidence()) + + assertEquals(null, parsed.specEntry) + assertEquals(0, parsed.bottomPurchaseEntryCount) + } + @Test fun bottomPurchaseInsideReviewCardIsNeverSpecEntry() { val snapshot = UiSnapshot( diff --git a/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseRehearsalExecutorTest.kt b/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseRehearsalExecutorTest.kt index e0a0eb8..5af1d33 100644 --- a/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseRehearsalExecutorTest.kt +++ b/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseRehearsalExecutorTest.kt @@ -605,6 +605,29 @@ class PurchaseRehearsalExecutorTest { assertFalse(driver.clicked.contains("商品评价")) } + @Test + fun `spec entry failures include scalar diagnostics only`() { + val driver = FakePurchaseDriver(missingSpecEntry = true) + val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {}) + .execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported) + + assertEquals("PURCHASE_SPEC_ENTRY_NOT_FOUND", outcome.errorCode) + assertEquals( + "没有找到安全的商品规格入口 [specEntryCandidates=0;explicit=0;nested=0;bottomPurchase=0;panelAlreadyOpen=false;reviewPage=false;pageEvidence=true]", + outcome.message, + ) + + val ambiguousDriver = FakePurchaseDriver(forcedEntryClickReason = FreshClickReason.TARGET_AMBIGUOUS) + val ambiguous = PurchaseRehearsalExecutor(ambiguousDriver, { ambiguousDriver.browser = true; true }, { null }, pause = {}) + .execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported) + + assertEquals("PURCHASE_SPEC_ENTRY_TARGET_AMBIGUOUS", ambiguous.errorCode) + assertEquals( + "规格入口候选不唯一 [specEntryCandidates=1;explicit=1;nested=0;bottomPurchase=0;panelAlreadyOpen=false;reviewPage=false;pageEvidence=true]", + ambiguous.message, + ) + } + @Test fun `parser keeps type only compatibility and rejects dangerous or unknown fields`() { val legacy = """{"schemaVersion":1,"ruleType":"pddPurchase","requiredCapabilities":["purchase.rehearsal.v1"],"actions":[{"type":"openProduct"}]}"""