diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 4204c63..3ded304 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 = 76 - versionName = "0.9.63" + versionCode = 77 + versionName = "0.9.64" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/GoAutoAccessibilityService.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/GoAutoAccessibilityService.kt index 59c1168..cb7fafa 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/GoAutoAccessibilityService.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/GoAutoAccessibilityService.kt @@ -468,17 +468,27 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto } override fun swipePurchase(direction: SwipeDirection, durationMs: Long): Boolean { - val root = rootInActiveWindow ?: return false + return swipePurchaseWithEvidence(direction, durationMs).result == PurchaseSwipeResult.SUCCESS + } + + override fun swipePurchaseWithEvidence(direction: SwipeDirection, durationMs: Long): PurchaseSwipeOutcome { + // Count and resolve against the same live root: a missing root or a + // failed dispatch must never masquerade as an empty scrollable list. + val root = rootInActiveWindow ?: return PurchaseSwipeOutcome(PurchaseSwipeResult.ROOT_UNAVAILABLE, 0) val candidates = mutableListOf() walk(root) { node -> if (node.isVisibleToUser && node.isScrollable) candidates += node } + if (candidates.isEmpty()) return PurchaseSwipeOutcome(PurchaseSwipeResult.NO_SCROLLABLE_TARGET, 0) val horizontal = direction == SwipeDirection.LEFT || direction == SwipeDirection.RIGHT val directional = candidates.filter { candidate -> Rect().also(candidate::getBoundsInScreen).let { if (horizontal) it.width() > it.height() else it.height() >= it.width() } } val target = (directional.ifEmpty { candidates }).maxByOrNull { candidate -> Rect().also(candidate::getBoundsInScreen).let { it.width().toLong() * it.height() } - } ?: return false - return swipeNode(target, direction, durationMs, preferScrollAction = false) + } ?: return PurchaseSwipeOutcome(PurchaseSwipeResult.FAILED, candidates.size) + val result = if (swipeNode(target, direction, durationMs, preferScrollAction = false)) { + PurchaseSwipeResult.SUCCESS + } else PurchaseSwipeResult.FAILED + return PurchaseSwipeOutcome(result, candidates.size) } override fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long): Boolean { 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 20012a1..a15dd6e 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 @@ -3,6 +3,10 @@ package cn.ilapage.goauto.agent.automation import java.net.URI import java.net.URLDecoder +enum class PurchaseSwipeResult { SUCCESS, NO_SCROLLABLE_TARGET, ROOT_UNAVAILABLE, FAILED } + +data class PurchaseSwipeOutcome(val result: PurchaseSwipeResult, val visibleScrollableCount: Int) + interface PurchaseUiDriver { fun capture(): UiSnapshot fun clickFresh(target: SnapshotNode): FreshActionResult @@ -32,6 +36,18 @@ interface PurchaseUiDriver { fun specRowSwipeFailureReason(): String = "gestureFailed" fun inputFresh(target: SnapshotNode, value: String): FreshActionResult fun swipePurchase(direction: SwipeDirection, durationMs: Long): Boolean + /** Detailed result only for the rule's reversible spec-panel reveal swipe. */ + fun swipePurchaseWithEvidence(direction: SwipeDirection, durationMs: Long): PurchaseSwipeOutcome { + val snapshot = capture() + val count = snapshot.nodes.count { it.visible && it.scrollable } + val result = when { + snapshot.nodes.isEmpty() -> PurchaseSwipeResult.ROOT_UNAVAILABLE + count == 0 -> PurchaseSwipeResult.NO_SCROLLABLE_TARGET + swipePurchase(direction, durationMs) -> PurchaseSwipeResult.SUCCESS + else -> PurchaseSwipeResult.FAILED + } + return PurchaseSwipeOutcome(result, count) + } fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long): Boolean fun pullDownGoodsPage(): Boolean = swipePurchase(SwipeDirection.DOWN, 550) fun backPurchase(): Boolean @@ -1150,17 +1166,34 @@ class PurchaseRehearsalExecutor( private fun applyPostAction(input: PurchaseExecutionInput, action: PurchaseAction): PurchaseExecutionOutcome? { if (action.waitAfterMs > 0) pause(action.waitAfterMs) action.swipeAfter?.let { swipe -> - // The stock purchase rule asks to reveal additional selector rows after - // opening the sheet. A fully-evidenced non-scrollable selector has no - // scroll target, and treating that absence as an action failure blocks - // an otherwise safe exact-spec flow. Keep all other configured swipes - // mandatory; this exception is limited to that confirmed panel state. - if (action.type == PurchaseActionType.OPEN_SPEC_PANEL && - currentScreen(input).specPanelType == SpecPanelType.NON_SCROLLABLE_CONFIRMATION - ) return null + val reveal = action.type == PurchaseActionType.OPEN_SPEC_PANEL + val initialScreen = if (reveal) currentScreen(input) else null + if (initialScreen?.specPanelType == SpecPanelType.NON_SCROLLABLE_CONFIRMATION) { + val count = initialScreen.sourceNodes.count { it.visible && it.scrollable } + panelDiagnostic("ruleSwipe;outcome=skipped_confirmed_panel;panel=${initialScreen.specPanelType};scrollables=$count;reveal=true") + return null + } repeat(swipe.count) { index -> - if (!driver.swipePurchase(swipe.direction, swipe.durationMs)) { - return failure("RULE_ACTION_FAILED", "规则要求的有限滑动失败") + // Keep other actions on the original Boolean path. Only the + // post-open reveal may treat an absent scroll target as a no-op. + val outcome = if (reveal) { + driver.swipePurchaseWithEvidence(swipe.direction, swipe.durationMs) + } else if (driver.swipePurchase(swipe.direction, swipe.durationMs)) { + PurchaseSwipeOutcome(PurchaseSwipeResult.SUCCESS, 0) + } else { + val count = driver.capture().nodes.count { it.visible && it.scrollable } + PurchaseSwipeOutcome(PurchaseSwipeResult.FAILED, count) + } + if (outcome.result != PurchaseSwipeResult.SUCCESS) { + val screen = currentScreen(input) + val evidence = "panel=${screen.specPanelType};scrollables=${outcome.visibleScrollableCount};" + + "reveal=$reveal;reason=${outcome.result.name.lowercase()}" + if (reveal && outcome.result == PurchaseSwipeResult.NO_SCROLLABLE_TARGET) { + panelDiagnostic("ruleSwipe;outcome=skipped_no_scroll_target;$evidence") + return null + } + panelDiagnostic("ruleSwipe;outcome=failed;$evidence") + return failure("RULE_ACTION_FAILED", "规则要求的有限滑动失败 [$evidence]") } if (index < swipe.count - 1 && swipe.intervalMs > 0) pause(swipe.intervalMs) } 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 bd02933..683b444 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 @@ -9,6 +9,8 @@ import cn.ilapage.goauto.agent.automation.PurchaseExecutionInput import cn.ilapage.goauto.agent.automation.PurchaseRehearsalExecutor import cn.ilapage.goauto.agent.automation.PurchaseRuleParser import cn.ilapage.goauto.agent.automation.PurchaseSpecGesturePolicy +import cn.ilapage.goauto.agent.automation.PurchaseSwipeOutcome +import cn.ilapage.goauto.agent.automation.PurchaseSwipeResult import cn.ilapage.goauto.agent.automation.PurchaseUiDriver import cn.ilapage.goauto.agent.automation.RuleValidationException import cn.ilapage.goauto.agent.automation.SnapshotNode @@ -766,11 +768,118 @@ class PurchaseRehearsalExecutorTest { @Test fun `open spec panel skips required follow-up swipe only for confirmed non-scrollable panel`() { val driver = FakePurchaseDriver(nonScrollablePanel = true, purchaseSwipeSucceeds = false) - val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {}) + val diagnostics = mutableListOf() + val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {}, panelDiagnostic = diagnostics::add) .execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported) assertEquals("rehearsal_completed", outcome.resultType) assertEquals(0, driver.swipeCount) + assertTrue(diagnostics.any { it.contains("skipped_confirmed_panel;panel=NON_SCROLLABLE_CONFIRMATION;scrollables=0;reveal=true") }) + } + + @Test + fun `open spec reveal skips missing scroll target regardless of panel classification`() { + val driver = FakePurchaseDriver( + panelOverride = SpecPanelFixtures.liveShapedSheet(), + purchaseSwipeSucceeds = false, + ) + var probes = 0 + val diagnostics = mutableListOf() + val outcome = PurchaseRehearsalExecutor( + driver, { driver.browser = true; true }, { probes++; "{}" }, + pause = {}, panelDiagnostic = diagnostics::add, + ).execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported) + + assertEquals(outcome.message, "spec_probe_completed", outcome.resultType) + assertEquals(1, probes) + assertEquals(0, driver.swipeCount) + assertTrue(diagnostics.any { it.contains("panel=REQUIRED_EVIDENCE;scrollables=0;reveal=true") }) + } + + @Test + fun `open spec reveal with a scroll target still fails on gesture failure`() { + val driver = FakePurchaseDriver(purchaseSwipeSucceeds = false) + var probes = 0 + val diagnostics = mutableListOf() + val outcome = PurchaseRehearsalExecutor( + driver, { driver.browser = true; true }, { probes++; "{}" }, + pause = {}, panelDiagnostic = diagnostics::add, + ).execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported) + + assertEquals("RULE_ACTION_FAILED", outcome.errorCode) + assertEquals(0, probes) + assertEquals(1, driver.swipeCount) + assertTrue(outcome.message.contains("panel=NORMAL_SCROLLABLE;scrollables=1;reveal=true;reason=failed")) + assertTrue(diagnostics.any { it.startsWith("ruleSwipe;outcome=failed;") }) + assertFalse(outcome.message.contains("黑色")) + assertFalse(outcome.message.contains(input().goodsId)) + } + + @Test + fun `open spec reveal does not skip a missing accessibility root`() { + val driver = FakePurchaseDriver(revealSwipeOutcomes = mutableListOf( + PurchaseSwipeOutcome(PurchaseSwipeResult.ROOT_UNAVAILABLE, 0), + )) + var probes = 0 + val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { probes++; "{}" }, pause = {}) + .execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported) + + assertEquals("RULE_ACTION_FAILED", outcome.errorCode) + assertEquals(0, probes) + assertTrue(outcome.message.contains("scrollables=0;reveal=true;reason=root_unavailable")) + } + + @Test + fun `open spec reveal stops remaining swipes after target disappears`() { + val driver = FakePurchaseDriver(revealSwipeOutcomes = mutableListOf( + PurchaseSwipeOutcome(PurchaseSwipeResult.SUCCESS, 1), + PurchaseSwipeOutcome(PurchaseSwipeResult.NO_SCROLLABLE_TARGET, 0), + )) + val pauses = mutableListOf() + val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{}" }, pause = pauses::add) + .execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported) + + assertEquals("spec_probe_completed", outcome.resultType) + assertEquals(1, driver.swipeCount) + assertEquals(1, pauses.count { it == 1000L }) + } + + @Test + fun `scrollable spec reveal preserves configured swipe count and interval`() { + val driver = FakePurchaseDriver() + val pauses = mutableListOf() + val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{}" }, pause = pauses::add) + .execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported) + + assertEquals("spec_probe_completed", outcome.resultType) + assertEquals(2, driver.swipeCount) + assertEquals(1, pauses.count { it == 1000L }) + } + + @Test + fun `missing scroll target outside spec reveal remains a mandatory action failure`() { + val driver = FakePurchaseDriver(purchaseSwipeSucceeds = false) + val configured = rule().replace( + "\"waitAfterMs\":700}", + "\"waitAfterMs\":700,\"swipeAfter\":{\"direction\":\"up\",\"count\":1,\"durationMs\":500,\"intervalMs\":0}}", + ) + val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{}" }, pause = {}) + .execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(configured), PurchaseAgentCapabilities.supported) + + assertEquals("RULE_ACTION_FAILED", outcome.errorCode) + assertTrue(outcome.message.contains("scrollables=0;reveal=false")) + assertFalse(driver.panel) + } + + @Test + fun `skipping a reveal does not skip a failed specification probe`() { + val driver = FakePurchaseDriver(panelOverride = SpecPanelFixtures.liveShapedSheet()) + var probes = 0 + val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { probes++; null }, pause = {}) + .execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported) + + assertEquals("PURCHASE_SPEC_NOT_MATCHED", outcome.errorCode) + assertEquals(1, probes) } @Test @@ -1249,6 +1358,8 @@ class PurchaseRehearsalExecutorTest { private val nonScrollablePanel: Boolean = false, private val unrecognizedPanel: Boolean = false, private val purchaseSwipeSucceeds: Boolean = true, + private val panelOverride: UiSnapshot? = null, + private val revealSwipeOutcomes: MutableList = mutableListOf(), initiallyInAgent: Boolean = false, private val panelBecomesUnknownAfterSizeProof: Boolean = false, ) : PurchaseUiDriver { @@ -1351,6 +1462,7 @@ class PurchaseRehearsalExecutorTest { if (includeReviewEntry) nodes += node("review", "商品评价", 20, 1200, 900, 1300, clickable = true) return UiSnapshot(PDD, ACTIVITY, nodes) } + panelOverride?.let { return it } if (unrecognizedPanel) { return UiSnapshot(PDD, ACTIVITY, listOf( node("content", "", 0, 0, 1080, 2200), @@ -1532,6 +1644,13 @@ class PurchaseRehearsalExecutorTest { return purchaseSwipeSucceeds } + override fun swipePurchaseWithEvidence(direction: SwipeDirection, durationMs: Long): PurchaseSwipeOutcome { + if (revealSwipeOutcomes.isEmpty()) return super.swipePurchaseWithEvidence(direction, durationMs) + val outcome = revealSwipeOutcomes.removeAt(0) + if (outcome.result == PurchaseSwipeResult.SUCCESS) swipePurchase(direction, durationMs) + return outcome + } + override fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long): Boolean { swipeInPaths += target.path if (restoreHiddenColorOnDownSwipe && quantity == 2L && direction == SwipeDirection.DOWN) {