fix(android): settle product page before spec entry (#266)

This commit is contained in:
QiuSW
2026-09-11 10:51:51 +08:00
parent b0256190a5
commit c548285d59
2 changed files with 107 additions and 14 deletions
@@ -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
@@ -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