fix(agent): search horizontally for exact purchase size (#230)
This commit is contained in:
@@ -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"
|
||||
|
||||
|
||||
+38
-16
@@ -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<VisibleSpecValue>): List<List<VisibleSpecValue>> {
|
||||
private fun horizontalSpecRow(
|
||||
screen: ParsedPddScreen,
|
||||
rows: List<List<VisibleSpecValue>>,
|
||||
): List<VisibleSpecValue>? = 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<VisibleSpecValue>): List<List<VisibleSpecValue>> {
|
||||
val sorted = values.sortedWith(compareBy({ it.node.bounds.centerY }, { it.node.bounds.left }))
|
||||
val rows = mutableListOf<MutableList<VisibleSpecValue>>()
|
||||
val centers = mutableListOf<Int>()
|
||||
|
||||
+91
-10
@@ -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<String> = listOf("黑色"),
|
||||
private val horizontalColorPages: List<List<String>>? = null,
|
||||
private val horizontalSizePages: List<List<String>>? = null,
|
||||
private val horizontalSpecSwipeSucceeds: Boolean = true,
|
||||
private val sizes: List<String> = listOf("XL"),
|
||||
private val priceCent: Long = 2_000,
|
||||
@@ -1130,6 +1193,8 @@ class PurchaseRehearsalExecutorTest {
|
||||
var backCount = 0
|
||||
var specTapCount = 0
|
||||
val horizontalSpecDirections = mutableListOf<SwipeDirection>()
|
||||
val horizontalSpecDimensions = mutableListOf<String>()
|
||||
val horizontalSpecAnchorPaths = mutableListOf<String>()
|
||||
val swipeInPaths = mutableListOf<String>()
|
||||
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<String>()
|
||||
val clickedPaths = mutableListOf<String>()
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user