diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 5a37b8c..cd2f19c 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -11,8 +11,8 @@ android { applicationId = "cn.ilapage.goauto.agent" minSdk = 23 targetSdk = 34 - versionCode = 67 - versionName = "0.9.54" + versionCode = 68 + versionName = "0.9.55" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/GoAutoAccessibilityService.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/GoAutoAccessibilityService.kt index 42cba3c..a5a3f06 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/GoAutoAccessibilityService.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/GoAutoAccessibilityService.kt @@ -474,9 +474,17 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto }.getOrDefault(false) } + private var lastSpecRowSwipeFailure = "none" + + override fun specRowSwipeFailureReason(): String = lastSpecRowSwipeFailure + override fun swipeSpec(direction: SwipeDirection, anchor: SnapshotNode?): Boolean { + lastSpecRowSwipeFailure = "none" if (anchor == null) return swipe(SemanticTarget.SPEC_PANEL, direction) - val root = rootInActiveWindow ?: return false + val root = rootInActiveWindow ?: run { + lastSpecRowSwipeFailure = "windowMissing" + return false + } val matches = mutableListOf() walk(root) { node -> val bounds = Rect().also(node::getBoundsInScreen) @@ -494,6 +502,7 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto val horizontal = direction == SwipeDirection.LEFT || direction == SwipeDirection.RIGHT if (matches.size != 1) { if (!SpecSwipeSafety.allowGlobalFallback(direction)) { + lastSpecRowSwipeFailure = if (matches.isEmpty()) "anchorMissing" else "anchorAmbiguous" Log.i("GoAutoCollector", "swipe direction=$direction result=blocked reason=anchor-not-unique matches=${matches.size}") return false } @@ -517,13 +526,16 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto if (anchoredTarget != null) { val bounds = Rect().also(anchoredTarget::getBoundsInScreen) Log.i("GoAutoCollector", "swipe direction=$direction anchored=true bounds=$bounds class=${anchoredTarget.className}") - return swipeNode( + val success = swipeNode( anchoredTarget, direction, preferScrollAction = SpecSwipeSafety.preferAccessibilityScrollAction(direction), ) + if (!success) lastSpecRowSwipeFailure = "gestureFailed" + return success } if (!SpecSwipeSafety.allowGlobalFallback(direction)) { + lastSpecRowSwipeFailure = "horizontalContainerMissing" Log.i("GoAutoCollector", "swipe direction=$direction result=blocked reason=no-anchored-horizontal-container") return false } 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 f0ff113..00361cc 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 @@ -29,6 +29,7 @@ interface PurchaseUiDriver { * scrollable ancestor. There is deliberately no global fallback. */ fun swipeSpecRow(target: SnapshotNode, direction: SwipeDirection): Boolean = false + fun specRowSwipeFailureReason(): String = "gestureFailed" fun inputFresh(target: SnapshotNode, value: String): FreshActionResult fun swipePurchase(direction: SwipeDirection, durationMs: Long): Boolean fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long): Boolean @@ -870,7 +871,7 @@ class PurchaseRehearsalExecutor( else -> null } val rows = specValueRows(values) - val signature = rows.flatten().joinToString("|") { value -> + val signature = screen.dimensions.flatMap { it.values }.joinToString("|") { value -> val bounds = value.node.bounds "${value.text}:${bounds.left},${bounds.top},${bounds.right},${bounds.bottom}:${value.available}" } @@ -879,72 +880,76 @@ class PurchaseRehearsalExecutor( var inspected = inspect() inspected.lookup?.let { return it } - var verticalRecoverySwipes = 0 - var previousVerticalSignature: String? = null - var verticalStableReads = 0 - while (horizontalSpecRow(inspected.screen, inspected.rows) == null && verticalRecoverySwipes < swipeLimit) { - val container = inspected.screen.specPanelContainer ?: break - if (!driver.swipePurchaseIn(container, SwipeDirection.DOWN, 350)) break - verticalRecoverySwipes++ - pause(300) - inspected = inspect() - inspected.lookup?.let { return it } - verticalStableReads = if (previousVerticalSignature == inspected.signature) verticalStableReads + 1 else 0 - previousVerticalSignature = inspected.signature - if (verticalStableReads >= stableReadLimit) break - } - - val initialRow = horizontalSpecRow(inspected.screen, inspected.rows) - ?: return SpecLookup(failure = failure( - SPEC_TARGET_NOT_VISIBLE, - "有界搜索后未找到精确规格 [dimension=$dimension;horizontalSwipes=0;verticalRecoverySwipes=$verticalRecoverySwipes;visibleCandidates=${inspected.rows.flatten().size};reason=noHorizontalRow]", - )) - + var verticalSwipes = 0 var horizontalSwipes = 0 - var previousSignature: String? = null - var stableReads = 0 - while (horizontalSwipes < swipeLimit) { - val anchor = horizontalSpecRow(inspected.screen, inspected.rows)?.firstOrNull()?.node ?: initialRow.first().node - if (!driver.swipeSpecRow(anchor, SwipeDirection.RIGHT)) { - return SpecLookup(failure = failure( - SPEC_TARGET_NOT_VISIBLE, - "有界搜索后未找到精确规格 [dimension=$dimension;horizontalSwipes=$horizontalSwipes;verticalRecoverySwipes=$verticalRecoverySwipes;visibleCandidates=${inspected.rows.flatten().size};reason=restoreSwipeFailed]", - )) + var lastHorizontalFailure = "none" + var termination = "budgetExhausted" + val horizontalBudget = mutableMapOf(SwipeDirection.RIGHT to swipeLimit, SwipeDirection.LEFT to swipeLimit) + + fun searchVisibleRows(): SpecLookup? { + for (direction in listOf(SwipeDirection.RIGHT, SwipeDirection.LEFT)) { + var stableReads = 0 + var previousSignature = inspected.signature + while (horizontalBudget.getValue(direction) > 0) { + // Always reacquire a supported row; never fall back to a stale node. + val anchor = horizontalSpecRow(inspected.screen, inspected.rows)?.firstOrNull()?.node ?: break + horizontalBudget[direction] = horizontalBudget.getValue(direction) - 1 + if (!driver.swipeSpecRow(anchor, direction)) { + lastHorizontalFailure = driver.specRowSwipeFailureReason() + // A failed gesture is not evidence that the target is absent. + inspected = inspect() + inspected.lookup?.let { return it } + break + } + horizontalSwipes++ + pause(300) + inspected = inspect() + inspected.lookup?.let { return it } + stableReads = if (previousSignature == inspected.signature) stableReads + 1 else 0 + previousSignature = inspected.signature + if (stableReads >= stableReadLimit) break + } } - horizontalSwipes++ - pause(300) - inspected = inspect() - inspected.lookup?.let { return it } - stableReads = if (previousSignature == inspected.signature) stableReads + 1 else 0 - previousSignature = inspected.signature - if (stableReads >= stableReadLimit) break + return null } - previousSignature = null - stableReads = 0 - while (horizontalSwipes < swipeLimit * 2) { - val anchor = horizontalSpecRow(inspected.screen, inspected.rows)?.firstOrNull()?.node - ?: return SpecLookup(failure = failure( - SPEC_TARGET_NOT_VISIBLE, - "有界搜索后未找到精确规格 [dimension=$dimension;horizontalSwipes=$horizontalSwipes;verticalRecoverySwipes=$verticalRecoverySwipes;visibleCandidates=${inspected.rows.flatten().size};reason=rowLost]", - )) - if (!driver.swipeSpecRow(anchor, SwipeDirection.LEFT)) { - return SpecLookup(failure = failure( - SPEC_TARGET_NOT_VISIBLE, - "有界搜索后未找到精确规格 [dimension=$dimension;horizontalSwipes=$horizontalSwipes;verticalRecoverySwipes=$verticalRecoverySwipes;visibleCandidates=${inspected.rows.flatten().size};reason=searchSwipeFailed]", - )) + // The original fast path remains first. Recover both ends of a vertical + // grid, inspecting each viewport for the exact target and supported rows. + val verticalLimit = DEFAULT_COLLECTOR.limits.getValue("specVerticalSwipes") + for (direction in listOf(SwipeDirection.DOWN, SwipeDirection.UP)) { + var stableReads = 0 + var previousSignature = inspected.signature + for (attempt in 0..verticalLimit) { + searchVisibleRows()?.let { return it } + if (attempt == verticalLimit) { + termination = "budgetExhausted" + break + } + val container = inspected.screen.specPanelContainer + if (container == null) { + termination = "verticalContainerMissing" + break + } + if (!driver.swipePurchaseIn(container, direction, 350)) { + termination = "verticalSwipeFailed" + break + } + verticalSwipes++ + pause(300) + inspected = inspect() + inspected.lookup?.let { return it } + stableReads = if (previousSignature == inspected.signature) stableReads + 1 else 0 + previousSignature = inspected.signature + if (stableReads >= stableReadLimit) { + searchVisibleRows()?.let { return it } + termination = "stableViewport" + break + } } - horizontalSwipes++ - pause(300) - inspected = inspect() - inspected.lookup?.let { return it } - stableReads = if (previousSignature == inspected.signature) stableReads + 1 else 0 - previousSignature = inspected.signature - if (stableReads >= stableReadLimit) break } return SpecLookup(failure = failure( SPEC_TARGET_NOT_VISIBLE, - "有界搜索后未找到精确规格 [dimension=$dimension;horizontalSwipes=$horizontalSwipes;verticalRecoverySwipes=$verticalRecoverySwipes;visibleCandidates=${inspected.rows.flatten().size};reason=edgeReached]", + "有界搜索后未找到精确规格 [dimension=$dimension;horizontalSwipes=$horizontalSwipes;verticalRecoverySwipes=$verticalSwipes;visibleCandidates=${inspected.rows.flatten().size};reason=$termination;horizontalFailure=$lastHorizontalFailure]", )) } @@ -952,7 +957,7 @@ class PurchaseRehearsalExecutor( screen: ParsedPddScreen, rows: List>, ): List? = rows.firstOrNull { row -> - row.size > 1 || row.any { value -> hasDedicatedHorizontalAncestor(screen, value.node) } + row.isNotEmpty() && row.all { value -> hasDedicatedHorizontalAncestor(screen, value.node) } } private fun hasDedicatedHorizontalAncestor(screen: ParsedPddScreen, source: SnapshotNode): Boolean { 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 2858e8d..f87e4ec 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 @@ -235,7 +235,7 @@ class PurchaseRehearsalExecutorTest { .execute(input().copy(mappedColor = "富贵粉"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported) assertEquals("PURCHASE_SPEC_TARGET_NOT_VISIBLE", outcome.errorCode) - assertTrue(outcome.message.contains("reason=noHorizontalRow")) + assertTrue(outcome.message.contains("horizontalSwipes=0")) assertTrue(driver.horizontalSpecDirections.isEmpty()) assertFalse(driver.clicked.contains("黑色")) } @@ -250,7 +250,7 @@ class PurchaseRehearsalExecutorTest { .execute(input().copy(mappedColor = "富贵粉"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported) assertEquals("PURCHASE_SPEC_TARGET_NOT_VISIBLE", outcome.errorCode) - assertTrue(outcome.message.contains("reason=restoreSwipeFailed")) + assertTrue(outcome.message.contains("horizontalFailure=gestureFailed")) assertTrue(driver.clicked.none { it == "富贵粉" }) } @@ -306,6 +306,45 @@ class PurchaseRehearsalExecutorTest { .all { it.contains("size-row") }) } + @Test + fun `two column size grid continues vertically without horizontal container`() { + val target = "3XL 推荐140-155斤" + val driver = FakePurchaseDriver(sizes = listOf(target), revealGridSizeAfterUpSwipes = 7) + val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {}) + .execute(input().copy(mappedSize = target), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported) + assertEquals("rehearsal_completed", outcome.resultType) + assertEquals(target, driver.size) + assertTrue(driver.horizontalSpecDirections.isEmpty()) + assertEquals(1, driver.clicked.count { it == target }) + } + + @Test + fun `failed horizontal gesture still permits vertical exact size recovery`() { + val target = "3XL 推荐140-155斤" + val driver = FakePurchaseDriver( + sizes = listOf(target), + horizontalSizePages = listOf(listOf("S", "M")), + horizontalSpecSwipeSucceeds = false, + revealGridSizeAfterUpSwipes = 7, + ) + val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {}) + .execute(input().copy(mappedSize = target), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported) + assertEquals(outcome.message, "rehearsal_completed", outcome.resultType) + assertEquals(target, driver.size) + assertTrue(driver.horizontalSpecDirections.isNotEmpty()) + } + + @Test + fun `missing size terminates at stable viewport with bounded gestures`() { + val driver = FakePurchaseDriver(sizes = listOf("S", "M")) + val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {}) + .execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported) + assertEquals("PURCHASE_SPEC_TARGET_NOT_VISIBLE", outcome.errorCode) + assertTrue(outcome.message.contains("reason=stableViewport")) + assertTrue(driver.horizontalSpecDirections.isEmpty()) + assertTrue(driver.swipeCount < 35) + } + @Test fun `visible exact size does not enter horizontal fallback`() { val driver = FakePurchaseDriver(sizes = listOf("L", "XL")) @@ -1148,6 +1187,7 @@ class PurchaseRehearsalExecutorTest { private val openReviewOnBottomClick: Boolean = false, private val reviewBackSucceeds: Boolean = true, private val hiddenSizeUntilUpSwipes: Int = 0, + private val revealGridSizeAfterUpSwipes: Int? = null, private val openClickResults: MutableList = mutableListOf(), private val openPddOnFailedClick: Boolean = false, private val sizeClickResults: MutableList = mutableListOf(), @@ -1305,6 +1345,10 @@ class PurchaseRehearsalExecutorTest { nodes += node("scroll/color-heading", "颜色分类", 20, 410, 300, 450, parentPath = "scroll") val selectedColor = if (quantity == 2L) selectedColorOverrideAfterQuantitySet ?: color else color val visibleColors = horizontalColorPages?.get(horizontalColorPage) ?: colors + val colorParent = if (horizontalColorPages != null && visibleColors.size > 1) "scroll/color-row" else "scroll" + if (colorParent != "scroll") { + nodes += node(colorParent, "", 0, 460, 1080, 550, scrollable = true, parentPath = "scroll") + } visibleColors.forEachIndexed { index, value -> nodes += node( "scroll/color-$index", @@ -1316,13 +1360,15 @@ class PurchaseRehearsalExecutorTest { clickable = true, selected = selectedColor == value, enabled = !allSpecsUnavailable, - parentPath = "scroll", + parentPath = colorParent, ) } } if (!hideSize) { nodes += node("scroll/size-heading", "尺码", 20, 650, 300, 690, parentPath = "scroll") - val visibleSizes = horizontalSizePages?.get(horizontalSizePage) ?: if (quantity == 2L && finalSizesAfterQuantitySet != null) { + val visibleSizes = if (revealGridSizeAfterUpSwipes != null) { + if (upSwipeCount >= revealGridSizeAfterUpSwipes) sizes else listOf("S", "M") + } else horizontalSizePages?.get(horizontalSizePage) ?: if (quantity == 2L && finalSizesAfterQuantitySet != null) { finalSizesAfterQuantitySet } else if (upSwipeCount >= hiddenSizeUntilUpSwipes) { sizes @@ -1338,9 +1384,9 @@ class PurchaseRehearsalExecutorTest { "$sizeParent/size-$index", visibleSize, 20 + index * 250, - 710, + 710 - if (revealGridSizeAfterUpSwipes != null) upSwipeCount.coerceAtMost(10) * 2 else 0, 220 + index * 250, - 780, + 780 - if (revealGridSizeAfterUpSwipes != null) upSwipeCount.coerceAtMost(10) * 2 else 0, clickable = true, selected = !hideSizeSelectedState && size == visibleSize, enabled = !allSpecsUnavailable && visibleSize !in unavailableSizes, @@ -1377,7 +1423,7 @@ class PurchaseRehearsalExecutorTest { return result } "选择规格", "免拼购买" -> if (entryActionHasEffect) panel = true - in (horizontalSizePages?.flatten() ?: sizes) -> { + in (horizontalSizePages.orEmpty().flatten() + sizes) -> { sizeClickCount++ val result = sizeClickResults.removeFirstOrNull() ?: FreshActionResult.SUCCESS if (result == FreshActionResult.SUCCESS || sizeSelectsOnFailedClick) size = target.label