diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index b411b3e..a539c89 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 = 60 - versionName = "0.9.47" + versionCode = 61 + versionName = "0.9.48" 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 516dd3e..c370a48 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 @@ -83,10 +83,15 @@ class PurchaseRehearsalExecutor( val specSelectionProofs = mutableMapOf() val live = PurchaseLiveAutomation(driver, pause) for (action in rule.actions) { - // spec_probe already opened this task's URL and the server reserves - // the device while matching. Skip the whole navigation action, - // including its configured wait/swipe hooks, in phase two. - if (input.phase == "purchase" && action.type == PurchaseActionType.OPEN_PRODUCT) continue + // The immediate phase-two handoff can reuse the PDD page retained by + // spec_probe. A later manual retry may start from Agent (or another + // unrelated screen), so only skip navigation when a fresh snapshot + // still carries safe PDD product/spec evidence. + if ( + input.phase == "purchase" && + action.type == PurchaseActionType.OPEN_PRODUCT && + canReuseCurrentProduct(input) + ) continue stepChanged(action.type.wireName) val failure = when (action.type) { PurchaseActionType.OPEN_PRODUCT -> openProduct(input, action) @@ -157,6 +162,11 @@ class PurchaseRehearsalExecutor( else failure("PURCHASE_RULE_INVALID", "正式采购规则缺少核单动作") } + private fun canReuseCurrentProduct(input: PurchaseExecutionInput): Boolean = + currentScreen(input).let { screen -> + screen.problem == null && screen.hasPurchaseProductEvidence() + } + private fun validateBeforeDeviceAction( input: PurchaseExecutionInput, rule: PurchaseRule, 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 47ffac5..d47dc03 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 @@ -89,6 +89,57 @@ class PurchaseRehearsalExecutorTest { assertFalse(pauses.contains(700)) } + @Test + fun `manual purchase retry from agent reopens the task product url`() { + val driver = FakePurchaseDriver(initiallyInAgent = true) + var openCount = 0 + val outcome = PurchaseRehearsalExecutor( + driver, + openLink = { + openCount++ + driver.leaveAgentAndOpenBrowser() + true + }, + probeSpecs = { null }, + pause = {}, + ).execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported) + + assertEquals("rehearsal_completed", outcome.resultType) + assertEquals(1, openCount) + assertEquals(1, driver.openClickCount) + } + + @Test + fun `purchase phase reopens when pdd foreground has no product evidence`() { + val driver = FakePurchaseDriver(loadingPddCaptures = 1) + var openCount = 0 + val outcome = PurchaseRehearsalExecutor( + driver, + openLink = { openCount++; driver.browser = true; true }, + probeSpecs = { null }, + pause = {}, + ).execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported) + + assertEquals("rehearsal_completed", outcome.resultType) + assertEquals(1, openCount) + assertEquals(1, driver.openClickCount) + } + + @Test + fun `purchase phase does not reuse a pdd login page`() { + val driver = FakePurchaseDriver(pddProblemLabels = listOf("手机号登录", "登录后继续")) + var openCount = 0 + val outcome = PurchaseRehearsalExecutor( + driver, + openLink = { openCount++; driver.browser = true; true }, + probeSpecs = { null }, + pause = {}, + ).execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported) + + assertEquals("PDD_LOGIN_REQUIRED", outcome.errorCode) + assertEquals(1, openCount) + } + @Test fun `price range failure reports the observed unit price`() { val driver = FakePurchaseDriver() @@ -622,14 +673,16 @@ class PurchaseRehearsalExecutorTest { } @Test - fun `verify product waits for product evidence instead of a visible loading frame`() { + fun `purchase reopens and waits for product evidence instead of a visible loading frame`() { val pauses = mutableListOf() val driver = FakePurchaseDriver(loadingPddCaptures = 1) val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = pauses::add) .execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported) assertEquals("rehearsal_completed", outcome.resultType) - assertEquals(2, pauses.count { it == 100L }) + // Two stable reads belong to reopening the product; verifyProduct then + // independently requires its second stable read before continuing. + assertEquals(3, pauses.count { it == 100L }) } @Test @@ -994,8 +1047,10 @@ class PurchaseRehearsalExecutorTest { private val nonScrollablePanel: Boolean = false, private val unrecognizedPanel: Boolean = false, private val purchaseSwipeSucceeds: Boolean = true, + initiallyInAgent: Boolean = false, ) : PurchaseUiDriver { var browser = false + private var inAgent = initiallyInAgent var panel = false var color: String? = null var size: String? = initialSize @@ -1019,6 +1074,9 @@ class PurchaseRehearsalExecutorTest { val clickedPaths = mutableListOf() override fun capture(): UiSnapshot { + if (inAgent) { + return UiSnapshot("cn.ilapage.goauto.agent", "MainActivity", listOf(node("content", "", 0, 0, 1080, 2200))) + } if (browser && !panel && color == null && size == null) { browserCaptureCount++ if (browserCaptureCount <= browserOpenVisibleAfterCaptures) { @@ -1145,6 +1203,11 @@ class PurchaseRehearsalExecutorTest { return UiSnapshot(PDD, ACTIVITY, nodes) } + fun leaveAgentAndOpenBrowser() { + inAgent = false + browser = true + } + override fun clickFresh(target: SnapshotNode): FreshActionResult { clicked += target.label clickedPaths += target.path