fix(android): settle product page before spec entry (#266)
This commit is contained in:
+79
-10
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user