diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 5a2185d..5a37b8c 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 = 66 - versionName = "0.9.53" + versionCode = 67 + versionName = "0.9.54" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" 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 ca496bf..f0ff113 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 @@ -823,18 +823,22 @@ class PurchaseRehearsalExecutor( currentSignature = inspected.signature } } - if (dimension == "color") { - locateExactColorHorizontally(input, target)?.let { return it } + if (dimension == "color" || dimension == "size") { + locateExactDimensionHorizontally(input, dimension, target)?.let { return it } } return SpecLookup(failure = failure(SPEC_TARGET_NOT_VISIBLE, "有界搜索后未找到精确规格")) } /** - * Compatibility fallback for horizontally paged PDD color rows. The + * Compatibility fallback for horizontally paged PDD color or size rows. The * established visible/vertical lookup above remains the primary path, so * already successful purchases never enter this branch. */ - private fun locateExactColorHorizontally(input: PurchaseExecutionInput, target: String): SpecLookup? { + private fun locateExactDimensionHorizontally( + input: PurchaseExecutionInput, + dimension: String, + target: String, + ): SpecLookup? { val swipeLimit = DEFAULT_COLLECTOR.limits.getValue("specHorizontalSwipes") val stableReadLimit = DEFAULT_COLLECTOR.limits.getValue("stableEdgeReads") data class ColorInspection( @@ -857,7 +861,7 @@ class PurchaseRehearsalExecutor( "", ) } - val values = screen.dimensions.filter { it.key == "color" }.flatMap { it.values } + val values = screen.dimensions.filter { it.key == dimension }.flatMap { it.values } val exact = values.filter { it.text == target } val lookup = when { exact.size > 1 -> SpecLookup(failure = failure(SPEC_TARGET_AMBIGUOUS, "精确规格匹配到多个控件")) @@ -865,7 +869,7 @@ class PurchaseRehearsalExecutor( exact.size == 1 -> SpecLookup(failure = failure(SPEC_SAFE_TARGET_MISSING, "精确规格当前不可安全点击")) else -> null } - val rows = specColorRows(values) + val rows = specValueRows(values) val signature = rows.flatten().joinToString("|") { value -> val bounds = value.node.bounds "${value.text}:${bounds.left},${bounds.top},${bounds.right},${bounds.bottom}:${value.available}" @@ -878,7 +882,7 @@ class PurchaseRehearsalExecutor( var verticalRecoverySwipes = 0 var previousVerticalSignature: String? = null var verticalStableReads = 0 - while (inspected.rows.none { it.size > 1 } && verticalRecoverySwipes < swipeLimit) { + while (horizontalSpecRow(inspected.screen, inspected.rows) == null && verticalRecoverySwipes < swipeLimit) { val container = inspected.screen.specPanelContainer ?: break if (!driver.swipePurchaseIn(container, SwipeDirection.DOWN, 350)) break verticalRecoverySwipes++ @@ -890,21 +894,21 @@ class PurchaseRehearsalExecutor( if (verticalStableReads >= stableReadLimit) break } - val initialRow = inspected.rows.firstOrNull { it.size > 1 } + val initialRow = horizontalSpecRow(inspected.screen, inspected.rows) ?: return SpecLookup(failure = failure( SPEC_TARGET_NOT_VISIBLE, - "有界搜索后未找到精确规格 [dimension=color;horizontalSwipes=0;verticalRecoverySwipes=$verticalRecoverySwipes;visibleCandidates=${inspected.rows.flatten().size};reason=noHorizontalRow]", + "有界搜索后未找到精确规格 [dimension=$dimension;horizontalSwipes=0;verticalRecoverySwipes=$verticalRecoverySwipes;visibleCandidates=${inspected.rows.flatten().size};reason=noHorizontalRow]", )) var horizontalSwipes = 0 var previousSignature: String? = null var stableReads = 0 while (horizontalSwipes < swipeLimit) { - val anchor = inspected.rows.firstOrNull { it.size > 1 }?.firstOrNull()?.node ?: initialRow.first().node + 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=color;horizontalSwipes=$horizontalSwipes;verticalRecoverySwipes=$verticalRecoverySwipes;visibleCandidates=${inspected.rows.flatten().size};reason=restoreSwipeFailed]", + "有界搜索后未找到精确规格 [dimension=$dimension;horizontalSwipes=$horizontalSwipes;verticalRecoverySwipes=$verticalRecoverySwipes;visibleCandidates=${inspected.rows.flatten().size};reason=restoreSwipeFailed]", )) } horizontalSwipes++ @@ -919,15 +923,15 @@ class PurchaseRehearsalExecutor( previousSignature = null stableReads = 0 while (horizontalSwipes < swipeLimit * 2) { - val anchor = inspected.rows.firstOrNull { it.size > 1 }?.firstOrNull()?.node + val anchor = horizontalSpecRow(inspected.screen, inspected.rows)?.firstOrNull()?.node ?: return SpecLookup(failure = failure( SPEC_TARGET_NOT_VISIBLE, - "有界搜索后未找到精确规格 [dimension=color;horizontalSwipes=$horizontalSwipes;verticalRecoverySwipes=$verticalRecoverySwipes;visibleCandidates=${inspected.rows.flatten().size};reason=rowLost]", + "有界搜索后未找到精确规格 [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=color;horizontalSwipes=$horizontalSwipes;verticalRecoverySwipes=$verticalRecoverySwipes;visibleCandidates=${inspected.rows.flatten().size};reason=searchSwipeFailed]", + "有界搜索后未找到精确规格 [dimension=$dimension;horizontalSwipes=$horizontalSwipes;verticalRecoverySwipes=$verticalRecoverySwipes;visibleCandidates=${inspected.rows.flatten().size};reason=searchSwipeFailed]", )) } horizontalSwipes++ @@ -940,11 +944,29 @@ class PurchaseRehearsalExecutor( } return SpecLookup(failure = failure( SPEC_TARGET_NOT_VISIBLE, - "有界搜索后未找到精确规格 [dimension=color;horizontalSwipes=$horizontalSwipes;verticalRecoverySwipes=$verticalRecoverySwipes;visibleCandidates=${inspected.rows.flatten().size};reason=edgeReached]", + "有界搜索后未找到精确规格 [dimension=$dimension;horizontalSwipes=$horizontalSwipes;verticalRecoverySwipes=$verticalRecoverySwipes;visibleCandidates=${inspected.rows.flatten().size};reason=edgeReached]", )) } - private fun specColorRows(values: List): List> { + private fun horizontalSpecRow( + screen: ParsedPddScreen, + rows: List>, + ): List? = rows.firstOrNull { row -> + row.size > 1 || row.any { value -> hasDedicatedHorizontalAncestor(screen, value.node) } + } + + private fun hasDedicatedHorizontalAncestor(screen: ParsedPddScreen, source: SnapshotNode): Boolean { + val byPath = screen.sourceNodes.associateBy(SnapshotNode::path) + var current: SnapshotNode? = source + while (current != null) { + if (current.path == screen.specPanelContainer?.path) return false + if (current.scrollable && current.bounds.width > current.bounds.height) return true + current = current.parentPath?.let(byPath::get) + } + return false + } + + private fun specValueRows(values: List): List> { val sorted = values.sortedWith(compareBy({ it.node.bounds.centerY }, { it.node.bounds.left })) val rows = mutableListOf>() val centers = mutableListOf() 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 97b6dac..2858e8d 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 @@ -254,6 +254,68 @@ class PurchaseRehearsalExecutorTest { assertTrue(driver.clicked.none { it == "富贵粉" }) } + @Test + fun `long exact size on later horizontal page is found after color selection`() { + val targetSize = "3XL 推荐140-155斤" + val driver = FakePurchaseDriver( + horizontalSizePages = listOf( + listOf("S 推荐80-95斤", "M 推荐95-110斤"), + listOf("2XL 推荐125-140斤", targetSize), + ), + ) + val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {}) + .execute(input().copy(mappedSize = targetSize), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported) + + assertEquals("rehearsal_completed", outcome.resultType) + assertEquals(targetSize, driver.size) + assertTrue(driver.horizontalSpecDimensions.contains("size")) + assertEquals(1, driver.clicked.count { it == targetSize }) + } + + @Test + fun `single visible long size uses its dedicated horizontal container`() { + val targetSize = "3XL 推荐140-155斤" + val driver = FakePurchaseDriver( + horizontalSizePages = listOf(listOf("S 推荐80-95斤"), listOf(targetSize)), + ) + val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {}) + .execute(input().copy(mappedSize = targetSize), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported) + + assertEquals("rehearsal_completed", outcome.resultType) + assertEquals(targetSize, driver.size) + assertTrue(driver.horizontalSpecDimensions.contains("size")) + } + + @Test + fun `color and size horizontal fallbacks reacquire their own row anchors`() { + val targetSize = "3XL 推荐140-155斤" + val driver = FakePurchaseDriver( + horizontalColorPages = listOf(listOf("黑色", "白色"), listOf("蓝色", "富贵粉")), + horizontalSizePages = listOf(listOf("S 推荐80-95斤", "M 推荐95-110斤"), listOf("2XL 推荐125-140斤", targetSize)), + ) + val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {}) + .execute( + input().copy(mappedColor = "富贵粉", mappedSize = targetSize), + PurchaseRuleParser.parse(rule()), + PurchaseAgentCapabilities.supported, + ) + + assertEquals("rehearsal_completed", outcome.resultType) + assertTrue(driver.horizontalSpecDimensions.containsAll(listOf("color", "size"))) + assertTrue(driver.horizontalSpecAnchorPaths.filterIndexed { index, _ -> driver.horizontalSpecDimensions[index] == "size" } + .all { it.contains("size-row") }) + } + + @Test + fun `visible exact size does not enter horizontal fallback`() { + val driver = FakePurchaseDriver(sizes = listOf("L", "XL")) + val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {}) + .execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported) + + assertEquals("rehearsal_completed", outcome.resultType) + assertFalse(driver.horizontalSpecDimensions.contains("size")) + } + @Test fun `failed size click result continues when exact size is actually selected`() { val driver = FakePurchaseDriver( @@ -1071,6 +1133,7 @@ class PurchaseRehearsalExecutorTest { private class FakePurchaseDriver( private val colors: List = listOf("黑色"), private val horizontalColorPages: List>? = null, + private val horizontalSizePages: List>? = null, private val horizontalSpecSwipeSucceeds: Boolean = true, private val sizes: List = listOf("XL"), private val priceCent: Long = 2_000, @@ -1130,6 +1193,8 @@ class PurchaseRehearsalExecutorTest { var backCount = 0 var specTapCount = 0 val horizontalSpecDirections = mutableListOf() + val horizontalSpecDimensions = mutableListOf() + val horizontalSpecAnchorPaths = mutableListOf() val swipeInPaths = mutableListOf() private var soldOut = soldOut private var allSpecsUnavailable = allSpecsUnavailable @@ -1139,6 +1204,7 @@ class PurchaseRehearsalExecutorTest { private var browserCaptureCount = 0 private var hiddenColorRestored = false private var horizontalColorPage = 0 + private var horizontalSizePage = 0 private var capturesAfterSizeSelection = 0 val clicked = mutableListOf() val clickedPaths = mutableListOf() @@ -1256,16 +1322,20 @@ class PurchaseRehearsalExecutorTest { } if (!hideSize) { nodes += node("scroll/size-heading", "尺码", 20, 650, 300, 690, parentPath = "scroll") - val visibleSizes = if (quantity == 2L && finalSizesAfterQuantitySet != null) { + val visibleSizes = horizontalSizePages?.get(horizontalSizePage) ?: if (quantity == 2L && finalSizesAfterQuantitySet != null) { finalSizesAfterQuantitySet } else if (upSwipeCount >= hiddenSizeUntilUpSwipes) { sizes } else { listOf("S") } + val sizeParent = if (horizontalSizePages != null) "scroll/size-row" else "scroll" + if (horizontalSizePages != null) { + nodes += node(sizeParent, "", 0, 700, 1080, 800, scrollable = true, parentPath = "scroll") + } visibleSizes.forEachIndexed { index, visibleSize -> nodes += node( - "scroll/size-$index", + "$sizeParent/size-$index", visibleSize, 20 + index * 250, 710, @@ -1274,7 +1344,7 @@ class PurchaseRehearsalExecutorTest { clickable = true, selected = !hideSizeSelectedState && size == visibleSize, enabled = !allSpecsUnavailable && visibleSize !in unavailableSizes, - parentPath = "scroll", + parentPath = sizeParent, ) } } @@ -1307,7 +1377,7 @@ class PurchaseRehearsalExecutorTest { return result } "选择规格", "免拼购买" -> if (entryActionHasEffect) panel = true - in sizes -> { + in (horizontalSizePages?.flatten() ?: sizes) -> { sizeClickCount++ val result = sizeClickResults.removeFirstOrNull() ?: FreshActionResult.SUCCESS if (result == FreshActionResult.SUCCESS || sizeSelectsOnFailedClick) size = target.label @@ -1330,7 +1400,7 @@ class PurchaseRehearsalExecutorTest { } return FreshClickOutcome(result, forcedEntryClickReason) } - if (target.label in sizes && forcedSizeClickReason != null) { + if (target.label in (horizontalSizePages?.flatten() ?: sizes) && forcedSizeClickReason != null) { sizeClickCount++ val result = when (forcedSizeClickReason) { FreshClickReason.ROOT_UNAVAILABLE, FreshClickReason.TARGET_NOT_FOUND -> FreshActionResult.NOT_FOUND @@ -1357,7 +1427,7 @@ class PurchaseRehearsalExecutorTest { if (specTapResult != FreshActionResult.SUCCESS || !specTapHasEffect) return specTapResult when { target.path in setOf("spec", "buy") -> panel = true - target.label in sizes -> size = target.label + target.label in (horizontalSizePages?.flatten() ?: sizes) -> size = target.label target.label in colors -> color = target.label } return FreshActionResult.SUCCESS @@ -1384,11 +1454,22 @@ class PurchaseRehearsalExecutorTest { override fun swipeSpecRow(target: SnapshotNode, direction: SwipeDirection): Boolean { horizontalSpecDirections += direction + horizontalSpecAnchorPaths += target.path + val dimension = if (target.label in (horizontalSizePages?.flatten() ?: emptyList())) "size" else "color" + horizontalSpecDimensions += dimension if (!horizontalSpecSwipeSucceeds) return false - horizontalColorPage = when (direction) { - SwipeDirection.LEFT -> (horizontalColorPage + 1).coerceAtMost((horizontalColorPages?.lastIndex ?: 0)) - SwipeDirection.RIGHT -> (horizontalColorPage - 1).coerceAtLeast(0) - else -> horizontalColorPage + if (dimension == "size") { + horizontalSizePage = when (direction) { + SwipeDirection.LEFT -> (horizontalSizePage + 1).coerceAtMost((horizontalSizePages?.lastIndex ?: 0)) + SwipeDirection.RIGHT -> (horizontalSizePage - 1).coerceAtLeast(0) + else -> horizontalSizePage + } + } else { + horizontalColorPage = when (direction) { + SwipeDirection.LEFT -> (horizontalColorPage + 1).coerceAtMost((horizontalColorPages?.lastIndex ?: 0)) + SwipeDirection.RIGHT -> (horizontalColorPage - 1).coerceAtLeast(0) + else -> horizontalColorPage + } } return true }