From 095dbfacbf5f832cd60e2bb7f23e7a69fd88f007 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Sat, 5 Sep 2026 16:33:24 +0800 Subject: [PATCH] fix(android): retain verified spec selections (#224) --- android/app/build.gradle.kts | 4 +-- .../automation/PurchaseRehearsalExecutor.kt | 36 ++++++++++++------- .../agent/PurchaseRehearsalExecutorTest.kt | 29 +++++++-------- 3 files changed, 39 insertions(+), 30 deletions(-) diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 8f447bd..e5f110d 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 = 58 - versionName = "0.9.45" + versionCode = 59 + versionName = "0.9.46" 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 5522f08..516dd3e 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 @@ -608,7 +608,11 @@ class PurchaseRehearsalExecutor( return matchingCandidates.size == 1 && matchingCandidates.single() == target } - private data class ExactSpecSelectionProof(val dimension: String, val target: String) + private data class ExactSpecSelectionProof( + val dimension: String, + val target: String, + val panelType: SpecPanelType, + ) private fun exactSpecSelectionProof( screen: ParsedPddScreen, @@ -620,7 +624,7 @@ class PurchaseRehearsalExecutor( val tokenCandidates = screen.dimensions.filter { it.key == dimension }.flatMap { it.values }.filter { candidate -> if (dimension == "size") SpecValueNormalizer.primarySizeToken(candidate.text) == token else candidate.text == token } - return ExactSpecSelectionProof(dimension, target).takeIf { + return ExactSpecSelectionProof(dimension, target, screen.specPanelType).takeIf { tokenCandidates.size == 1 && tokenCandidates.single().text == target } } @@ -631,6 +635,7 @@ class PurchaseRehearsalExecutor( val summaryTokenMatched: Boolean, val candidateCount: Int, val proofPresent: Boolean, + val targetMatchCount: Int = 0, val relocationAttempted: Boolean = false, val relocationSwipes: Int = 0, ) @@ -643,15 +648,21 @@ class PurchaseRehearsalExecutor( ): FinalSpecVerification { val candidates = screen.dimensions.filter { it.key == dimension }.flatMap { it.values } val matchingNodes = candidates.filter { it.text == target } - val proofPresent = proof?.dimension == dimension && proof.target == target + val proofPresent = proof?.dimension == dimension && proof.target == target && proof.panelType == screen.specPanelType if (matchingNodes.size == 1 && (matchingNodes.single().node.selected || matchingNodes.single().node.checked)) { - return FinalSpecVerification(true, "selected_node", false, candidates.size, proofPresent) + return FinalSpecVerification(true, "selected_node", false, candidates.size, proofPresent, matchingNodes.size) + } + if (candidates.any { it.text != target && (it.node.selected || it.node.checked) }) { + return FinalSpecVerification(false, "visible_selected_conflict", false, candidates.size, proofPresent, matchingNodes.size) } val token = if (dimension == "size") SpecValueNormalizer.primarySizeToken(target) else target.trim() val summaryTokenMatched = !token.isNullOrBlank() && SpecValueNormalizer.summaryHasExactToken(screen.selectedSummary, token) if (!summaryTokenMatched) { - return FinalSpecVerification(false, "summary_token_missing", false, candidates.size, proofPresent) + if (screen.selectedSummary == null && screen.specPanelOpen && proofPresent) { + return FinalSpecVerification(true, "attempt_selection_state", false, candidates.size, true, matchingNodes.size) + } + return FinalSpecVerification(false, "summary_token_missing", false, candidates.size, proofPresent, matchingNodes.size) } // PDD can rerender or scroll the previously selected dimension out of // the current viewport while the selected summary remains visible. A @@ -662,15 +673,15 @@ class PurchaseRehearsalExecutor( if (dimension == "size") SpecValueNormalizer.primarySizeToken(candidate.text) == token else candidate.text == token } if (visibleTokenMatches.size == 1 && visibleTokenMatches.single().text == target) { - return FinalSpecVerification(true, "visible_candidate", true, candidates.size, proofPresent) + return FinalSpecVerification(true, "visible_candidate", true, candidates.size, proofPresent, matchingNodes.size) } if (visibleTokenMatches.isNotEmpty()) { - return FinalSpecVerification(false, "visible_candidate_conflict", true, candidates.size, proofPresent) + return FinalSpecVerification(false, "visible_candidate_conflict", true, candidates.size, proofPresent, matchingNodes.size) } return if (proofPresent) { - FinalSpecVerification(true, "attempt_selection_proof", true, candidates.size, true) + FinalSpecVerification(true, "attempt_selection_proof", true, candidates.size, true, matchingNodes.size) } else { - FinalSpecVerification(false, "selection_proof_missing", true, candidates.size, false) + FinalSpecVerification(false, "selection_proof_missing", true, candidates.size, false, matchingNodes.size) } } @@ -685,7 +696,7 @@ class PurchaseRehearsalExecutor( 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 (verification.confirmed || verification.targetMatchCount > 0 || verification.reason == "visible_selected_conflict") return verification if (!screen.specPanelOpen) return verification.copy(reason = "relocation_panel_closed", relocationAttempted = true) var signature = screen.dimensions.joinToString("|") { item -> @@ -728,7 +739,7 @@ class PurchaseRehearsalExecutor( relocationAttempted = true, relocationSwipes = swipes, ) - if (verification.confirmed || verification.candidateCount > 0) return verification + if (verification.confirmed || verification.targetMatchCount > 0 || verification.reason == "visible_selected_conflict") return verification val refreshedSignature = screen.dimensions.joinToString("|") { item -> "${item.key}:${item.values.joinToString(",") { value -> "${value.text}:${value.available}" }}" } @@ -837,7 +848,8 @@ class PurchaseRehearsalExecutor( 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},targetMatches=${verification.targetMatchCount}," + + "proof=${verification.proofPresent}," + "relocated=${verification.relocationAttempted},relocationSwipes=${verification.relocationSwipes}" return failure(SPEC_SELECTION_UNCONFIRMED, "最终规格复核未能确认精确选中状态 [$diagnostic]") } 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 172bf6f..47ffac5 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 @@ -233,7 +233,7 @@ class PurchaseRehearsalExecutorTest { } @Test - fun `final verification rejects prior proof when selected summary is no longer visible`() { + fun `final verification keeps exact attempt selection when summary is no longer visible`() { val driver = FakePurchaseDriver( hideColorAfterQuantitySet = true, hideSelectedSummaryAfterQuantitySet = true, @@ -241,15 +241,12 @@ class PurchaseRehearsalExecutorTest { 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("reason=summary_token_missing")) - assertTrue(outcome.message.contains("proof=true")) + assertEquals("rehearsal_completed", outcome.resultType) + assertEquals(1, driver.clicked.count { it == "黑色" }) } @Test - fun `final verification relocates hidden selected color when summary is unavailable`() { + fun `final verification reuses exact attempt state without scrolling back`() { val driver = FakePurchaseDriver( hideColorAfterQuantitySet = true, hideSelectedSummaryAfterQuantitySet = true, @@ -259,18 +256,17 @@ class PurchaseRehearsalExecutorTest { .execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported) assertEquals("rehearsal_completed", outcome.resultType) - assertTrue(driver.swipeInPaths.contains("scroll")) + assertFalse(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`() { + fun `final verification rejects explicit conflicting selected color`() { val driver = FakePurchaseDriver( - hideColorAfterQuantitySet = true, + colors = listOf("黑色", "白色"), hideSelectedSummaryAfterQuantitySet = true, - restoreHiddenColorOnDownSwipe = true, - hideRestoredColorSelectedState = true, + selectedColorOverrideAfterQuantitySet = "白色", ) val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {}) .execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported) @@ -278,8 +274,8 @@ class PurchaseRehearsalExecutorTest { 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")) + assertTrue(outcome.message.contains("reason=visible_selected_conflict")) + assertTrue(outcome.message.contains("targetMatches=1")) assertEquals(1, driver.clicked.count { it == "黑色" }) } @@ -986,7 +982,7 @@ class PurchaseRehearsalExecutorTest { private val hideSizeAfterQuantitySet: Boolean = false, private val hideSelectedSummaryAfterQuantitySet: Boolean = false, private val restoreHiddenColorOnDownSwipe: Boolean = false, - private val hideRestoredColorSelectedState: Boolean = false, + private val selectedColorOverrideAfterQuantitySet: String? = null, private val selectedSummaryOverrideAfterQuantitySet: String? = null, private val finalSizesAfterQuantitySet: List? = null, soldOut: Boolean = false, @@ -1102,6 +1098,7 @@ class PurchaseRehearsalExecutorTest { } if (!hideColor) { nodes += node("scroll/color-heading", "颜色分类", 20, 410, 300, 450, parentPath = "scroll") + val selectedColor = if (quantity == 2L) selectedColorOverrideAfterQuantitySet ?: color else color colors.forEachIndexed { index, value -> nodes += node( "scroll/color-$index", @@ -1111,7 +1108,7 @@ class PurchaseRehearsalExecutorTest { 200 + index * 220, 540, clickable = true, - selected = color == value && !(quantity == 2L && hiddenColorRestored && hideRestoredColorSelectedState), + selected = selectedColor == value, enabled = !allSpecsUnavailable, parentPath = "scroll", )