fix(android): revalidate offscreen selected specs (#224)

This commit is contained in:
QiuSW
2026-09-05 16:21:01 +08:00
parent d84f6ddf21
commit cdcb6930d0
3 changed files with 129 additions and 8 deletions
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId = "cn.ilapage.goauto.agent"
minSdk = 23
targetSdk = 34
versionCode = 57
versionName = "0.9.44"
versionCode = 58
versionName = "0.9.45"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -631,6 +631,8 @@ class PurchaseRehearsalExecutor(
val summaryTokenMatched: Boolean,
val candidateCount: Int,
val proofPresent: Boolean,
val relocationAttempted: Boolean = false,
val relocationSwipes: Int = 0,
)
private fun verifyExactSpecSelection(
@@ -672,6 +674,71 @@ class PurchaseRehearsalExecutor(
}
}
private fun verifyExactSpecSelectionWithRelocation(
input: PurchaseExecutionInput,
dimension: String,
target: String,
proof: ExactSpecSelectionProof?,
): FinalSpecVerification {
var screen = currentScreen(input)
screen.problem?.let {
return FinalSpecVerification(false, it.code, false, 0, proof != null)
}
var verification = verifyExactSpecSelection(screen, dimension, target, proof)
if (verification.confirmed || verification.candidateCount > 0) return verification
if (!screen.specPanelOpen) return verification.copy(reason = "relocation_panel_closed", relocationAttempted = true)
var signature = screen.dimensions.joinToString("|") { item ->
"${item.key}:${item.values.joinToString(",") { value -> "${value.text}:${value.available}" }}"
}
var swipes = 0
for ((direction, limit) in listOf(SwipeDirection.DOWN to 3, SwipeDirection.UP to 6)) {
for (attempt in 0 until limit) {
val container = screen.specPanelContainer
?: return verification.copy(
reason = "relocation_container_missing",
relocationAttempted = true,
relocationSwipes = swipes,
)
if (!driver.swipePurchaseIn(container, direction, 350)) {
return verification.copy(
reason = "relocation_swipe_failed",
relocationAttempted = true,
relocationSwipes = swipes,
)
}
swipes++
pause(300)
screen = currentScreen(input)
screen.problem?.let {
return verification.copy(
reason = it.code,
relocationAttempted = true,
relocationSwipes = swipes,
)
}
if (!screen.specPanelOpen) {
return verification.copy(
reason = "relocation_panel_closed",
relocationAttempted = true,
relocationSwipes = swipes,
)
}
verification = verifyExactSpecSelection(screen, dimension, target, proof).copy(
relocationAttempted = true,
relocationSwipes = swipes,
)
if (verification.confirmed || verification.candidateCount > 0) return verification
val refreshedSignature = screen.dimensions.joinToString("|") { item ->
"${item.key}:${item.values.joinToString(",") { value -> "${value.text}:${value.available}" }}"
}
if (refreshedSignature == signature) break
signature = refreshedSignature
}
}
return verification.copy(relocationAttempted = true, relocationSwipes = swipes)
}
private data class SpecLookup(val node: SnapshotNode? = null, val failure: PurchaseExecutionOutcome? = null)
/**
@@ -759,23 +826,25 @@ class PurchaseRehearsalExecutor(
observedPrice: Long?,
selectionProofs: Map<String, ExactSpecSelectionProof>,
): PurchaseExecutionOutcome? {
val screen = currentScreen(input)
screen.problem?.let { return failure(it.code, it.message) }
val selected = listOf("color" to input.mappedColor, "size" to input.mappedSize).filter { it.second.isNotBlank() }.map { (dimension, rawTarget) ->
dimension to (normalizedTarget(dimension, rawTarget)
?: return failure(SPEC_SAFE_TARGET_MISSING, "下发规格无法安全规范化"))
}
selected.forEach { (dimension, target) ->
val verification = verifyExactSpecSelection(screen, dimension, target, selectionProofs[dimension])
val verification = verifyExactSpecSelectionWithRelocation(input, dimension, target, selectionProofs[dimension])
if (!verification.confirmed) {
val screen = currentScreen(input)
val panel = screen.specPanelType.name.lowercase()
val diagnostic = "dimension=$dimension,reason=${verification.reason},panel=$panel," +
"summary=${screen.selectedSummary != null},tokenMatched=${verification.summaryTokenMatched}," +
"candidates=${verification.candidateCount},proof=${verification.proofPresent}"
"candidates=${verification.candidateCount},proof=${verification.proofPresent}," +
"relocated=${verification.relocationAttempted},relocationSwipes=${verification.relocationSwipes}"
return failure(SPEC_SELECTION_UNCONFIRMED, "最终规格复核未能确认精确选中状态 [$diagnostic]")
}
}
if (readQuantity() != input.quantity) return failure("PURCHASE_QUANTITY_MISMATCH", "最终数量复核失败")
val screen = currentScreen(input)
screen.problem?.let { return failure(it.code, it.message) }
val price = screen.priceCent ?: observedPrice ?: return failure("RULE_NOT_MATCHED", "最终价格复核失败")
if (price !in input.minUnitPriceCent..input.maxUnitPriceCent) {
return failure("PURCHASE_PRICE_OUT_OF_RANGE", "当前商品单价超出允许范围", price)
@@ -248,6 +248,41 @@ class PurchaseRehearsalExecutorTest {
assertTrue(outcome.message.contains("proof=true"))
}
@Test
fun `final verification relocates hidden selected color when summary is unavailable`() {
val driver = FakePurchaseDriver(
hideColorAfterQuantitySet = true,
hideSelectedSummaryAfterQuantitySet = true,
restoreHiddenColorOnDownSwipe = true,
)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("rehearsal_completed", outcome.resultType)
assertTrue(driver.swipeInPaths.contains("scroll"))
assertEquals(1, driver.clicked.count { it == "黑色" })
assertEquals(1, driver.clicked.count { it == "XL" })
}
@Test
fun `final verification rejects relocated color without fresh selected state`() {
val driver = FakePurchaseDriver(
hideColorAfterQuantitySet = true,
hideSelectedSummaryAfterQuantitySet = true,
restoreHiddenColorOnDownSwipe = true,
hideRestoredColorSelectedState = true,
)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("failed", outcome.resultType)
assertEquals("PURCHASE_SPEC_SELECTION_UNCONFIRMED", outcome.errorCode)
assertTrue(outcome.message.contains("dimension=color"))
assertTrue(outcome.message.contains("relocated=true"))
assertTrue(outcome.message.contains("relocationSwipes=1"))
assertEquals(1, driver.clicked.count { it == "黑色" })
}
@Test
fun `final verification never uses XL proof for a 2XL summary`() {
val driver = FakePurchaseDriver(
@@ -950,6 +985,8 @@ class PurchaseRehearsalExecutorTest {
private val hideColorAfterQuantitySet: Boolean = false,
private val hideSizeAfterQuantitySet: Boolean = false,
private val hideSelectedSummaryAfterQuantitySet: Boolean = false,
private val restoreHiddenColorOnDownSwipe: Boolean = false,
private val hideRestoredColorSelectedState: Boolean = false,
private val selectedSummaryOverrideAfterQuantitySet: String? = null,
private val finalSizesAfterQuantitySet: List<String>? = null,
soldOut: Boolean = false,
@@ -981,6 +1018,7 @@ class PurchaseRehearsalExecutorTest {
private var reviewPage = false
private var pddCaptureCount = 0
private var browserCaptureCount = 0
private var hiddenColorRestored = false
val clicked = mutableListOf<String>()
val clickedPaths = mutableListOf<String>()
@@ -1045,7 +1083,7 @@ class PurchaseRehearsalExecutorTest {
node("panel-title", "确认款式", 20, 396, 300, 430),
))
}
val hideColor = hideColorAfterQuantitySet && quantity == 2L
val hideColor = hideColorAfterQuantitySet && quantity == 2L && !hiddenColorRestored
val hideSize = hideSizeAfterQuantitySet && quantity == 2L
val hideSummary = hideSelectedSummaryAfterQuantitySet && quantity == 2L
val displayedSummary = if (quantity == 2L && selectedSummaryOverrideAfterQuantitySet != null) {
@@ -1065,7 +1103,18 @@ class PurchaseRehearsalExecutorTest {
if (!hideColor) {
nodes += node("scroll/color-heading", "颜色分类", 20, 410, 300, 450, parentPath = "scroll")
colors.forEachIndexed { index, value ->
nodes += node("scroll/color-$index", value, 20 + index * 220, 470, 200 + index * 220, 540, clickable = true, selected = color == value, enabled = !allSpecsUnavailable, parentPath = "scroll")
nodes += node(
"scroll/color-$index",
value,
20 + index * 220,
470,
200 + index * 220,
540,
clickable = true,
selected = color == value && !(quantity == 2L && hiddenColorRestored && hideRestoredColorSelectedState),
enabled = !allSpecsUnavailable,
parentPath = "scroll",
)
}
}
if (!hideSize) {
@@ -1185,6 +1234,9 @@ class PurchaseRehearsalExecutorTest {
override fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long): Boolean {
swipeInPaths += target.path
if (restoreHiddenColorOnDownSwipe && quantity == 2L && direction == SwipeDirection.DOWN) {
hiddenColorRestored = true
}
return swipePurchase(direction, durationMs)
}