From ec8b14ae21a10a3bd4d07c040c36e850bd256671 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Tue, 8 Sep 2026 17:42:05 +0800 Subject: [PATCH] fix(android): recover spec entry despite product page animation (#243) --- android/app/build.gradle.kts | 4 +- .../automation/PurchaseRehearsalExecutor.kt | 40 ++++++++++++- .../agent/PurchaseRehearsalExecutorTest.kt | 60 +++++++++++++++++++ 3 files changed, 99 insertions(+), 5 deletions(-) diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index f9f5eb8..9db325e 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 = 73 - versionName = "0.9.60" + versionCode = 74 + versionName = "0.9.61" 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 cf39d3e..b28c729 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 @@ -95,6 +95,7 @@ class PurchaseRehearsalExecutor( private val beforeOrderSubmit: (FinalConfirmationEvidence) -> Unit = { throw PurchaseLiveException("PURCHASE_MODE_NOT_ALLOWED", "当前执行器没有正式采购授权") }, ) { private var purchasePanelContext: PurchasePanelContext? = null + private var pageIdentity: Pair = null to null fun execute(input: PurchaseExecutionInput, rule: PurchaseRule, supportedCapabilities: Set): PurchaseExecutionOutcome { purchasePanelContext = null @@ -376,7 +377,9 @@ class PurchaseRehearsalExecutor( screen = currentScreen(input) } val beforeSignature = specActionSignature(screen) + val beforeIdentity = pageIdentity val click = driver.clickFreshDetailed(requireNotNull(target)) + panelDiagnostic("entrySource=${screen.specEntrySource ?: "unknown"};entryClick=${click.result};entryReason=${click.reason}") when (click.result) { // The parser already narrowed to a single semantic candidate; the // ambiguity here comes from the live tree matching that target more @@ -391,14 +394,22 @@ class PurchaseRehearsalExecutor( var wait = waitForSpecPanel(input, beforeSignature) wait.failure?.let { return it } if (wait.opened) return null - if (wait.changed) { + val refreshed = currentScreen(input) + refreshed.problem?.let { return failure(it.code, it.message) } + if (refreshed.reviewPageOpen) return leaveUnexpectedReviewPage(input) + if (refreshed.specPanelOpen) return null + val recoveryTarget = recoverableSpecEntry(screen, refreshed, action, beforeIdentity) + if (recoveryTarget == null) { + panelDiagnostic("entryRecovery=rejected;pageChanged=${wait.changed};${panelEvidence(refreshed)}") return failure( SPEC_PANEL_EVIDENCE_NOT_MATCHED, "规格入口点击后页面已变化,但规格面板强证据不足 [${panelEvidence(wait.screen)}]", ) } - when (driver.tapSpecFresh(requireNotNull(target))) { + val gesture = driver.tapSpecFresh(requireNotNull(recoveryTarget)) + panelDiagnostic("entryRecovery=attempted;gesture=$gesture;pageChanged=${wait.changed}") + when (gesture) { FreshActionResult.AMBIGUOUS -> return failure( SPEC_ENTRY_TARGET_AMBIGUOUS, "规格入口手势目标不唯一 [${specEntryEvidence(screen, 1, entryReadyWaitPolls)}]", @@ -412,7 +423,7 @@ class PurchaseRehearsalExecutor( wait = waitForSpecPanel(input, specActionSignature(wait.screen)) wait.failure?.let { return it } if (wait.opened) return null - if (wait.changed) { + if (wait.changed && recoverableSpecEntry(screen, wait.screen, action, beforeIdentity) == null) { return failure( SPEC_PANEL_EVIDENCE_NOT_MATCHED, "规格入口手势后页面已变化,但规格面板强证据不足 [${panelEvidence(wait.screen)}]", @@ -424,6 +435,28 @@ class PurchaseRehearsalExecutor( ) } + // Whole-page animations are not proof of navigation. Recover only a freshly + // parsed, unchanged semantic product entry; never reuse the old node/coordinates. + private fun recoverableSpecEntry( + before: ParsedPddScreen, + after: ParsedPddScreen, + 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 + 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 } + } + private data class SpecPanelWait( val screen: ParsedPddScreen, val opened: Boolean, @@ -1134,6 +1167,7 @@ class PurchaseRehearsalExecutor( private fun currentScreen(input: PurchaseExecutionInput): ParsedPddScreen { val snapshot = driver.capture() + pageIdentity = snapshot.packageName to snapshot.activityName val screen = PddScreenParser.parse(snapshot, DEFAULT_COLLECTOR, input.goodsId, null, purchasePanelContext) purchasePanelContext = if (screen.isPddPackage && screen.problem == null && screen.specPanelOpen) { screen.specPanelContainer?.let { 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 de6e7f5..b98f7f7 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 @@ -883,6 +883,53 @@ class PurchaseRehearsalExecutorTest { assertTrue(driver.panel) } + @Test + fun `dynamic product page recovers ineffective and failed entry clicks once`() { + for (reason in listOf(null, FreshClickReason.ACTION_CLICK_FALSE, FreshClickReason.TARGET_NOT_FOUND)) { + val driver = FakePurchaseDriver(entryActionHasEffect = false, dynamicProduct = true, + forcedEntryClickReason = reason, specTapResult = FreshActionResult.SUCCESS) + val logs = mutableListOf() + val outcome = PurchaseRehearsalExecutor(driver, { true }, { null }, pause = {}, panelDiagnostic = logs::add) + .execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported) + assertEquals(outcome.message, "rehearsal_completed", outcome.resultType) + assertEquals(1, driver.specTapCount) + assertTrue(logs.any { it.contains("entryRecovery=attempted") }) + assertFalse(logs.any { it.contains("测试商品") || it.contains("选择规格") }) + } + } + + @Test + fun `dynamic product page with ineffective gesture reports no effect without second tap`() { + val driver = FakePurchaseDriver(entryActionHasEffect = false, dynamicProduct = true, + specTapResult = FreshActionResult.SUCCESS, specTapHasEffect = false) + val outcome = PurchaseRehearsalExecutor(driver, { true }, { null }, pause = {}) + .execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported) + assertEquals("PURCHASE_SPEC_ENTRY_CLICK_NO_EFFECT", outcome.errorCode) + assertEquals(1, driver.specTapCount) + } + + @Test + fun `dynamic product with close control never receives recovery tap`() { + val driver = FakePurchaseDriver(entryActionHasEffect = false, dynamicProduct = true, productOverlay = true, + specTapResult = FreshActionResult.SUCCESS) + val outcome = PurchaseRehearsalExecutor(driver, { true }, { null }, pause = {}) + .execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported) + assertEquals("PURCHASE_SPEC_PANEL_EVIDENCE_NOT_MATCHED", outcome.errorCode) + assertEquals(0, driver.specTapCount) + } + + @Test + fun `changed entry identity or unsafe page never receives recovery tap`() { + for (mode in listOf("missing", "duplicate", "activity", "payment")) { + val driver = FakePurchaseDriver(entryActionHasEffect = false, dynamicProduct = true, + afterEntryMutation = mode, specTapResult = FreshActionResult.SUCCESS) + val outcome = PurchaseRehearsalExecutor(driver, { true }, { null }, pause = {}) + .execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported) + assertEquals(mode, "failed", outcome.resultType) + assertEquals(mode, 0, driver.specTapCount) + } + } + @Test fun `unchanged spec entry after action and gesture returns no effect`() { val driver = FakePurchaseDriver( @@ -1308,6 +1355,9 @@ class PurchaseRehearsalExecutorTest { private val forcedEntryClickReason: FreshClickReason? = null, private val initialSize: String? = null, private val entryActionHasEffect: Boolean = true, + private val dynamicProduct: Boolean = false, + private val productOverlay: Boolean = false, + private val afterEntryMutation: String = "", private val specTapResult: FreshActionResult = FreshActionResult.FAILED, private val specTapHasEffect: Boolean = true, private val sizeSelectsOnFailedClick: Boolean = false, @@ -1361,6 +1411,7 @@ class PurchaseRehearsalExecutorTest { private var horizontalColorPage = 0 private var horizontalSizePage = 0 private var capturesAfterSizeSelection = 0 + private var entryAttempted = false val clicked = mutableListOf() val clickedPaths = mutableListOf() @@ -1423,6 +1474,8 @@ class PurchaseRehearsalExecutorTest { node("title", "测试商品标题文本", 20, 200, 900, 280, className = "android.widget.ViewPager"), ) val specEntryReady = pddCaptureCount > specEntryVisibleAfterPddCaptures + if (dynamicProduct) nodes += node("decoration", "", 20, 400 + pddCaptureCount % 3, 100, 460) + if (productOverlay) nodes += node("overlay-close", "关闭", 900, 500, 1000, 570, clickable = true) if (bottomPurchaseEntry && specEntryReady) { nodes += node("buy", "", 500, 1800, 1080, 2180, clickable = true) nodes += node("buy/price", "¥20.00", 560, 1840, 760, 1910, parentPath = "buy") @@ -1431,6 +1484,12 @@ class PurchaseRehearsalExecutorTest { nodes += node("spec", "选择规格", 20, 1000, 900, 1100, clickable = true) } if (includeReviewEntry) nodes += node("review", "商品评价", 20, 1200, 900, 1300, clickable = true) + if (entryAttempted) { + if (afterEntryMutation == "missing") nodes.removeAll { it.path == "spec" } + if (afterEntryMutation == "duplicate") nodes += node("spec2", "选择规格", 20, 1120, 900, 1190, clickable = true) + if (afterEntryMutation == "payment") nodes += node("payment", "微信支付", 20, 1800, 900, 1900) + if (afterEntryMutation == "activity") return UiSnapshot(PDD, "OtherActivity", nodes) + } return UiSnapshot(PDD, ACTIVITY, nodes) } if (unrecognizedPanel) { @@ -1570,6 +1629,7 @@ class PurchaseRehearsalExecutorTest { } override fun clickFreshDetailed(target: SnapshotNode): FreshClickOutcome { + if (target.path in setOf("spec", "buy")) entryAttempted = true if (target.path in setOf("spec", "buy") && forcedEntryClickReason != null) { val result = when (forcedEntryClickReason) { FreshClickReason.ROOT_UNAVAILABLE, FreshClickReason.TARGET_NOT_FOUND -> FreshActionResult.NOT_FOUND