fix(android): recover spec entry despite product page animation (#243)

This commit is contained in:
QiuSW
2026-09-08 17:42:05 +08:00
parent 7ac1355a71
commit ec8b14ae21
3 changed files with 99 additions and 5 deletions
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId = "cn.ilapage.goauto.agent"
minSdk = 23
targetSdk = 34
versionCode = 73
versionName = "0.9.60"
versionCode = 74
versionName = "0.9.61"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -95,6 +95,7 @@ class PurchaseRehearsalExecutor(
private val beforeOrderSubmit: (FinalConfirmationEvidence) -> Unit = { throw PurchaseLiveException("PURCHASE_MODE_NOT_ALLOWED", "当前执行器没有正式采购授权") },
) {
private var purchasePanelContext: PurchasePanelContext? = null
private var pageIdentity: Pair<String?, String?> = null to null
fun execute(input: PurchaseExecutionInput, rule: PurchaseRule, supportedCapabilities: Set<String>): PurchaseExecutionOutcome {
purchasePanelContext = null
@@ -376,7 +377,9 @@ class PurchaseRehearsalExecutor(
screen = currentScreen(input)
}
val beforeSignature = specActionSignature(screen)
val beforeIdentity = pageIdentity
val click = driver.clickFreshDetailed(requireNotNull(target))
panelDiagnostic("entrySource=${screen.specEntrySource ?: "unknown"};entryClick=${click.result};entryReason=${click.reason}")
when (click.result) {
// The parser already narrowed to a single semantic candidate; the
// ambiguity here comes from the live tree matching that target more
@@ -391,14 +394,22 @@ class PurchaseRehearsalExecutor(
var wait = waitForSpecPanel(input, beforeSignature)
wait.failure?.let { return it }
if (wait.opened) return null
if (wait.changed) {
val refreshed = currentScreen(input)
refreshed.problem?.let { return failure(it.code, it.message) }
if (refreshed.reviewPageOpen) return leaveUnexpectedReviewPage(input)
if (refreshed.specPanelOpen) return null
val recoveryTarget = recoverableSpecEntry(screen, refreshed, action, beforeIdentity)
if (recoveryTarget == null) {
panelDiagnostic("entryRecovery=rejected;pageChanged=${wait.changed};${panelEvidence(refreshed)}")
return failure(
SPEC_PANEL_EVIDENCE_NOT_MATCHED,
"规格入口点击后页面已变化,但规格面板强证据不足 [${panelEvidence(wait.screen)}]",
)
}
when (driver.tapSpecFresh(requireNotNull(target))) {
val gesture = driver.tapSpecFresh(requireNotNull(recoveryTarget))
panelDiagnostic("entryRecovery=attempted;gesture=$gesture;pageChanged=${wait.changed}")
when (gesture) {
FreshActionResult.AMBIGUOUS -> return failure(
SPEC_ENTRY_TARGET_AMBIGUOUS,
"规格入口手势目标不唯一 [${specEntryEvidence(screen, 1, entryReadyWaitPolls)}]",
@@ -412,7 +423,7 @@ class PurchaseRehearsalExecutor(
wait = waitForSpecPanel(input, specActionSignature(wait.screen))
wait.failure?.let { return it }
if (wait.opened) return null
if (wait.changed) {
if (wait.changed && recoverableSpecEntry(screen, wait.screen, action, beforeIdentity) == null) {
return failure(
SPEC_PANEL_EVIDENCE_NOT_MATCHED,
"规格入口手势后页面已变化,但规格面板强证据不足 [${panelEvidence(wait.screen)}]",
@@ -424,6 +435,28 @@ class PurchaseRehearsalExecutor(
)
}
// Whole-page animations are not proof of navigation. Recover only a freshly
// parsed, unchanged semantic product entry; never reuse the old node/coordinates.
private fun recoverableSpecEntry(
before: ParsedPddScreen,
after: ParsedPddScreen,
action: PurchaseAction,
identity: Pair<String?, String?>,
): SnapshotNode? {
if (identity.first == null || identity.second == null || identity != pageIdentity ||
!after.isPddPackage || !after.pageEvidenceMatched || after.problem != null || after.reviewPageOpen ||
after.specPanelOpen || after.hasCloseControl || after.hasPaymentArea || after.hasOrderSubmitAction ||
after.hasQuantityControls || after.hasSelectionSummary ||
before.summary.title.isNullOrBlank() || before.summary.title != after.summary.title ||
before.specEntrySource == null || before.specEntrySource != after.specEntrySource ||
before.specEntry?.label != after.specEntry?.label ||
after.explicitSpecEntryCount > 1 || after.nestedSpecEntryCount > 1
) return null
val anchor = after.specEntry ?: return null
if (action.textAliases?.let { specEntryMatchesAliases(after, anchor, it) } == false) return null
return (after.specEntryClickTarget ?: anchor).takeIf { it.visible && it.enabled }
}
private data class SpecPanelWait(
val screen: ParsedPddScreen,
val opened: Boolean,
@@ -1134,6 +1167,7 @@ class PurchaseRehearsalExecutor(
private fun currentScreen(input: PurchaseExecutionInput): ParsedPddScreen {
val snapshot = driver.capture()
pageIdentity = snapshot.packageName to snapshot.activityName
val screen = PddScreenParser.parse(snapshot, DEFAULT_COLLECTOR, input.goodsId, null, purchasePanelContext)
purchasePanelContext = if (screen.isPddPackage && screen.problem == null && screen.specPanelOpen) {
screen.specPanelContainer?.let {
@@ -883,6 +883,53 @@ class PurchaseRehearsalExecutorTest {
assertTrue(driver.panel)
}
@Test
fun `dynamic product page recovers ineffective and failed entry clicks once`() {
for (reason in listOf(null, FreshClickReason.ACTION_CLICK_FALSE, FreshClickReason.TARGET_NOT_FOUND)) {
val driver = FakePurchaseDriver(entryActionHasEffect = false, dynamicProduct = true,
forcedEntryClickReason = reason, specTapResult = FreshActionResult.SUCCESS)
val logs = mutableListOf<String>()
val outcome = PurchaseRehearsalExecutor(driver, { true }, { null }, pause = {}, panelDiagnostic = logs::add)
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals(outcome.message, "rehearsal_completed", outcome.resultType)
assertEquals(1, driver.specTapCount)
assertTrue(logs.any { it.contains("entryRecovery=attempted") })
assertFalse(logs.any { it.contains("测试商品") || it.contains("选择规格") })
}
}
@Test
fun `dynamic product page with ineffective gesture reports no effect without second tap`() {
val driver = FakePurchaseDriver(entryActionHasEffect = false, dynamicProduct = true,
specTapResult = FreshActionResult.SUCCESS, specTapHasEffect = false)
val outcome = PurchaseRehearsalExecutor(driver, { true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("PURCHASE_SPEC_ENTRY_CLICK_NO_EFFECT", outcome.errorCode)
assertEquals(1, driver.specTapCount)
}
@Test
fun `dynamic product with close control never receives recovery tap`() {
val driver = FakePurchaseDriver(entryActionHasEffect = false, dynamicProduct = true, productOverlay = true,
specTapResult = FreshActionResult.SUCCESS)
val outcome = PurchaseRehearsalExecutor(driver, { true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("PURCHASE_SPEC_PANEL_EVIDENCE_NOT_MATCHED", outcome.errorCode)
assertEquals(0, driver.specTapCount)
}
@Test
fun `changed entry identity or unsafe page never receives recovery tap`() {
for (mode in listOf("missing", "duplicate", "activity", "payment")) {
val driver = FakePurchaseDriver(entryActionHasEffect = false, dynamicProduct = true,
afterEntryMutation = mode, specTapResult = FreshActionResult.SUCCESS)
val outcome = PurchaseRehearsalExecutor(driver, { true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals(mode, "failed", outcome.resultType)
assertEquals(mode, 0, driver.specTapCount)
}
}
@Test
fun `unchanged spec entry after action and gesture returns no effect`() {
val driver = FakePurchaseDriver(
@@ -1308,6 +1355,9 @@ class PurchaseRehearsalExecutorTest {
private val forcedEntryClickReason: FreshClickReason? = null,
private val initialSize: String? = null,
private val entryActionHasEffect: Boolean = true,
private val dynamicProduct: Boolean = false,
private val productOverlay: Boolean = false,
private val afterEntryMutation: String = "",
private val specTapResult: FreshActionResult = FreshActionResult.FAILED,
private val specTapHasEffect: Boolean = true,
private val sizeSelectsOnFailedClick: Boolean = false,
@@ -1361,6 +1411,7 @@ class PurchaseRehearsalExecutorTest {
private var horizontalColorPage = 0
private var horizontalSizePage = 0
private var capturesAfterSizeSelection = 0
private var entryAttempted = false
val clicked = mutableListOf<String>()
val clickedPaths = mutableListOf<String>()
@@ -1423,6 +1474,8 @@ class PurchaseRehearsalExecutorTest {
node("title", "测试商品标题文本", 20, 200, 900, 280, className = "android.widget.ViewPager"),
)
val specEntryReady = pddCaptureCount > specEntryVisibleAfterPddCaptures
if (dynamicProduct) nodes += node("decoration", "", 20, 400 + pddCaptureCount % 3, 100, 460)
if (productOverlay) nodes += node("overlay-close", "关闭", 900, 500, 1000, 570, clickable = true)
if (bottomPurchaseEntry && specEntryReady) {
nodes += node("buy", "", 500, 1800, 1080, 2180, clickable = true)
nodes += node("buy/price", "¥20.00", 560, 1840, 760, 1910, parentPath = "buy")
@@ -1431,6 +1484,12 @@ class PurchaseRehearsalExecutorTest {
nodes += node("spec", "选择规格", 20, 1000, 900, 1100, clickable = true)
}
if (includeReviewEntry) nodes += node("review", "商品评价", 20, 1200, 900, 1300, clickable = true)
if (entryAttempted) {
if (afterEntryMutation == "missing") nodes.removeAll { it.path == "spec" }
if (afterEntryMutation == "duplicate") nodes += node("spec2", "选择规格", 20, 1120, 900, 1190, clickable = true)
if (afterEntryMutation == "payment") nodes += node("payment", "微信支付", 20, 1800, 900, 1900)
if (afterEntryMutation == "activity") return UiSnapshot(PDD, "OtherActivity", nodes)
}
return UiSnapshot(PDD, ACTIVITY, nodes)
}
if (unrecognizedPanel) {
@@ -1570,6 +1629,7 @@ class PurchaseRehearsalExecutorTest {
}
override fun clickFreshDetailed(target: SnapshotNode): FreshClickOutcome {
if (target.path in setOf("spec", "buy")) entryAttempted = true
if (target.path in setOf("spec", "buy") && forcedEntryClickReason != null) {
val result = when (forcedEntryClickReason) {
FreshClickReason.ROOT_UNAVAILABLE, FreshClickReason.TARGET_NOT_FOUND -> FreshActionResult.NOT_FOUND