fix(android): revalidate offscreen selected specs (#224)
This commit is contained in:
@@ -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"
|
||||
|
||||
|
||||
+73
-4
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user