Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a3367a4ea | ||
|
|
c80c746943 | ||
|
|
bec8c84321 | ||
|
|
7ea34e32c1 | ||
|
|
d1ddb04959 | ||
|
|
ca815c30de |
@@ -11,8 +11,8 @@ android {
|
||||
applicationId = "cn.ilapage.goauto.agent"
|
||||
minSdk = 23
|
||||
targetSdk = 34
|
||||
versionCode = 75
|
||||
versionName = "0.9.62"
|
||||
versionCode = 79
|
||||
versionName = "0.9.66"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
|
||||
+137
-15
@@ -34,6 +34,9 @@ class PurchaseLiveAutomation(
|
||||
private val pause: (Long) -> Unit = Thread::sleep,
|
||||
) {
|
||||
private var submitAttempted = false
|
||||
private var savedAddressProof: ShippingAddressProof? = null
|
||||
private var savedAddressActivity: String? = null
|
||||
private var structuralSubmitAnchor: SnapshotNode? = null
|
||||
var lastOrderReadFailure: PurchaseOrderReadFailure? = null
|
||||
private set
|
||||
|
||||
@@ -301,10 +304,11 @@ class PurchaseLiveAutomation(
|
||||
private fun nodeArea(node: SnapshotNode): Long = node.bounds.width.toLong() * node.bounds.height
|
||||
|
||||
fun finalConfirmation(input: PurchaseExecutionInput, address: ShippingAddressProof): FinalConfirmationEvidence {
|
||||
structuralSubmitAnchor = null
|
||||
val snapshot = driver.capture()
|
||||
pageProblem(snapshot)
|
||||
if (snapshot.packageName != PDD_PACKAGE) fail("PURCHASE_CONFIRMATION_LOST", "最终提交前页面已经变化,禁止创建订单")
|
||||
if (!hasFinalSavedAddressEvidence(snapshot, address.expectedAddress, address.suffix)) {
|
||||
if (!hasSavedAddressEvidence(snapshot, address.expectedAddress, address.suffix)) {
|
||||
fail("PURCHASE_ADDRESS_UPDATE_FAILED", "收货地址保存后复核失败,未创建订单")
|
||||
}
|
||||
val submit = finalSubmitTargets(snapshot)
|
||||
@@ -318,6 +322,7 @@ class PurchaseLiveAutomation(
|
||||
if (price !in input.minUnitPriceCent..input.maxUnitPriceCent) fail("PURCHASE_PRICE_OUT_OF_RANGE", "当前商品单价超出允许范围", price)
|
||||
val quantities = snapshot.nodes.filter { it.visible && it.enabled && it.className?.endsWith("EditText") == true }.mapNotNull { it.label.toLongOrNull() }
|
||||
if (quantities.singleOrNull() != input.quantity) fail("PURCHASE_QUANTITY_MISMATCH", "创建订单前数量复核失败")
|
||||
structuralSubmitAnchor = submit.single().takeIf { legacySubmitTargets(snapshot).isEmpty() }
|
||||
return FinalConfirmationEvidence(input.goodsId, input.mappedColor, input.mappedSize, input.quantity, price, address.suffix, snapshot.activityName.orEmpty())
|
||||
}
|
||||
|
||||
@@ -329,6 +334,18 @@ class PurchaseLiveAutomation(
|
||||
if (snapshot.packageName != PDD_PACKAGE || targets.size != 1) {
|
||||
fail("PURCHASE_SUBMIT_TARGET_AMBIGUOUS", "最终页面或创建订单按钮已变化,禁止创建订单")
|
||||
}
|
||||
if (legacySubmitTargets(snapshot).isEmpty() && structuralSubmitAnchor == null) {
|
||||
fail("PURCHASE_SUBMIT_TARGET_AMBIGUOUS", "下单按钮尚未完成最终复核,禁止创建订单")
|
||||
}
|
||||
structuralSubmitAnchor?.let { anchor ->
|
||||
val fresh = targets.single()
|
||||
if (fresh.path != anchor.path || fresh.className != anchor.className || fresh.label != anchor.label ||
|
||||
kotlin.math.abs(fresh.bounds.left - anchor.bounds.left) > 32 ||
|
||||
kotlin.math.abs(fresh.bounds.top - anchor.bounds.top) > 32 ||
|
||||
kotlin.math.abs(fresh.bounds.right - anchor.bounds.right) > 32 ||
|
||||
kotlin.math.abs(fresh.bounds.bottom - anchor.bounds.bottom) > 32
|
||||
) fail("PURCHASE_SUBMIT_TARGET_AMBIGUOUS", "最终下单按钮已变化,禁止创建订单")
|
||||
}
|
||||
submitAttempted = true
|
||||
when (driver.clickFresh(targets.single())) {
|
||||
FreshActionResult.SUCCESS -> Unit
|
||||
@@ -466,6 +483,9 @@ class PurchaseLiveAutomation(
|
||||
snapshot.packageName == PDD_PACKAGE && snapshot.activityName in PDD_PAYMENT_ACTIVITIES
|
||||
|
||||
private fun editAndVerifyAddress(panel: UiSnapshot, suffix: String): ShippingAddressProof {
|
||||
savedAddressProof = null
|
||||
savedAddressActivity = null
|
||||
structuralSubmitAnchor = null
|
||||
val modify = addressModifyTargets(panel)
|
||||
if (modify.size != 1) fail("PURCHASE_ADDRESS_EDIT_AMBIGUOUS", "没有找到唯一的地址修改按钮,未创建订单")
|
||||
click(modify.single(), "修改地址")
|
||||
@@ -485,18 +505,27 @@ class PurchaseLiveAutomation(
|
||||
val save = uniqueClickable(stable, stable.nodes.filter { it.visible && it.enabled && it.label == "保存" })
|
||||
if (save.size != 1) fail("PURCHASE_ADDRESS_UPDATE_FAILED", "地址保存按钮不唯一,未创建订单")
|
||||
click(save.single(), "保存地址")
|
||||
val savedEvidence = waitForStableAddressEditorExit()
|
||||
if (!hasFinalSavedAddressEvidence(savedEvidence, expected, suffix)) {
|
||||
var savedEvidence = waitForStableAddressEditorExit()
|
||||
if (!savedAddressPageReady(savedEvidence, expected, suffix)) {
|
||||
if (isPurchaseConfirmationPanel(savedEvidence)) {
|
||||
restoreFinalEvidenceInCurrentPanel(savedEvidence, expected, suffix)
|
||||
savedEvidence = restoreFinalEvidenceInCurrentPanel(savedEvidence, expected, suffix)
|
||||
} else {
|
||||
if (!driver.backPurchase()) fail("PURCHASE_ADDRESS_UPDATE_FAILED", "地址保存后无法返回订单页面,未创建订单")
|
||||
waitFor("PURCHASE_ADDRESS_SAVE_TIMEOUT", "地址保存后无法返回订单页面,未创建订单") {
|
||||
hasFinalSavedAddressEvidence(it, expected, suffix)
|
||||
val returned = waitFor("PURCHASE_ADDRESS_SAVE_TIMEOUT", "地址保存后无法返回订单页面,未创建订单") {
|
||||
savedAddressPageReady(it, expected, suffix) || isPurchaseConfirmationPanel(it)
|
||||
}
|
||||
savedEvidence = if (!savedAddressPageReady(returned, expected, suffix)) {
|
||||
restoreFinalEvidenceInCurrentPanel(returned, expected, suffix)
|
||||
} else returned
|
||||
}
|
||||
}
|
||||
return ShippingAddressProof(expected, suffix)
|
||||
val proof = ShippingAddressProof(expected, suffix)
|
||||
savedAddressProof = proof
|
||||
savedAddressActivity = savedEvidence.activityName
|
||||
if (finalSubmitTargets(savedEvidence).size != 1) {
|
||||
fail("PURCHASE_SUBMIT_TARGET_AMBIGUOUS", "地址已保存,但未能识别唯一的下单按钮,未创建订单")
|
||||
}
|
||||
return proof
|
||||
}
|
||||
|
||||
private fun isPurchaseConfirmationPanel(snapshot: UiSnapshot): Boolean {
|
||||
@@ -513,17 +542,18 @@ class PurchaseLiveAutomation(
|
||||
initial: UiSnapshot,
|
||||
expected: String,
|
||||
suffix: String,
|
||||
) {
|
||||
): UiSnapshot {
|
||||
var snapshot = initial
|
||||
var previousSignature: String? = null
|
||||
repeat(ADDRESS_CONFIRMATION_SCROLL_LIMIT) { attempt ->
|
||||
if (hasFinalSavedAddressEvidence(snapshot, expected, suffix)) return
|
||||
if (savedAddressPageReady(snapshot, expected, suffix)) return snapshot
|
||||
if (!isPurchaseConfirmationPanel(snapshot)) {
|
||||
fail("PURCHASE_ADDRESS_SAVE_TIMEOUT", "地址保存后采购面板发生变化,未创建订单")
|
||||
}
|
||||
val panels = purchasePanelScrollTargets(snapshot)
|
||||
val panels = savedAddressPanelScrollTargets(snapshot)
|
||||
if (panels.size != 1) {
|
||||
fail("PURCHASE_ADDRESS_ENTRY_AMBIGUOUS", "地址保存后没有找到唯一的规格面板滚动区域,未创建订单")
|
||||
val reason = if (panels.isEmpty()) "未找到可用的规格面板滚动区域" else "找到多个规格面板滚动区域"
|
||||
fail("PURCHASE_ADDRESS_ENTRY_AMBIGUOUS", "地址保存后${reason},未创建订单")
|
||||
}
|
||||
val panel = panels.single()
|
||||
val signature = viewportSignature(snapshot, panel)
|
||||
@@ -538,9 +568,10 @@ class PurchaseLiveAutomation(
|
||||
snapshot = driver.capture()
|
||||
pageProblem(snapshot)
|
||||
}
|
||||
if (!hasFinalSavedAddressEvidence(snapshot, expected, suffix)) {
|
||||
if (!savedAddressPageReady(snapshot, expected, suffix)) {
|
||||
fail("PURCHASE_ADDRESS_SAVE_TIMEOUT", "地址保存后规格面板未找到完整订单确认信息,未创建订单")
|
||||
}
|
||||
return snapshot
|
||||
}
|
||||
|
||||
private fun hasSavedAddressEvidence(snapshot: UiSnapshot, expected: String, suffix: String): Boolean {
|
||||
@@ -557,8 +588,9 @@ class PurchaseLiveAutomation(
|
||||
return suffixMatches.size == 1
|
||||
}
|
||||
|
||||
private fun hasFinalSavedAddressEvidence(snapshot: UiSnapshot, expected: String, suffix: String): Boolean =
|
||||
hasSavedAddressEvidence(snapshot, expected, suffix) && finalSubmitTargets(snapshot).size == 1
|
||||
private fun savedAddressPageReady(snapshot: UiSnapshot, expected: String, suffix: String): Boolean =
|
||||
hasSavedAddressEvidence(snapshot, expected, suffix) &&
|
||||
(legacySubmitTargets(snapshot).isNotEmpty() || savedPurchasePanel(snapshot, expected, suffix) != null)
|
||||
|
||||
private fun waitForStableAddressEditorExit(): UiSnapshot {
|
||||
var consecutiveExits = 0
|
||||
@@ -591,6 +623,20 @@ class PurchaseLiveAutomation(
|
||||
fail(code, message)
|
||||
}
|
||||
|
||||
private fun savedAddressPanelScrollTargets(snapshot: UiSnapshot): List<SnapshotNode> {
|
||||
val panel = PddScreenParser.parse(snapshot, PurchaseRehearsalExecutor.DEFAULT_COLLECTOR, "", null)
|
||||
.specPanelContainer ?: return purchasePanelScrollTargets(snapshot)
|
||||
val screenWidth = snapshot.nodes.maxOfOrNull { it.bounds.right }?.coerceAtLeast(1) ?: 1
|
||||
val screenHeight = snapshot.nodes.maxOfOrNull { it.bounds.bottom }?.coerceAtLeast(1) ?: 1
|
||||
return snapshot.nodes.filter { node ->
|
||||
node.visible && node.enabled && node.scrollable &&
|
||||
node.bounds.width * 100 >= screenWidth * 60 &&
|
||||
node.bounds.height * 100 >= screenHeight * 20 &&
|
||||
node.bounds.left >= panel.bounds.left && node.bounds.right <= panel.bounds.right &&
|
||||
node.bounds.top >= panel.bounds.top && node.bounds.bottom <= panel.bounds.bottom
|
||||
}.distinctBy { it.path }
|
||||
}
|
||||
|
||||
private fun purchasePanelScrollTargets(snapshot: UiSnapshot): List<SnapshotNode> {
|
||||
val screenWidth = snapshot.nodes.maxOfOrNull { it.bounds.right }?.coerceAtLeast(1) ?: 1
|
||||
val screenHeight = snapshot.nodes.maxOfOrNull { it.bounds.bottom }?.coerceAtLeast(1) ?: 1
|
||||
@@ -678,11 +724,84 @@ class PurchaseLiveAutomation(
|
||||
pause(500)
|
||||
}
|
||||
|
||||
private fun finalSubmitTargets(snapshot: UiSnapshot): List<SnapshotNode> = uniqueClickable(
|
||||
private fun legacySubmitTargets(snapshot: UiSnapshot): List<SnapshotNode> = uniqueClickable(
|
||||
snapshot,
|
||||
snapshot.nodes.filter { node -> node.visible && node.enabled && FINAL_SUBMIT_MARKERS.any { node.label == it || node.label.startsWith(it) } },
|
||||
)
|
||||
|
||||
/** Only the just-saved address flow may use a footer whose display copy is unknown. */
|
||||
private fun finalSubmitTargets(snapshot: UiSnapshot): List<SnapshotNode> {
|
||||
val legacy = legacySubmitTargets(snapshot)
|
||||
if (legacy.isNotEmpty()) return legacy
|
||||
val proof = savedAddressProof ?: return emptyList()
|
||||
if (snapshot.activityName != savedAddressActivity) return emptyList()
|
||||
val panel = savedPurchasePanel(snapshot, proof.expectedAddress, proof.suffix) ?: return emptyList()
|
||||
val visible = snapshot.nodes.filter { it.visible }
|
||||
val byPath = visible.associateBy(SnapshotNode::path)
|
||||
val candidates = visible.filter { node ->
|
||||
node.path.startsWith("${panel.path}/") && node.enabled && node.clickable && !node.scrollable &&
|
||||
node.bounds.width >= panel.bounds.width / 5 && node.bounds.height >= 2 &&
|
||||
node.bounds.height <= panel.bounds.height / 4 &&
|
||||
node.bounds.bottom <= panel.bounds.bottom &&
|
||||
node.bounds.bottom >= panel.bounds.bottom - panel.bounds.height / 20 &&
|
||||
node.bounds.left >= panel.bounds.left && node.bounds.right <= panel.bounds.right
|
||||
}.filter { node ->
|
||||
// A spec option at the scroll edge is not a fixed footer action.
|
||||
var parent = node.parentPath?.let(byPath::get)
|
||||
var inScroller = false
|
||||
while (parent != null && parent.path != panel.path) {
|
||||
if (parent.scrollable) inScroller = true
|
||||
parent = parent.parentPath?.let(byPath::get)
|
||||
}
|
||||
!inScroller && parent?.path == panel.path
|
||||
}
|
||||
val independent = candidates.filter { parent ->
|
||||
candidates.none { it.path.startsWith("${parent.path}/") }
|
||||
}
|
||||
// Do not silently choose one of several footer actions by text or price.
|
||||
if (independent.size != 1) return emptyList()
|
||||
val button = independent.single()
|
||||
val content = visible.filter { it.path == button.path || it.path.startsWith("${button.path}/") }
|
||||
val labels = content.map { it.label.replace(" ", "") }
|
||||
if (labels.any { label -> STRUCTURAL_ACTION_DENY.any(label::contains) } ||
|
||||
labels.none { FOOTER_PRICE.containsMatchIn(it) } ||
|
||||
labels.none { FOOTER_PRICE.replace(it, "").any(Char::isLetter) }
|
||||
) return emptyList()
|
||||
// Keep the original text anchor; clickFresh reacquires it and its nearest clickable parent.
|
||||
return listOfNotNull(content.firstOrNull {
|
||||
it.enabled && it.label.isNotBlank() && nearestClickableAncestor(it, byPath)?.path == button.path
|
||||
})
|
||||
}
|
||||
|
||||
private fun savedPurchasePanel(snapshot: UiSnapshot, expected: String, suffix: String): SnapshotNode? {
|
||||
if (snapshot.packageName != PDD_PACKAGE || snapshot.activityName.isNullOrBlank() ||
|
||||
isKnownPddPaymentActivity(snapshot) || !hasSavedAddressEvidence(snapshot, expected, suffix)
|
||||
) return null
|
||||
val visible = snapshot.nodes.filter { it.visible }
|
||||
if (visible.any { node -> STRUCTURAL_PAGE_DENY.any(node.label::contains) }) return null
|
||||
val resolution = resolveAddressEntries(snapshot)
|
||||
if (resolution.addressCardCount != 1 || resolution.tapTargetCount != 1) return null
|
||||
val address = resolution.target?.node ?: return null
|
||||
val quantities = visible.filter { it.enabled && it.className?.endsWith("EditText") == true && (it.label.toLongOrNull() ?: 0) > 0 }
|
||||
val summaries = visible.filter { it.label.replace(" ", "").startsWith("已选") }
|
||||
val evidence = quantities.singleOrNull() ?: summaries.singleOrNull() ?: return null
|
||||
val byPath = visible.associateBy(SnapshotNode::path)
|
||||
val screenHeight = visible.maxOfOrNull { it.bounds.bottom } ?: return null
|
||||
val screenWidth = visible.maxOfOrNull { it.bounds.right } ?: return null
|
||||
var common = address.parentPath?.let(byPath::get)
|
||||
while (common != null) {
|
||||
if (evidence.path.startsWith("${common.path}/")) {
|
||||
// Nearest common ancestor is the whole popup, not its inner spec RecyclerView.
|
||||
return common.takeIf {
|
||||
!it.scrollable && it.bounds.width >= screenWidth * 3 / 5 &&
|
||||
it.bounds.height > 0 && it.bounds.height < screenHeight * 95 / 100
|
||||
}
|
||||
}
|
||||
common = common.parentPath?.let(byPath::get)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun orderConfirmationReady(snapshot: UiSnapshot): Boolean {
|
||||
if (snapshot.packageName != PDD_PACKAGE) return false
|
||||
if (snapshot.nodes.any { it.visible && it.enabled && MASKED_PHONE.containsMatchIn(it.label) }) return true
|
||||
@@ -746,6 +865,9 @@ class PurchaseLiveAutomation(
|
||||
const val WECHAT_PACKAGE = "com.tencent.mm"
|
||||
val MASKED_PHONE = Regex("(?<![0-9])[0-9]{3}\\*{4}[0-9]{4}(?![0-9])")
|
||||
val FINAL_SUBMIT_MARKERS = listOf("提交订单", "现在买,仅", "确认购买")
|
||||
val FOOTER_PRICE = Regex("(?:[¥¥]\\s*\\d+(?:\\.\\d{1,2})?|\\d+(?:\\.\\d{1,2})?\\s*元)")
|
||||
val STRUCTURAL_ACTION_DENY = listOf("支付", "付款", "先用后付", "收货", "地址", "修改", "保存", "取消", "返回", "关闭", "客服", "收藏", "订单详情", "查看订单")
|
||||
val STRUCTURAL_PAGE_DENY = listOf("立即支付", "确认支付", "输入支付密码", "待付款", "待支付", "订单编号", "订单号", "订单详情", "修改收货地址", "选择收货地址")
|
||||
val PAYMENT_MARKERS = listOf("立即支付", "确认支付", "输入支付密码")
|
||||
val UNPAID_MARKERS = listOf("待付款", "待支付", "去支付")
|
||||
val ORDER_DETAIL_ENTRY_MARKERS = setOf("查看订单", "订单详情")
|
||||
|
||||
+48
-14
@@ -96,6 +96,7 @@ class PurchaseRehearsalExecutor(
|
||||
) {
|
||||
private var purchasePanelContext: PurchasePanelContext? = null
|
||||
private var pageIdentity: Pair<String?, String?> = null to null
|
||||
private var entryRecoveryReason = "not_checked"
|
||||
|
||||
fun execute(input: PurchaseExecutionInput, rule: PurchaseRule, supportedCapabilities: Set<String>): PurchaseExecutionOutcome {
|
||||
purchasePanelContext = null
|
||||
@@ -400,7 +401,7 @@ class PurchaseRehearsalExecutor(
|
||||
if (refreshed.specPanelOpen) return null
|
||||
val recoveryTarget = recoverableSpecEntry(screen, refreshed, action, beforeIdentity)
|
||||
if (recoveryTarget == null) {
|
||||
panelDiagnostic("entryRecovery=rejected;pageChanged=${wait.changed};${panelEvidence(refreshed)}")
|
||||
panelDiagnostic("entryRecovery=rejected;reason=$entryRecoveryReason;pageChanged=${wait.changed};${panelEvidence(refreshed)}")
|
||||
return failure(
|
||||
SPEC_PANEL_EVIDENCE_NOT_MATCHED,
|
||||
"规格入口点击后页面已变化,但规格面板强证据不足 [${panelEvidence(wait.screen)}]",
|
||||
@@ -443,18 +444,38 @@ class PurchaseRehearsalExecutor(
|
||||
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
|
||||
entryRecoveryReason = when {
|
||||
identity.first == null || identity.second == null -> "identity_missing"
|
||||
identity != pageIdentity -> "identity_changed"
|
||||
!after.isPddPackage || !after.pageEvidenceMatched -> "product_context_missing"
|
||||
after.problem != null || after.reviewPageOpen -> "page_problem"
|
||||
after.specPanelOpen -> "panel_open"
|
||||
after.hasCloseControl -> "close_control"
|
||||
after.hasPaymentArea -> "payment_area"
|
||||
after.hasOrderSubmitAction -> "order_action"
|
||||
after.hasQuantityControls -> "quantity_controls"
|
||||
after.hasSelectionSummary -> "selection_summary"
|
||||
before.summary.title.isNullOrBlank() -> "title_missing"
|
||||
before.summary.title != after.summary.title -> "title_changed"
|
||||
after.specEntry == null -> "entry_missing"
|
||||
before.specEntrySource == null -> "entry_source_missing"
|
||||
before.specEntrySource != after.specEntrySource -> "entry_source_changed"
|
||||
before.specEntry?.label != after.specEntry.label -> "entry_label_changed"
|
||||
after.explicitSpecEntryCount > 1 || after.nestedSpecEntryCount > 1 -> "entry_ambiguous"
|
||||
else -> "eligible"
|
||||
}
|
||||
if (entryRecoveryReason != "eligible") 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 }
|
||||
if (action.textAliases?.let { specEntryMatchesAliases(after, anchor, it) } == false) {
|
||||
entryRecoveryReason = "rule_alias_mismatch"
|
||||
return null
|
||||
}
|
||||
val target = after.specEntryClickTarget ?: anchor
|
||||
if (!target.visible || !target.enabled) {
|
||||
entryRecoveryReason = "entry_unavailable"
|
||||
return null
|
||||
}
|
||||
return target
|
||||
}
|
||||
|
||||
private data class SpecPanelWait(
|
||||
@@ -582,6 +603,7 @@ class PurchaseRehearsalExecutor(
|
||||
): PurchaseExecutionOutcome? {
|
||||
val initial = currentScreen(input)
|
||||
initial.problem?.let { return failure(it.code, it.message) }
|
||||
unavailableExactSpec(initial, dimension, target)?.let { return it }
|
||||
if (rememberExactSelection(initial, dimension, target, selectionProofs)) return null
|
||||
|
||||
val lookup = locateExactSpec(input, dimension, target)
|
||||
@@ -832,6 +854,18 @@ class PurchaseRehearsalExecutor(
|
||||
|
||||
private data class SpecLookup(val node: SnapshotNode? = null, val failure: PurchaseExecutionOutcome? = null)
|
||||
|
||||
private fun unavailableExactSpec(screen: ParsedPddScreen, dimension: String, target: String): PurchaseExecutionOutcome? {
|
||||
if (!screen.specPanelOpen) return null
|
||||
val exact = screen.dimensions.filter { it.key == dimension }.flatMap { it.values }.filter { it.text == target }
|
||||
return if (exact.size == 1 && !exact.single().available) unavailableSpec(dimension) else null
|
||||
}
|
||||
|
||||
private fun unavailableSpec(dimension: String): PurchaseExecutionOutcome {
|
||||
val role = if (dimension == "color") "颜色" else "尺码"
|
||||
panelDiagnostic("specLookup=unavailable;dimension=$dimension;reason=exact_target_unavailable")
|
||||
return failure(SPEC_SAFE_TARGET_MISSING, "目标${role}已售罄或当前不可选,未创建订单")
|
||||
}
|
||||
|
||||
/**
|
||||
* Searches only parsed, selectable values in the already-open spec panel.
|
||||
* A short downward pass first restores the top when a previous action left
|
||||
@@ -868,7 +902,7 @@ class PurchaseRehearsalExecutor(
|
||||
}
|
||||
if (exact.size == 1 && exact.single().available) return Inspection(SpecLookup(node = exact.single().node), "", screen.specPanelContainer)
|
||||
if (exact.size == 1) {
|
||||
return Inspection(SpecLookup(failure = failure(SPEC_SAFE_TARGET_MISSING, "精确规格当前不可安全点击")), "", screen.specPanelContainer)
|
||||
return Inspection(SpecLookup(failure = unavailableSpec(dimension)), "", screen.specPanelContainer)
|
||||
}
|
||||
val signature = screen.dimensions.joinToString("|") { item ->
|
||||
"${item.key}:${item.values.joinToString(",") { value -> "${value.text}:${value.available}" }}"
|
||||
@@ -933,7 +967,7 @@ class PurchaseRehearsalExecutor(
|
||||
val lookup = when {
|
||||
exact.size > 1 -> SpecLookup(failure = failure(SPEC_TARGET_AMBIGUOUS, "精确规格匹配到多个控件"))
|
||||
exact.size == 1 && exact.single().available -> SpecLookup(node = exact.single().node)
|
||||
exact.size == 1 -> SpecLookup(failure = failure(SPEC_SAFE_TARGET_MISSING, "精确规格当前不可安全点击"))
|
||||
exact.size == 1 -> SpecLookup(failure = unavailableSpec(dimension))
|
||||
else -> null
|
||||
}
|
||||
val rows = specValueRows(values)
|
||||
|
||||
@@ -18,6 +18,161 @@ import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class PurchaseLiveAutomationTest {
|
||||
@Test
|
||||
fun `saved address recognizes split dynamic footer outside spec scroller without a swipe`() {
|
||||
for (label in listOf("复购价,", "专享优惠,")) {
|
||||
val driver = LiveDriver(savedStructuredFooter = label)
|
||||
val automation = PurchaseLiveAutomation(driver, pause = {})
|
||||
val proof = automation.updateShippingAddress("_cg81")
|
||||
automation.finalConfirmation(input().copy(addressSuffix = "_cg81"), proof)
|
||||
automation.submitOrderOnce()
|
||||
assertEquals(1, driver.submitClicks)
|
||||
assertEquals(0, driver.scopedSwipes)
|
||||
assertEquals(0, driver.backCount)
|
||||
assertTrue(driver.clicked.contains(label))
|
||||
assertEquals("PURCHASE_SUBMIT_ALREADY_ATTEMPTED",
|
||||
(runCatching { automation.submitOrderOnce() }.exceptionOrNull() as PurchaseLiveException).code)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `one back from address transition can recognize dynamic footer`() {
|
||||
val driver = LiveDriver(savedStructuredFooter = "复购价,", savedTransitionWithoutLegacyContext = true)
|
||||
val automation = PurchaseLiveAutomation(driver, pause = {})
|
||||
val proof = automation.updateShippingAddress("_cg81")
|
||||
automation.finalConfirmation(input().copy(addressSuffix = "_cg81"), proof)
|
||||
assertEquals(1, driver.backCount)
|
||||
assertEquals(0, driver.scopedSwipes)
|
||||
assertEquals(0, driver.submitClicks)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `saved address with missing ambiguous price only or payment footer reports button failure without scrolling`() {
|
||||
val mutations: List<(UiSnapshot) -> UiSnapshot> = listOf(
|
||||
{ s -> s.copy(nodes = s.nodes.filterNot { it.path.startsWith("root/popup/footer") }) },
|
||||
{ s -> s.copy(nodes = s.nodes + s.nodes.filter { it.path.startsWith("root/popup/footer") }.map {
|
||||
it.copy(path = it.path.replace("footer", "second-footer"), parentPath = it.parentPath?.replace("footer", "second-footer"))
|
||||
}) },
|
||||
{ s -> s.copy(nodes = s.nodes.filterNot { it.path == "root/popup/footer/label" }) },
|
||||
{ s -> s.copy(nodes = s.nodes.map { if (it.path == "root/popup/footer") it.copy(clickable = false) else it }) },
|
||||
{ s -> s.copy(nodes = s.nodes.map { if (it.path == "root/popup/footer/label") it.copy(text = "去付款") else it }) },
|
||||
{ s -> s.copy(nodes = s.nodes.map { if (it.path == "root/popup/footer") it.copy(enabled = false) else it }) },
|
||||
)
|
||||
for (mutation in mutations) {
|
||||
val driver = LiveDriver(savedStructuredFooter = "复购价,")
|
||||
driver.structuredTransform = mutation
|
||||
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg81") }
|
||||
.exceptionOrNull() as PurchaseLiveException
|
||||
assertEquals("PURCHASE_SUBMIT_TARGET_AMBIGUOUS", error.code)
|
||||
assertEquals(0, driver.scopedSwipes)
|
||||
assertEquals(0, driver.backCount)
|
||||
assertEquals(0, driver.submitClicks)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `legacy submit labels retain priority over structure`() {
|
||||
for (label in listOf("提交订单", "现在买,仅20元", "确认购买")) {
|
||||
val driver = LiveDriver(savedStructuredFooter = label)
|
||||
val automation = PurchaseLiveAutomation(driver, pause = {})
|
||||
val proof = automation.updateShippingAddress("_cg81")
|
||||
automation.finalConfirmation(input().copy(addressSuffix = "_cg81"), proof)
|
||||
automation.submitOrderOnce()
|
||||
assertEquals(1, driver.submitClicks)
|
||||
assertEquals(0, driver.scopedSwipes)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `structural footer cannot bypass final spec quantity price or address verification`() {
|
||||
val cases = listOf(
|
||||
input().copy(mappedColor = "白色") to "PURCHASE_SPEC_NOT_MATCHED",
|
||||
input().copy(quantity = 3) to "PURCHASE_QUANTITY_MISMATCH",
|
||||
input().copy(maxUnitPriceCent = 100) to "PURCHASE_PRICE_OUT_OF_RANGE",
|
||||
)
|
||||
for ((request, code) in cases) {
|
||||
val driver = LiveDriver(savedStructuredFooter = "复购价,")
|
||||
val automation = PurchaseLiveAutomation(driver, pause = {})
|
||||
val proof = automation.updateShippingAddress("_cg81")
|
||||
val error = runCatching { automation.finalConfirmation(request.copy(addressSuffix = "_cg81"), proof) }
|
||||
.exceptionOrNull() as PurchaseLiveException
|
||||
assertEquals(code, error.code)
|
||||
assertEquals(0, driver.submitClicks)
|
||||
}
|
||||
val driver = LiveDriver(savedStructuredFooter = "复购价,")
|
||||
val automation = PurchaseLiveAutomation(driver, pause = {})
|
||||
val proof = automation.updateShippingAddress("_cg81")
|
||||
driver.structuredTransform = { s -> s.copy(nodes = s.nodes.filterNot { it.path.endsWith("/address/detail") }) }
|
||||
assertEquals("PURCHASE_ADDRESS_UPDATE_FAILED",
|
||||
(runCatching { automation.finalConfirmation(input(), proof) }.exceptionOrNull() as PurchaseLiveException).code)
|
||||
assertEquals(0, driver.submitClicks)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `structural footer needs this execution saved address and unchanged page at submission`() {
|
||||
val driver = LiveDriver(savedStructuredFooter = "复购价,")
|
||||
val automation = PurchaseLiveAutomation(driver, pause = {})
|
||||
val proof = automation.updateShippingAddress("_cg81")
|
||||
assertEquals("PURCHASE_SUBMIT_TARGET_AMBIGUOUS",
|
||||
(runCatching { automation.submitOrderOnce() }.exceptionOrNull() as PurchaseLiveException).code)
|
||||
assertEquals("PURCHASE_SUBMIT_TARGET_AMBIGUOUS",
|
||||
(runCatching { PurchaseLiveAutomation(driver, pause = {}).finalConfirmation(input(), proof) }
|
||||
.exceptionOrNull() as PurchaseLiveException).code)
|
||||
automation.finalConfirmation(input().copy(addressSuffix = "_cg81"), proof)
|
||||
driver.structuredTransform = { s -> s.copy(nodes = s.nodes.map {
|
||||
if (it.path.endsWith("footer/label")) it.copy(text = "另一操作") else it
|
||||
}) }
|
||||
assertEquals("PURCHASE_SUBMIT_TARGET_AMBIGUOUS",
|
||||
(runCatching { automation.submitOrderOnce() }.exceptionOrNull() as PurchaseLiveException).code)
|
||||
assertEquals(0, driver.submitClicks)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `footer duplicate text nodes remain one action and external overlays are not candidates`() {
|
||||
val driver = LiveDriver(savedStructuredFooter = "复购价,")
|
||||
driver.structuredTransform = { s -> s.copy(nodes = s.nodes + listOf(
|
||||
s.nodes.first { it.path == "root/popup/footer/label" }.copy(
|
||||
path = "root/popup/footer/duplicate-label", bounds = NodeBounds(760, 2203, 950, 2262)),
|
||||
s.nodes.first { it.path == "root/popup/footer" }.copy(path = "root/overlay", parentPath = "root", text = "悬浮按钮"),
|
||||
)) }
|
||||
val automation = PurchaseLiveAutomation(driver, pause = {})
|
||||
val proof = automation.updateShippingAddress("_cg81")
|
||||
automation.finalConfirmation(input().copy(addressSuffix = "_cg81"), proof)
|
||||
automation.submitOrderOnce()
|
||||
assertEquals(1, driver.submitClicks)
|
||||
assertFalse(driver.clicked.contains("悬浮按钮"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `failed revalidation clears structural submit authorization`() {
|
||||
val driver = LiveDriver(savedStructuredFooter = "复购价,")
|
||||
val automation = PurchaseLiveAutomation(driver, pause = {})
|
||||
val proof = automation.updateShippingAddress("_cg81")
|
||||
automation.finalConfirmation(input(), proof)
|
||||
assertTrue(runCatching { automation.finalConfirmation(input().copy(quantity = 3), proof) }.isFailure)
|
||||
assertTrue(runCatching { automation.submitOrderOnce() }.isFailure)
|
||||
assertEquals(0, driver.submitClicks)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `address alone full page ancestor and order or payment pages cannot authorize structural footer`() {
|
||||
val mutations: List<(UiSnapshot) -> UiSnapshot> = listOf(
|
||||
{ s -> s.copy(nodes = s.nodes.filterNot { it.path.endsWith("/quantity") || it.path.endsWith("/selected") }) },
|
||||
{ s -> s.copy(nodes = s.nodes.map { if (it.path == "root/popup") it.copy(bounds = NodeBounds(0, 0, 1080, 2354)) else it }) },
|
||||
{ s -> s.copy(activityName = "com.xunmeng.pinduoduo.app_pay.core.PayActivity") },
|
||||
{ s -> s.copy(packageName = "example.other") },
|
||||
{ s -> s.copy(nodes = s.nodes.map { if (it.path.endsWith("/selected")) it.copy(text = "订单详情") else it }) },
|
||||
)
|
||||
for (mutation in mutations) {
|
||||
val driver = LiveDriver(savedStructuredFooter = "复购价,")
|
||||
val automation = PurchaseLiveAutomation(driver, pause = {})
|
||||
val proof = automation.updateShippingAddress("_cg81")
|
||||
driver.structuredTransform = mutation
|
||||
assertTrue(runCatching { automation.finalConfirmation(input(), proof) }.isFailure)
|
||||
assertEquals(0, driver.submitClicks)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `verified spec panel advances through one exact confirm target`() {
|
||||
val driver = SpecConfirmationDriver()
|
||||
@@ -141,6 +296,72 @@ class PurchaseLiveAutomationTest {
|
||||
assertEquals(0, driver.submitClicks)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `saved address back into short spec panel restores without aspect ratio restriction`() {
|
||||
val driver = LiveDriver(savedTransitionWithoutLegacyContext = true, backReturnsToSpecPanel = true,
|
||||
savedPanelBounds = NodeBounds(0, 400, 1080, 1325))
|
||||
val automation = PurchaseLiveAutomation(driver, pause = {})
|
||||
val address = automation.updateShippingAddress("_cg81")
|
||||
|
||||
assertEquals("_cg81", automation.finalConfirmation(input().copy(addressSuffix = "_cg81"), address).addressSuffix)
|
||||
assertEquals(1, driver.backCount)
|
||||
assertEquals(1, driver.scopedSwipes)
|
||||
assertEquals(0, driver.submitClicks)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `short saved spec panel still stops when unchanged`() {
|
||||
val driver = LiveDriver(saveReturnsToSpecPanel = true, savedSpecPanelRecoveryStuck = true,
|
||||
savedPanelBounds = NodeBounds(0, 400, 1080, 1325))
|
||||
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg81") }
|
||||
.exceptionOrNull() as PurchaseLiveException
|
||||
assertEquals("PURCHASE_ADDRESS_SAVE_TIMEOUT", error.code)
|
||||
assertEquals(0, driver.backCount)
|
||||
assertTrue(driver.scopedSwipes in 1..5)
|
||||
assertEquals(0, driver.submitClicks)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `missing and duplicate saved panel scroll regions fail distinctly without gestures`() {
|
||||
for (count in listOf(0, 2)) {
|
||||
val driver = LiveDriver(saveReturnsToSpecPanel = true, savedPanelScrollCount = count)
|
||||
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg81") }
|
||||
.exceptionOrNull() as PurchaseLiveException
|
||||
assertEquals("PURCHASE_ADDRESS_ENTRY_AMBIGUOUS", error.code)
|
||||
assertTrue(error.message.orEmpty().contains(if (count == 0) "未找到可用" else "找到多个"))
|
||||
assertEquals(0, driver.scopedSwipes)
|
||||
assertEquals(0, driver.submitClicks)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `saved address back into spec panel restores final evidence with one back`() {
|
||||
val driver = LiveDriver(savedTransitionWithoutLegacyContext = true, backReturnsToSpecPanel = true)
|
||||
val automation = PurchaseLiveAutomation(driver, pause = {})
|
||||
|
||||
val address = automation.updateShippingAddress("_cg81")
|
||||
val final = automation.finalConfirmation(input().copy(addressSuffix = "_cg81"), address)
|
||||
|
||||
assertEquals("_cg81", final.addressSuffix)
|
||||
assertEquals(1, driver.backCount)
|
||||
assertEquals(1, driver.scopedSwipes)
|
||||
assertEquals(0, driver.submitClicks)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `saved address back into stuck spec panel fails without another back or submit`() {
|
||||
val driver = LiveDriver(savedTransitionWithoutLegacyContext = true, backReturnsToSpecPanel = true,
|
||||
savedSpecPanelRecoveryStuck = true)
|
||||
|
||||
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg81") }
|
||||
.exceptionOrNull() as PurchaseLiveException
|
||||
|
||||
assertEquals("PURCHASE_ADDRESS_SAVE_TIMEOUT", error.code)
|
||||
assertEquals(1, driver.backCount)
|
||||
assertTrue(driver.scopedSwipes in 1..5)
|
||||
assertEquals(0, driver.submitClicks)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `saved address returning to spec panel is recovered without pressing back`() {
|
||||
val driver = LiveDriver(saveReturnsToSpecPanel = true)
|
||||
@@ -645,7 +866,11 @@ class PurchaseLiveAutomationTest {
|
||||
private val savedTransitionWithoutLegacyContext: Boolean = false,
|
||||
private val savedTransitionHidesSuffix: Boolean = false,
|
||||
private val saveReturnsToSpecPanel: Boolean = false,
|
||||
private val backReturnsToSpecPanel: Boolean = false,
|
||||
private val savedPanelBounds: NodeBounds = NodeBounds(0, 400, 1080, 2100),
|
||||
private val savedPanelScrollCount: Int = 1,
|
||||
private val savedSpecPanelRecoveryStuck: Boolean = false,
|
||||
private val savedStructuredFooter: String? = null,
|
||||
private val duplicatePhoneNodesSameCard: Boolean = false,
|
||||
private val duplicateSemanticAddressCards: Boolean = false,
|
||||
private val duplicateAddressCards: Boolean = false,
|
||||
@@ -676,16 +901,33 @@ class PurchaseLiveAutomationTest {
|
||||
var orderDetailEntryClicks = 0
|
||||
var postSubmitCaptureCount = 0
|
||||
val currentPage: String get() = page
|
||||
var structuredTransform: (UiSnapshot) -> UiSnapshot = { it }
|
||||
|
||||
override fun capture(): UiSnapshot {
|
||||
if (submitClicks > 0) {
|
||||
postSubmitCaptureCount++
|
||||
if (pendingPostSubmitPages.isNotEmpty()) page = pendingPostSubmitPages.removeAt(0)
|
||||
}
|
||||
return currentSnapshot()
|
||||
return currentSnapshot().let { if (page == "structured") structuredTransform(it) else it }
|
||||
}
|
||||
|
||||
private fun currentSnapshot(): UiSnapshot = when (page) {
|
||||
"structured" -> snapshot(listOf(
|
||||
node("root", "", bounds = NodeBounds(0, 0, 1080, 2354)),
|
||||
node("root/popup", "", clickable = true, parentPath = "root", bounds = NodeBounds(0, 366, 1080, 2306)),
|
||||
node("root/popup/address", "", clickable = true, parentPath = "root/popup", bounds = NodeBounds(0, 400, 1080, 530)),
|
||||
node("root/popup/address/phone", "138****5678", parentPath = "root/popup/address", bounds = NodeBounds(30, 400, 400, 450)),
|
||||
node("root/popup/address/detail", address, parentPath = "root/popup/address", bounds = NodeBounds(30, 456, 1000, 505)),
|
||||
node("root/popup/price", "¥20.00", parentPath = "root/popup", bounds = NodeBounds(396, 620, 700, 690)),
|
||||
node("root/popup/selected", "已选 黑色 XL", parentPath = "root/popup", bounds = NodeBounds(396, 731, 1053, 845)),
|
||||
node("root/popup/quantity", "2", className = "android.widget.EditText", parentPath = "root/popup", bounds = NodeBounds(462, 881, 540, 959)),
|
||||
node("root/popup/specs", "", scrollable = true, className = "androidx.recyclerview.widget.RecyclerView", parentPath = "root/popup", bounds = NodeBounds(0, 1132, 1080, 2057)),
|
||||
node("root/popup/specs/color", "颜色分类", parentPath = "root/popup/specs", bounds = NodeBounds(36, 1157, 287, 1218)),
|
||||
node("root/popup/payment-method", "微信支付", clickable = true, parentPath = "root/popup", bounds = NodeBounds(0, 2057, 1080, 2156)),
|
||||
node("root/popup/footer", "", clickable = true, className = "android.widget.FrameLayout", parentPath = "root/popup", bounds = NodeBounds(0, 2159, 1080, 2306)),
|
||||
node("root/popup/footer/label", requireNotNull(savedStructuredFooter), parentPath = "root/popup/footer", bounds = NodeBounds(353, 2203, 557, 2262)),
|
||||
node("root/popup/footer/amount", "20.00元", parentPath = "root/popup/footer", bounds = NodeBounds(557, 2203, 727, 2262)),
|
||||
))
|
||||
"panel" -> snapshot(listOf(
|
||||
node("title", "收货地址"),
|
||||
node("modify-parent", "", clickable = true),
|
||||
@@ -708,7 +950,7 @@ class PurchaseLiveAutomationTest {
|
||||
))
|
||||
"post-save-spec" -> snapshot(listOf(
|
||||
node("root", "", bounds = NodeBounds(0, 0, 1080, 2200)),
|
||||
node("panel", "", scrollable = true, bounds = NodeBounds(0, 400, 1080, 2100)),
|
||||
node("panel", "", scrollable = true, bounds = savedPanelBounds),
|
||||
node("panel-title", "确认款式", bounds = NodeBounds(20, 396, 300, 430)),
|
||||
node("panel/price", "¥20.00", parentPath = "panel", bounds = NodeBounds(20, 460, 300, 520)),
|
||||
node("panel/selected", "已选 黑色 XL", parentPath = "panel", bounds = NodeBounds(20, 540, 700, 600)),
|
||||
@@ -720,7 +962,8 @@ class PurchaseLiveAutomationTest {
|
||||
node("panel/confirm", "确定", clickable = true, parentPath = "panel", bounds = NodeBounds(20, 1200, 500, 1280)),
|
||||
node("submit-parent", "", clickable = true, bounds = NodeBounds(20, 1900, 1000, 2100)),
|
||||
node("submit", "提交订单", parentPath = "submit-parent", bounds = NodeBounds(520, 1940, 980, 2040)),
|
||||
))
|
||||
).map { if (it.path == "panel" && savedPanelScrollCount == 0) it.copy(enabled = false) else it } +
|
||||
if (savedPanelScrollCount > 1) listOf(node("panel/duplicate", "", scrollable = true, bounds = savedPanelBounds, parentPath = "panel")) else emptyList())
|
||||
"order" -> snapshot(listOf(node("status", "待付款"), node("order", "订单号:PDD-202608210001"), node("time", "下单时间:2026-08-21 10:30:00"), node("pay", "立即支付", clickable = true)))
|
||||
"order-folded" -> snapshot(listOf(node("status", "待付款"), node("pay", "立即支付", clickable = true)))
|
||||
"order-folded-payment-activity" -> UiSnapshot(PDD, "com.xunmeng.pinduoduo.app_pay.core.PayActivity", listOf(
|
||||
@@ -799,6 +1042,11 @@ class PurchaseLiveAutomationTest {
|
||||
|
||||
override fun clickFresh(target: SnapshotNode): FreshActionResult {
|
||||
clicked += target.label
|
||||
if (target.path == "root/popup/footer/label") {
|
||||
submitClicks++
|
||||
page = "order"
|
||||
return FreshActionResult.SUCCESS
|
||||
}
|
||||
when (target.label) {
|
||||
"138****5678" -> page = "panel"
|
||||
"修改" -> page = "edit"
|
||||
@@ -806,6 +1054,7 @@ class PurchaseLiveAutomationTest {
|
||||
page = when {
|
||||
saveReturnsToSpecPanel -> "post-save-spec"
|
||||
savedTransitionWithoutLegacyContext -> "saved-transition"
|
||||
savedStructuredFooter != null -> "structured"
|
||||
else -> "panel"
|
||||
}
|
||||
}
|
||||
@@ -863,6 +1112,7 @@ class PurchaseLiveAutomationTest {
|
||||
page = when (page) {
|
||||
"chooser" -> "payment"
|
||||
"payment" -> if (orderEvidenceBelowFold) "order-folded" else "order"
|
||||
"saved-transition" -> if (savedStructuredFooter != null) "structured" else if (backReturnsToSpecPanel) "post-save-spec" else "confirmation"
|
||||
else -> "confirmation"
|
||||
}
|
||||
return true
|
||||
|
||||
@@ -930,12 +930,34 @@ class PurchaseRehearsalExecutorTest {
|
||||
@Test
|
||||
fun `changed entry identity or unsafe page never receives recovery tap`() {
|
||||
for (mode in listOf("missing", "duplicate", "activity", "payment")) {
|
||||
val diagnostics = mutableListOf<String>()
|
||||
val driver = FakePurchaseDriver(entryActionHasEffect = false, dynamicProduct = true,
|
||||
afterEntryMutation = mode, specTapResult = FreshActionResult.SUCCESS)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { true }, { null }, pause = {})
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { true }, { null }, pause = {}, panelDiagnostic = diagnostics::add)
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
assertEquals(mode, "failed", outcome.resultType)
|
||||
assertEquals(mode, 0, driver.specTapCount)
|
||||
val expected = mapOf("missing" to "entry_missing", "duplicate" to "entry_ambiguous",
|
||||
"activity" to "identity_changed", "payment" to "payment_area").getValue(mode)
|
||||
assertTrue(diagnostics.toString(), diagnostics.any { it.contains("entryRecovery=rejected;reason=$expected;") })
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unavailable exact color stops without selection click or searching even when selected`() {
|
||||
for (selected in listOf(null, "黑色")) {
|
||||
val driver = FakePurchaseDriver(colors = listOf("黑色", "白色"),
|
||||
unavailableColors = setOf("黑色")).apply { color = selected }
|
||||
val logs = mutableListOf<String>()
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { true }, { null }, pause = {}, panelDiagnostic = logs::add)
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
assertEquals("PURCHASE_SPEC_SAFE_TARGET_MISSING", outcome.errorCode)
|
||||
assertEquals("目标颜色已售罄或当前不可选,未创建订单", outcome.message)
|
||||
assertFalse(driver.clicked.contains("黑色"))
|
||||
assertEquals(0, driver.specTapCount)
|
||||
assertEquals(0, driver.swipeCount)
|
||||
assertTrue(logs.any { it == "specLookup=unavailable;dimension=color;reason=exact_target_unavailable" })
|
||||
assertFalse(logs.any { it.contains("黑色") })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1385,6 +1407,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
private val pullDownSucceeds: Boolean = true,
|
||||
private val loseEvidenceAfterPull: Boolean = false,
|
||||
private val unavailableSizes: Set<String> = emptySet(),
|
||||
private val unavailableColors: Set<String> = emptySet(),
|
||||
allSpecsUnavailable: Boolean = false,
|
||||
private val nonScrollablePanel: Boolean = false,
|
||||
private val confirmationPanelKind: String? = null,
|
||||
@@ -1561,7 +1584,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
540,
|
||||
clickable = true,
|
||||
selected = selectedColor == value,
|
||||
enabled = !allSpecsUnavailable,
|
||||
enabled = !allSpecsUnavailable && value !in unavailableColors,
|
||||
parentPath = colorParent,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Business-Rules-and-Glossary
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Business-Rules-and-Glossary.-
|
||||
wiki_revision: eda7ac819c2147201d749cbef9400bd916822b87
|
||||
synchronized_at: 2026-09-08T10:09:39Z
|
||||
wiki_revision: a445dfe3b76336f5c7387a2da9760c5d6e383ff6
|
||||
synchronized_at: 2026-09-09T02:47:12Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 业务规则与术语
|
||||
@@ -494,3 +494,20 @@ Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态
|
||||
实现 93aab6a,Android 0.9.62/versionCode 75;已构建并授权覆盖安装,真实采购效果待验收。仅在颜色维度完整精确目标已选中、但存在非目标已选候选时,识别同一卡片重复表达:卡片必须是滚动容器直接子级的可点击非滚动 android.view.ViewGroup,节点父链明确、可见启用且边界包含;其他冲突节点仅可为该卡片自身或其中的 ImageView。另一文字选项、另一张卡片、多个精确目标、目标未选中、父链/边界不明均不适用例外。额外核对内存源节点以拒绝被解析器按同名去重隐藏的跨卡片目标;不保存原始树。
|
||||
|
||||
不删除规格数字、不模糊匹配、不改变通用解析器、尺码确认、摘要兜底、入口/滚动/地址/价格/创建订单。没有非目标选中冲突时继续原判断;仅把同卡片容器/图片的重复选中表达从冲突中排除,不新增点击。
|
||||
|
||||
|
||||
## 精确规格不可用与入口恢复诊断(#245)
|
||||
|
||||
实现 ca815c3,Android 0.9.63/versionCode 76;已通过单元测试并构建,未安装、未进行真实采购验收。入口恢复沿用 #243 条件,拒绝时通过 entryRecovery=rejected;reason=<固定原因码> 区分页面身份、商品上下文、面板/支付区域、数量/摘要、标题、入口来源/文案、歧义、规则别名及可用性;不放宽条件,不新增点击。
|
||||
|
||||
在规格面板识别出唯一完整精确候选且 available=false 时,立即返回既有 PURCHASE_SPEC_SAFE_TARGET_MISSING,提示目标颜色/尺码已售罄或当前不可选,未创建订单;已选中目标同样不能绕过不可用检查。只记录 dimension 和 exact_target_unavailable 固定结构证据,不记录规格原文或原始树。原有纵向与横向定位在此条件下本就停止,不能将本改动描述为修复“已识别售罄仍继续搜索”。未识别精确候选时继续既有有界搜索,不推断售罄,不替换规格;解析器、匹配规则、滚动预算、地址与下单流程不变。
|
||||
|
||||
## 改地址返回后的动态提交按钮(#248)
|
||||
|
||||
实现绑定 `c80c746`,Android 0.9.66 / versionCode 79;已通过 353 项 Android 单元测试并构建,尚未安装或进行真实采购验收。
|
||||
|
||||
- 地址回读与最终提交按钮定位分开判断。旧“提交订单”“现在买,仅”“确认购买”识别继续优先;存在旧文案候选但歧义时不使用结构补充选择其中一个。地址确实缺失时保留既有有限恢复;地址及采购面板上下文已确认后,不因底部按钮文字变化而滚动规格列表。按钮缺失或不唯一使用 `PURCHASE_SUBMIT_TARGET_AMBIGUOUS` 提示按钮问题,不误报地址保存超时。
|
||||
- 结构补充只属于当前执行器中已完成地址修改后的返回流程,不能由其他页面或旧执行的地址证明启用。当前须为 PDD、地址后缀按既有规则回读唯一、地址入口唯一,并存在唯一数量输入或已选摘要;通过地址入口与该证据的最近共同祖先确定整个采购弹层,不把颜色/尺码滚动区域当成弹层。排除全屏大祖先、地址编辑、已知支付 Activity 及订单结果/支付页面,后续定位要求 Activity 与地址返回时一致。
|
||||
- 旧文案无候选时,只接受弹层底部固定区域内一个独立、可见启用、非滚动的可点击容器;不在规格滚动区内选择按钮,不取控件树末项,不使用固定屏幕点击坐标。文字与人民币金额子节点合并为一个按钮;支持如“复购价,”及非支付展示文字变化,但单独价格、多个操作按钮或支付/付款/地址/返回等非提交动作均不放行。
|
||||
- 地址返回检查、最终复核和提交共用目标定位。结构路径保留原文字节点为重新定位锚点,由已有无障碍点击重新查找其可点击父级;不新增下单手势兜底。最终复核成功后才允许该结构路径提交;提交前再次检查路径、类别、文字与边界,发生变化即停止;复核失败清除本地结构提交锚点。
|
||||
- 既有规格、数量、价格和地址复核,任务租约、服务端/本地不可逆边界及单次提交规则不变。结构识别不支持支付,不增加采购员操作、配置或审批;不改变 Admin、接口、数据库、规则 JSON、通用解析器及滑动执行器。原始控件树、整屏截图、地址全文和凭据不得落盘或上传。
|
||||
|
||||
Reference in New Issue
Block a user