diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 2518b52..420255f 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 = 34 - versionName = "0.9.21" + versionCode = 35 + versionName = "0.9.22" 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 582828d..06e6a7a 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 @@ -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", 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 411aaf6..3359aa6 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 @@ -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 = mutableListOf(), private val openPddOnFailedClick: Boolean = false, + private val sizeClickResults: MutableList = 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() 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--