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 1f3fdfc..bd0c15d 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 @@ -224,8 +224,9 @@ class PurchaseRehearsalExecutor( repeat(50) { val snapshot = driver.capture() pageProblem(snapshot)?.let { return it } - if (snapshot.packageName == PDD_PACKAGE && snapshot.nodes.any { it.visible }) { - return recoverSoldOut(input, PddScreenParser.parse(snapshot, DEFAULT_COLLECTOR, input.goodsId, null)) + val screen = PddScreenParser.parse(snapshot, DEFAULT_COLLECTOR, input.goodsId, null) + if (screen.hasPurchaseProductEvidence()) { + return recoverSoldOut(input, screen) } pause(100) } @@ -266,35 +267,44 @@ class PurchaseRehearsalExecutor( private fun openSpecPanel(input: PurchaseExecutionInput, action: PurchaseAction): PurchaseExecutionOutcome? { var screen = currentScreen(input) - if (screen.reviewPageOpen) return leaveUnexpectedReviewPage(input) - screen.problem?.let { return failure(it.code, it.message) } - if (screen.specPanelOpen) return null - val safeCandidates = listOfNotNull( - screen.specEntry?.let { anchor -> anchor to (screen.specEntryClickTarget ?: anchor) }, - screen.quickConfirmationEntry?.let { it to it }, - ).distinctBy { it.second.path } - // A rule alias may only narrow the semantic candidates already accepted - // by PddScreenParser. It must never turn into a raw-page text lookup: - // review cards and unrelated controls can share arbitrary labels. - val candidates = action.textAliases?.let { aliases -> - safeCandidates.filter { (anchor, _) -> specEntryMatchesAliases(screen, anchor, aliases) } - } ?: safeCandidates - if (candidates.size > 1) { - panelDiagnostic(specEntryEvidence(screen, candidates.size)) - return failure(SPEC_ENTRY_TARGET_AMBIGUOUS, "规格入口候选不唯一 [${specEntryEvidence(screen, candidates.size)}]") + var entryReadyWaitPolls = 0 + var target: SnapshotNode? = null + while (target == null) { + if (screen.reviewPageOpen) return leaveUnexpectedReviewPage(input) + screen.problem?.let { return failure(it.code, it.message) } + if (screen.specPanelOpen) return null + val safeCandidates = listOfNotNull( + screen.specEntry?.let { anchor -> anchor to (screen.specEntryClickTarget ?: anchor) }, + screen.quickConfirmationEntry?.let { it to it }, + ).distinctBy { it.second.path } + // A rule alias may only narrow the semantic candidates already accepted + // by PddScreenParser. It must never turn into a raw-page text lookup: + // review cards and unrelated controls can share arbitrary labels. + val candidates = action.textAliases?.let { aliases -> + safeCandidates.filter { (anchor, _) -> specEntryMatchesAliases(screen, anchor, aliases) } + } ?: safeCandidates + if (candidates.size > 1) { + 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 + if (entryReadyWaitPolls >= SPEC_ENTRY_READY_WAIT_POLLS) { + panelDiagnostic(specEntryEvidence(screen, 0, entryReadyWaitPolls)) + return failure(SPEC_ENTRY_NOT_FOUND, "没有找到安全的商品规格入口 [${specEntryEvidence(screen, 0, entryReadyWaitPolls)}]") + } + pause(SPEC_ENTRY_READY_POLL_MILLIS) + entryReadyWaitPolls++ + screen = currentScreen(input) } - val target = candidates.singleOrNull()?.second ?: run { - panelDiagnostic(specEntryEvidence(screen, 0)) - return failure(SPEC_ENTRY_NOT_FOUND, "没有找到安全的商品规格入口 [${specEntryEvidence(screen, 0)}]") - } - val click = driver.clickFreshDetailed(target) + val click = driver.clickFreshDetailed(requireNotNull(target)) when (click.result) { // The parser already narrowed to a single semantic candidate; the // ambiguity here comes from the live tree matching that target more // than once at click time, so the wording must not claim otherwise. FreshActionResult.AMBIGUOUS -> return failure( SPEC_ENTRY_TARGET_AMBIGUOUS, - "规格入口点击目标不唯一 [${specEntryEvidence(screen, candidates.size)}]", + "规格入口点击目标不唯一 [${specEntryEvidence(screen, 1, entryReadyWaitPolls)}]", ) FreshActionResult.SUCCESS -> Unit else -> return failure(SPEC_ENTRY_CLICK_FAILED, click.reason.specEntrySubreason()) @@ -315,11 +325,12 @@ class PurchaseRehearsalExecutor( "options=${screen.panelOptionCount};summary=${screen.hasSelectionSummary};quantity=${screen.hasQuantityControls};" + "orderAction=${screen.hasOrderSubmitAction};pageEvidence=${screen.pageEvidenceMatched}" - private fun specEntryEvidence(screen: ParsedPddScreen, candidateCount: Int): String = + private fun specEntryEvidence(screen: ParsedPddScreen, candidateCount: Int, entryReadyWaitPolls: Int = 0): String = "specEntryCandidates=$candidateCount;explicit=${screen.explicitSpecEntryCount};" + "nested=${screen.nestedSpecEntryCount};bottomPurchase=${screen.bottomPurchaseEntryCount};" + "panelAlreadyOpen=${screen.specPanelOpen};reviewPage=${screen.reviewPageOpen};" + - "pageEvidence=${screen.pageEvidenceMatched}" + "pageEvidence=${screen.pageEvidenceMatched};entryReadyWaitPolls=$entryReadyWaitPolls;" + + "entryReadyWaitMillis=${entryReadyWaitPolls * SPEC_ENTRY_READY_POLL_MILLIS}" private fun specEntryMatchesAliases(screen: ParsedPddScreen, candidate: SnapshotNode, aliases: List): Boolean { val prefix = "${candidate.path}/" @@ -677,6 +688,8 @@ class PurchaseRehearsalExecutor( private const val OPEN_PRODUCT_POLL_LIMIT = 50 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_READY_POLL_MILLIS = 100L private const val SPEC_SELECTION_CLICK_ATTEMPTS = 3 private const val SPEC_SELECTION_SUCCESS_VERIFY_POLLS = 20 private const val SPEC_SELECTION_FAILED_VERIFY_POLLS = 5 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 8db029c..47a302c 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 @@ -445,23 +445,52 @@ class PurchaseRehearsalExecutorTest { @Test fun `missing spec entry emits scalar source counts without node text`() { val diagnostics = mutableListOf() + val pauses = mutableListOf() val driver = FakePurchaseDriver(missingSpecEntry = true) val outcome = PurchaseRehearsalExecutor( driver, { driver.browser = true; true }, { null }, - pause = {}, + pause = pauses::add, panelDiagnostic = diagnostics::add, ).execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported) assertEquals("PURCHASE_SPEC_ENTRY_NOT_FOUND", outcome.errorCode) assertEquals( - "specEntryCandidates=0;explicit=0;nested=0;bottomPurchase=0;panelAlreadyOpen=false;reviewPage=false;pageEvidence=true", + "specEntryCandidates=0;explicit=0;nested=0;bottomPurchase=0;panelAlreadyOpen=false;reviewPage=false;pageEvidence=true;entryReadyWaitPolls=20;entryReadyWaitMillis=2000", diagnostics.single(), ) + // One 100ms pause belongs to the existing open-product foreground poll; + // the diagnostic proves the entry-ready loop itself used exactly 20. + assertEquals(21, pauses.count { it == 100L }) assertFalse(diagnostics.single().contains("选择规格")) } + @Test + fun `open spec panel waits for a late safe bottom purchase entry`() { + val pauses = mutableListOf() + val driver = FakePurchaseDriver(bottomPurchaseEntry = true, specEntryVisibleAfterPddCaptures = 4) + val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{\"dimensions\":[]}" }, pause = pauses::add) + .execute(input().copy(executionMode = "live", phase = "spec_probe"), PurchaseRuleParser.parse(liveRule()), PurchaseAgentCapabilities.supported) + + assertEquals("spec_probe_completed", outcome.resultType) + 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 }) + } + + @Test + fun `verify product 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(1, pauses.count { it == 100L }) + } + @Test fun `transient sold out page recovers before opening specs`() { val driver = FakePurchaseDriver(soldOut = true, recoverSoldOutAfterPull = true) @@ -613,7 +642,7 @@ class PurchaseRehearsalExecutorTest { assertEquals("PURCHASE_SPEC_ENTRY_NOT_FOUND", outcome.errorCode) assertEquals( - "没有找到安全的商品规格入口 [specEntryCandidates=0;explicit=0;nested=0;bottomPurchase=0;panelAlreadyOpen=false;reviewPage=false;pageEvidence=true]", + "没有找到安全的商品规格入口 [specEntryCandidates=0;explicit=0;nested=0;bottomPurchase=0;panelAlreadyOpen=false;reviewPage=false;pageEvidence=true;entryReadyWaitPolls=20;entryReadyWaitMillis=2000]", outcome.message, ) @@ -623,7 +652,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]", + "规格入口点击目标不唯一 [specEntryCandidates=1;explicit=1;nested=0;bottomPurchase=0;panelAlreadyOpen=false;reviewPage=false;pageEvidence=true;entryReadyWaitPolls=0;entryReadyWaitMillis=0]", ambiguous.message, ) } @@ -777,6 +806,8 @@ class PurchaseRehearsalExecutorTest { private val duplicateOpen: Boolean = false, private val bottomPurchaseEntry: Boolean = false, private val missingSpecEntry: Boolean = false, + private val specEntryVisibleAfterPddCaptures: Int = 0, + private val loadingPddCaptures: Int = 0, private val includeReviewEntry: Boolean = false, private val openReviewOnBottomClick: Boolean = false, private val reviewBackSucceeds: Boolean = true, @@ -819,6 +850,7 @@ class PurchaseRehearsalExecutorTest { private var allSpecsUnavailable = allSpecsUnavailable private var productEvidenceLost = false private var reviewPage = false + private var pddCaptureCount = 0 val clicked = mutableListOf() val clickedPaths = mutableListOf() @@ -829,6 +861,10 @@ class PurchaseRehearsalExecutorTest { openNodes += node("content", "", 0, 0, 1080, 2200) return UiSnapshot("com.heytap.browser", "BrowserActivity", openNodes) } + pddCaptureCount++ + if (pddCaptureCount <= loadingPddCaptures) { + return UiSnapshot(PDD, ACTIVITY, listOf(node("content", "", 0, 0, 1080, 2200))) + } if (!panel) { if (reviewPage) { return UiSnapshot(PDD, ACTIVITY, listOf( @@ -844,18 +880,21 @@ class PurchaseRehearsalExecutorTest { if (soldOut) { return UiSnapshot(PDD, ACTIVITY, listOf( node("content", "", 0, 0, 1080, 2200), + node("title", "测试商品标题文本", 20, 200, 900, 280, className = "android.widget.ViewPager"), node("sold-out", "商品已售罄", 100, 300, 900, 380), node("similar", "相似商品", 100, 500, 900, 580), )) } val nodes = mutableListOf( node("content", "", 0, 0, 1080, 2200), + node("title", "测试商品标题文本", 20, 200, 900, 280, className = "android.widget.ViewPager"), ) - if (bottomPurchaseEntry) { + val specEntryReady = pddCaptureCount > specEntryVisibleAfterPddCaptures + if (bottomPurchaseEntry && specEntryReady) { nodes += node("buy", "", 500, 1800, 1080, 2180, clickable = true) nodes += node("buy/price", "¥20.00", 560, 1840, 760, 1910, parentPath = "buy") nodes += node("buy/label", "免拼购买", 780, 1840, 1040, 1910, parentPath = "buy") - } else if (!missingSpecEntry) { + } else if (!missingSpecEntry && specEntryReady) { nodes += node("spec", "选择规格", 20, 1000, 900, 1100, clickable = true) } if (includeReviewEntry) nodes += node("review", "商品评价", 20, 1200, 900, 1300, clickable = true)