fix(#151): verify exact spec selection after click

This commit is contained in:
QiuSW
2026-08-29 14:31:03 +08:00
parent d847b00c5b
commit 4a7a483272
3 changed files with 107 additions and 21 deletions
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId = "cn.ilapage.goauto.agent"
minSdk = 23
targetSdk = 34
versionCode = 34
versionName = "0.9.21"
versionCode = 35
versionName = "0.9.22"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -240,24 +240,7 @@ class PurchaseRehearsalExecutor(
}
listOf("color" to input.mappedColor, "size" to input.mappedSize).forEach { (dimension, target) ->
if (target.isBlank()) return@forEach
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 选择失败")
}
var selected = false
repeat(20) {
val refreshed = currentScreen(input)
refreshed.problem?.let { return failure(it.code, it.message) }
selected = refreshed.selectedSummary?.contains(target) == true ||
refreshed.dimensions.filter { it.key == dimension }.flatMap { it.values }
.any { it.text == target && (it.node.selected || it.node.checked) }
if (selected) return@repeat
pause(100)
}
if (!selected) return failure("PURCHASE_SPEC_NOT_MATCHED", "规格 $target 未能精确选中")
selectExactSpecWithRetry(input, dimension, target)?.let { return it }
}
val requiredCapability = if (input.executionMode == "live") PurchaseAgentCapabilities.LIVE_V1 else PurchaseAgentCapabilities.REHEARSAL_V1
if (rule.requiredCapabilities.none { it == requiredCapability }) {
@@ -266,6 +249,57 @@ class PurchaseRehearsalExecutor(
return null
}
private fun selectExactSpecWithRetry(
input: PurchaseExecutionInput,
dimension: String,
target: String,
): PurchaseExecutionOutcome? {
var lastClickReason = FreshClickReason.UNKNOWN
var clickSucceeded = false
repeat(SPEC_SELECTION_CLICK_ATTEMPTS) {
val lookup = locateExactSpec(input, dimension, target)
lookup.failure?.let { return it }
val outcome = driver.clickFreshDetailed(requireNotNull(lookup.node))
lastClickReason = outcome.reason
if (outcome.result == FreshActionResult.AMBIGUOUS) {
return failure("RULE_AMBIGUOUS", "规格 $target 匹配到多个控件")
}
clickSucceeded = clickSucceeded || outcome.result == FreshActionResult.SUCCESS
// PDD rerenders the remaining options after a color selection. A
// stale node can report NOT_FOUND/FAILED even when the exact option
// was selected. Always verify the observable selected state before
// deciding whether to refind and retry the same exact target.
val verifyPolls = if (outcome.result == FreshActionResult.SUCCESS) {
SPEC_SELECTION_SUCCESS_VERIFY_POLLS
} else {
SPEC_SELECTION_FAILED_VERIFY_POLLS
}
repeat(verifyPolls) {
val refreshed = currentScreen(input)
refreshed.problem?.let { return failure(it.code, it.message) }
if (isExactSpecSelected(refreshed, dimension, target)) return null
pause(SPEC_SELECTION_POLL_MILLIS)
}
}
if (clickSucceeded) {
return failure("PURCHASE_SPEC_NOT_MATCHED", "规格 $target 未能精确选中")
}
val message = when (lastClickReason) {
FreshClickReason.ROOT_UNAVAILABLE, FreshClickReason.TARGET_NOT_FOUND -> "规格 $target 点击前发生变化"
FreshClickReason.NO_CLICKABLE_ANCESTOR -> "规格 $target 暂时无法点击"
FreshClickReason.ACTION_CLICK_FALSE -> "规格 $target 点击未生效"
else -> "规格 $target 选择失败"
}
return failure("RULE_ACTION_FAILED", message)
}
private fun isExactSpecSelected(screen: ParsedPddScreen, dimension: String, target: String): Boolean =
screen.selectedSummary?.contains(target) == true ||
screen.dimensions.filter { it.key == dimension }.flatMap { it.values }
.any { it.text == target && (it.node.selected || it.node.checked) }
private data class SpecLookup(val node: SnapshotNode? = null, val failure: PurchaseExecutionOutcome? = null)
/**
@@ -404,6 +438,10 @@ class PurchaseRehearsalExecutor(
private const val OPEN_PRODUCT_POLL_LIMIT = 50
private const val OPEN_PRODUCT_RETRY_POLLS = 10
private const val OPEN_PRODUCT_POLL_MILLIS = 100L
private const val SPEC_SELECTION_CLICK_ATTEMPTS = 3
private const val SPEC_SELECTION_SUCCESS_VERIFY_POLLS = 20
private const val SPEC_SELECTION_FAILED_VERIFY_POLLS = 5
private const val SPEC_SELECTION_POLL_MILLIS = 100L
val DEFAULT_COLLECTOR = PddCollectorConfig(
collectorId = "pddProductDetailV1",
specEntryStrategy = "safeBottomSpecEntryV1",
@@ -78,6 +78,46 @@ class PurchaseRehearsalExecutorTest {
assertEquals(3, driver.upSwipeCount)
}
@Test
fun `failed size click result continues when exact size is actually selected`() {
val driver = FakePurchaseDriver(
sizeClickResults = mutableListOf(FreshActionResult.FAILED),
sizeSelectsOnFailedClick = true,
)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("rehearsal_completed", outcome.resultType)
assertEquals(1, driver.sizeClickCount)
assertEquals("XL", driver.size)
}
@Test
fun `rerendered exact size is refound and selected on the second attempt`() {
val driver = FakePurchaseDriver(
sizeClickResults = mutableListOf(FreshActionResult.NOT_FOUND, FreshActionResult.SUCCESS),
)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("rehearsal_completed", outcome.resultType)
assertEquals(2, driver.sizeClickCount)
assertEquals("XL", driver.size)
}
@Test
fun `persistent exact size click failure stops after three attempts`() {
val driver = FakePurchaseDriver(
sizeClickResults = MutableList(3) { FreshActionResult.FAILED },
)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("RULE_ACTION_FAILED", outcome.errorCode)
assertEquals(3, driver.sizeClickCount)
assertEquals(null, driver.size)
}
@Test
fun `price outside range fails before any order action`() {
val driver = FakePurchaseDriver(priceCent = 4_000)
@@ -266,6 +306,8 @@ class PurchaseRehearsalExecutorTest {
private val hiddenSizeUntilUpSwipes: Int = 0,
private val openClickResults: MutableList<FreshActionResult> = mutableListOf(),
private val openPddOnFailedClick: Boolean = false,
private val sizeClickResults: MutableList<FreshActionResult> = mutableListOf(),
private val sizeSelectsOnFailedClick: Boolean = false,
) : PurchaseUiDriver {
var browser = false
var panel = false
@@ -275,6 +317,7 @@ class PurchaseRehearsalExecutorTest {
var swipeCount = 0
var upSwipeCount = 0
var openClickCount = 0
var sizeClickCount = 0
val clicked = mutableListOf<String>()
override fun capture(): UiSnapshot {
@@ -329,7 +372,12 @@ class PurchaseRehearsalExecutorTest {
return result
}
"选择规格", "免拼购买" -> panel = true
"XL" -> size = "XL"
"XL" -> {
sizeClickCount++
val result = sizeClickResults.removeFirstOrNull() ?: FreshActionResult.SUCCESS
if (result == FreshActionResult.SUCCESS || sizeSelectsOnFailedClick) size = "XL"
return result
}
in colors -> color = target.label
"增加数量" -> quantity++
"减少数量" -> quantity--