From cdcb6930d056e1f4138a536050caf3bb555feba9 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Sat, 5 Sep 2026 16:21:01 +0800 Subject: [PATCH] fix(android): revalidate offscreen selected specs (#224) --- android/app/build.gradle.kts | 4 +- .../automation/PurchaseRehearsalExecutor.kt | 77 ++++++++++++++++++- .../agent/PurchaseRehearsalExecutorTest.kt | 56 +++++++++++++- 3 files changed, 129 insertions(+), 8 deletions(-) diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 49f2338..8f447bd 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 = 57 - versionName = "0.9.44" + versionCode = 58 + versionName = "0.9.45" 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 3d8f485..5522f08 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 @@ -631,6 +631,8 @@ class PurchaseRehearsalExecutor( val summaryTokenMatched: Boolean, val candidateCount: Int, val proofPresent: Boolean, + val relocationAttempted: Boolean = false, + val relocationSwipes: Int = 0, ) private fun verifyExactSpecSelection( @@ -672,6 +674,71 @@ class PurchaseRehearsalExecutor( } } + private fun verifyExactSpecSelectionWithRelocation( + input: PurchaseExecutionInput, + dimension: String, + target: String, + proof: ExactSpecSelectionProof?, + ): FinalSpecVerification { + var screen = currentScreen(input) + screen.problem?.let { + return FinalSpecVerification(false, it.code, false, 0, proof != null) + } + var verification = verifyExactSpecSelection(screen, dimension, target, proof) + if (verification.confirmed || verification.candidateCount > 0) return verification + if (!screen.specPanelOpen) return verification.copy(reason = "relocation_panel_closed", relocationAttempted = true) + + var signature = screen.dimensions.joinToString("|") { item -> + "${item.key}:${item.values.joinToString(",") { value -> "${value.text}:${value.available}" }}" + } + var swipes = 0 + for ((direction, limit) in listOf(SwipeDirection.DOWN to 3, SwipeDirection.UP to 6)) { + for (attempt in 0 until limit) { + val container = screen.specPanelContainer + ?: return verification.copy( + reason = "relocation_container_missing", + relocationAttempted = true, + relocationSwipes = swipes, + ) + if (!driver.swipePurchaseIn(container, direction, 350)) { + return verification.copy( + reason = "relocation_swipe_failed", + relocationAttempted = true, + relocationSwipes = swipes, + ) + } + swipes++ + pause(300) + screen = currentScreen(input) + screen.problem?.let { + return verification.copy( + reason = it.code, + relocationAttempted = true, + relocationSwipes = swipes, + ) + } + if (!screen.specPanelOpen) { + return verification.copy( + reason = "relocation_panel_closed", + relocationAttempted = true, + relocationSwipes = swipes, + ) + } + verification = verifyExactSpecSelection(screen, dimension, target, proof).copy( + relocationAttempted = true, + relocationSwipes = swipes, + ) + if (verification.confirmed || verification.candidateCount > 0) return verification + val refreshedSignature = screen.dimensions.joinToString("|") { item -> + "${item.key}:${item.values.joinToString(",") { value -> "${value.text}:${value.available}" }}" + } + if (refreshedSignature == signature) break + signature = refreshedSignature + } + } + return verification.copy(relocationAttempted = true, relocationSwipes = swipes) + } + private data class SpecLookup(val node: SnapshotNode? = null, val failure: PurchaseExecutionOutcome? = null) /** @@ -759,23 +826,25 @@ class PurchaseRehearsalExecutor( observedPrice: Long?, selectionProofs: Map, ): PurchaseExecutionOutcome? { - val screen = currentScreen(input) - screen.problem?.let { return failure(it.code, it.message) } val selected = listOf("color" to input.mappedColor, "size" to input.mappedSize).filter { it.second.isNotBlank() }.map { (dimension, rawTarget) -> dimension to (normalizedTarget(dimension, rawTarget) ?: return failure(SPEC_SAFE_TARGET_MISSING, "下发规格无法安全规范化")) } selected.forEach { (dimension, target) -> - val verification = verifyExactSpecSelection(screen, dimension, target, selectionProofs[dimension]) + val verification = verifyExactSpecSelectionWithRelocation(input, dimension, target, selectionProofs[dimension]) if (!verification.confirmed) { + val screen = currentScreen(input) val panel = screen.specPanelType.name.lowercase() val diagnostic = "dimension=$dimension,reason=${verification.reason},panel=$panel," + "summary=${screen.selectedSummary != null},tokenMatched=${verification.summaryTokenMatched}," + - "candidates=${verification.candidateCount},proof=${verification.proofPresent}" + "candidates=${verification.candidateCount},proof=${verification.proofPresent}," + + "relocated=${verification.relocationAttempted},relocationSwipes=${verification.relocationSwipes}" return failure(SPEC_SELECTION_UNCONFIRMED, "最终规格复核未能确认精确选中状态 [$diagnostic]") } } if (readQuantity() != input.quantity) return failure("PURCHASE_QUANTITY_MISMATCH", "最终数量复核失败") + val screen = currentScreen(input) + screen.problem?.let { return failure(it.code, it.message) } val price = screen.priceCent ?: observedPrice ?: return failure("RULE_NOT_MATCHED", "最终价格复核失败") if (price !in input.minUnitPriceCent..input.maxUnitPriceCent) { return failure("PURCHASE_PRICE_OUT_OF_RANGE", "当前商品单价超出允许范围", price) 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 435c35c..172bf6f 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 @@ -248,6 +248,41 @@ class PurchaseRehearsalExecutorTest { assertTrue(outcome.message.contains("proof=true")) } + @Test + fun `final verification relocates hidden selected color when summary is unavailable`() { + val driver = FakePurchaseDriver( + hideColorAfterQuantitySet = true, + hideSelectedSummaryAfterQuantitySet = true, + restoreHiddenColorOnDownSwipe = true, + ) + val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {}) + .execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported) + + assertEquals("rehearsal_completed", outcome.resultType) + assertTrue(driver.swipeInPaths.contains("scroll")) + assertEquals(1, driver.clicked.count { it == "黑色" }) + assertEquals(1, driver.clicked.count { it == "XL" }) + } + + @Test + fun `final verification rejects relocated color without fresh selected state`() { + val driver = FakePurchaseDriver( + hideColorAfterQuantitySet = true, + hideSelectedSummaryAfterQuantitySet = true, + restoreHiddenColorOnDownSwipe = true, + hideRestoredColorSelectedState = true, + ) + val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {}) + .execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported) + + assertEquals("failed", outcome.resultType) + assertEquals("PURCHASE_SPEC_SELECTION_UNCONFIRMED", outcome.errorCode) + assertTrue(outcome.message.contains("dimension=color")) + assertTrue(outcome.message.contains("relocated=true")) + assertTrue(outcome.message.contains("relocationSwipes=1")) + assertEquals(1, driver.clicked.count { it == "黑色" }) + } + @Test fun `final verification never uses XL proof for a 2XL summary`() { val driver = FakePurchaseDriver( @@ -950,6 +985,8 @@ class PurchaseRehearsalExecutorTest { private val hideColorAfterQuantitySet: Boolean = false, private val hideSizeAfterQuantitySet: Boolean = false, private val hideSelectedSummaryAfterQuantitySet: Boolean = false, + private val restoreHiddenColorOnDownSwipe: Boolean = false, + private val hideRestoredColorSelectedState: Boolean = false, private val selectedSummaryOverrideAfterQuantitySet: String? = null, private val finalSizesAfterQuantitySet: List? = null, soldOut: Boolean = false, @@ -981,6 +1018,7 @@ class PurchaseRehearsalExecutorTest { private var reviewPage = false private var pddCaptureCount = 0 private var browserCaptureCount = 0 + private var hiddenColorRestored = false val clicked = mutableListOf() val clickedPaths = mutableListOf() @@ -1045,7 +1083,7 @@ class PurchaseRehearsalExecutorTest { node("panel-title", "确认款式", 20, 396, 300, 430), )) } - val hideColor = hideColorAfterQuantitySet && quantity == 2L + val hideColor = hideColorAfterQuantitySet && quantity == 2L && !hiddenColorRestored val hideSize = hideSizeAfterQuantitySet && quantity == 2L val hideSummary = hideSelectedSummaryAfterQuantitySet && quantity == 2L val displayedSummary = if (quantity == 2L && selectedSummaryOverrideAfterQuantitySet != null) { @@ -1065,7 +1103,18 @@ class PurchaseRehearsalExecutorTest { if (!hideColor) { nodes += node("scroll/color-heading", "颜色分类", 20, 410, 300, 450, parentPath = "scroll") colors.forEachIndexed { index, value -> - nodes += node("scroll/color-$index", value, 20 + index * 220, 470, 200 + index * 220, 540, clickable = true, selected = color == value, enabled = !allSpecsUnavailable, parentPath = "scroll") + nodes += node( + "scroll/color-$index", + value, + 20 + index * 220, + 470, + 200 + index * 220, + 540, + clickable = true, + selected = color == value && !(quantity == 2L && hiddenColorRestored && hideRestoredColorSelectedState), + enabled = !allSpecsUnavailable, + parentPath = "scroll", + ) } } if (!hideSize) { @@ -1185,6 +1234,9 @@ class PurchaseRehearsalExecutorTest { override fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long): Boolean { swipeInPaths += target.path + if (restoreHiddenColorOnDownSwipe && quantity == 2L && direction == SwipeDirection.DOWN) { + hiddenColorRestored = true + } return swipePurchase(direction, durationMs) }