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 aa484fa..1a39376 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 @@ -249,7 +249,16 @@ class PurchaseRehearsalExecutor( pddForegroundObserved = true val screen = PddScreenParser.parse(snapshot, DEFAULT_COLLECTOR, input.goodsId, null) stableEvidenceReads = if (screen.hasPurchaseProductEvidence()) stableEvidenceReads + 1 else 0 - if (stableEvidenceReads >= PRODUCT_PAGE_STABLE_READS) return null + if (stableEvidenceReads >= PRODUCT_PAGE_STABLE_READS) { + // A product-page accessibility tree can appear before PDD has + // finished wiring click listeners and bottom-sheet transitions. + // Give the page one short settle window, then require the + // evidence again before moving to the spec entry. + pause(PRODUCT_PAGE_SETTLE_MILLIS) + val settled = PddScreenParser.parse(driver.capture(), DEFAULT_COLLECTOR, input.goodsId, null) + if (settled.hasPurchaseProductEvidence()) return null + stableEvidenceReads = 0 + } pause(OPEN_PRODUCT_POLL_MILLIS) return@repeat } @@ -295,7 +304,10 @@ class PurchaseRehearsalExecutor( if (screen.hasPurchaseProductEvidence()) { stableEvidenceReads++ if (stableEvidenceReads >= PRODUCT_PAGE_STABLE_READS) { - return recoverSoldOut(input, screen) + pause(PRODUCT_PAGE_SETTLE_MILLIS) + val settled = PddScreenParser.parse(driver.capture(), DEFAULT_COLLECTOR, input.goodsId, null) + if (settled.hasPurchaseProductEvidence()) return recoverSoldOut(input, settled) + stableEvidenceReads = 0 } } else { stableEvidenceReads = 0 @@ -341,6 +353,9 @@ class PurchaseRehearsalExecutor( var screen = currentScreen(input) var entryReadyWaitPolls = 0 var target: SnapshotNode? = null + var stableTargetPath: String? = null + var stableTargetBounds: NodeBounds? = null + var stableTargetReads = 0 while (target == null) { if (screen.reviewPageOpen) return leaveUnexpectedReviewPage(input) screen.problem?.let { return failure(it.code, it.message) } @@ -359,8 +374,38 @@ class PurchaseRehearsalExecutor( panelDiagnostic(specEntryEvidence(screen, candidates.size, entryReadyWaitPolls)) return failure(SPEC_ENTRY_TARGET_AMBIGUOUS, "规格入口候选不唯一 [${specEntryEvidence(screen, candidates.size, entryReadyWaitPolls)}]") } - target = candidates.singleOrNull()?.second - if (target != null) continue + val candidate = candidates.singleOrNull()?.second + if (candidate != null) { + val unchanged = candidate.path == stableTargetPath && candidate.bounds == stableTargetBounds + stableTargetReads = if (unchanged) stableTargetReads + 1 else 1 + stableTargetPath = candidate.path + stableTargetBounds = candidate.bounds + if (stableTargetReads >= SPEC_ENTRY_STABLE_READS) { + // Reacquire once more immediately before clicking so a node + // rebuilt during the settle window is never reused. + val latest = currentScreen(input) + val latestCandidates = listOfNotNull( + latest.specEntry?.let { anchor -> anchor to (latest.specEntryClickTarget ?: anchor) }, + latest.quickConfirmationEntry?.let { it to it }, + ).distinctBy { it.second.path } + .let { candidatesToFilter -> + action.textAliases?.let { aliases -> + candidatesToFilter.filter { (anchor, _) -> specEntryMatchesAliases(latest, anchor, aliases) } + } ?: candidatesToFilter + } + if (latestCandidates.size > 1) { + return failure(SPEC_ENTRY_TARGET_AMBIGUOUS, "规格入口点击目标不唯一 [${specEntryEvidence(latest, latestCandidates.size, entryReadyWaitPolls)}]") + } + target = latestCandidates.singleOrNull()?.second + if (target != null) screen = latest + if (target != null) continue + stableTargetReads = 0 + } + } else { + stableTargetReads = 0 + stableTargetPath = null + stableTargetBounds = null + } if (entryReadyWaitPolls >= SPEC_ENTRY_READY_WAIT_POLLS) { panelDiagnostic(specEntryEvidence(screen, 0, entryReadyWaitPolls)) return failure(SPEC_ENTRY_NOT_FOUND, "没有找到安全的商品规格入口 [${specEntryEvidence(screen, 0, entryReadyWaitPolls)}]") @@ -385,11 +430,33 @@ class PurchaseRehearsalExecutor( var wait = waitForSpecPanel(input, beforeSignature) wait.failure?.let { return it } if (wait.opened) return null + var gestureBaseline = wait.screen if (wait.changed) { - return failure( - SPEC_PANEL_EVIDENCE_NOT_MATCHED, - "规格入口点击后页面已变化,但规格面板强证据不足 [${panelEvidence(wait.screen)}]", - ) + // A page transition can be caused by PDD rerendering the entry before + // the bottom sheet becomes observable. Re-locate the fresh entry and + // allow exactly one controlled retry while still on the same product. + if (!wait.screen.isPddPackage || !wait.screen.pageEvidenceMatched) { + return failure(SPEC_PANEL_EVIDENCE_NOT_MATCHED, "规格入口点击后页面已变化,但规格面板强证据不足 [${panelEvidence(wait.screen)}]") + } + val retryScreen = currentScreen(input) + if (!retryScreen.isPddPackage || !retryScreen.pageEvidenceMatched || retryScreen.specPanelOpen) { + return failure(SPEC_PANEL_EVIDENCE_NOT_MATCHED, "规格入口点击后页面已变化,但规格面板强证据不足 [${panelEvidence(retryScreen)}]") + } + val retryCandidates = listOfNotNull( + retryScreen.specEntry?.let { anchor -> anchor to (retryScreen.specEntryClickTarget ?: anchor) }, + retryScreen.quickConfirmationEntry?.let { it to it }, + ).distinctBy { it.second.path } + .let { candidatesToFilter -> + action.textAliases?.let { aliases -> + candidatesToFilter.filter { (anchor, _) -> specEntryMatchesAliases(retryScreen, anchor, aliases) } + } ?: candidatesToFilter + } + if (retryCandidates.size != 1) { + return failure(SPEC_PANEL_EVIDENCE_NOT_MATCHED, "规格入口点击后页面已变化,但规格面板强证据不足 [${panelEvidence(retryScreen)}]") + } + target = retryCandidates.single().second + screen = retryScreen + gestureBaseline = retryScreen } when (driver.tapSpecFresh(requireNotNull(target))) { @@ -403,7 +470,7 @@ class PurchaseRehearsalExecutor( click.reason.specEntrySubreasonAfterGestureFailure(), ) } - wait = waitForSpecPanel(input, specActionSignature(wait.screen)) + wait = waitForSpecPanel(input, specActionSignature(gestureBaseline)) wait.failure?.let { return it } if (wait.opened) return null if (wait.changed) { @@ -1161,11 +1228,13 @@ class PurchaseRehearsalExecutor( private const val OPEN_PRODUCT_POLL_LIMIT = 150 private const val PRODUCT_PAGE_POLL_LIMIT = 150 private const val PRODUCT_PAGE_STABLE_READS = 2 + private const val PRODUCT_PAGE_SETTLE_MILLIS = 1_000L private const val OPEN_PRODUCT_RETRY_POLLS = 10 private const val OPEN_PRODUCT_POLL_MILLIS = 100L private const val SPEC_ENTRY_READY_WAIT_POLLS = 20 + private const val SPEC_ENTRY_STABLE_READS = 2 private const val SPEC_ENTRY_READY_POLL_MILLIS = 100L - private const val SPEC_POST_CLICK_VERIFY_POLLS = 30 + private const val SPEC_POST_CLICK_VERIFY_POLLS = 50 private const val SPEC_SELECTION_SUCCESS_VERIFY_POLLS = 20 private const val SPEC_SELECTION_FAILED_VERIFY_POLLS = 5 private const val SPEC_SELECTION_POLL_MILLIS = 100L 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 e6faabe..181865a 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 @@ -788,6 +788,21 @@ class PurchaseRehearsalExecutorTest { assertFalse(outcome.message.orEmpty().contains("确认款式")) } + @Test + fun `changed product page retries a still visible spec entry once`() { + val driver = FakePurchaseDriver( + entryActionHasEffect = false, + entryActionChangesPageWithoutPanel = true, + specTapResult = FreshActionResult.SUCCESS, + ) + val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {}) + .execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported) + + assertEquals(outcome.toString(), "rehearsal_completed", outcome.resultType) + assertEquals(1, driver.openEntryClickCount) + assertEquals(1, driver.specTapCount) + } + @Test fun `unchanged spec entry action uses one verified center gesture`() { val driver = FakePurchaseDriver( @@ -864,7 +879,7 @@ class PurchaseRehearsalExecutorTest { assertTrue(driver.clickedPaths.contains("buy")) // One 100ms pause belongs to the existing open-product foreground poll; // two belong to the entry-ready wait before the bottom bar appears. - assertEquals(3, pauses.count { it == 100L }) + assertEquals(4, pauses.count { it == 100L }) } @Test @@ -877,7 +892,7 @@ class PurchaseRehearsalExecutorTest { assertEquals("rehearsal_completed", outcome.resultType) // Two stable reads belong to reopening the product; verifyProduct then // independently requires its second stable read before continuing. - assertEquals(3, pauses.count { it == 100L }) + assertEquals(4, pauses.count { it == 100L }) } @Test @@ -1052,7 +1067,7 @@ class PurchaseRehearsalExecutorTest { assertEquals("PURCHASE_SPEC_ENTRY_TARGET_AMBIGUOUS", ambiguous.errorCode) assertEquals( - "规格入口点击目标不唯一 [specEntryCandidates=1;explicit=1;nested=0;bottomPurchase=0;panelAlreadyOpen=false;reviewPage=false;pageEvidence=true;entryReadyWaitPolls=0;entryReadyWaitMillis=0]", + "规格入口点击目标不唯一 [specEntryCandidates=1;explicit=1;nested=0;bottomPurchase=0;panelAlreadyOpen=false;reviewPage=false;pageEvidence=true;entryReadyWaitPolls=1;entryReadyWaitMillis=100]", ambiguous.message, ) } @@ -1227,6 +1242,7 @@ class PurchaseRehearsalExecutorTest { private val forcedEntryClickReason: FreshClickReason? = null, private val initialSize: String? = null, private val entryActionHasEffect: Boolean = true, + private val entryActionChangesPageWithoutPanel: Boolean = false, private val specTapResult: FreshActionResult = FreshActionResult.FAILED, private val specTapHasEffect: Boolean = true, private val sizeSelectsOnFailedClick: Boolean = false, @@ -1274,6 +1290,7 @@ class PurchaseRehearsalExecutorTest { private var reviewPage = false private var pddCaptureCount = 0 private var browserCaptureCount = 0 + private var entryActionChanged = false private var hiddenColorRestored = false private var horizontalColorPage = 0 private var horizontalSizePage = 0 @@ -1347,6 +1364,7 @@ class PurchaseRehearsalExecutorTest { } else if (!missingSpecEntry && specEntryReady) { nodes += node("spec", "选择规格", 20, 1000, 900, 1100, clickable = true) } + if (entryActionChanged) nodes += node("rerender", "页面已刷新", 20, 1100, 400, 1180) if (includeReviewEntry) nodes += node("review", "商品评价", 20, 1200, 900, 1300, clickable = true) return UiSnapshot(PDD, ACTIVITY, nodes) } @@ -1463,7 +1481,10 @@ class PurchaseRehearsalExecutorTest { if (result == FreshActionResult.SUCCESS || openPddOnFailedClick) browser = false return result } - "选择规格", "免拼购买" -> if (entryActionHasEffect) panel = true + "选择规格", "免拼购买" -> { + if (entryActionChangesPageWithoutPanel) entryActionChanged = true + if (entryActionHasEffect) panel = true + } in (horizontalSizePages.orEmpty().flatten() + sizes) -> { sizeClickCount++ val result = sizeClickResults.removeFirstOrNull() ?: FreshActionResult.SUCCESS @@ -1520,6 +1541,9 @@ class PurchaseRehearsalExecutorTest { return FreshActionResult.SUCCESS } + val openEntryClickCount: Int + get() = clicked.count { it == "选择规格" || it == "免拼购买" } + override fun inputFresh(target: SnapshotNode, value: String): FreshActionResult { quantity = value.toLong() return FreshActionResult.SUCCESS