From ca815c30de540ca3091ffcd0c5d9ae9a265ffeaa Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Wed, 9 Sep 2026 09:21:27 +0800 Subject: [PATCH] fix(android): clarify entry recovery and unavailable exact specs #245 --- android/app/build.gradle.kts | 4 +- .../automation/PurchaseRehearsalExecutor.kt | 62 ++++++++++++++----- .../agent/PurchaseRehearsalExecutorTest.kt | 27 +++++++- 3 files changed, 75 insertions(+), 18 deletions(-) diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 459f99b..4204c63 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 = 75 - versionName = "0.9.62" + versionCode = 76 + versionName = "0.9.63" 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 b69897d..ba0b73c 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 @@ -96,6 +96,7 @@ class PurchaseRehearsalExecutor( ) { private var purchasePanelContext: PurchasePanelContext? = null private var pageIdentity: Pair = null to null + private var entryRecoveryReason = "not_checked" fun execute(input: PurchaseExecutionInput, rule: PurchaseRule, supportedCapabilities: Set): PurchaseExecutionOutcome { purchasePanelContext = null @@ -400,7 +401,7 @@ class PurchaseRehearsalExecutor( if (refreshed.specPanelOpen) return null val recoveryTarget = recoverableSpecEntry(screen, refreshed, action, beforeIdentity) if (recoveryTarget == null) { - panelDiagnostic("entryRecovery=rejected;pageChanged=${wait.changed};${panelEvidence(refreshed)}") + panelDiagnostic("entryRecovery=rejected;reason=$entryRecoveryReason;pageChanged=${wait.changed};${panelEvidence(refreshed)}") return failure( SPEC_PANEL_EVIDENCE_NOT_MATCHED, "规格入口点击后页面已变化,但规格面板强证据不足 [${panelEvidence(wait.screen)}]", @@ -443,18 +444,38 @@ class PurchaseRehearsalExecutor( action: PurchaseAction, identity: Pair, ): SnapshotNode? { - if (identity.first == null || identity.second == null || identity != pageIdentity || - !after.isPddPackage || !after.pageEvidenceMatched || after.problem != null || after.reviewPageOpen || - after.specPanelOpen || after.hasCloseControl || after.hasPaymentArea || after.hasOrderSubmitAction || - after.hasQuantityControls || after.hasSelectionSummary || - before.summary.title.isNullOrBlank() || before.summary.title != after.summary.title || - before.specEntrySource == null || before.specEntrySource != after.specEntrySource || - before.specEntry?.label != after.specEntry?.label || - after.explicitSpecEntryCount > 1 || after.nestedSpecEntryCount > 1 - ) return null + entryRecoveryReason = when { + identity.first == null || identity.second == null -> "identity_missing" + identity != pageIdentity -> "identity_changed" + !after.isPddPackage || !after.pageEvidenceMatched -> "product_context_missing" + after.problem != null || after.reviewPageOpen -> "page_problem" + after.specPanelOpen -> "panel_open" + after.hasCloseControl -> "close_control" + after.hasPaymentArea -> "payment_area" + after.hasOrderSubmitAction -> "order_action" + after.hasQuantityControls -> "quantity_controls" + after.hasSelectionSummary -> "selection_summary" + before.summary.title.isNullOrBlank() -> "title_missing" + before.summary.title != after.summary.title -> "title_changed" + after.specEntry == null -> "entry_missing" + before.specEntrySource == null -> "entry_source_missing" + before.specEntrySource != after.specEntrySource -> "entry_source_changed" + before.specEntry?.label != after.specEntry.label -> "entry_label_changed" + after.explicitSpecEntryCount > 1 || after.nestedSpecEntryCount > 1 -> "entry_ambiguous" + else -> "eligible" + } + if (entryRecoveryReason != "eligible") return null val anchor = after.specEntry ?: return null - if (action.textAliases?.let { specEntryMatchesAliases(after, anchor, it) } == false) return null - return (after.specEntryClickTarget ?: anchor).takeIf { it.visible && it.enabled } + if (action.textAliases?.let { specEntryMatchesAliases(after, anchor, it) } == false) { + entryRecoveryReason = "rule_alias_mismatch" + return null + } + val target = after.specEntryClickTarget ?: anchor + if (!target.visible || !target.enabled) { + entryRecoveryReason = "entry_unavailable" + return null + } + return target } private data class SpecPanelWait( @@ -582,6 +603,7 @@ class PurchaseRehearsalExecutor( ): PurchaseExecutionOutcome? { val initial = currentScreen(input) initial.problem?.let { return failure(it.code, it.message) } + unavailableExactSpec(initial, dimension, target)?.let { return it } if (rememberExactSelection(initial, dimension, target, selectionProofs)) return null val lookup = locateExactSpec(input, dimension, target) @@ -832,6 +854,18 @@ class PurchaseRehearsalExecutor( private data class SpecLookup(val node: SnapshotNode? = null, val failure: PurchaseExecutionOutcome? = null) + private fun unavailableExactSpec(screen: ParsedPddScreen, dimension: String, target: String): PurchaseExecutionOutcome? { + if (!screen.specPanelOpen) return null + val exact = screen.dimensions.filter { it.key == dimension }.flatMap { it.values }.filter { it.text == target } + return if (exact.size == 1 && !exact.single().available) unavailableSpec(dimension) else null + } + + private fun unavailableSpec(dimension: String): PurchaseExecutionOutcome { + val role = if (dimension == "color") "颜色" else "尺码" + panelDiagnostic("specLookup=unavailable;dimension=$dimension;reason=exact_target_unavailable") + return failure(SPEC_SAFE_TARGET_MISSING, "目标${role}已售罄或当前不可选,未创建订单") + } + /** * Searches only parsed, selectable values in the already-open spec panel. * A short downward pass first restores the top when a previous action left @@ -868,7 +902,7 @@ class PurchaseRehearsalExecutor( } if (exact.size == 1 && exact.single().available) return Inspection(SpecLookup(node = exact.single().node), "", screen.specPanelContainer) if (exact.size == 1) { - return Inspection(SpecLookup(failure = failure(SPEC_SAFE_TARGET_MISSING, "精确规格当前不可安全点击")), "", screen.specPanelContainer) + return Inspection(SpecLookup(failure = unavailableSpec(dimension)), "", screen.specPanelContainer) } val signature = screen.dimensions.joinToString("|") { item -> "${item.key}:${item.values.joinToString(",") { value -> "${value.text}:${value.available}" }}" @@ -933,7 +967,7 @@ class PurchaseRehearsalExecutor( val lookup = when { exact.size > 1 -> SpecLookup(failure = failure(SPEC_TARGET_AMBIGUOUS, "精确规格匹配到多个控件")) exact.size == 1 && exact.single().available -> SpecLookup(node = exact.single().node) - exact.size == 1 -> SpecLookup(failure = failure(SPEC_SAFE_TARGET_MISSING, "精确规格当前不可安全点击")) + exact.size == 1 -> SpecLookup(failure = unavailableSpec(dimension)) else -> null } val rows = specValueRows(values) 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 7efa7c5..c7e970d 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 @@ -930,12 +930,34 @@ class PurchaseRehearsalExecutorTest { @Test fun `changed entry identity or unsafe page never receives recovery tap`() { for (mode in listOf("missing", "duplicate", "activity", "payment")) { + val diagnostics = mutableListOf() val driver = FakePurchaseDriver(entryActionHasEffect = false, dynamicProduct = true, afterEntryMutation = mode, specTapResult = FreshActionResult.SUCCESS) - val outcome = PurchaseRehearsalExecutor(driver, { true }, { null }, pause = {}) + val outcome = PurchaseRehearsalExecutor(driver, { true }, { null }, pause = {}, panelDiagnostic = diagnostics::add) .execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported) assertEquals(mode, "failed", outcome.resultType) assertEquals(mode, 0, driver.specTapCount) + val expected = mapOf("missing" to "entry_missing", "duplicate" to "entry_ambiguous", + "activity" to "identity_changed", "payment" to "payment_area").getValue(mode) + assertTrue(diagnostics.toString(), diagnostics.any { it.contains("entryRecovery=rejected;reason=$expected;") }) + } + } + + @Test + fun `unavailable exact color stops without selection click or searching even when selected`() { + for (selected in listOf(null, "黑色")) { + val driver = FakePurchaseDriver(colors = listOf("黑色", "白色"), + unavailableColors = setOf("黑色")).apply { color = selected } + val logs = mutableListOf() + val outcome = PurchaseRehearsalExecutor(driver, { true }, { null }, pause = {}, panelDiagnostic = logs::add) + .execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported) + assertEquals("PURCHASE_SPEC_SAFE_TARGET_MISSING", outcome.errorCode) + assertEquals("目标颜色已售罄或当前不可选,未创建订单", outcome.message) + assertFalse(driver.clicked.contains("黑色")) + assertEquals(0, driver.specTapCount) + assertEquals(0, driver.swipeCount) + assertTrue(logs.any { it == "specLookup=unavailable;dimension=color;reason=exact_target_unavailable" }) + assertFalse(logs.any { it.contains("黑色") }) } } @@ -1385,6 +1407,7 @@ class PurchaseRehearsalExecutorTest { private val pullDownSucceeds: Boolean = true, private val loseEvidenceAfterPull: Boolean = false, private val unavailableSizes: Set = emptySet(), + private val unavailableColors: Set = emptySet(), allSpecsUnavailable: Boolean = false, private val nonScrollablePanel: Boolean = false, private val confirmationPanelKind: String? = null, @@ -1561,7 +1584,7 @@ class PurchaseRehearsalExecutorTest { 540, clickable = true, selected = selectedColor == value, - enabled = !allSpecsUnavailable, + enabled = !allSpecsUnavailable && value !in unavailableColors, parentPath = colorParent, ) }