fix(#64): bound purchase spec selection retries

This commit is contained in:
QiuSW
2026-08-22 09:07:13 +08:00
parent ec9f47f2ab
commit 22a024be64
6 changed files with 129 additions and 18 deletions
@@ -215,12 +215,9 @@ class PurchaseRehearsalExecutor(
}
listOf("color" to input.mappedColor, "size" to input.mappedSize).forEach { (dimension, target) ->
if (target.isBlank()) return@forEach
val screen = currentScreen(input)
screen.problem?.let { return failure(it.code, it.message) }
val values = screen.dimensions.filter { it.key == dimension }.flatMap { it.values }.filter { it.text == target && it.available }
if (values.isEmpty()) return failure("PURCHASE_SPEC_NOT_MATCHED", "没有找到规格:$target")
if (values.size != 1) return failure("RULE_AMBIGUOUS", "规格 $target 匹配到多个控件")
when (driver.clickFresh(values.single().node)) {
val lookup = locateExactSpec(input, dimension, target)
lookup.failure?.let { return it }
when (driver.clickFresh(requireNotNull(lookup.node))) {
FreshActionResult.AMBIGUOUS -> return failure("RULE_AMBIGUOUS", "规格 $target 匹配到多个控件")
FreshActionResult.SUCCESS -> Unit
else -> return failure("RULE_ACTION_FAILED", "规格 $target 选择失败")
@@ -244,6 +241,50 @@ class PurchaseRehearsalExecutor(
return null
}
private data class SpecLookup(val node: SnapshotNode? = null, val failure: PurchaseExecutionOutcome? = null)
/**
* Searches only parsed, selectable values in the already-open spec panel.
* A short downward pass first restores the top when a previous action left
* the panel lower down; the bounded upward pass then visits lower values.
* Every gesture is followed by a fresh parse, and unchanged evidence ends
* that direction early.
*/
private fun locateExactSpec(input: PurchaseExecutionInput, dimension: String, target: String): SpecLookup {
fun inspect(): Pair<SpecLookup?, String> {
val screen = currentScreen(input)
screen.problem?.let { return SpecLookup(failure = failure(it.code, it.message)) to "" }
if (!screen.specPanelOpen) {
return SpecLookup(failure = failure("RULE_NOT_MATCHED", "商品规格面板已经关闭")) to ""
}
val dimensionValues = screen.dimensions.filter { it.key == dimension }.flatMap { it.values }
val exact = dimensionValues.filter { it.text == target && it.available }
if (exact.size > 1) {
return SpecLookup(failure = failure("RULE_AMBIGUOUS", "规格 $target 匹配到多个控件")) to ""
}
if (exact.size == 1) return SpecLookup(node = exact.single().node) to ""
val signature = screen.dimensions.joinToString("|") { item ->
"${item.key}:${item.values.joinToString(",") { value -> "${value.text}:${value.available}" }}"
}
return null to signature
}
var inspected = inspect()
inspected.first?.let { return it }
var currentSignature = inspected.second
for ((direction, limit) in listOf(SwipeDirection.DOWN to 3, SwipeDirection.UP to 6)) {
for (attempt in 0 until limit) {
if (!driver.swipePurchase(direction, 350)) break
pause(300)
inspected = inspect()
inspected.first?.let { return it }
if (inspected.second == currentSignature) break
currentSignature = inspected.second
}
}
return SpecLookup(failure = failure("PURCHASE_SPEC_NOT_MATCHED", "没有找到规格:$target"))
}
private fun setQuantity(quantity: Long): PurchaseExecutionOutcome? {
val snapshot = driver.capture()
pageProblem(snapshot)?.let { return it }
@@ -63,6 +63,18 @@ class PurchaseRehearsalExecutorTest {
assertEquals("spec_probe_completed", outcome.resultType)
assertEquals("{\"dimensions\":[]}", outcome.probedSpecs)
assertFalse(driver.clicked.contains("黑色加绒"))
assertTrue(driver.swipeCount <= 11)
}
@Test
fun `exact size below the visible panel is found after a bounded vertical search`() {
val driver = FakePurchaseDriver(hiddenSizeUntilUpSwipes = 3)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("rehearsal_completed", outcome.resultType)
assertTrue(driver.clicked.contains("XL"))
assertEquals(3, driver.upSwipeCount)
}
@Test
@@ -209,6 +221,7 @@ class PurchaseRehearsalExecutorTest {
private val duplicateOpen: Boolean = false,
private val bottomPurchaseEntry: Boolean = false,
private val includeReviewEntry: Boolean = false,
private val hiddenSizeUntilUpSwipes: Int = 0,
) : PurchaseUiDriver {
var browser = false
var panel = false
@@ -216,6 +229,7 @@ class PurchaseRehearsalExecutorTest {
var size: String? = null
var quantity = 1L
var swipeCount = 0
var upSwipeCount = 0
val clicked = mutableListOf<String>()
override fun capture(): UiSnapshot {
@@ -251,7 +265,8 @@ class PurchaseRehearsalExecutorTest {
nodes += node("scroll/color-$index", value, 20 + index * 220, 470, 200 + index * 220, 540, clickable = true, selected = color == value, parentPath = "scroll")
}
nodes += node("scroll/size-heading", "尺码", 20, 650, 300, 690, parentPath = "scroll")
nodes += node("scroll/size", "XL", 20, 710, 200, 780, clickable = true, selected = size == "XL", parentPath = "scroll")
val visibleSize = if (upSwipeCount >= hiddenSizeUntilUpSwipes) "XL" else "S"
nodes += node("scroll/size", visibleSize, 20, 710, 200, 780, clickable = true, selected = size == visibleSize, parentPath = "scroll")
nodes += node("quantity", quantity.toString(), 400, 800, 600, 870, className = "android.widget.EditText")
nodes += node("confirm", "确定", 20, 900, 500, 980, clickable = true)
nodes += node("order", "提交订单", 20, 1100, 500, 1180, clickable = true)
@@ -279,6 +294,7 @@ class PurchaseRehearsalExecutorTest {
override fun swipePurchase(direction: SwipeDirection, durationMs: Long): Boolean {
swipeCount++
if (direction == SwipeDirection.UP) upSwipeCount++
return true
}