Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
644e7642ee | ||
|
|
a0a25298c3 | ||
|
|
c548285d59 | ||
|
|
b0256190a5 | ||
|
|
5f4d69b3ae | ||
|
|
1900dab32e | ||
|
|
0cb36b1e73 | ||
|
|
3efe4f64a5 | ||
|
|
486dff29fd | ||
|
|
c8e5b99b0c | ||
|
|
666d19ad66 | ||
|
|
c2c1044dbb | ||
|
|
4e6afc2d25 | ||
|
|
7b9fcfb8c1 | ||
|
|
1f40a7fcb1 | ||
|
|
c9aaade6e9 | ||
|
|
5ee3b62906 | ||
|
|
b829a203dc | ||
|
|
9320e5528c | ||
|
|
41fe461f94 | ||
|
|
b306f417d5 | ||
|
|
8f5ff4525e | ||
|
|
9124e92ed6 |
@@ -11,8 +11,8 @@ android {
|
||||
applicationId = "cn.ilapage.goauto.agent"
|
||||
minSdk = 23
|
||||
targetSdk = 34
|
||||
versionCode = 59
|
||||
versionName = "0.9.46"
|
||||
versionCode = 72
|
||||
versionName = "0.9.59"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
|
||||
+75
-12
@@ -330,6 +330,42 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
|
||||
}
|
||||
}
|
||||
|
||||
override fun clickAddressEntryFresh(target: SnapshotNode): FreshClickOutcome {
|
||||
val root = rootInActiveWindow ?: return FreshClickOutcome(FreshActionResult.NOT_FOUND, FreshClickReason.ROOT_UNAVAILABLE)
|
||||
val indexes = target.path.split('/').mapNotNull(String::toIntOrNull)
|
||||
if (indexes.isEmpty() || indexes.first() != 0 || indexes.size != target.path.split('/').size) {
|
||||
return FreshClickOutcome(FreshActionResult.NOT_FOUND, FreshClickReason.TARGET_NOT_FOUND)
|
||||
}
|
||||
var node = root
|
||||
for (index in indexes.drop(1)) {
|
||||
node = node.getChild(index)
|
||||
?: return FreshClickOutcome(FreshActionResult.NOT_FOUND, FreshClickReason.TARGET_NOT_FOUND)
|
||||
}
|
||||
val bounds = Rect().also(node::getBoundsInScreen)
|
||||
if (!node.isVisibleToUser || !node.isEnabled ||
|
||||
node.preferredOrDescendantLabel() != target.label ||
|
||||
node.className?.toString() != target.className ||
|
||||
kotlin.math.abs(bounds.centerX() - target.bounds.centerX) > 32 ||
|
||||
kotlin.math.abs(bounds.centerY() - target.bounds.centerY) > 32
|
||||
) {
|
||||
return FreshClickOutcome(FreshActionResult.NOT_FOUND, FreshClickReason.TARGET_NOT_FOUND)
|
||||
}
|
||||
var ancestorDepth = 0
|
||||
while (!node.isClickable) {
|
||||
node = node.parent ?: return FreshClickOutcome(
|
||||
FreshActionResult.FAILED,
|
||||
FreshClickReason.NO_CLICKABLE_ANCESTOR,
|
||||
clickableAncestorDepth = ancestorDepth,
|
||||
)
|
||||
ancestorDepth++
|
||||
}
|
||||
return if (node.performAction(AccessibilityNodeInfo.ACTION_CLICK)) {
|
||||
FreshClickOutcome(FreshActionResult.SUCCESS, FreshClickReason.SUCCESS, 1, ancestorDepth)
|
||||
} else {
|
||||
FreshClickOutcome(FreshActionResult.FAILED, FreshClickReason.ACTION_CLICK_FALSE, 1, ancestorDepth)
|
||||
}
|
||||
}
|
||||
|
||||
override fun tapPurchaseFresh(target: SnapshotNode): FreshActionResult {
|
||||
val root = rootInActiveWindow ?: return FreshActionResult.NOT_FOUND
|
||||
val candidates = mutableListOf<AccessibilityNodeInfo>()
|
||||
@@ -414,17 +450,27 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
|
||||
|
||||
override fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long): Boolean {
|
||||
val root = rootInActiveWindow ?: return false
|
||||
val matches = mutableListOf<AccessibilityNodeInfo>()
|
||||
walk(root) { node ->
|
||||
val bounds = Rect().also(node::getBoundsInScreen)
|
||||
if (node.isVisibleToUser && node.isEnabled && node.isScrollable &&
|
||||
node.className?.toString() == target.className &&
|
||||
kotlin.math.abs(bounds.centerX() - target.bounds.centerX) <= 32 &&
|
||||
kotlin.math.abs(bounds.centerY() - target.bounds.centerY) <= 32
|
||||
) matches += node
|
||||
val candidates = mutableListOf<PurchaseScrollCandidate>()
|
||||
val liveNodes = mutableMapOf<String, AccessibilityNodeInfo>()
|
||||
fun visit(node: AccessibilityNodeInfo, path: String) {
|
||||
if (node.isVisibleToUser && node.isEnabled && node.isScrollable) {
|
||||
val bounds = Rect().also(node::getBoundsInScreen)
|
||||
candidates += PurchaseScrollCandidate(
|
||||
path, node.className?.toString(),
|
||||
NodeBounds(bounds.left, bounds.top, bounds.right, bounds.bottom),
|
||||
)
|
||||
liveNodes[path] = node
|
||||
}
|
||||
for (index in 0 until node.childCount) {
|
||||
node.getChild(index)?.let { visit(it, "$path/$index") }
|
||||
}
|
||||
}
|
||||
if (matches.size != 1) return false
|
||||
return swipeNode(matches.single(), direction, durationMs, preferScrollAction = true)
|
||||
visit(root, "0")
|
||||
val resolved = PurchaseScrollLocator.locate(
|
||||
PurchaseScrollCandidate(target.path, target.className, target.bounds), candidates,
|
||||
) ?: return false
|
||||
val current = liveNodes[resolved.path] ?: return false
|
||||
return swipeNode(current, direction, durationMs, preferScrollAction = true)
|
||||
}
|
||||
|
||||
override fun backPurchase(): Boolean = performGlobalAction(GLOBAL_ACTION_BACK)
|
||||
@@ -438,9 +484,17 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
|
||||
}.getOrDefault(false)
|
||||
}
|
||||
|
||||
private var lastSpecRowSwipeFailure = "none"
|
||||
|
||||
override fun specRowSwipeFailureReason(): String = lastSpecRowSwipeFailure
|
||||
|
||||
override fun swipeSpec(direction: SwipeDirection, anchor: SnapshotNode?): Boolean {
|
||||
lastSpecRowSwipeFailure = "none"
|
||||
if (anchor == null) return swipe(SemanticTarget.SPEC_PANEL, direction)
|
||||
val root = rootInActiveWindow ?: return false
|
||||
val root = rootInActiveWindow ?: run {
|
||||
lastSpecRowSwipeFailure = "windowMissing"
|
||||
return false
|
||||
}
|
||||
val matches = mutableListOf<AccessibilityNodeInfo>()
|
||||
walk(root) { node ->
|
||||
val bounds = Rect().also(node::getBoundsInScreen)
|
||||
@@ -458,6 +512,7 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
|
||||
val horizontal = direction == SwipeDirection.LEFT || direction == SwipeDirection.RIGHT
|
||||
if (matches.size != 1) {
|
||||
if (!SpecSwipeSafety.allowGlobalFallback(direction)) {
|
||||
lastSpecRowSwipeFailure = if (matches.isEmpty()) "anchorMissing" else "anchorAmbiguous"
|
||||
Log.i("GoAutoCollector", "swipe direction=$direction result=blocked reason=anchor-not-unique matches=${matches.size}")
|
||||
return false
|
||||
}
|
||||
@@ -481,19 +536,27 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
|
||||
if (anchoredTarget != null) {
|
||||
val bounds = Rect().also(anchoredTarget::getBoundsInScreen)
|
||||
Log.i("GoAutoCollector", "swipe direction=$direction anchored=true bounds=$bounds class=${anchoredTarget.className}")
|
||||
return swipeNode(
|
||||
val success = swipeNode(
|
||||
anchoredTarget,
|
||||
direction,
|
||||
preferScrollAction = SpecSwipeSafety.preferAccessibilityScrollAction(direction),
|
||||
)
|
||||
if (!success) lastSpecRowSwipeFailure = "gestureFailed"
|
||||
return success
|
||||
}
|
||||
if (!SpecSwipeSafety.allowGlobalFallback(direction)) {
|
||||
lastSpecRowSwipeFailure = "horizontalContainerMissing"
|
||||
Log.i("GoAutoCollector", "swipe direction=$direction result=blocked reason=no-anchored-horizontal-container")
|
||||
return false
|
||||
}
|
||||
return swipe(SemanticTarget.SPEC_PANEL, direction)
|
||||
}
|
||||
|
||||
override fun swipeSpecRow(target: SnapshotNode, direction: SwipeDirection): Boolean {
|
||||
if (direction != SwipeDirection.LEFT && direction != SwipeDirection.RIGHT) return false
|
||||
return swipeSpec(direction, target)
|
||||
}
|
||||
|
||||
override fun pullDownSpecPanel(anchor: SnapshotNode): Boolean {
|
||||
if (!anchor.scrollable) return false
|
||||
val root = rootInActiveWindow ?: return false
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
package cn.ilapage.goauto.agent.automation
|
||||
|
||||
internal object PddLaunchFallback {
|
||||
fun open(preferDirect: Boolean, direct: () -> Boolean, browser: () -> Boolean): Boolean {
|
||||
if (preferDirect && runCatching(direct).getOrDefault(false)) return true
|
||||
return runCatching(browser).getOrDefault(false)
|
||||
}
|
||||
}
|
||||
@@ -31,11 +31,21 @@ object PddPageClassifier {
|
||||
}
|
||||
|
||||
class PddLinkLauncher(private val context: Context) {
|
||||
fun open(url: String): Boolean {
|
||||
fun open(url: String, preferDirect: Boolean = false): Boolean {
|
||||
val uri = runCatching { Uri.parse(url) }.getOrNull() ?: return false
|
||||
if (uri.scheme !in setOf("http", "https") || !isPddHost(uri.host) || uri.getQueryParameter("goods_id").isNullOrBlank()) {
|
||||
return false
|
||||
}
|
||||
return PddLaunchFallback.open(preferDirect, direct = {
|
||||
val direct = Intent(Intent.ACTION_VIEW, uri)
|
||||
.setPackage("com.xunmeng.pinduoduo")
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
|
||||
context.startActivity(direct)
|
||||
true
|
||||
}, browser = { openBrowser(uri) })
|
||||
}
|
||||
|
||||
private fun openBrowser(uri: Uri): Boolean {
|
||||
val base = Intent(Intent.ACTION_VIEW, uri).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
val browser = BROWSER_PACKAGES.firstOrNull { packageName ->
|
||||
runCatching { context.packageManager.getPackageInfo(packageName, 0) }.isSuccess
|
||||
|
||||
+50
-2
@@ -99,6 +99,8 @@ object PddSoldOutRecoveryDefaults {
|
||||
const val SETTLE_MILLIS = 2_000L
|
||||
}
|
||||
|
||||
data class PurchasePanelContext(val container: SnapshotNode, val exactColor: String)
|
||||
|
||||
data class ParsedPddScreen(
|
||||
val summary: ProductSummary,
|
||||
val dimensions: List<VisibleDimension>,
|
||||
@@ -128,6 +130,10 @@ data class ParsedPddScreen(
|
||||
val bottomPurchaseEntryCount: Int,
|
||||
val problem: PageProblem?,
|
||||
val sourceNodes: List<SnapshotNode>,
|
||||
val isPddPackage: Boolean,
|
||||
val hasCloseControl: Boolean = false,
|
||||
val hasPaymentArea: Boolean = false,
|
||||
val purchaseContextMatched: Boolean = false,
|
||||
) {
|
||||
fun isTransientSoldOut(
|
||||
exactText: String,
|
||||
@@ -162,6 +168,7 @@ data class ParsedPddScreen(
|
||||
}
|
||||
|
||||
object PddScreenParser {
|
||||
private const val PDD_PACKAGE = "com.xunmeng.pinduoduo"
|
||||
private data class SafeSpecEntry(val anchor: SnapshotNode, val clickTarget: SnapshotNode)
|
||||
private val pricePattern = Regex("[¥¥]\\s*([0-9]+(?:\\.[0-9]{1,2})?)")
|
||||
private val salesPattern = Regex("已拼\\s*[0-9]+(?:\\.[0-9]+)?\\s*(?:万|亿)?\\s*\\+?\\s*(?:件|人)?")
|
||||
@@ -179,7 +186,7 @@ object PddScreenParser {
|
||||
// purchase/order/payment controls must never become collection click targets.
|
||||
private val nonConfigurableClickDenylist = listOf("提交订单", "确认订单", "支付", "付款")
|
||||
|
||||
fun parse(snapshot: UiSnapshot, config: PddCollectorConfig, goodsId: String, evidence: PageEvidence?): ParsedPddScreen {
|
||||
fun parse(snapshot: UiSnapshot, config: PddCollectorConfig, goodsId: String, evidence: PageEvidence?, purchaseContext: PurchasePanelContext? = null): ParsedPddScreen {
|
||||
val visibleNodes = snapshot.nodes.filter { it.visible }
|
||||
val visible = visibleNodes.mapNotNull { node ->
|
||||
val descendants = descendants(node, visibleNodes)
|
||||
@@ -304,9 +311,24 @@ object PddScreenParser {
|
||||
// Opening evidence is intentionally independent from whether the current
|
||||
// viewport still exposes a clickable spec value. PDD may hide the only
|
||||
// selected value or restore a previously scrolled confirmation panel.
|
||||
val continuedPurchasePanel = snapshot.packageName == PDD_PACKAGE && problem == null &&
|
||||
purchaseContext != null && headedPanelScrollable != null &&
|
||||
headedPanelScrollable.path == purchaseContext.container.path &&
|
||||
headedPanelScrollable.className == purchaseContext.container.className &&
|
||||
kotlin.math.abs(headedPanelScrollable.bounds.left - purchaseContext.container.bounds.left) <= 32 &&
|
||||
kotlin.math.abs(headedPanelScrollable.bounds.right - purchaseContext.container.bounds.right) <= 32 &&
|
||||
minOf(headedPanelScrollable.bounds.bottom, purchaseContext.container.bounds.bottom) >
|
||||
maxOf(headedPanelScrollable.bounds.top, purchaseContext.container.bounds.top)
|
||||
val structuredSelectionPanel = headedPanelScrollable != null &&
|
||||
(headings.size >= 2 || (continuedPurchasePanel && headings.isNotEmpty())) &&
|
||||
dimensions.any { it.values.isNotEmpty() } && hasClose &&
|
||||
hasQuantityControls && hasPaymentArea && hasOrderSubmitAction
|
||||
// Some selected-spec panels omit both the "已选" prefix and a confirm
|
||||
// button. Keep the same structural evidence required for checkout.
|
||||
val specPanelType = when {
|
||||
quickConfirmationEvidence -> SpecPanelType.QUICK_CONFIRMATION
|
||||
orderConfirmationEvidence -> SpecPanelType.ORDER_CONFIRMATION
|
||||
structuredSelectionPanel -> SpecPanelType.NORMAL_SCROLLABLE
|
||||
panelScrollable != null && (hasSelectionSummary || hasSubmitHint || (hasPanelTitle && hasPanelAction)) -> SpecPanelType.NORMAL_SCROLLABLE
|
||||
hasSelectionSummary && hasPanelTitle && hasPanelAction -> SpecPanelType.NON_SCROLLABLE_CONFIRMATION
|
||||
// Some PDD builds expose the complete selector as non-scrollable
|
||||
@@ -320,6 +342,28 @@ object PddScreenParser {
|
||||
else -> SpecPanelType.UNKNOWN
|
||||
}
|
||||
val panelOpen = specPanelType != SpecPanelType.UNKNOWN
|
||||
val unprefixedSummary = if (structuredSelectionPanel) {
|
||||
val quantity = quantityInputs.single()
|
||||
val knownColors = (dimensions.filter { it.key == "color" }.flatMap { it.values }.map { it.text } +
|
||||
listOfNotNull(purchaseContext?.exactColor?.takeIf { continuedPurchasePanel && it.isNotBlank() })).distinct()
|
||||
val byPath = visibleNodes.associateBy { it.path }
|
||||
var region = quantity.parentPath?.let(byPath::get)
|
||||
var summary: String? = null
|
||||
while (region != null && region.bounds.bottom <= headedPanelScrollable!!.bounds.top) {
|
||||
val candidates = visible.filter { node ->
|
||||
node.path.startsWith("${region!!.path}/") && !node.clickable &&
|
||||
node.className?.endsWith("TextView") == true &&
|
||||
node.bounds.bottom <= quantity.bounds.top && node.label.length <= 160 &&
|
||||
knownColors.any { SpecValueNormalizer.summaryHasExactToken(node.label, it) }
|
||||
}.map { it.label }.distinct()
|
||||
if (candidates.isNotEmpty()) {
|
||||
summary = candidates.singleOrNull()
|
||||
break
|
||||
}
|
||||
region = region.parentPath?.let(byPath::get)
|
||||
}
|
||||
summary
|
||||
} else null
|
||||
val firstHeadingTop = headings.firstOrNull()?.bounds?.top ?: Int.MAX_VALUE
|
||||
val price = visible.asSequence()
|
||||
.filter { it.bounds.top < firstHeadingTop }
|
||||
@@ -371,7 +415,7 @@ object PddScreenParser {
|
||||
selectedSummary = labels.firstOrNull {
|
||||
val compact = it.replace(" ", "")
|
||||
textAliases.selection.selectedPrefixes.any(compact::startsWith)
|
||||
},
|
||||
} ?: unprefixedSummary,
|
||||
priceCent = price,
|
||||
specPanelOpen = panelOpen,
|
||||
specPanelType = specPanelType,
|
||||
@@ -390,6 +434,9 @@ object PddScreenParser {
|
||||
panelHeadingCount = headings.size,
|
||||
panelOptionCount = dimensions.sumOf { it.values.size },
|
||||
hasSelectionSummary = hasSelectionSummary,
|
||||
hasCloseControl = hasClose,
|
||||
hasPaymentArea = hasPaymentArea,
|
||||
purchaseContextMatched = continuedPurchasePanel,
|
||||
hasQuantityControls = hasQuantityControls,
|
||||
hasOrderSubmitAction = hasOrderSubmitAction,
|
||||
explicitSpecEntryCount = explicitSpecEntries.size,
|
||||
@@ -397,6 +444,7 @@ object PddScreenParser {
|
||||
bottomPurchaseEntryCount = bottomSpecEntries.size,
|
||||
problem = problem,
|
||||
sourceNodes = visibleNodes,
|
||||
isPddPackage = snapshot.packageName == PDD_PACKAGE,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+251
-12
@@ -42,7 +42,7 @@ class PurchaseLiveAutomation(
|
||||
* page. Only the selector's unique exact confirm button is clickable; an
|
||||
* order-submit or payment control can never satisfy this transition.
|
||||
*/
|
||||
fun advanceToOrderConfirmation() {
|
||||
fun advanceToOrderConfirmation(allowUnclassifiedPanelWithSelectionProof: Boolean = false) {
|
||||
var snapshot = driver.capture()
|
||||
pageProblem(snapshot)
|
||||
if (orderConfirmationReady(snapshot)) return
|
||||
@@ -50,7 +50,16 @@ class PurchaseLiveAutomation(
|
||||
fail("PURCHASE_SPEC_CONFIRMATION_NOT_READY", "当前不是拼多多规格页面,未创建订单")
|
||||
}
|
||||
val screen = PddScreenParser.parse(snapshot, PurchaseRehearsalExecutor.DEFAULT_COLLECTOR, "", null)
|
||||
if (screen.specPanelType !in setOf(SpecPanelType.NORMAL_SCROLLABLE, SpecPanelType.NON_SCROLLABLE_CONFIRMATION)) {
|
||||
val unclassifiedSelectionPanelWithProof = allowUnclassifiedPanelWithSelectionProof &&
|
||||
screen.specPanelType == SpecPanelType.UNKNOWN &&
|
||||
screen.priceCent != null &&
|
||||
snapshot.nodes.count {
|
||||
it.visible && it.enabled && it.className?.endsWith("EditText") == true && it.label.toLongOrNull() != null
|
||||
} == 1
|
||||
if (
|
||||
screen.specPanelType !in setOf(SpecPanelType.NORMAL_SCROLLABLE, SpecPanelType.NON_SCROLLABLE_CONFIRMATION) &&
|
||||
!unclassifiedSelectionPanelWithProof
|
||||
) {
|
||||
fail("PURCHASE_SPEC_CONFIRMATION_NOT_READY", "当前规格面板不能安全确认,未创建订单")
|
||||
}
|
||||
val aliases = PurchaseRehearsalExecutor.DEFAULT_COLLECTOR.textAliases.specPanel.confirmAliases
|
||||
@@ -86,12 +95,31 @@ class PurchaseLiveAutomation(
|
||||
var unchangedCount = 0
|
||||
for (attempt in 0 until 5) {
|
||||
pageProblem(snapshot)
|
||||
val entries = mergedDirect(snapshot.nodes.filter { it.visible && it.enabled && MASKED_PHONE.containsMatchIn(it.label) })
|
||||
if (entries.size == 1) {
|
||||
when (driver.tapPurchaseFresh(entries.single())) {
|
||||
val resolution = resolveAddressEntries(snapshot)
|
||||
if (resolution.addressCardCount > 1 || resolution.tapTargetCount > 1) {
|
||||
fail("PURCHASE_ADDRESS_ENTRY_AMBIGUOUS", "收货地址入口不唯一,未创建订单 [${resolution.diagnostic()}]")
|
||||
}
|
||||
if (resolution.target != null) {
|
||||
pause(ADDRESS_ENTRY_STABLE_INTERVAL_MS)
|
||||
val stableSnapshot = driver.capture()
|
||||
pageProblem(stableSnapshot)
|
||||
val stableResolution = resolveAddressEntries(stableSnapshot)
|
||||
if (stableResolution.addressCardCount > 1 || stableResolution.tapTargetCount > 1) {
|
||||
fail("PURCHASE_ADDRESS_ENTRY_AMBIGUOUS", "收货地址入口不唯一,未创建订单 [${stableResolution.diagnostic()}]")
|
||||
}
|
||||
if (stableResolution.target == null || stableResolution.fingerprint() != resolution.fingerprint()) {
|
||||
snapshot = stableSnapshot
|
||||
continue
|
||||
}
|
||||
val target = stableResolution.target
|
||||
val result = when (target.activation) {
|
||||
AddressEntryActivation.CLICK -> driver.clickAddressEntryFresh(target.node).result
|
||||
AddressEntryActivation.TAP -> driver.tapPurchaseFresh(target.node)
|
||||
}
|
||||
when (result) {
|
||||
FreshActionResult.SUCCESS -> Unit
|
||||
FreshActionResult.AMBIGUOUS -> fail("PURCHASE_ADDRESS_ENTRY_AMBIGUOUS", "收货地址入口不唯一,未创建订单")
|
||||
else -> fail("PURCHASE_ADDRESS_ENTRY_NOT_READY", "收货地址入口点击失败,未创建订单")
|
||||
FreshActionResult.AMBIGUOUS -> fail("PURCHASE_ADDRESS_ENTRY_AMBIGUOUS", "收货地址入口不唯一,未创建订单 [${stableResolution.diagnostic()}]")
|
||||
else -> fail("PURCHASE_ADDRESS_ENTRY_NOT_READY", "收货地址入口点击失败,未创建订单 [${stableResolution.diagnostic()}]")
|
||||
}
|
||||
pause(1_000)
|
||||
snapshot = waitFor("PURCHASE_ADDRESS_PANEL_TIMEOUT", "收货地址页面打开超时,未创建订单") {
|
||||
@@ -99,7 +127,9 @@ class PurchaseLiveAutomation(
|
||||
}
|
||||
return editAndVerifyAddress(snapshot, addressSuffix)
|
||||
}
|
||||
if (entries.size > 1) fail("PURCHASE_ADDRESS_ENTRY_AMBIGUOUS", "收货地址入口不唯一,未创建订单")
|
||||
if (resolution.phoneNodeCount > 0) {
|
||||
fail("PURCHASE_ADDRESS_ENTRY_NOT_READY", "收货地址入口无法安全定位,未创建订单 [${resolution.diagnostic()}]")
|
||||
}
|
||||
val panel = purchasePanelScrollTargets(snapshot)
|
||||
if (panel.size != 1) fail("PURCHASE_ADDRESS_ENTRY_AMBIGUOUS", "没有找到唯一的规格面板滚动区域,未创建订单")
|
||||
val signature = viewportSignature(snapshot, panel.single())
|
||||
@@ -115,6 +145,161 @@ class PurchaseLiveAutomation(
|
||||
fail("PURCHASE_ADDRESS_PANEL_TIMEOUT", "规格面板下拉后仍未找到收货地址,未创建订单")
|
||||
}
|
||||
|
||||
private enum class AddressEntryActivation { CLICK, TAP }
|
||||
|
||||
private data class AddressEntryTarget(
|
||||
val node: SnapshotNode,
|
||||
val activation: AddressEntryActivation,
|
||||
val cardKey: String,
|
||||
)
|
||||
|
||||
private data class AddressCardCandidate(
|
||||
val phone: SnapshotNode,
|
||||
val clickableCard: SnapshotNode?,
|
||||
val semanticKey: String?,
|
||||
)
|
||||
|
||||
private data class AddressEntryResolution(
|
||||
val phoneNodeCount: Int,
|
||||
val addressCardCount: Int,
|
||||
val tapTargetCount: Int,
|
||||
val target: AddressEntryTarget?,
|
||||
) {
|
||||
fun diagnostic(): String =
|
||||
"phoneNodes=$phoneNodeCount;addressCards=$addressCardCount;tapTargets=$tapTargetCount"
|
||||
|
||||
fun fingerprint(): String? = target?.let {
|
||||
listOf(
|
||||
it.activation.name,
|
||||
it.cardKey,
|
||||
it.node.label,
|
||||
it.node.className.orEmpty(),
|
||||
it.node.bounds.left,
|
||||
it.node.bounds.top,
|
||||
it.node.bounds.right,
|
||||
it.node.bounds.bottom,
|
||||
).joinToString(":")
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolveAddressEntries(snapshot: UiSnapshot): AddressEntryResolution {
|
||||
val phones = snapshot.nodes.filter {
|
||||
it.visible && it.enabled && maskedPhone(it.label) != null
|
||||
}.distinctBy { it.path }
|
||||
if (phones.isEmpty()) return AddressEntryResolution(0, 0, 0, null)
|
||||
|
||||
val byPath = snapshot.nodes.associateBy(SnapshotNode::path)
|
||||
val candidates = phones.map { phone ->
|
||||
val card = nearestClickableAncestor(phone, byPath)
|
||||
AddressCardCandidate(phone, card, card?.let { addressCardSemanticKey(it, snapshot) })
|
||||
}
|
||||
val groups = mutableListOf<MutableList<AddressCardCandidate>>()
|
||||
candidates.forEach { candidate ->
|
||||
val matching = groups.filter { group -> group.any { sameLogicalAddress(it, candidate) } }
|
||||
if (matching.isEmpty()) {
|
||||
groups += mutableListOf(candidate)
|
||||
} else {
|
||||
val primary = matching.first()
|
||||
primary += candidate
|
||||
matching.drop(1).forEach { duplicate ->
|
||||
primary += duplicate
|
||||
groups.remove(duplicate)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val targets = groups.mapIndexed { index, group ->
|
||||
val clickable = group.filter { it.clickableCard != null }
|
||||
.minWithOrNull(compareBy<AddressCardCandidate>({ nodeArea(requireNotNull(it.clickableCard)) }, { it.phone.path }))
|
||||
if (clickable != null) {
|
||||
AddressEntryTarget(clickable.phone, AddressEntryActivation.CLICK, logicalAddressKey(group, index))
|
||||
} else {
|
||||
AddressEntryTarget(group.minBy { nodeArea(it.phone) }.phone, AddressEntryActivation.TAP, logicalAddressKey(group, index))
|
||||
}
|
||||
}
|
||||
|
||||
val actionable = targets.filter { it.node.bounds.width >= 2 && it.node.bounds.height >= 2 }
|
||||
return AddressEntryResolution(
|
||||
phoneNodeCount = phones.size,
|
||||
addressCardCount = targets.size,
|
||||
tapTargetCount = actionable.size,
|
||||
target = actionable.singleOrNull(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun nearestClickableAncestor(
|
||||
source: SnapshotNode,
|
||||
byPath: Map<String, SnapshotNode>,
|
||||
): SnapshotNode? {
|
||||
var current: SnapshotNode? = source
|
||||
while (current != null) {
|
||||
if (current.clickable && current.visible && current.enabled) return current
|
||||
current = current.parentPath?.let(byPath::get)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun addressCardSemanticKey(card: SnapshotNode, snapshot: UiSnapshot): String? {
|
||||
val labels = snapshot.nodes.asSequence()
|
||||
.filter { it.visible && (it.path == card.path || it.path.startsWith("${card.path}/")) }
|
||||
.map { normalizeAddressSemantic(it.label) }
|
||||
.filter { it.isNotBlank() && maskedPhone(it) == null && it !in ADDRESS_CARD_GENERIC_LABELS }
|
||||
.distinct()
|
||||
.sorted()
|
||||
.toList()
|
||||
if (labels.none { it.length >= ADDRESS_SEMANTIC_MIN_LENGTH }) return null
|
||||
return labels.joinToString("|")
|
||||
}
|
||||
|
||||
private fun normalizeAddressSemantic(value: String): String = value
|
||||
.replace(Regex("\\s+"), "")
|
||||
.replace(",", ",")
|
||||
.replace(":", ":")
|
||||
.lowercase()
|
||||
|
||||
private fun sameLogicalAddress(first: AddressCardCandidate, second: AddressCardCandidate): Boolean {
|
||||
if (maskedPhone(first.phone.label) != maskedPhone(second.phone.label)) return false
|
||||
if (first.clickableCard?.path != null && first.clickableCard.path == second.clickableCard?.path) return true
|
||||
if (sameAddressRegion(first.phone, second.phone)) return true
|
||||
val firstCard = first.clickableCard
|
||||
val secondCard = second.clickableCard
|
||||
if (firstCard != null && secondCard != null && boundsRepresentSameRegion(firstCard.bounds, secondCard.bounds)) return true
|
||||
return first.semanticKey != null && first.semanticKey == second.semanticKey
|
||||
}
|
||||
|
||||
private fun boundsRepresentSameRegion(first: NodeBounds, second: NodeBounds): Boolean {
|
||||
val overlapWidth = (minOf(first.right, second.right) - maxOf(first.left, second.left)).coerceAtLeast(0)
|
||||
val overlapHeight = (minOf(first.bottom, second.bottom) - maxOf(first.top, second.top)).coerceAtLeast(0)
|
||||
val overlap = overlapWidth.toLong() * overlapHeight
|
||||
val smaller = minOf(first.width.toLong() * first.height, second.width.toLong() * second.height)
|
||||
return smaller > 0 && overlap * 100 >= smaller * ADDRESS_CARD_OVERLAP_PERCENT
|
||||
}
|
||||
|
||||
private fun logicalAddressKey(group: List<AddressCardCandidate>, index: Int): String {
|
||||
val semantic = group.mapNotNull(AddressCardCandidate::semanticKey).distinct().singleOrNull()
|
||||
return semantic?.let { "semantic:$it" } ?: "geometry:$index:${group.minOf { it.phone.bounds.top }}"
|
||||
}
|
||||
|
||||
private fun sameAddressRegion(first: SnapshotNode, second: SnapshotNode): Boolean {
|
||||
if (maskedPhone(first.label) != maskedPhone(second.label)) return false
|
||||
val overlapWidth = (minOf(first.bounds.right, second.bounds.right) - maxOf(first.bounds.left, second.bounds.left)).coerceAtLeast(0)
|
||||
val overlapHeight = (minOf(first.bounds.bottom, second.bounds.bottom) - maxOf(first.bounds.top, second.bounds.top)).coerceAtLeast(0)
|
||||
val overlap = overlapWidth.toLong() * overlapHeight
|
||||
val smaller = minOf(nodeArea(first), nodeArea(second))
|
||||
val containsCenter = (
|
||||
first.bounds.centerX in second.bounds.left..second.bounds.right &&
|
||||
first.bounds.centerY in second.bounds.top..second.bounds.bottom
|
||||
) || (
|
||||
second.bounds.centerX in first.bounds.left..first.bounds.right &&
|
||||
second.bounds.centerY in first.bounds.top..first.bounds.bottom
|
||||
)
|
||||
return (smaller > 0 && overlap * 100 >= smaller * 50) || containsCenter
|
||||
}
|
||||
|
||||
private fun maskedPhone(value: String): String? = MASKED_PHONE.find(value)?.value
|
||||
|
||||
private fun nodeArea(node: SnapshotNode): Long = node.bounds.width.toLong() * node.bounds.height
|
||||
|
||||
fun finalConfirmation(input: PurchaseExecutionInput, address: ShippingAddressProof): FinalConfirmationEvidence {
|
||||
val snapshot = driver.capture()
|
||||
pageProblem(snapshot)
|
||||
@@ -302,14 +487,62 @@ class PurchaseLiveAutomation(
|
||||
click(save.single(), "保存地址")
|
||||
val savedEvidence = waitForStableAddressEditorExit()
|
||||
if (!hasFinalSavedAddressEvidence(savedEvidence, expected, suffix)) {
|
||||
if (!driver.backPurchase()) fail("PURCHASE_ADDRESS_UPDATE_FAILED", "地址保存后无法返回订单页面,未创建订单")
|
||||
waitFor("PURCHASE_ADDRESS_SAVE_TIMEOUT", "地址保存后无法返回订单页面,未创建订单") {
|
||||
hasFinalSavedAddressEvidence(it, expected, suffix)
|
||||
if (isPurchaseConfirmationPanel(savedEvidence)) {
|
||||
restoreFinalEvidenceInCurrentPanel(savedEvidence, expected, suffix)
|
||||
} else {
|
||||
if (!driver.backPurchase()) fail("PURCHASE_ADDRESS_UPDATE_FAILED", "地址保存后无法返回订单页面,未创建订单")
|
||||
waitFor("PURCHASE_ADDRESS_SAVE_TIMEOUT", "地址保存后无法返回订单页面,未创建订单") {
|
||||
hasFinalSavedAddressEvidence(it, expected, suffix)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ShippingAddressProof(expected, suffix)
|
||||
}
|
||||
|
||||
private fun isPurchaseConfirmationPanel(snapshot: UiSnapshot): Boolean {
|
||||
if (snapshot.packageName != PDD_PACKAGE || shippingAddressEditors(snapshot).isNotEmpty()) return false
|
||||
val screen = PddScreenParser.parse(snapshot, PurchaseRehearsalExecutor.DEFAULT_COLLECTOR, "", null)
|
||||
return screen.specPanelType in setOf(
|
||||
SpecPanelType.NORMAL_SCROLLABLE,
|
||||
SpecPanelType.NON_SCROLLABLE_CONFIRMATION,
|
||||
SpecPanelType.ORDER_CONFIRMATION,
|
||||
)
|
||||
}
|
||||
|
||||
private fun restoreFinalEvidenceInCurrentPanel(
|
||||
initial: UiSnapshot,
|
||||
expected: String,
|
||||
suffix: String,
|
||||
) {
|
||||
var snapshot = initial
|
||||
var previousSignature: String? = null
|
||||
repeat(ADDRESS_CONFIRMATION_SCROLL_LIMIT) { attempt ->
|
||||
if (hasFinalSavedAddressEvidence(snapshot, expected, suffix)) return
|
||||
if (!isPurchaseConfirmationPanel(snapshot)) {
|
||||
fail("PURCHASE_ADDRESS_SAVE_TIMEOUT", "地址保存后采购面板发生变化,未创建订单")
|
||||
}
|
||||
val panels = purchasePanelScrollTargets(snapshot)
|
||||
if (panels.size != 1) {
|
||||
fail("PURCHASE_ADDRESS_ENTRY_AMBIGUOUS", "地址保存后没有找到唯一的规格面板滚动区域,未创建订单")
|
||||
}
|
||||
val panel = panels.single()
|
||||
val signature = viewportSignature(snapshot, panel)
|
||||
if (signature == previousSignature) {
|
||||
fail("PURCHASE_ADDRESS_SAVE_TIMEOUT", "地址保存后规格面板未找到完整订单确认信息,未创建订单")
|
||||
}
|
||||
previousSignature = signature
|
||||
if (!driver.swipePurchaseIn(panel, SwipeDirection.DOWN, 350)) {
|
||||
fail("PURCHASE_ADDRESS_SAVE_TIMEOUT", "地址保存后规格面板无法定位订单确认信息,未创建订单")
|
||||
}
|
||||
if (attempt < ADDRESS_CONFIRMATION_SCROLL_LIMIT - 1) pause(ADDRESS_CONFIRMATION_SCROLL_INTERVAL_MS)
|
||||
snapshot = driver.capture()
|
||||
pageProblem(snapshot)
|
||||
}
|
||||
if (!hasFinalSavedAddressEvidence(snapshot, expected, suffix)) {
|
||||
fail("PURCHASE_ADDRESS_SAVE_TIMEOUT", "地址保存后规格面板未找到完整订单确认信息,未创建订单")
|
||||
}
|
||||
}
|
||||
|
||||
private fun hasSavedAddressEvidence(snapshot: UiSnapshot, expected: String, suffix: String): Boolean {
|
||||
if (snapshot.packageName != PDD_PACKAGE || shippingAddressEditors(snapshot).isNotEmpty()) return false
|
||||
val visible = snapshot.nodes.filter { it.visible }
|
||||
@@ -372,7 +605,7 @@ class PurchaseLiveAutomation(
|
||||
private fun viewportSignature(snapshot: UiSnapshot, panel: SnapshotNode): String = snapshot.nodes
|
||||
.filter { node -> node.visible && inside(node.bounds, panel.bounds) }
|
||||
.joinToString("|") { node ->
|
||||
listOf(node.className.orEmpty(), node.bounds.left, node.bounds.top, node.bounds.right, node.bounds.bottom, node.clickable, node.scrollable).joinToString(":")
|
||||
listOf(node.path, node.className.orEmpty(), node.bounds.left, node.bounds.top, node.bounds.right, node.bounds.bottom, node.clickable, node.scrollable).joinToString(":")
|
||||
}
|
||||
|
||||
private fun inside(child: NodeBounds, parent: NodeBounds): Boolean =
|
||||
@@ -535,5 +768,11 @@ class PurchaseLiveAutomation(
|
||||
const val ORDER_RESULT_SAMPLE_INTERVAL_MS = 200L
|
||||
const val SPEC_CONFIRMATION_MAX_SAMPLES = 20
|
||||
const val SPEC_CONFIRMATION_SAMPLE_INTERVAL_MS = 100L
|
||||
const val ADDRESS_ENTRY_STABLE_INTERVAL_MS = 200L
|
||||
const val ADDRESS_CARD_OVERLAP_PERCENT = 70
|
||||
const val ADDRESS_SEMANTIC_MIN_LENGTH = 4
|
||||
val ADDRESS_CARD_GENERIC_LABELS = setOf("收货地址", "修改", "默认地址", "默认")
|
||||
const val ADDRESS_CONFIRMATION_SCROLL_LIMIT = 5
|
||||
const val ADDRESS_CONFIRMATION_SCROLL_INTERVAL_MS = 500L
|
||||
}
|
||||
}
|
||||
|
||||
+365
-34
@@ -10,6 +10,13 @@ interface PurchaseUiDriver {
|
||||
result = clickFresh(target),
|
||||
reason = FreshClickReason.UNKNOWN,
|
||||
)
|
||||
/**
|
||||
* Reacquires one address-entry node by its accessibility path and validates
|
||||
* its immutable snapshot traits before clicking its nearest clickable
|
||||
* ancestor. This is intentionally limited to the reversible transition
|
||||
* from order confirmation into address management.
|
||||
*/
|
||||
fun clickAddressEntryFresh(target: SnapshotNode): FreshClickOutcome = clickFreshDetailed(target)
|
||||
fun tapPurchaseFresh(target: SnapshotNode): FreshActionResult
|
||||
/**
|
||||
* Reacquires and center-taps a target that the purchase parser has already
|
||||
@@ -17,6 +24,12 @@ interface PurchaseUiDriver {
|
||||
* must still verify the semantic postcondition from a fresh snapshot.
|
||||
*/
|
||||
fun tapSpecFresh(target: SnapshotNode): FreshActionResult = FreshActionResult.FAILED
|
||||
/**
|
||||
* Reacquires a parsed spec option and swipes only its nearest horizontal
|
||||
* scrollable ancestor. There is deliberately no global fallback.
|
||||
*/
|
||||
fun swipeSpecRow(target: SnapshotNode, direction: SwipeDirection): Boolean = false
|
||||
fun specRowSwipeFailureReason(): String = "gestureFailed"
|
||||
fun inputFresh(target: SnapshotNode, value: String): FreshActionResult
|
||||
fun swipePurchase(direction: SwipeDirection, durationMs: Long): Boolean
|
||||
fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long): Boolean
|
||||
@@ -75,7 +88,10 @@ class PurchaseRehearsalExecutor(
|
||||
private val panelDiagnostic: (String) -> Unit = {},
|
||||
private val beforeOrderSubmit: (FinalConfirmationEvidence) -> Unit = { throw PurchaseLiveException("PURCHASE_MODE_NOT_ALLOWED", "当前执行器没有正式采购授权") },
|
||||
) {
|
||||
private var purchasePanelContext: PurchasePanelContext? = null
|
||||
|
||||
fun execute(input: PurchaseExecutionInput, rule: PurchaseRule, supportedCapabilities: Set<String>): PurchaseExecutionOutcome {
|
||||
purchasePanelContext = null
|
||||
validateBeforeDeviceAction(input, rule, supportedCapabilities)?.let { return it }
|
||||
var observedPrice: Long? = null
|
||||
var addressProof: ShippingAddressProof? = null
|
||||
@@ -83,10 +99,15 @@ class PurchaseRehearsalExecutor(
|
||||
val specSelectionProofs = mutableMapOf<String, ExactSpecSelectionProof>()
|
||||
val live = PurchaseLiveAutomation(driver, pause)
|
||||
for (action in rule.actions) {
|
||||
// spec_probe already opened this task's URL and the server reserves
|
||||
// the device while matching. Skip the whole navigation action,
|
||||
// including its configured wait/swipe hooks, in phase two.
|
||||
if (input.phase == "purchase" && action.type == PurchaseActionType.OPEN_PRODUCT) continue
|
||||
// The immediate phase-two handoff can reuse the PDD page retained by
|
||||
// spec_probe. A later manual retry may start from Agent (or another
|
||||
// unrelated screen), so only skip navigation when a fresh snapshot
|
||||
// still carries safe PDD product/spec evidence.
|
||||
if (
|
||||
input.phase == "purchase" &&
|
||||
action.type == PurchaseActionType.OPEN_PRODUCT &&
|
||||
canReuseCurrentProduct(input)
|
||||
) continue
|
||||
stepChanged(action.type.wireName)
|
||||
val failure = when (action.type) {
|
||||
PurchaseActionType.OPEN_PRODUCT -> openProduct(input, action)
|
||||
@@ -105,7 +126,7 @@ class PurchaseRehearsalExecutor(
|
||||
null
|
||||
}
|
||||
PurchaseActionType.UPDATE_SHIPPING_ADDRESS -> try {
|
||||
live.advanceToOrderConfirmation()
|
||||
live.advanceToOrderConfirmation(hasAllRecordedSelectionProofs(input, specSelectionProofs))
|
||||
addressProof = live.updateShippingAddress(input.addressSuffix)
|
||||
null
|
||||
} catch (error: PurchaseLiveException) {
|
||||
@@ -157,6 +178,11 @@ class PurchaseRehearsalExecutor(
|
||||
else failure("PURCHASE_RULE_INVALID", "正式采购规则缺少核单动作")
|
||||
}
|
||||
|
||||
private fun canReuseCurrentProduct(input: PurchaseExecutionInput): Boolean =
|
||||
currentScreen(input).let { screen ->
|
||||
screen.problem == null && screen.hasPurchaseProductEvidence()
|
||||
}
|
||||
|
||||
private fun validateBeforeDeviceAction(
|
||||
input: PurchaseExecutionInput,
|
||||
rule: PurchaseRule,
|
||||
@@ -208,6 +234,7 @@ class PurchaseRehearsalExecutor(
|
||||
}
|
||||
|
||||
private fun openProduct(input: PurchaseExecutionInput, action: PurchaseAction): PurchaseExecutionOutcome? {
|
||||
purchasePanelContext = null
|
||||
if (!openLink(input.url)) return failure("PDD_LINK_INVALID", "任务中的 PDD 链接无法打开")
|
||||
val aliases = action.textAliases ?: listOf("打开拼多多APP", "打开拼多多 App", "打开")
|
||||
var clickAttempted = false
|
||||
@@ -222,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
|
||||
}
|
||||
@@ -268,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
|
||||
@@ -314,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) }
|
||||
@@ -332,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)}]")
|
||||
@@ -358,11 +430,58 @@ 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
|
||||
}
|
||||
|
||||
// Give PDD's bottom-sheet re-render a short settle window after an
|
||||
// accessibility click that produced no observable transition, then
|
||||
// reacquire the target before the single controlled gesture retry.
|
||||
if (!wait.changed) {
|
||||
pause(SPEC_ENTRY_GESTURE_RETRY_SETTLE_MILLIS)
|
||||
val refreshed = currentScreen(input)
|
||||
if (!refreshed.isPddPackage || !refreshed.pageEvidenceMatched || refreshed.specPanelOpen) {
|
||||
return failure(SPEC_PANEL_EVIDENCE_NOT_MATCHED, "规格入口点击后页面已变化,但规格面板强证据不足 [${panelEvidence(refreshed)}]")
|
||||
}
|
||||
val refreshedCandidates = listOfNotNull(
|
||||
refreshed.specEntry?.let { anchor -> anchor to (refreshed.specEntryClickTarget ?: anchor) },
|
||||
refreshed.quickConfirmationEntry?.let { it to it },
|
||||
).distinctBy { it.second.path }
|
||||
.let { candidatesToFilter ->
|
||||
action.textAliases?.let { aliases ->
|
||||
candidatesToFilter.filter { (anchor, _) -> specEntryMatchesAliases(refreshed, anchor, aliases) }
|
||||
} ?: candidatesToFilter
|
||||
}
|
||||
if (refreshedCandidates.size != 1) {
|
||||
return failure(SPEC_ENTRY_CLICK_FAILED, "gesture_failed_after_action_click_no_effect")
|
||||
}
|
||||
target = refreshedCandidates.single().second
|
||||
gestureBaseline = refreshed
|
||||
}
|
||||
|
||||
when (driver.tapSpecFresh(requireNotNull(target))) {
|
||||
@@ -376,7 +495,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) {
|
||||
@@ -450,6 +569,7 @@ class PurchaseRehearsalExecutor(
|
||||
private fun specEntryEvidence(screen: ParsedPddScreen, candidateCount: Int, entryReadyWaitPolls: Int = 0): String =
|
||||
"specEntryCandidates=$candidateCount;explicit=${screen.explicitSpecEntryCount};" +
|
||||
"nested=${screen.nestedSpecEntryCount};bottomPurchase=${screen.bottomPurchaseEntryCount};" +
|
||||
"source=${screen.specEntrySource ?: "none"};" +
|
||||
"panelAlreadyOpen=${screen.specPanelOpen};reviewPage=${screen.reviewPageOpen};" +
|
||||
"pageEvidence=${screen.pageEvidenceMatched};entryReadyWaitPolls=$entryReadyWaitPolls;" +
|
||||
"entryReadyWaitMillis=${entryReadyWaitPolls * SPEC_ENTRY_READY_POLL_MILLIS}"
|
||||
@@ -590,10 +710,16 @@ class PurchaseRehearsalExecutor(
|
||||
|
||||
private fun isExactSpecSelected(screen: ParsedPddScreen, dimension: String, target: String): Boolean {
|
||||
val candidates = screen.dimensions.filter { it.key == dimension }.flatMap { it.values }
|
||||
if (candidates.any { it.text != target && (it.node.selected || it.node.checked) }) return false
|
||||
if (candidates.any { it.text == target && (it.node.selected || it.node.checked) }) return true
|
||||
if (screen.specPanelOpen && fullSummaryTargetMatches(screen.selectedSummary, target)) return true
|
||||
return summarySelectionMatches(screen.selectedSummary, dimension, target, candidates)
|
||||
}
|
||||
|
||||
private fun fullSummaryTargetMatches(summary: String?, target: String): Boolean =
|
||||
summary != null && Regex("(^|[\\s,,、/|;;::])" + Regex.escape(target) + "($|[\\s,,、/|;;::])")
|
||||
.containsMatchIn(summary)
|
||||
|
||||
private fun summarySelectionMatches(
|
||||
summary: String?,
|
||||
dimension: String,
|
||||
@@ -625,7 +751,8 @@ class PurchaseRehearsalExecutor(
|
||||
if (dimension == "size") SpecValueNormalizer.primarySizeToken(candidate.text) == token else candidate.text == token
|
||||
}
|
||||
return ExactSpecSelectionProof(dimension, target, screen.specPanelType).takeIf {
|
||||
tokenCandidates.size == 1 && tokenCandidates.single().text == target
|
||||
(tokenCandidates.size == 1 && tokenCandidates.single().text == target) ||
|
||||
(tokenCandidates.isEmpty() && screen.specPanelOpen && fullSummaryTargetMatches(screen.selectedSummary, target))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -634,7 +761,8 @@ class PurchaseRehearsalExecutor(
|
||||
val reason: String,
|
||||
val summaryTokenMatched: Boolean,
|
||||
val candidateCount: Int,
|
||||
val proofPresent: Boolean,
|
||||
val proofRecorded: Boolean,
|
||||
val proofUsable: Boolean,
|
||||
val targetMatchCount: Int = 0,
|
||||
val relocationAttempted: Boolean = false,
|
||||
val relocationSwipes: Int = 0,
|
||||
@@ -648,21 +776,25 @@ class PurchaseRehearsalExecutor(
|
||||
): FinalSpecVerification {
|
||||
val candidates = screen.dimensions.filter { it.key == dimension }.flatMap { it.values }
|
||||
val matchingNodes = candidates.filter { it.text == target }
|
||||
val proofPresent = proof?.dimension == dimension && proof.target == target && proof.panelType == screen.specPanelType
|
||||
val proofRecorded = proof?.dimension == dimension && proof.target == target
|
||||
val proofUsable = proofRecorded && (
|
||||
proof?.panelType == screen.specPanelType ||
|
||||
(screen.specPanelType == SpecPanelType.UNKNOWN && screen.isPddPackage && screen.rootAvailable)
|
||||
)
|
||||
if (matchingNodes.size == 1 && (matchingNodes.single().node.selected || matchingNodes.single().node.checked)) {
|
||||
return FinalSpecVerification(true, "selected_node", false, candidates.size, proofPresent, matchingNodes.size)
|
||||
return FinalSpecVerification(true, "selected_node", false, candidates.size, proofRecorded, proofUsable, matchingNodes.size)
|
||||
}
|
||||
if (candidates.any { it.text != target && (it.node.selected || it.node.checked) }) {
|
||||
return FinalSpecVerification(false, "visible_selected_conflict", false, candidates.size, proofPresent, matchingNodes.size)
|
||||
return FinalSpecVerification(false, "visible_selected_conflict", false, candidates.size, proofRecorded, proofUsable, matchingNodes.size)
|
||||
}
|
||||
|
||||
val token = if (dimension == "size") SpecValueNormalizer.primarySizeToken(target) else target.trim()
|
||||
val summaryTokenMatched = !token.isNullOrBlank() && SpecValueNormalizer.summaryHasExactToken(screen.selectedSummary, token)
|
||||
if (!summaryTokenMatched) {
|
||||
if (screen.selectedSummary == null && screen.specPanelOpen && proofPresent) {
|
||||
return FinalSpecVerification(true, "attempt_selection_state", false, candidates.size, true, matchingNodes.size)
|
||||
if (screen.selectedSummary == null && proofUsable) {
|
||||
return FinalSpecVerification(true, "attempt_selection_state", false, candidates.size, true, true, matchingNodes.size)
|
||||
}
|
||||
return FinalSpecVerification(false, "summary_token_missing", false, candidates.size, proofPresent, matchingNodes.size)
|
||||
return FinalSpecVerification(false, "summary_token_missing", false, candidates.size, proofRecorded, proofUsable, matchingNodes.size)
|
||||
}
|
||||
// PDD can rerender or scroll the previously selected dimension out of
|
||||
// the current viewport while the selected summary remains visible. A
|
||||
@@ -673,15 +805,15 @@ class PurchaseRehearsalExecutor(
|
||||
if (dimension == "size") SpecValueNormalizer.primarySizeToken(candidate.text) == token else candidate.text == token
|
||||
}
|
||||
if (visibleTokenMatches.size == 1 && visibleTokenMatches.single().text == target) {
|
||||
return FinalSpecVerification(true, "visible_candidate", true, candidates.size, proofPresent, matchingNodes.size)
|
||||
return FinalSpecVerification(true, "visible_candidate", true, candidates.size, proofRecorded, proofUsable, matchingNodes.size)
|
||||
}
|
||||
if (visibleTokenMatches.isNotEmpty()) {
|
||||
return FinalSpecVerification(false, "visible_candidate_conflict", true, candidates.size, proofPresent, matchingNodes.size)
|
||||
return FinalSpecVerification(false, "visible_candidate_conflict", true, candidates.size, proofRecorded, proofUsable, matchingNodes.size)
|
||||
}
|
||||
return if (proofPresent) {
|
||||
FinalSpecVerification(true, "attempt_selection_proof", true, candidates.size, true, matchingNodes.size)
|
||||
return if (proofUsable) {
|
||||
FinalSpecVerification(true, "attempt_selection_proof", true, candidates.size, true, true, matchingNodes.size)
|
||||
} else {
|
||||
FinalSpecVerification(false, "selection_proof_missing", true, candidates.size, false, matchingNodes.size)
|
||||
FinalSpecVerification(false, "selection_proof_missing", true, candidates.size, proofRecorded, false, matchingNodes.size)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -693,7 +825,7 @@ class PurchaseRehearsalExecutor(
|
||||
): FinalSpecVerification {
|
||||
var screen = currentScreen(input)
|
||||
screen.problem?.let {
|
||||
return FinalSpecVerification(false, it.code, false, 0, proof != null)
|
||||
return FinalSpecVerification(false, it.code, false, 0, proof != null, false)
|
||||
}
|
||||
var verification = verifyExactSpecSelection(screen, dimension, target, proof)
|
||||
if (verification.confirmed || verification.targetMatchCount > 0 || verification.reason == "visible_selected_conflict") return verification
|
||||
@@ -759,13 +891,27 @@ class PurchaseRehearsalExecutor(
|
||||
* Every gesture is followed by a fresh parse, and unchanged evidence ends
|
||||
* that direction early.
|
||||
*/
|
||||
private fun panelRecognitionFailure(screen: ParsedPddScreen, dimension: String): PurchaseExecutionOutcome =
|
||||
failure("RULE_NOT_MATCHED",
|
||||
"未能识别当前商品规格面板 [dimension=$dimension;panel=${screen.specPanelType};headings=${screen.panelHeadingCount};options=${screen.panelOptionCount};summary=${screen.hasSelectionSummary};quantity=${screen.hasQuantityControls};orderAction=${screen.hasOrderSubmitAction};close=${screen.hasCloseControl};payment=${screen.hasPaymentArea};contextMatched=${screen.purchaseContextMatched}]")
|
||||
|
||||
private fun stableSelectionScreen(input: PurchaseExecutionInput): ParsedPddScreen {
|
||||
var screen = currentScreen(input)
|
||||
repeat(2) {
|
||||
if (screen.specPanelOpen || screen.problem != null) return screen
|
||||
pause(SPEC_SELECTION_POLL_MILLIS)
|
||||
screen = currentScreen(input)
|
||||
}
|
||||
return screen
|
||||
}
|
||||
|
||||
private fun locateExactSpec(input: PurchaseExecutionInput, dimension: String, target: String): SpecLookup {
|
||||
data class Inspection(val lookup: SpecLookup?, val signature: String, val container: SnapshotNode?)
|
||||
fun inspect(): Inspection {
|
||||
val screen = currentScreen(input)
|
||||
val screen = stableSelectionScreen(input)
|
||||
screen.problem?.let { return Inspection(SpecLookup(failure = failure(it.code, it.message)), "", null) }
|
||||
if (!screen.specPanelOpen) {
|
||||
return Inspection(SpecLookup(failure = failure("RULE_NOT_MATCHED", "商品规格面板已经关闭")), "", null)
|
||||
return Inspection(SpecLookup(failure = panelRecognitionFailure(screen, dimension)), "", null)
|
||||
}
|
||||
val dimensionValues = screen.dimensions.filter { it.key == dimension }.flatMap { it.values }
|
||||
val exact = dimensionValues.filter { it.text == target }
|
||||
@@ -796,9 +942,171 @@ class PurchaseRehearsalExecutor(
|
||||
currentSignature = inspected.signature
|
||||
}
|
||||
}
|
||||
if (dimension == "color" || dimension == "size") {
|
||||
locateExactDimensionHorizontally(input, dimension, target)?.let { return it }
|
||||
}
|
||||
return SpecLookup(failure = failure(SPEC_TARGET_NOT_VISIBLE, "有界搜索后未找到精确规格"))
|
||||
}
|
||||
|
||||
/**
|
||||
* Compatibility fallback for horizontally paged PDD color or size rows. The
|
||||
* established visible/vertical lookup above remains the primary path, so
|
||||
* already successful purchases never enter this branch.
|
||||
*/
|
||||
private fun locateExactDimensionHorizontally(
|
||||
input: PurchaseExecutionInput,
|
||||
dimension: String,
|
||||
target: String,
|
||||
): SpecLookup? {
|
||||
val swipeLimit = DEFAULT_COLLECTOR.limits.getValue("specHorizontalSwipes")
|
||||
val stableReadLimit = DEFAULT_COLLECTOR.limits.getValue("stableEdgeReads")
|
||||
data class ColorInspection(
|
||||
val lookup: SpecLookup?,
|
||||
val screen: ParsedPddScreen,
|
||||
val rows: List<List<VisibleSpecValue>>,
|
||||
val signature: String,
|
||||
)
|
||||
|
||||
fun inspect(): ColorInspection {
|
||||
val screen = stableSelectionScreen(input)
|
||||
screen.problem?.let {
|
||||
return ColorInspection(SpecLookup(failure = failure(it.code, it.message)), screen, emptyList(), "")
|
||||
}
|
||||
if (!screen.specPanelOpen) {
|
||||
return ColorInspection(
|
||||
SpecLookup(failure = panelRecognitionFailure(screen, dimension)),
|
||||
screen,
|
||||
emptyList(),
|
||||
"",
|
||||
)
|
||||
}
|
||||
val values = screen.dimensions.filter { it.key == dimension }.flatMap { it.values }
|
||||
val exact = values.filter { it.text == target }
|
||||
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, "精确规格当前不可安全点击"))
|
||||
else -> null
|
||||
}
|
||||
val rows = specValueRows(values)
|
||||
val signature = screen.dimensions.flatMap { it.values }.joinToString("|") { value ->
|
||||
val bounds = value.node.bounds
|
||||
"${value.text}:${bounds.left},${bounds.top},${bounds.right},${bounds.bottom}:${value.available}"
|
||||
}
|
||||
return ColorInspection(lookup, screen, rows, signature)
|
||||
}
|
||||
|
||||
var inspected = inspect()
|
||||
inspected.lookup?.let { return it }
|
||||
var verticalSwipes = 0
|
||||
var horizontalSwipes = 0
|
||||
var lastHorizontalFailure = "none"
|
||||
var termination = "budgetExhausted"
|
||||
val horizontalBudget = mutableMapOf(SwipeDirection.RIGHT to swipeLimit, SwipeDirection.LEFT to swipeLimit)
|
||||
|
||||
fun searchVisibleRows(): SpecLookup? {
|
||||
for (direction in listOf(SwipeDirection.RIGHT, SwipeDirection.LEFT)) {
|
||||
var stableReads = 0
|
||||
var previousSignature = inspected.signature
|
||||
while (horizontalBudget.getValue(direction) > 0) {
|
||||
// Always reacquire a supported row; never fall back to a stale node.
|
||||
val anchor = horizontalSpecRow(inspected.screen, inspected.rows)?.firstOrNull()?.node ?: break
|
||||
horizontalBudget[direction] = horizontalBudget.getValue(direction) - 1
|
||||
if (!driver.swipeSpecRow(anchor, direction)) {
|
||||
lastHorizontalFailure = driver.specRowSwipeFailureReason()
|
||||
// A failed gesture is not evidence that the target is absent.
|
||||
inspected = inspect()
|
||||
inspected.lookup?.let { return it }
|
||||
break
|
||||
}
|
||||
horizontalSwipes++
|
||||
pause(300)
|
||||
inspected = inspect()
|
||||
inspected.lookup?.let { return it }
|
||||
stableReads = if (previousSignature == inspected.signature) stableReads + 1 else 0
|
||||
previousSignature = inspected.signature
|
||||
if (stableReads >= stableReadLimit) break
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// The original fast path remains first. Recover both ends of a vertical
|
||||
// grid, inspecting each viewport for the exact target and supported rows.
|
||||
val verticalLimit = DEFAULT_COLLECTOR.limits.getValue("specVerticalSwipes")
|
||||
for (direction in listOf(SwipeDirection.DOWN, SwipeDirection.UP)) {
|
||||
var stableReads = 0
|
||||
var previousSignature = inspected.signature
|
||||
for (attempt in 0..verticalLimit) {
|
||||
searchVisibleRows()?.let { return it }
|
||||
if (attempt == verticalLimit) {
|
||||
termination = "budgetExhausted"
|
||||
break
|
||||
}
|
||||
val container = inspected.screen.specPanelContainer
|
||||
if (container == null) {
|
||||
termination = "verticalContainerMissing"
|
||||
break
|
||||
}
|
||||
if (!driver.swipePurchaseIn(container, direction, 350)) {
|
||||
termination = "verticalSwipeFailed"
|
||||
break
|
||||
}
|
||||
verticalSwipes++
|
||||
pause(300)
|
||||
inspected = inspect()
|
||||
inspected.lookup?.let { return it }
|
||||
stableReads = if (previousSignature == inspected.signature) stableReads + 1 else 0
|
||||
previousSignature = inspected.signature
|
||||
if (stableReads >= stableReadLimit) {
|
||||
searchVisibleRows()?.let { return it }
|
||||
termination = "stableViewport"
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return SpecLookup(failure = failure(
|
||||
SPEC_TARGET_NOT_VISIBLE,
|
||||
"有界搜索后未找到精确规格 [dimension=$dimension;horizontalSwipes=$horizontalSwipes;verticalRecoverySwipes=$verticalSwipes;visibleCandidates=${inspected.rows.flatten().size};reason=$termination;horizontalFailure=$lastHorizontalFailure]",
|
||||
))
|
||||
}
|
||||
|
||||
private fun horizontalSpecRow(
|
||||
screen: ParsedPddScreen,
|
||||
rows: List<List<VisibleSpecValue>>,
|
||||
): List<VisibleSpecValue>? = rows.firstOrNull { row ->
|
||||
row.isNotEmpty() && row.all { value -> hasDedicatedHorizontalAncestor(screen, value.node) }
|
||||
}
|
||||
|
||||
private fun hasDedicatedHorizontalAncestor(screen: ParsedPddScreen, source: SnapshotNode): Boolean {
|
||||
val byPath = screen.sourceNodes.associateBy(SnapshotNode::path)
|
||||
var current: SnapshotNode? = source
|
||||
while (current != null) {
|
||||
if (current.path == screen.specPanelContainer?.path) return false
|
||||
if (current.scrollable && current.bounds.width > current.bounds.height) return true
|
||||
current = current.parentPath?.let(byPath::get)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun specValueRows(values: List<VisibleSpecValue>): List<List<VisibleSpecValue>> {
|
||||
val sorted = values.sortedWith(compareBy({ it.node.bounds.centerY }, { it.node.bounds.left }))
|
||||
val rows = mutableListOf<MutableList<VisibleSpecValue>>()
|
||||
val centers = mutableListOf<Int>()
|
||||
sorted.forEach { value ->
|
||||
val tolerance = (value.node.bounds.height / 2).coerceIn(24, 80)
|
||||
val index = centers.indexOfFirst { kotlin.math.abs(value.node.bounds.centerY - it) <= tolerance }
|
||||
if (index < 0) {
|
||||
rows += mutableListOf(value)
|
||||
centers += value.node.bounds.centerY
|
||||
} else {
|
||||
rows[index] += value
|
||||
centers[index] = rows[index].map { it.node.bounds.centerY }.average().toInt()
|
||||
}
|
||||
}
|
||||
return rows.onEach { row -> row.sortBy { it.node.bounds.left } }
|
||||
}
|
||||
|
||||
private fun setQuantity(quantity: Long): PurchaseExecutionOutcome? {
|
||||
val snapshot = driver.capture()
|
||||
pageProblem(snapshot)?.let { return it }
|
||||
@@ -849,7 +1157,7 @@ class PurchaseRehearsalExecutor(
|
||||
val diagnostic = "dimension=$dimension,reason=${verification.reason},panel=$panel," +
|
||||
"summary=${screen.selectedSummary != null},tokenMatched=${verification.summaryTokenMatched}," +
|
||||
"candidates=${verification.candidateCount},targetMatches=${verification.targetMatchCount}," +
|
||||
"proof=${verification.proofPresent}," +
|
||||
"proofRecorded=${verification.proofRecorded},proofUsable=${verification.proofUsable}," +
|
||||
"relocated=${verification.relocationAttempted},relocationSwipes=${verification.relocationSwipes}"
|
||||
return failure(SPEC_SELECTION_UNCONFIRMED, "最终规格复核未能确认精确选中状态 [$diagnostic]")
|
||||
}
|
||||
@@ -864,6 +1172,18 @@ class PurchaseRehearsalExecutor(
|
||||
return null
|
||||
}
|
||||
|
||||
private fun hasAllRecordedSelectionProofs(
|
||||
input: PurchaseExecutionInput,
|
||||
proofs: Map<String, ExactSpecSelectionProof>,
|
||||
): Boolean {
|
||||
val required = listOf("color" to input.mappedColor, "size" to input.mappedSize)
|
||||
.filter { it.second.isNotBlank() }
|
||||
.map { (dimension, rawTarget) -> dimension to normalizedTarget(dimension, rawTarget) }
|
||||
return required.isNotEmpty() && required.all { (dimension, target) ->
|
||||
target != null && proofs[dimension]?.let { it.dimension == dimension && it.target == target } == true
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyPostAction(input: PurchaseExecutionInput, action: PurchaseAction): PurchaseExecutionOutcome? {
|
||||
if (action.waitAfterMs > 0) pause(action.waitAfterMs)
|
||||
action.swipeAfter?.let { swipe ->
|
||||
@@ -898,8 +1218,16 @@ class PurchaseRehearsalExecutor(
|
||||
return normalized.takeIf(SpecValueNormalizer::isSafeSize)
|
||||
}
|
||||
|
||||
private fun currentScreen(input: PurchaseExecutionInput): ParsedPddScreen =
|
||||
PddScreenParser.parse(driver.capture(), DEFAULT_COLLECTOR, input.goodsId, null)
|
||||
private fun currentScreen(input: PurchaseExecutionInput): ParsedPddScreen {
|
||||
val snapshot = driver.capture()
|
||||
val screen = PddScreenParser.parse(snapshot, DEFAULT_COLLECTOR, input.goodsId, null, purchasePanelContext)
|
||||
purchasePanelContext = if (screen.isPddPackage && screen.problem == null && screen.specPanelOpen) {
|
||||
screen.specPanelContainer?.let {
|
||||
PurchasePanelContext(it, normalizedTarget("color", input.mappedColor).orEmpty())
|
||||
}
|
||||
} else null
|
||||
return screen
|
||||
}
|
||||
|
||||
private fun pageProblem(snapshot: UiSnapshot): PurchaseExecutionOutcome? =
|
||||
PddPageClassifier.classify(snapshot.packageName, snapshot.activityName, snapshot.nodes.filter { it.visible }.map { it.label })
|
||||
@@ -926,11 +1254,14 @@ 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_GESTURE_RETRY_SETTLE_MILLIS = 500L
|
||||
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
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package cn.ilapage.goauto.agent.automation
|
||||
|
||||
internal data class PurchaseScrollCandidate(
|
||||
val path: String,
|
||||
val className: String?,
|
||||
val bounds: NodeBounds,
|
||||
)
|
||||
|
||||
internal object PurchaseScrollLocator {
|
||||
fun locate(target: PurchaseScrollCandidate, candidates: List<PurchaseScrollCandidate>): PurchaseScrollCandidate? {
|
||||
val compatible = candidates.filter {
|
||||
it.className == target.className && boundsMatch(it.bounds, target.bounds)
|
||||
}
|
||||
val samePath = compatible.filter { it.path == target.path }
|
||||
if (samePath.isNotEmpty()) return samePath.singleOrNull()
|
||||
return compatible.singleOrNull()
|
||||
}
|
||||
|
||||
private fun boundsMatch(a: NodeBounds, b: NodeBounds): Boolean =
|
||||
kotlin.math.abs(a.left - b.left) <= 32 &&
|
||||
kotlin.math.abs(a.top - b.top) <= 32 &&
|
||||
kotlin.math.abs(a.right - b.right) <= 32 &&
|
||||
kotlin.math.abs(a.bottom - b.bottom) <= 32
|
||||
}
|
||||
@@ -73,6 +73,7 @@ data class CurrentPageIdentity(
|
||||
data class PurchaseAgentTask(
|
||||
val taskId: Long,
|
||||
val taskAttemptId: String,
|
||||
val attemptNumber: Int,
|
||||
val phase: String,
|
||||
val executionMode: String,
|
||||
val status: String,
|
||||
@@ -589,6 +590,7 @@ class AgentApiClient(private val serverUrl: String) {
|
||||
private fun purchaseTask(data: JSONObject) = PurchaseAgentTask(
|
||||
taskId = data.getLong("taskId"),
|
||||
taskAttemptId = data.optString("taskAttemptId"),
|
||||
attemptNumber = data.optInt("attemptNumber", 1),
|
||||
phase = data.optString("phase"),
|
||||
executionMode = data.getString("executionMode"),
|
||||
status = data.getString("status"),
|
||||
|
||||
+33
-5
@@ -1,7 +1,7 @@
|
||||
package cn.ilapage.goauto.agent.persistence
|
||||
|
||||
internal object AgentDiagnosticSchema {
|
||||
const val VERSION = 2
|
||||
const val VERSION = 3
|
||||
|
||||
val colorDiagnosticColumns = linkedMapOf(
|
||||
"color_row_count" to "INTEGER",
|
||||
@@ -14,6 +14,15 @@ internal object AgentDiagnosticSchema {
|
||||
"horizontal_swipe_count" to "INTEGER",
|
||||
)
|
||||
|
||||
val purchaseFailureColumns = linkedMapOf(
|
||||
"attempt_id" to "TEXT",
|
||||
"device_id" to "INTEGER",
|
||||
"error_code" to "TEXT",
|
||||
"failure_message" to "TEXT",
|
||||
"page_evidence" to "TEXT",
|
||||
"last_step" to "TEXT",
|
||||
)
|
||||
|
||||
val createTableSql =
|
||||
"""CREATE TABLE agent_diagnostic (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
@@ -42,14 +51,33 @@ internal object AgentDiagnosticSchema {
|
||||
initial_selected_size_count INTEGER,
|
||||
selected_summary_present INTEGER,
|
||||
horizontal_swipe_count INTEGER,
|
||||
attempt_id TEXT,
|
||||
device_id INTEGER,
|
||||
error_code TEXT,
|
||||
failure_message TEXT,
|
||||
page_evidence TEXT,
|
||||
last_step TEXT,
|
||||
agent_version TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
)""".trimIndent()
|
||||
|
||||
fun v2MigrationStatements(oldVersion: Int, newVersion: Int, existingColumns: Set<String>): List<String> {
|
||||
if (oldVersion >= 2 || newVersion < 2) return emptyList()
|
||||
return colorDiagnosticColumns.mapNotNull { (name, definition) ->
|
||||
if (name in existingColumns) null else "ALTER TABLE agent_diagnostic ADD COLUMN $name $definition"
|
||||
fun migrationStatements(oldVersion: Int, newVersion: Int, existingColumns: Set<String>): List<String> {
|
||||
if (oldVersion >= newVersion) return emptyList()
|
||||
val statements = mutableListOf<String>()
|
||||
if (oldVersion < 2 && newVersion >= 2) {
|
||||
colorDiagnosticColumns.forEach { (name, definition) ->
|
||||
if (name !in existingColumns) statements += "ALTER TABLE agent_diagnostic ADD COLUMN $name $definition"
|
||||
}
|
||||
}
|
||||
if (oldVersion < 3 && newVersion >= 3) {
|
||||
purchaseFailureColumns.forEach { (name, definition) ->
|
||||
if (name !in existingColumns) statements += "ALTER TABLE agent_diagnostic ADD COLUMN $name $definition"
|
||||
}
|
||||
}
|
||||
return statements
|
||||
}
|
||||
|
||||
// Kept for existing migration tests and callers; new code should use migrationStatements.
|
||||
fun v2MigrationStatements(oldVersion: Int, newVersion: Int, existingColumns: Set<String>): List<String> =
|
||||
migrationStatements(oldVersion, newVersion, existingColumns)
|
||||
}
|
||||
|
||||
+78
-2
@@ -4,6 +4,8 @@ import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import android.database.sqlite.SQLiteOpenHelper
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import cn.ilapage.goauto.agent.BuildConfig
|
||||
|
||||
enum class AgentDiagnosticStage {
|
||||
@@ -71,6 +73,7 @@ enum class AgentDiagnosticReason {
|
||||
LINK_AMBIGUOUS,
|
||||
LINK_NOT_FOUND,
|
||||
UNKNOWN,
|
||||
PURCHASE_FAILURE,
|
||||
}
|
||||
|
||||
data class AgentDiagnosticEvent(
|
||||
@@ -99,6 +102,12 @@ data class AgentDiagnosticEvent(
|
||||
val initialSelectedSizeCount: Int? = null,
|
||||
val selectedSummaryPresent: Boolean? = null,
|
||||
val horizontalSwipeCount: Int? = null,
|
||||
val attemptId: String? = null,
|
||||
val deviceId: Long? = null,
|
||||
val errorCode: String? = null,
|
||||
val failureMessage: String? = null,
|
||||
val pageEvidence: String? = null,
|
||||
val lastStep: String? = null,
|
||||
val createdAt: Long = System.currentTimeMillis(),
|
||||
)
|
||||
|
||||
@@ -118,7 +127,7 @@ internal object AgentDiagnosticRetentionPolicy {
|
||||
fun cutoff(createdAt: Long): Long = createdAt - RETENTION_MILLIS
|
||||
}
|
||||
|
||||
class AgentDiagnosticStore(context: Context) : SQLiteOpenHelper(context, DATABASE_NAME, null, DATABASE_VERSION) {
|
||||
class AgentDiagnosticStore(private val context: Context) : SQLiteOpenHelper(context, DATABASE_NAME, null, DATABASE_VERSION) {
|
||||
override fun onCreate(db: SQLiteDatabase) {
|
||||
db.execSQL(AgentDiagnosticSchema.createTableSql)
|
||||
db.execSQL("CREATE INDEX idx_agent_diagnostic_task ON agent_diagnostic(task_id, id)")
|
||||
@@ -126,7 +135,7 @@ class AgentDiagnosticStore(context: Context) : SQLiteOpenHelper(context, DATABAS
|
||||
|
||||
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
|
||||
val existing = columnNames(db)
|
||||
AgentDiagnosticSchema.v2MigrationStatements(oldVersion, newVersion, existing).forEach(db::execSQL)
|
||||
AgentDiagnosticSchema.migrationStatements(oldVersion, newVersion, existing).forEach(db::execSQL)
|
||||
}
|
||||
|
||||
private fun columnNames(db: SQLiteDatabase): Set<String> =
|
||||
@@ -171,6 +180,12 @@ class AgentDiagnosticStore(context: Context) : SQLiteOpenHelper(context, DATABAS
|
||||
putNullableInt("initial_selected_size_count", event.initialSelectedSizeCount)
|
||||
putNullableBoolean("selected_summary_present", event.selectedSummaryPresent)
|
||||
putNullableInt("horizontal_swipe_count", event.horizontalSwipeCount)
|
||||
event.attemptId?.let { put("attempt_id", it.take(MAX_TEXT_CHARS)) }
|
||||
putNullableLong("device_id", event.deviceId)
|
||||
event.errorCode?.let { put("error_code", it.take(MAX_ERROR_CODE_CHARS)) }
|
||||
event.failureMessage?.let { put("failure_message", sanitizeText(it)) }
|
||||
event.pageEvidence?.let { put("page_evidence", sanitizeText(it)) }
|
||||
event.lastStep?.let { put("last_step", it.take(MAX_TEXT_CHARS)) }
|
||||
put("agent_version", BuildConfig.VERSION_NAME)
|
||||
put("created_at", event.createdAt)
|
||||
}
|
||||
@@ -186,6 +201,53 @@ class AgentDiagnosticStore(context: Context) : SQLiteOpenHelper(context, DATABAS
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns only the allow-listed, structured fields; raw accessibility data is never exported. */
|
||||
@Synchronized
|
||||
fun exportTaskJson(taskId: Long): String {
|
||||
require(taskId > 0)
|
||||
val events = JSONArray()
|
||||
readableDatabase.query(
|
||||
"agent_diagnostic",
|
||||
arrayOf(
|
||||
"task_id", "stage", "reason", "attempt", "elapsed_ms", "attempt_id", "device_id",
|
||||
"error_code", "failure_message", "page_evidence", "last_step", "agent_version", "created_at",
|
||||
),
|
||||
"task_id = ?",
|
||||
arrayOf(taskId.toString()),
|
||||
null,
|
||||
null,
|
||||
"id ASC",
|
||||
).use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
events.put(JSONObject().apply {
|
||||
put("taskId", cursor.getLong(0))
|
||||
put("stage", cursor.getString(1))
|
||||
put("reason", cursor.getString(2))
|
||||
put("attempt", cursor.getInt(3))
|
||||
put("elapsedMs", cursor.getLong(4))
|
||||
putNullable("attemptId", cursor, 5)
|
||||
if (!cursor.isNull(6)) put("deviceId", cursor.getLong(6))
|
||||
putNullable("errorCode", cursor, 7)
|
||||
putNullable("message", cursor, 8)
|
||||
putNullable("pageEvidence", cursor, 9)
|
||||
putNullable("lastStep", cursor, 10)
|
||||
put("agentVersion", cursor.getString(11))
|
||||
put("createdAt", cursor.getLong(12))
|
||||
})
|
||||
}
|
||||
}
|
||||
return JSONObject().put("taskId", taskId).put("events", events).toString()
|
||||
}
|
||||
|
||||
/** Writes a task export into app-specific external storage so it can be pulled over USB. */
|
||||
@Synchronized
|
||||
fun exportTaskJsonFile(taskId: Long, fileName: String = "task-$taskId.json"): java.io.File {
|
||||
val safeName = fileName.replace(Regex("[^A-Za-z0-9._-]"), "_").take(96)
|
||||
val directory = context.getExternalFilesDir("diagnostics") ?: error("诊断导出目录不可用")
|
||||
if (!directory.exists() && !directory.mkdirs()) error("诊断导出目录创建失败")
|
||||
return java.io.File(directory, safeName).apply { writeText(exportTaskJson(taskId), Charsets.UTF_8) }
|
||||
}
|
||||
|
||||
private fun ContentValues.putNullableBoolean(key: String, value: Boolean?) {
|
||||
value?.let { put(key, if (it) 1 else 0) }
|
||||
}
|
||||
@@ -194,11 +256,25 @@ class AgentDiagnosticStore(context: Context) : SQLiteOpenHelper(context, DATABAS
|
||||
value?.let { put(key, it.coerceAtLeast(0)) }
|
||||
}
|
||||
|
||||
private fun ContentValues.putNullableLong(key: String, value: Long?) {
|
||||
value?.let { put(key, it) }
|
||||
}
|
||||
|
||||
private fun JSONObject.putNullable(key: String, cursor: android.database.Cursor, index: Int) {
|
||||
if (!cursor.isNull(index)) put(key, cursor.getString(index))
|
||||
}
|
||||
|
||||
private fun sanitizeText(value: String): String = value
|
||||
.replace(Regex("(?i)(addressSuffix|收货地址|详细地址)\\s*[:=:][^;,,\\]]+"), "[REDACTED]")
|
||||
.take(MAX_TEXT_CHARS)
|
||||
|
||||
companion object {
|
||||
private const val DATABASE_NAME = "goauto_diagnostics.db"
|
||||
private const val DATABASE_VERSION = AgentDiagnosticSchema.VERSION
|
||||
private const val MAX_CLASS_NAME_CHARS = 160
|
||||
private const val MAX_ROW_VALUE_COUNTS_CHARS = 160
|
||||
private const val MAX_TEXT_CHARS = 512
|
||||
private const val MAX_ERROR_CODE_CHARS = 96
|
||||
private val ROW_VALUE_COUNTS_PATTERN = Regex("[0-9]+(?:,[0-9]+)*")
|
||||
private val ALLOWED_ZONES = setOf("top-left", "top-center", "top-right", "middle", "bottom")
|
||||
}
|
||||
|
||||
+68
-2
@@ -445,7 +445,10 @@ class AgentForegroundService : Service() {
|
||||
GoAutoAccessibilityService.instance?.dismissPurchaseResultBubble()
|
||||
acquireTaskWakeLock()
|
||||
var resultSafelyStored = false
|
||||
var diagnosticRecorded = false
|
||||
var activeTask = initial
|
||||
val lastStep = AtomicReference("started")
|
||||
val lastPanelEvidence = AtomicReference<String?>(null)
|
||||
try {
|
||||
val claimed = if (initial.status == "pending") {
|
||||
api.claimPurchaseTask(initial.taskId, UUID.randomUUID().toString(), token)
|
||||
@@ -453,6 +456,7 @@ class AgentForegroundService : Service() {
|
||||
val task = if (claimed.status == "pending") {
|
||||
api.startPurchaseTask(claimed.taskId, UUID.randomUUID().toString(), token)
|
||||
} else claimed
|
||||
activeTask = task
|
||||
check(task.status == "running" && task.taskAttemptId.isNotBlank()) { "采购任务没有有效 attempt" }
|
||||
val snapshotHashValid = task.ruleSnapshotHash.matches(Regex("^[0-9a-f]{64}$"))
|
||||
val snapshotHash = task.ruleSnapshotHash.takeIf { snapshotHashValid } ?: "0".repeat(64)
|
||||
@@ -480,13 +484,16 @@ class AgentForegroundService : Service() {
|
||||
} else {
|
||||
PurchaseRehearsalExecutor(
|
||||
driver = accessibility,
|
||||
openLink = { PddLinkLauncher(this).open(it) },
|
||||
openLink = { PddLinkLauncher(this).open(it, preferDirect = true) },
|
||||
probeSpecs = { collectPurchaseProbe(accessibility, task, parsedRule) },
|
||||
stepChanged = { step ->
|
||||
lastStep.set(step)
|
||||
purchaseStore.updateStep(task.taskId, task.taskAttemptId, step)
|
||||
},
|
||||
panelDiagnostic = { evidence -> Log.i("GoAutoPurchasePanel", "task=${task.taskId};$evidence") },
|
||||
panelDiagnostic = { evidence ->
|
||||
lastPanelEvidence.set(evidence)
|
||||
Log.i("GoAutoPurchasePanel", "task=${task.taskId};$evidence")
|
||||
},
|
||||
beforeOrderSubmit = { evidence ->
|
||||
val boundaryRequestId = UUID.randomUUID().toString()
|
||||
val finalEvidence = JSONObject()
|
||||
@@ -526,6 +533,10 @@ class AgentForegroundService : Service() {
|
||||
val payload = purchaseResultPayload(requestId, task.taskAttemptId, outcome)
|
||||
purchaseStore.completeAndEnqueue(task.taskId, task.taskAttemptId, requestId, payload)
|
||||
resultSafelyStored = true
|
||||
if (outcome.resultType == "failed") {
|
||||
recordPurchaseDiagnostic(task, outcome, lastStep.get(), lastPanelEvidence.get())
|
||||
diagnosticRecorded = true
|
||||
}
|
||||
PurchaseResultBubblePolicy.create(
|
||||
taskId = task.taskId,
|
||||
resultType = outcome.resultType,
|
||||
@@ -540,8 +551,24 @@ class AgentForegroundService : Service() {
|
||||
stateStore.update(if (outcome.resultType == "failed") "TASK_ERROR" else "ONLINE", message, tokenStored = true)
|
||||
updateNotification(if (outcome.resultType == "failed") "$taskLabel #${task.taskId} 失败" else "$taskLabel #${task.taskId} 已提交")
|
||||
} catch (error: AgentApiException) {
|
||||
if (!diagnosticRecorded) {
|
||||
recordPurchaseDiagnostic(
|
||||
activeTask,
|
||||
PurchaseExecutionOutcome("failed", error.code, error.message ?: "采购接口调用失败"),
|
||||
lastStep.get(),
|
||||
lastPanelEvidence.get(),
|
||||
)
|
||||
}
|
||||
stateStore.update("TASK_ERROR", "${error.code}:${error.message}", tokenStored = true)
|
||||
} catch (error: Exception) {
|
||||
if (!diagnosticRecorded) {
|
||||
recordPurchaseDiagnostic(
|
||||
activeTask,
|
||||
PurchaseExecutionOutcome("failed", "AGENT_PURCHASE_EXCEPTION", error.message ?: "采购执行异常"),
|
||||
lastStep.get(),
|
||||
lastPanelEvidence.get(),
|
||||
)
|
||||
}
|
||||
stateStore.update("TASK_ERROR", error.message ?: "采购演练执行异常", tokenStored = true)
|
||||
} finally {
|
||||
if (!resultSafelyStored) cancelIdleReturn("采购结果未安全保存")
|
||||
@@ -549,6 +576,45 @@ class AgentForegroundService : Service() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun recordPurchaseDiagnostic(
|
||||
task: PurchaseAgentTask,
|
||||
outcome: PurchaseExecutionOutcome,
|
||||
lastStep: String,
|
||||
panelEvidence: String?,
|
||||
) {
|
||||
if (task.taskId <= 0L) return
|
||||
val deviceId = runCatching { identityStore.credentials()?.deviceId }.getOrNull()
|
||||
val event = AgentDiagnosticEvent(
|
||||
taskId = task.taskId,
|
||||
stage = purchaseDiagnosticStage(lastStep),
|
||||
reason = AgentDiagnosticReason.PURCHASE_FAILURE,
|
||||
attempt = task.attemptNumber,
|
||||
attemptId = task.taskAttemptId.takeIf { it.isNotBlank() },
|
||||
deviceId = deviceId,
|
||||
errorCode = outcome.errorCode,
|
||||
failureMessage = outcome.message,
|
||||
pageEvidence = panelEvidence,
|
||||
lastStep = lastStep,
|
||||
)
|
||||
diagnosticExecutor.execute {
|
||||
runCatching {
|
||||
diagnosticStore.record(event)
|
||||
val export = diagnosticStore.exportTaskJsonFile(task.taskId)
|
||||
Log.i("GoAutoDiagnostic", "purchase diagnostic exported task=${task.taskId};file=${export.absolutePath}")
|
||||
}.onFailure { error ->
|
||||
Log.w("GoAutoDiagnostic", "purchase diagnostic export failed: ${error.javaClass.simpleName}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun purchaseDiagnosticStage(step: String): AgentDiagnosticStage = when (step) {
|
||||
"openProduct" -> AgentDiagnosticStage.DETAIL_ENTRY
|
||||
"openSpecPanel" -> AgentDiagnosticStage.SPEC_PANEL_ENTRY
|
||||
"selectSpec" -> AgentDiagnosticStage.SIZE_DISCOVERY
|
||||
"verifyUnitPrice", "verifyOrderSummary" -> AgentDiagnosticStage.PAGE_STABILITY
|
||||
else -> AgentDiagnosticStage.DETAIL_ENTRY
|
||||
}
|
||||
|
||||
private fun collectPurchaseProbe(accessibility: GoAutoAccessibilityService, task: PurchaseAgentTask, purchaseRule: PurchaseRule): String? {
|
||||
val snapshot = accessibility.capture()
|
||||
val activity = snapshot.activityName ?: return null
|
||||
|
||||
@@ -83,10 +83,12 @@ internal object CollectionResetPolicy {
|
||||
internal object PurchaseRetryPolicy {
|
||||
fun showsAction(status: String, retryable: Boolean): Boolean = status == "failed" && retryable
|
||||
|
||||
fun usesInPlaceReset(continuing: Boolean): Boolean = !continuing
|
||||
|
||||
fun confirmationMessage(continuing: Boolean = false): String = if (continuing) {
|
||||
"替代商品已完成匹配。系统会保留原任务并创建一笔新采购任务;可能创建拼多多待付款订单,但不会支付。"
|
||||
} else {
|
||||
"系统会保留原任务,并根据当前商品档案和最新采购规则创建一笔新采购任务;可能创建拼多多待付款订单,但不会支付。"
|
||||
"系统会复用原采购任务并开始新一次执行,使用当前有效采购规则,商品和规格等任务快照保持不变;可能创建拼多多待付款订单,但不会支付。"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -746,7 +748,7 @@ class TaskHistoryFragment : Fragment() {
|
||||
contentDescription = "重试采购任务 CG-${task.taskId}"
|
||||
setOnClickListener { confirmPurchaseRetry(task) }
|
||||
}, collectionCardParams())
|
||||
resultColumn.addView(context.centeredMessage("重试边界", "保留当前失败任务并创建新任务;新任务读取当前商品档案和采购规则,不会执行支付。"))
|
||||
resultColumn.addView(context.centeredMessage("重试边界", "复用当前任务并新增一次执行;使用当前有效采购规则,商品和规格快照不变,不会执行支付。"))
|
||||
} else if (!replacementInProgress && task.status == "failed") {
|
||||
val reason = task.retryDisabledReason?.takeIf(String::isNotBlank) ?: "请在管理端核对任务状态。"
|
||||
resultColumn.addView(context.centeredMessage("不可重试", reason))
|
||||
@@ -864,18 +866,25 @@ class TaskHistoryFragment : Fragment() {
|
||||
val generation = ++requestGeneration
|
||||
showLoading(if (continuing) "正在提交继续采购请求…" else "正在提交重试请求…")
|
||||
Thread {
|
||||
runCatching {
|
||||
runCatching<Unit> {
|
||||
val client = AgentApiClient(serverUrl)
|
||||
val requestId = UUID.randomUUID().toString()
|
||||
client.retryPurchaseTask(taskId, requestId, credentials.token)
|
||||
}
|
||||
.onSuccess { result ->
|
||||
if (PurchaseRetryPolicy.usesInPlaceReset(continuing)) {
|
||||
val result = client.resetPurchaseTask(taskId, requestId, credentials.token)
|
||||
resultColumn.post {
|
||||
if (!isAdded || generation != requestGeneration) return@post
|
||||
AgentForegroundService.start(requireContext())
|
||||
showPurchaseResetSuccess(result.taskNo, result.taskId, result.attemptNumber)
|
||||
}
|
||||
} else {
|
||||
val result = client.retryPurchaseTask(taskId, requestId, credentials.token)
|
||||
resultColumn.post {
|
||||
if (!isAdded || generation != requestGeneration) return@post
|
||||
AgentForegroundService.start(requireContext())
|
||||
showPurchaseRetrySuccess(result.sourceTaskNo, result.taskNo, result.taskId)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onFailure { error ->
|
||||
resultColumn.post {
|
||||
if (isAdded && generation == requestGeneration) {
|
||||
@@ -886,6 +895,24 @@ class TaskHistoryFragment : Fragment() {
|
||||
}.start()
|
||||
}
|
||||
|
||||
private fun showPurchaseResetSuccess(taskNo: String, taskId: Long, attemptNumber: Int) {
|
||||
resultColumn.removeAllViews()
|
||||
resultColumn.addView(requireContext().centeredMessage(
|
||||
"$taskNo 已进入第 $attemptNumber 次执行",
|
||||
"原采购任务已复用,并将由当前设备按正常队列执行;系统不会支付。",
|
||||
))
|
||||
resultColumn.addView(MaterialButton(requireContext()).apply {
|
||||
text = "查看当前任务"
|
||||
minimumHeight = requireContext().dp(48)
|
||||
setOnClickListener { loadPurchaseDetail(taskId) }
|
||||
}, collectionCardParams())
|
||||
resultColumn.addView(MaterialButton(requireContext(), null, com.google.android.material.R.attr.materialButtonOutlinedStyle).apply {
|
||||
text = "返回采购记录"
|
||||
minimumHeight = requireContext().dp(48)
|
||||
setOnClickListener { page = 1; load() }
|
||||
}, collectionCardParams())
|
||||
}
|
||||
|
||||
private fun showPurchaseRetrySuccess(sourceTaskNo: String, taskNo: String, taskId: Long) {
|
||||
resultColumn.removeAllViews()
|
||||
resultColumn.addView(requireContext().centeredMessage(
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package cn.ilapage.goauto.agent
|
||||
|
||||
import cn.ilapage.goauto.agent.automation.PddLaunchFallback
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
|
||||
class PddLaunchFallbackTest {
|
||||
@Test fun directSuccessDoesNotStartBrowser() {
|
||||
assertTrue(PddLaunchFallback.open(true, { true }, { error("browser must not start") }))
|
||||
}
|
||||
@Test fun missingHandlerFallsBackOnce() {
|
||||
var browserCalls = 0
|
||||
assertTrue(PddLaunchFallback.open(true, { throw IllegalStateException("no handler") }, { browserCalls++; true }))
|
||||
assertEquals(1, browserCalls)
|
||||
}
|
||||
@Test fun rejectedDirectLaunchFallsBack() {
|
||||
assertTrue(PddLaunchFallback.open(true, { false }, { true }))
|
||||
assertFalse(PddLaunchFallback.open(true, { false }, { false }))
|
||||
}
|
||||
@Test fun collectionKeepsBrowserEntry() {
|
||||
var directCalls = 0
|
||||
assertTrue(PddLaunchFallback.open(false, { directCalls++; true }, { true }))
|
||||
assertEquals(0, directCalls)
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import cn.ilapage.goauto.agent.automation.PddCollectorConfig
|
||||
import cn.ilapage.goauto.agent.automation.PddCollectorDriver
|
||||
import cn.ilapage.goauto.agent.automation.PddProductDetailCollector
|
||||
import cn.ilapage.goauto.agent.automation.PddScreenParser
|
||||
import cn.ilapage.goauto.agent.automation.PurchasePanelContext
|
||||
import cn.ilapage.goauto.agent.automation.SnapshotNode
|
||||
import cn.ilapage.goauto.agent.automation.SpecPanelType
|
||||
import cn.ilapage.goauto.agent.automation.SwipeDirection
|
||||
@@ -26,6 +27,62 @@ import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class PddProductDetailCollectorTest {
|
||||
private fun prefixlessPanel(): UiSnapshot = UiSnapshot(PDD_PACKAGE, ACTIVITY, listOf(
|
||||
node("root", "", 0, 0, 1080, 2376),
|
||||
node("close", "关闭", 970, 270, 1050, 350, clickable = true),
|
||||
node("info", "", 0, 770, 1080, 1157),
|
||||
node("info/summary", "米白色(有里布) 2XL 建议131到150斤", 396, 878, 1053, 992, parentPath = "info"),
|
||||
node("info/quantity", "1", 480, 1046, 561, 1121, className = "android.widget.EditText", parentPath = "info"),
|
||||
node("minus", "减少数量", 396, 1046, 470, 1121, clickable = true),
|
||||
node("plus", "增加数量", 570, 1046, 645, 1121, clickable = true),
|
||||
node("scroll", "", 0, 1294, 1080, 2079, scrollable = true),
|
||||
node("scroll/color-title", "颜色分类", 36, 1319, 216, 1380, parentPath = "scroll"),
|
||||
node("scroll/color", "米白色(有里布)", 36, 1405, 352, 1808, clickable = true, selected = true, parentPath = "scroll"),
|
||||
node("scroll/size-title", "尺码", 36, 1858, 126, 1911, parentPath = "scroll"),
|
||||
node("scroll/size", "XL 建议111到130斤", 36, 1930, 440, 2015, clickable = true, parentPath = "scroll"),
|
||||
node("payment", "微信支付", 112, 2100, 929, 2157),
|
||||
node("submit", "提交订单", 375, 2225, 705, 2284, clickable = true),
|
||||
))
|
||||
|
||||
@Test fun `prefixless selected panel retains structural recognition and product summary`() {
|
||||
val parsed = PddScreenParser.parse(prefixlessPanel(), config(), GOODS_ID, evidence())
|
||||
assertEquals(SpecPanelType.NORMAL_SCROLLABLE, parsed.specPanelType)
|
||||
assertEquals("米白色(有里布) 2XL 建议131到150斤", parsed.selectedSummary)
|
||||
}
|
||||
|
||||
@Test fun `prefixless panel requires quantity and order evidence`() {
|
||||
for (missing in listOf("plus", "submit", "payment")) {
|
||||
val snapshot = prefixlessPanel()
|
||||
val parsed = PddScreenParser.parse(snapshot.copy(nodes = snapshot.nodes.filterNot { it.path == missing }), config(), GOODS_ID, evidence())
|
||||
assertFalse(parsed.specPanelOpen)
|
||||
assertEquals(null, parsed.selectedSummary)
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun `ambiguous product summaries are not used`() {
|
||||
val snapshot = prefixlessPanel()
|
||||
val extra = node("info/other", "米白色(有里布) XL 建议111到130斤", 396, 800, 1053, 870, parentPath = "info")
|
||||
val parsed = PddScreenParser.parse(snapshot.copy(nodes = snapshot.nodes + extra), config(), GOODS_ID, evidence())
|
||||
assertEquals(null, parsed.selectedSummary)
|
||||
}
|
||||
|
||||
@Test fun `scrolling away color heading and options preserves known purchase panel and summary`() {
|
||||
val initial = prefixlessPanel()
|
||||
val opened = PddScreenParser.parse(initial, config(), GOODS_ID, evidence())
|
||||
val context = PurchasePanelContext(requireNotNull(opened.specPanelContainer), "米白色(有里布)")
|
||||
val scrolled = initial.copy(nodes = initial.nodes.filterNot { it.path.startsWith("scroll/color") })
|
||||
val continued = PddScreenParser.parse(scrolled, config(), GOODS_ID, evidence(), context)
|
||||
assertEquals(1, continued.panelHeadingCount)
|
||||
assertTrue(continued.specPanelOpen)
|
||||
assertEquals("米白色(有里布) 2XL 建议131到150斤", continued.selectedSummary)
|
||||
assertTrue(continued.dimensions.none { it.key == "color" })
|
||||
assertFalse(PddScreenParser.parse(scrolled, config(), GOODS_ID, evidence()).specPanelOpen)
|
||||
assertFalse(PddScreenParser.parse(scrolled, config(), GOODS_ID, evidence(),
|
||||
context.copy(container = context.container.copy(path = "other"))).specPanelOpen)
|
||||
assertFalse(PddScreenParser.parse(scrolled.copy(nodes = scrolled.nodes.filterNot { it.path == "submit" }),
|
||||
config(), GOODS_ID, evidence(), context).specPanelOpen)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parser removes only trailing size price and keeps raw evidence`() {
|
||||
val snapshot = UiSnapshot(
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package cn.ilapage.goauto.agent
|
||||
|
||||
import cn.ilapage.goauto.agent.automation.FreshActionResult
|
||||
import cn.ilapage.goauto.agent.automation.FreshClickOutcome
|
||||
import cn.ilapage.goauto.agent.automation.FreshClickReason
|
||||
import cn.ilapage.goauto.agent.automation.NodeBounds
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseExecutionInput
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseLiveAutomation
|
||||
@@ -56,6 +58,41 @@ class PurchaseLiveAutomationTest {
|
||||
assertTrue(driver.clicked.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `recorded exact selections allow one unique confirm on an unclassified pdd panel`() {
|
||||
val driver = SpecConfirmationDriver(unclassifiedSpecPanel = true)
|
||||
|
||||
PurchaseLiveAutomation(driver, pause = {}).advanceToOrderConfirmation(
|
||||
allowUnclassifiedPanelWithSelectionProof = true,
|
||||
)
|
||||
|
||||
assertEquals(listOf("确定"), driver.clicked)
|
||||
assertEquals("order", driver.page)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unclassified pdd panel without recorded selections remains blocked`() {
|
||||
val driver = SpecConfirmationDriver(unclassifiedSpecPanel = true)
|
||||
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}).advanceToOrderConfirmation() }
|
||||
.exceptionOrNull() as PurchaseLiveException
|
||||
|
||||
assertEquals("PURCHASE_SPEC_CONFIRMATION_NOT_READY", error.code)
|
||||
assertTrue(driver.clicked.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `recorded selections never authorize a generic unclassified pdd dialog`() {
|
||||
val driver = SpecConfirmationDriver(unclassifiedSpecPanel = true, unclassifiedHasPurchaseEvidence = false)
|
||||
val error = runCatching {
|
||||
PurchaseLiveAutomation(driver, pause = {}).advanceToOrderConfirmation(
|
||||
allowUnclassifiedPanelWithSelectionProof = true,
|
||||
)
|
||||
}.exceptionOrNull() as PurchaseLiveException
|
||||
|
||||
assertEquals("PURCHASE_SPEC_CONFIRMATION_NOT_READY", error.code)
|
||||
assertTrue(driver.clicked.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `address is retagged verified and final order button can only be clicked once`() {
|
||||
val driver = LiveDriver()
|
||||
@@ -104,6 +141,31 @@ class PurchaseLiveAutomationTest {
|
||||
assertEquals(0, driver.submitClicks)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `saved address returning to spec panel is recovered without pressing back`() {
|
||||
val driver = LiveDriver(saveReturnsToSpecPanel = true)
|
||||
val automation = PurchaseLiveAutomation(driver, pause = {})
|
||||
|
||||
val address = automation.updateShippingAddress("_cg91")
|
||||
val final = automation.finalConfirmation(input().copy(addressSuffix = "_cg91"), address)
|
||||
|
||||
assertEquals("_cg91", final.addressSuffix)
|
||||
assertEquals(0, driver.backCount)
|
||||
assertEquals(1, driver.scopedSwipes)
|
||||
assertEquals(0, driver.submitClicks)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `saved address stuck on spec panel fails without backing out or submitting`() {
|
||||
val driver = LiveDriver(saveReturnsToSpecPanel = true, savedSpecPanelRecoveryStuck = true)
|
||||
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg92") }
|
||||
.exceptionOrNull() as PurchaseLiveException
|
||||
|
||||
assertEquals("PURCHASE_ADDRESS_SAVE_TIMEOUT", error.code)
|
||||
assertEquals(0, driver.backCount)
|
||||
assertEquals(0, driver.submitClicks)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `edit field containing suffix cannot impersonate post save evidence`() {
|
||||
val driver = LiveDriver(saveStaysInEdit = true)
|
||||
@@ -439,6 +501,52 @@ class PurchaseLiveAutomationTest {
|
||||
assertTrue(driver.clicked.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `duplicate masked phone nodes in one clickable address card are activated once`() {
|
||||
val driver = LiveDriver(duplicatePhoneNodesSameCard = true)
|
||||
|
||||
PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg94")
|
||||
|
||||
assertEquals(1, driver.clicked.count { it == "138****5678" })
|
||||
assertEquals(0, driver.addressTaps)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `duplicate semantic address cards in separate accessibility trees are activated once`() {
|
||||
val driver = LiveDriver(duplicateSemanticAddressCards = true)
|
||||
|
||||
PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg94")
|
||||
|
||||
assertEquals(1, driver.addressPathClicks)
|
||||
assertEquals(1, driver.clicked.count { it == "138****5678" })
|
||||
assertEquals(0, driver.addressTaps)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `two independent address cards remain ambiguous without any click`() {
|
||||
val driver = LiveDriver(duplicateAddressCards = true)
|
||||
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg95") }
|
||||
.exceptionOrNull() as PurchaseLiveException
|
||||
|
||||
assertEquals("PURCHASE_ADDRESS_ENTRY_AMBIGUOUS", error.code)
|
||||
assertTrue(error.message.orEmpty().contains("phoneNodes=2;addressCards=2;tapTargets=2"))
|
||||
assertFalse(error.message.orEmpty().contains("138"))
|
||||
assertTrue(driver.clicked.isEmpty())
|
||||
assertEquals(0, driver.addressTaps)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `address entry path drift fails without fallback tap`() {
|
||||
val driver = LiveDriver(duplicatePhoneNodesSameCard = true, addressEntryPathUnavailable = true)
|
||||
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg94") }
|
||||
.exceptionOrNull() as PurchaseLiveException
|
||||
|
||||
assertEquals("PURCHASE_ADDRESS_ENTRY_NOT_READY", error.code)
|
||||
assertTrue(driver.clicked.isEmpty())
|
||||
assertEquals(1, driver.addressPathClicks)
|
||||
assertEquals(0, driver.addressTaps)
|
||||
}
|
||||
|
||||
private fun input() = PurchaseExecutionInput(
|
||||
taskId = 11,
|
||||
executionMode = "live",
|
||||
@@ -457,6 +565,8 @@ class PurchaseLiveAutomationTest {
|
||||
private val confirmLabels: List<String> = listOf("确定"),
|
||||
private val advanceAfterClick: Boolean = true,
|
||||
private val specPanelScrollable: Boolean = false,
|
||||
private val unclassifiedSpecPanel: Boolean = false,
|
||||
private val unclassifiedHasPurchaseEvidence: Boolean = true,
|
||||
startOnOrderPage: Boolean = false,
|
||||
) : PurchaseUiDriver {
|
||||
var page = if (startOnOrderPage) "order" else "spec"
|
||||
@@ -475,6 +585,14 @@ class PurchaseLiveAutomationTest {
|
||||
} else {
|
||||
snapshot(buildList {
|
||||
add(node("root", "", bounds = NodeBounds(0, 0, 1080, 2200)))
|
||||
if (unclassifiedSpecPanel) {
|
||||
if (unclassifiedHasPurchaseEvidence) {
|
||||
add(node("price", "¥20.00"))
|
||||
add(node("quantity", "1", className = "android.widget.EditText"))
|
||||
}
|
||||
confirmLabels.forEachIndexed { index, label -> add(node("confirm-$index", label, clickable = true)) }
|
||||
return@buildList
|
||||
}
|
||||
if (specPanelScrollable) {
|
||||
add(node("scroll", "", scrollable = true, bounds = NodeBounds(0, 400, 1080, 1500)))
|
||||
}
|
||||
@@ -526,6 +644,12 @@ class PurchaseLiveAutomationTest {
|
||||
private val saveStaysInEdit: Boolean = false,
|
||||
private val savedTransitionWithoutLegacyContext: Boolean = false,
|
||||
private val savedTransitionHidesSuffix: Boolean = false,
|
||||
private val saveReturnsToSpecPanel: Boolean = false,
|
||||
private val savedSpecPanelRecoveryStuck: Boolean = false,
|
||||
private val duplicatePhoneNodesSameCard: Boolean = false,
|
||||
private val duplicateSemanticAddressCards: Boolean = false,
|
||||
private val duplicateAddressCards: Boolean = false,
|
||||
private val addressEntryPathUnavailable: Boolean = false,
|
||||
private val wechatLoginAfterSubmit: Boolean = false,
|
||||
private val wechatRestoreStuck: Boolean = false,
|
||||
private val orderEvidenceBelowFold: Boolean = false,
|
||||
@@ -544,6 +668,7 @@ class PurchaseLiveAutomationTest {
|
||||
var scopedSwipes = 0
|
||||
var genericSwipes = 0
|
||||
var addressTaps = 0
|
||||
var addressPathClicks = 0
|
||||
var lastInputTargetPath: String? = null
|
||||
var backCount = 0
|
||||
var postSubmitBackCount = 0
|
||||
@@ -581,6 +706,21 @@ class PurchaseLiveAutomationTest {
|
||||
node("transition-title", "选择收货信息"),
|
||||
node("address-summary", if (savedTransitionHidesSuffix) "已保存的收货信息" else address.substring(address.lastIndexOf("_cg"))),
|
||||
))
|
||||
"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-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)),
|
||||
node("panel/color-heading", "颜色分类", parentPath = "panel", bounds = NodeBounds(20, 650, 300, 700)),
|
||||
node("panel/color", "黑色", clickable = true, parentPath = "panel", bounds = NodeBounds(20, 720, 220, 790)),
|
||||
node("panel/size-heading", "尺码", parentPath = "panel", bounds = NodeBounds(20, 850, 300, 900)),
|
||||
node("panel/size", "XL", clickable = true, parentPath = "panel", bounds = NodeBounds(20, 920, 220, 990)),
|
||||
node("panel/quantity", "2", className = "android.widget.EditText", parentPath = "panel", bounds = NodeBounds(400, 1050, 600, 1120)),
|
||||
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)),
|
||||
))
|
||||
"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(
|
||||
@@ -614,7 +754,30 @@ class PurchaseLiveAutomationTest {
|
||||
)
|
||||
if (duplicatePanels) nodes += node("panel2", "", scrollable = true, bounds = NodeBounds(0, 500, 1080, 2000))
|
||||
if (addressVisible) {
|
||||
nodes += node("phone", "138****5678")
|
||||
when {
|
||||
duplicateSemanticAddressCards -> {
|
||||
nodes += node("address-layer-a", "", clickable = true, bounds = NodeBounds(0, 620, 1080, 840))
|
||||
nodes += node("address-layer-a/phone", "138****5678", parentPath = "address-layer-a", bounds = NodeBounds(20, 650, 400, 710))
|
||||
nodes += node("address-layer-a/detail", "广东省广州市天园街道骏景花园", parentPath = "address-layer-a", bounds = NodeBounds(20, 720, 900, 790))
|
||||
nodes += node("address-layer-b", "", clickable = true, bounds = NodeBounds(0, 900, 1080, 1120))
|
||||
nodes += node("address-layer-b/phone", "138****5678", parentPath = "address-layer-b", bounds = NodeBounds(20, 930, 400, 990))
|
||||
nodes += node("address-layer-b/detail", "广东省广州市天园街道骏景花园", parentPath = "address-layer-b", bounds = NodeBounds(20, 1000, 900, 1070))
|
||||
}
|
||||
duplicateAddressCards -> {
|
||||
nodes += node("address-card-a", "", clickable = true, bounds = NodeBounds(0, 620, 1080, 820))
|
||||
nodes += node("address-card-a/phone", "138****5678", parentPath = "address-card-a", bounds = NodeBounds(20, 650, 400, 710))
|
||||
nodes += node("address-card-a/detail", "广东省广州市天园街道一号", parentPath = "address-card-a", bounds = NodeBounds(20, 720, 900, 780))
|
||||
nodes += node("address-card-b", "", clickable = true, bounds = NodeBounds(0, 840, 1080, 1040))
|
||||
nodes += node("address-card-b/phone", "138****5678", parentPath = "address-card-b", bounds = NodeBounds(20, 870, 400, 930))
|
||||
nodes += node("address-card-b/detail", "广东省广州市天园街道二号", parentPath = "address-card-b", bounds = NodeBounds(20, 940, 900, 1000))
|
||||
}
|
||||
duplicatePhoneNodesSameCard -> {
|
||||
nodes += node("address-card", "", clickable = true, bounds = NodeBounds(0, 620, 1080, 900))
|
||||
nodes += node("address-card/phone-a", "138****5678", parentPath = "address-card", bounds = NodeBounds(20, 650, 400, 710))
|
||||
nodes += node("address-card/phone-b", "138****5678", parentPath = "address-card", bounds = NodeBounds(20, 720, 440, 790))
|
||||
}
|
||||
else -> nodes += node("phone", "138****5678")
|
||||
}
|
||||
val suffixStart = address.lastIndexOf("_cg")
|
||||
val addressBody = if (suffixStart >= 0) address.substring(0, suffixStart) else address
|
||||
val addressSuffix = if (suffixStart >= 0) address.substring(suffixStart) else ""
|
||||
@@ -639,7 +802,13 @@ class PurchaseLiveAutomationTest {
|
||||
when (target.label) {
|
||||
"138****5678" -> page = "panel"
|
||||
"修改" -> page = "edit"
|
||||
"保存" -> if (!saveStaysInEdit) page = if (savedTransitionWithoutLegacyContext) "saved-transition" else "panel"
|
||||
"保存" -> if (!saveStaysInEdit) {
|
||||
page = when {
|
||||
saveReturnsToSpecPanel -> "post-save-spec"
|
||||
savedTransitionWithoutLegacyContext -> "saved-transition"
|
||||
else -> "panel"
|
||||
}
|
||||
}
|
||||
"提交订单" -> {
|
||||
submitClicks++
|
||||
page = when {
|
||||
@@ -657,6 +826,14 @@ class PurchaseLiveAutomationTest {
|
||||
return FreshActionResult.SUCCESS
|
||||
}
|
||||
|
||||
override fun clickAddressEntryFresh(target: SnapshotNode): FreshClickOutcome {
|
||||
addressPathClicks++
|
||||
if (addressEntryPathUnavailable) {
|
||||
return FreshClickOutcome(FreshActionResult.NOT_FOUND, FreshClickReason.TARGET_NOT_FOUND)
|
||||
}
|
||||
return FreshClickOutcome(clickFresh(target), FreshClickReason.SUCCESS, 1, 1)
|
||||
}
|
||||
|
||||
override fun tapPurchaseFresh(target: SnapshotNode): FreshActionResult {
|
||||
addressTaps++
|
||||
clicked += target.label
|
||||
@@ -677,6 +854,7 @@ class PurchaseLiveAutomationTest {
|
||||
override fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long): Boolean {
|
||||
scopedSwipes++
|
||||
if (target.path == "panel" && direction == SwipeDirection.DOWN) addressVisible = true
|
||||
if (page == "post-save-spec" && direction == SwipeDirection.DOWN && !savedSpecPanelRecoveryStuck) page = "confirmation"
|
||||
return true
|
||||
}
|
||||
override fun backPurchase(): Boolean {
|
||||
|
||||
+373
-25
@@ -22,6 +22,36 @@ import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class PurchaseRehearsalExecutorTest {
|
||||
@Test
|
||||
fun `color selection then single size heading completes without selecting color again`() {
|
||||
val driver = FakePurchaseDriver(prefixlessSingleHeadingAfterColor = true)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
assertEquals(outcome.message, "rehearsal_completed", outcome.resultType)
|
||||
assertEquals(1, driver.clicked.count { it == "黑色" })
|
||||
assertEquals(1, driver.clicked.count { it == "XL" })
|
||||
}
|
||||
@Test
|
||||
fun `full exact summary confirms size after option leaves viewport`() {
|
||||
val target = "2XL 建议131到150斤"
|
||||
val driver = FakePurchaseDriver(sizes = listOf(target), hideSizeAfterSelection = true)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
.execute(input().copy(mappedSize = target), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
assertEquals(outcome.message, "rehearsal_completed", outcome.resultType)
|
||||
assertEquals(1, driver.clicked.count { it == target })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `same size token with different full range cannot establish selection`() {
|
||||
val target = "2XL 建议131到150斤"
|
||||
val driver = FakePurchaseDriver(
|
||||
sizes = listOf(target), hideSizeAfterSelection = true,
|
||||
selectedSizeSummaryOverride = "2XL 建议151到170斤",
|
||||
)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
.execute(input().copy(mappedSize = target), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
assertEquals("failed", outcome.resultType)
|
||||
}
|
||||
@Test
|
||||
fun `spec gesture policy excludes irreversible and out of bounds targets`() {
|
||||
fun target(label: String, bounds: NodeBounds = NodeBounds(20, 100, 300, 180)) = SnapshotNode(
|
||||
@@ -89,6 +119,57 @@ class PurchaseRehearsalExecutorTest {
|
||||
assertFalse(pauses.contains(700))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `manual purchase retry from agent reopens the task product url`() {
|
||||
val driver = FakePurchaseDriver(initiallyInAgent = true)
|
||||
var openCount = 0
|
||||
val outcome = PurchaseRehearsalExecutor(
|
||||
driver,
|
||||
openLink = {
|
||||
openCount++
|
||||
driver.leaveAgentAndOpenBrowser()
|
||||
true
|
||||
},
|
||||
probeSpecs = { null },
|
||||
pause = {},
|
||||
).execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("rehearsal_completed", outcome.resultType)
|
||||
assertEquals(1, openCount)
|
||||
assertEquals(1, driver.openClickCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `purchase phase reopens when pdd foreground has no product evidence`() {
|
||||
val driver = FakePurchaseDriver(loadingPddCaptures = 1)
|
||||
var openCount = 0
|
||||
val outcome = PurchaseRehearsalExecutor(
|
||||
driver,
|
||||
openLink = { openCount++; driver.browser = true; true },
|
||||
probeSpecs = { null },
|
||||
pause = {},
|
||||
).execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("rehearsal_completed", outcome.resultType)
|
||||
assertEquals(1, openCount)
|
||||
assertEquals(1, driver.openClickCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `purchase phase does not reuse a pdd login page`() {
|
||||
val driver = FakePurchaseDriver(pddProblemLabels = listOf("手机号登录", "登录后继续"))
|
||||
var openCount = 0
|
||||
val outcome = PurchaseRehearsalExecutor(
|
||||
driver,
|
||||
openLink = { openCount++; driver.browser = true; true },
|
||||
probeSpecs = { null },
|
||||
pause = {},
|
||||
).execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("PDD_LOGIN_REQUIRED", outcome.errorCode)
|
||||
assertEquals(1, openCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `price range failure reports the observed unit price`() {
|
||||
val driver = FakePurchaseDriver()
|
||||
@@ -151,6 +232,159 @@ class PurchaseRehearsalExecutorTest {
|
||||
assertTrue(driver.swipeInPaths.all { it == "scroll" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `exact color on later horizontal page is found by bounded fallback`() {
|
||||
val driver = FakePurchaseDriver(
|
||||
horizontalColorPages = listOf(listOf("黑色", "白色"), listOf("蓝色", "富贵粉")),
|
||||
)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
.execute(input().copy(mappedColor = "富贵粉"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("rehearsal_completed", outcome.resultType)
|
||||
assertEquals("富贵粉", driver.color)
|
||||
assertTrue(driver.horizontalSpecDirections.contains(SwipeDirection.LEFT))
|
||||
assertEquals(1, driver.clicked.count { it == "富贵粉" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `visible exact color keeps established path without horizontal fallback`() {
|
||||
val driver = FakePurchaseDriver(colors = listOf("黑色", "白色"))
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("rehearsal_completed", outcome.resultType)
|
||||
assertTrue(driver.horizontalSpecDirections.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `single column colors never trigger horizontal fallback`() {
|
||||
val driver = FakePurchaseDriver(
|
||||
horizontalColorPages = listOf(listOf("黑色"), listOf("富贵粉")),
|
||||
)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
.execute(input().copy(mappedColor = "富贵粉"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("PURCHASE_SPEC_TARGET_NOT_VISIBLE", outcome.errorCode)
|
||||
assertTrue(outcome.message.contains("horizontalSwipes=0"))
|
||||
assertTrue(driver.horizontalSpecDirections.isEmpty())
|
||||
assertFalse(driver.clicked.contains("黑色"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `failed horizontal color swipe remains safely failed`() {
|
||||
val driver = FakePurchaseDriver(
|
||||
horizontalColorPages = listOf(listOf("黑色", "白色"), listOf("蓝色", "富贵粉")),
|
||||
horizontalSpecSwipeSucceeds = false,
|
||||
)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
.execute(input().copy(mappedColor = "富贵粉"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("PURCHASE_SPEC_TARGET_NOT_VISIBLE", outcome.errorCode)
|
||||
assertTrue(outcome.message.contains("horizontalFailure=gestureFailed"))
|
||||
assertTrue(driver.clicked.none { it == "富贵粉" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `long exact size on later horizontal page is found after color selection`() {
|
||||
val targetSize = "3XL 推荐140-155斤"
|
||||
val driver = FakePurchaseDriver(
|
||||
horizontalSizePages = listOf(
|
||||
listOf("S 推荐80-95斤", "M 推荐95-110斤"),
|
||||
listOf("2XL 推荐125-140斤", targetSize),
|
||||
),
|
||||
)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
.execute(input().copy(mappedSize = targetSize), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("rehearsal_completed", outcome.resultType)
|
||||
assertEquals(targetSize, driver.size)
|
||||
assertTrue(driver.horizontalSpecDimensions.contains("size"))
|
||||
assertEquals(1, driver.clicked.count { it == targetSize })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `single visible long size uses its dedicated horizontal container`() {
|
||||
val targetSize = "3XL 推荐140-155斤"
|
||||
val driver = FakePurchaseDriver(
|
||||
horizontalSizePages = listOf(listOf("S 推荐80-95斤"), listOf(targetSize)),
|
||||
)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
.execute(input().copy(mappedSize = targetSize), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("rehearsal_completed", outcome.resultType)
|
||||
assertEquals(targetSize, driver.size)
|
||||
assertTrue(driver.horizontalSpecDimensions.contains("size"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `color and size horizontal fallbacks reacquire their own row anchors`() {
|
||||
val targetSize = "3XL 推荐140-155斤"
|
||||
val driver = FakePurchaseDriver(
|
||||
horizontalColorPages = listOf(listOf("黑色", "白色"), listOf("蓝色", "富贵粉")),
|
||||
horizontalSizePages = listOf(listOf("S 推荐80-95斤", "M 推荐95-110斤"), listOf("2XL 推荐125-140斤", targetSize)),
|
||||
)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
.execute(
|
||||
input().copy(mappedColor = "富贵粉", mappedSize = targetSize),
|
||||
PurchaseRuleParser.parse(rule()),
|
||||
PurchaseAgentCapabilities.supported,
|
||||
)
|
||||
|
||||
assertEquals("rehearsal_completed", outcome.resultType)
|
||||
assertTrue(driver.horizontalSpecDimensions.containsAll(listOf("color", "size")))
|
||||
assertTrue(driver.horizontalSpecAnchorPaths.filterIndexed { index, _ -> driver.horizontalSpecDimensions[index] == "size" }
|
||||
.all { it.contains("size-row") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `two column size grid continues vertically without horizontal container`() {
|
||||
val target = "3XL 推荐140-155斤"
|
||||
val driver = FakePurchaseDriver(sizes = listOf(target), revealGridSizeAfterUpSwipes = 7)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
.execute(input().copy(mappedSize = target), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
assertEquals("rehearsal_completed", outcome.resultType)
|
||||
assertEquals(target, driver.size)
|
||||
assertTrue(driver.horizontalSpecDirections.isEmpty())
|
||||
assertEquals(1, driver.clicked.count { it == target })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `failed horizontal gesture still permits vertical exact size recovery`() {
|
||||
val target = "3XL 推荐140-155斤"
|
||||
val driver = FakePurchaseDriver(
|
||||
sizes = listOf(target),
|
||||
horizontalSizePages = listOf(listOf("S", "M")),
|
||||
horizontalSpecSwipeSucceeds = false,
|
||||
revealGridSizeAfterUpSwipes = 7,
|
||||
)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
.execute(input().copy(mappedSize = target), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
assertEquals(outcome.message, "rehearsal_completed", outcome.resultType)
|
||||
assertEquals(target, driver.size)
|
||||
assertTrue(driver.horizontalSpecDirections.isNotEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `missing size terminates at stable viewport with bounded gestures`() {
|
||||
val driver = FakePurchaseDriver(sizes = listOf("S", "M"))
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
assertEquals("PURCHASE_SPEC_TARGET_NOT_VISIBLE", outcome.errorCode)
|
||||
assertTrue(outcome.message.contains("reason=stableViewport"))
|
||||
assertTrue(driver.horizontalSpecDirections.isEmpty())
|
||||
assertTrue(driver.swipeCount < 35)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `visible exact size does not enter horizontal fallback`() {
|
||||
val driver = FakePurchaseDriver(sizes = listOf("L", "XL"))
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("rehearsal_completed", outcome.resultType)
|
||||
assertFalse(driver.horizontalSpecDimensions.contains("size"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `failed size click result continues when exact size is actually selected`() {
|
||||
val driver = FakePurchaseDriver(
|
||||
@@ -245,6 +479,17 @@ class PurchaseRehearsalExecutorTest {
|
||||
assertEquals(1, driver.clicked.count { it == "黑色" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `final verification keeps current attempt proof when selected panel becomes unclassified`() {
|
||||
val driver = FakePurchaseDriver(panelBecomesUnknownAfterSizeProof = true)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
.execute(input().copy(quantity = 1), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("rehearsal_completed", outcome.resultType)
|
||||
assertEquals(1, driver.clicked.count { it == "黑色" })
|
||||
assertEquals(1, driver.clicked.count { it == "XL" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `final verification reuses exact attempt state without scrolling back`() {
|
||||
val driver = FakePurchaseDriver(
|
||||
@@ -310,7 +555,8 @@ class PurchaseRehearsalExecutorTest {
|
||||
assertEquals("PURCHASE_SPEC_SELECTION_UNCONFIRMED", outcome.errorCode)
|
||||
assertTrue(outcome.message.contains("dimension=size"))
|
||||
assertTrue(outcome.message.contains("reason=visible_candidate_conflict"))
|
||||
assertTrue(outcome.message.contains("proof=true"))
|
||||
assertTrue(outcome.message.contains("proofRecorded=true"))
|
||||
assertTrue(outcome.message.contains("proofUsable=true"))
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -542,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(
|
||||
@@ -600,7 +861,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
|
||||
assertEquals("PURCHASE_SPEC_ENTRY_NOT_FOUND", outcome.errorCode)
|
||||
assertEquals(
|
||||
"specEntryCandidates=0;explicit=0;nested=0;bottomPurchase=0;panelAlreadyOpen=false;reviewPage=false;pageEvidence=true;entryReadyWaitPolls=20;entryReadyWaitMillis=2000",
|
||||
"specEntryCandidates=0;explicit=0;nested=0;bottomPurchase=0;source=none;panelAlreadyOpen=false;reviewPage=false;pageEvidence=true;entryReadyWaitPolls=20;entryReadyWaitMillis=2000",
|
||||
diagnostics.single(),
|
||||
)
|
||||
assertEquals(21, pauses.count { it == 100L })
|
||||
@@ -618,18 +879,20 @@ 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
|
||||
fun `verify product waits for product evidence instead of a visible loading frame`() {
|
||||
fun `purchase reopens and waits for product evidence instead of a visible loading frame`() {
|
||||
val pauses = mutableListOf<Long>()
|
||||
val driver = FakePurchaseDriver(loadingPddCaptures = 1)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = pauses::add)
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("rehearsal_completed", outcome.resultType)
|
||||
assertEquals(2, pauses.count { it == 100L })
|
||||
// Two stable reads belong to reopening the product; verifyProduct then
|
||||
// independently requires its second stable read before continuing.
|
||||
assertEquals(4, pauses.count { it == 100L })
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -794,7 +1057,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
|
||||
assertEquals("PURCHASE_SPEC_ENTRY_NOT_FOUND", outcome.errorCode)
|
||||
assertEquals(
|
||||
"没有找到安全的商品规格入口 [specEntryCandidates=0;explicit=0;nested=0;bottomPurchase=0;panelAlreadyOpen=false;reviewPage=false;pageEvidence=true;entryReadyWaitPolls=20;entryReadyWaitMillis=2000]",
|
||||
"没有找到安全的商品规格入口 [specEntryCandidates=0;explicit=0;nested=0;bottomPurchase=0;source=none;panelAlreadyOpen=false;reviewPage=false;pageEvidence=true;entryReadyWaitPolls=20;entryReadyWaitMillis=2000]",
|
||||
outcome.message,
|
||||
)
|
||||
|
||||
@@ -804,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;source=explicit_selection;panelAlreadyOpen=false;reviewPage=false;pageEvidence=true;entryReadyWaitPolls=1;entryReadyWaitMillis=100]",
|
||||
ambiguous.message,
|
||||
)
|
||||
}
|
||||
@@ -953,6 +1216,9 @@ class PurchaseRehearsalExecutorTest {
|
||||
|
||||
private class FakePurchaseDriver(
|
||||
private val colors: List<String> = listOf("黑色"),
|
||||
private val horizontalColorPages: List<List<String>>? = null,
|
||||
private val horizontalSizePages: List<List<String>>? = null,
|
||||
private val horizontalSpecSwipeSucceeds: Boolean = true,
|
||||
private val sizes: List<String> = listOf("XL"),
|
||||
private val priceCent: Long = 2_000,
|
||||
private val duplicateOpen: Boolean = false,
|
||||
@@ -966,6 +1232,9 @@ class PurchaseRehearsalExecutorTest {
|
||||
private val openReviewOnBottomClick: Boolean = false,
|
||||
private val reviewBackSucceeds: Boolean = true,
|
||||
private val hiddenSizeUntilUpSwipes: Int = 0,
|
||||
private val prefixlessSingleHeadingAfterColor: Boolean = false,
|
||||
private val hideSizeAfterSelection: Boolean = false,
|
||||
private val revealGridSizeAfterUpSwipes: Int? = null,
|
||||
private val openClickResults: MutableList<FreshActionResult> = mutableListOf(),
|
||||
private val openPddOnFailedClick: Boolean = false,
|
||||
private val sizeClickResults: MutableList<FreshActionResult> = mutableListOf(),
|
||||
@@ -973,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,
|
||||
@@ -994,8 +1264,11 @@ class PurchaseRehearsalExecutorTest {
|
||||
private val nonScrollablePanel: Boolean = false,
|
||||
private val unrecognizedPanel: Boolean = false,
|
||||
private val purchaseSwipeSucceeds: Boolean = true,
|
||||
initiallyInAgent: Boolean = false,
|
||||
private val panelBecomesUnknownAfterSizeProof: Boolean = false,
|
||||
) : PurchaseUiDriver {
|
||||
var browser = false
|
||||
private var inAgent = initiallyInAgent
|
||||
var panel = false
|
||||
var color: String? = null
|
||||
var size: String? = initialSize
|
||||
@@ -1007,6 +1280,9 @@ class PurchaseRehearsalExecutorTest {
|
||||
var pullDownCount = 0
|
||||
var backCount = 0
|
||||
var specTapCount = 0
|
||||
val horizontalSpecDirections = mutableListOf<SwipeDirection>()
|
||||
val horizontalSpecDimensions = mutableListOf<String>()
|
||||
val horizontalSpecAnchorPaths = mutableListOf<String>()
|
||||
val swipeInPaths = mutableListOf<String>()
|
||||
private var soldOut = soldOut
|
||||
private var allSpecsUnavailable = allSpecsUnavailable
|
||||
@@ -1014,11 +1290,18 @@ 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
|
||||
private var capturesAfterSizeSelection = 0
|
||||
val clicked = mutableListOf<String>()
|
||||
val clickedPaths = mutableListOf<String>()
|
||||
|
||||
override fun capture(): UiSnapshot {
|
||||
if (inAgent) {
|
||||
return UiSnapshot("cn.ilapage.goauto.agent", "MainActivity", listOf(node("content", "", 0, 0, 1080, 2200)))
|
||||
}
|
||||
if (browser && !panel && color == null && size == null) {
|
||||
browserCaptureCount++
|
||||
if (browserCaptureCount <= browserOpenVisibleAfterCaptures) {
|
||||
@@ -1029,6 +1312,17 @@ class PurchaseRehearsalExecutorTest {
|
||||
openNodes += node("content", "", 0, 0, 1080, 2200)
|
||||
return UiSnapshot("com.heytap.browser", "BrowserActivity", openNodes)
|
||||
}
|
||||
if (panelBecomesUnknownAfterSizeProof && panel && size != null) {
|
||||
capturesAfterSizeSelection++
|
||||
if (capturesAfterSizeSelection > 1) {
|
||||
return UiSnapshot(PDD, ACTIVITY, listOf(
|
||||
node("content", "", 0, 0, 1080, 2200),
|
||||
node("price", "¥20.00", 20, 300, 300, 360),
|
||||
node("quantity", "1", 400, 800, 600, 870, className = "android.widget.EditText"),
|
||||
node("confirm", "确定", 20, 900, 500, 980, clickable = true),
|
||||
))
|
||||
}
|
||||
}
|
||||
pddCaptureCount++
|
||||
if (pddCaptureCount <= loadingPddCaptures) {
|
||||
return UiSnapshot(PDD, ACTIVITY, listOf(node("content", "", 0, 0, 1080, 2200)))
|
||||
@@ -1070,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)
|
||||
}
|
||||
@@ -1079,8 +1374,9 @@ class PurchaseRehearsalExecutorTest {
|
||||
node("panel-title", "确认款式", 20, 396, 300, 430),
|
||||
))
|
||||
}
|
||||
val hideColor = hideColorAfterQuantitySet && quantity == 2L && !hiddenColorRestored
|
||||
val hideSize = hideSizeAfterQuantitySet && quantity == 2L
|
||||
val singleHeading = prefixlessSingleHeadingAfterColor && color != null
|
||||
val hideColor = singleHeading || (hideColorAfterQuantitySet && quantity == 2L && !hiddenColorRestored)
|
||||
val hideSize = (hideSizeAfterQuantitySet && quantity == 2L) || (hideSizeAfterSelection && size != null)
|
||||
val hideSummary = hideSelectedSummaryAfterQuantitySet && quantity == 2L
|
||||
val displayedSummary = if (quantity == 2L && selectedSummaryOverrideAfterQuantitySet != null) {
|
||||
selectedSummaryOverrideAfterQuantitySet
|
||||
@@ -1093,13 +1389,25 @@ class PurchaseRehearsalExecutorTest {
|
||||
node("title", "确认款式", 20, 396, 300, 430),
|
||||
)
|
||||
if (!nonScrollablePanel) nodes += node("scroll", "", 0, 400, 1080, 950, scrollable = true)
|
||||
if (!hideSummary) {
|
||||
if (singleHeading) {
|
||||
nodes += node("info", "", 0, 300, 1080, 400)
|
||||
nodes += node("info/summary", displayedSummary, 20, 320, 700, 350, parentPath = "info")
|
||||
nodes += node("close", "关闭", 980, 300, 1060, 350, clickable = true)
|
||||
nodes += node("minus", "减少数量", 300, 360, 380, 390, clickable = true)
|
||||
nodes += node("plus", "增加数量", 620, 360, 700, 390, clickable = true)
|
||||
nodes += node("payment", "微信支付", 600, 1000, 900, 1050)
|
||||
} else if (!hideSummary) {
|
||||
nodes += node("selected", "已选 $displayedSummary", 20, 365, 700, 395)
|
||||
}
|
||||
if (!hideColor) {
|
||||
nodes += node("scroll/color-heading", "颜色分类", 20, 410, 300, 450, parentPath = "scroll")
|
||||
val selectedColor = if (quantity == 2L) selectedColorOverrideAfterQuantitySet ?: color else color
|
||||
colors.forEachIndexed { index, value ->
|
||||
val visibleColors = horizontalColorPages?.get(horizontalColorPage) ?: colors
|
||||
val colorParent = if (horizontalColorPages != null && visibleColors.size > 1) "scroll/color-row" else "scroll"
|
||||
if (colorParent != "scroll") {
|
||||
nodes += node(colorParent, "", 0, 460, 1080, 550, scrollable = true, parentPath = "scroll")
|
||||
}
|
||||
visibleColors.forEachIndexed { index, value ->
|
||||
nodes += node(
|
||||
"scroll/color-$index",
|
||||
value,
|
||||
@@ -1110,41 +1418,53 @@ class PurchaseRehearsalExecutorTest {
|
||||
clickable = true,
|
||||
selected = selectedColor == value,
|
||||
enabled = !allSpecsUnavailable,
|
||||
parentPath = "scroll",
|
||||
parentPath = colorParent,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (!hideSize) {
|
||||
nodes += node("scroll/size-heading", "尺码", 20, 650, 300, 690, parentPath = "scroll")
|
||||
val visibleSizes = if (quantity == 2L && finalSizesAfterQuantitySet != null) {
|
||||
val visibleSizes = if (revealGridSizeAfterUpSwipes != null) {
|
||||
if (upSwipeCount >= revealGridSizeAfterUpSwipes) sizes else listOf("S", "M")
|
||||
} else horizontalSizePages?.get(horizontalSizePage) ?: if (quantity == 2L && finalSizesAfterQuantitySet != null) {
|
||||
finalSizesAfterQuantitySet
|
||||
} else if (upSwipeCount >= hiddenSizeUntilUpSwipes) {
|
||||
sizes
|
||||
} else {
|
||||
listOf("S")
|
||||
}
|
||||
val sizeParent = if (horizontalSizePages != null) "scroll/size-row" else "scroll"
|
||||
if (horizontalSizePages != null) {
|
||||
nodes += node(sizeParent, "", 0, 700, 1080, 800, scrollable = true, parentPath = "scroll")
|
||||
}
|
||||
visibleSizes.forEachIndexed { index, visibleSize ->
|
||||
nodes += node(
|
||||
"scroll/size-$index",
|
||||
"$sizeParent/size-$index",
|
||||
visibleSize,
|
||||
20 + index * 250,
|
||||
710,
|
||||
710 - if (revealGridSizeAfterUpSwipes != null) upSwipeCount.coerceAtMost(10) * 2 else 0,
|
||||
220 + index * 250,
|
||||
780,
|
||||
780 - if (revealGridSizeAfterUpSwipes != null) upSwipeCount.coerceAtMost(10) * 2 else 0,
|
||||
clickable = true,
|
||||
selected = !hideSizeSelectedState && size == visibleSize,
|
||||
enabled = !allSpecsUnavailable && visibleSize !in unavailableSizes,
|
||||
parentPath = "scroll",
|
||||
parentPath = sizeParent,
|
||||
)
|
||||
}
|
||||
}
|
||||
nodes += node("quantity", quantity.toString(), 400, 800, 600, 870, className = "android.widget.EditText")
|
||||
nodes += node("confirm", "确定", 20, 900, 500, 980, clickable = true)
|
||||
nodes += node("order", "提交订单", 20, 1100, 500, 1180, clickable = true)
|
||||
nodes += if (singleHeading) node("info/quantity", quantity.toString(), 400, 360, 600, 390, className = "android.widget.EditText", parentPath = "info")
|
||||
else node("quantity", quantity.toString(), 400, 800, 600, 870, className = "android.widget.EditText")
|
||||
if (!singleHeading) nodes += node("confirm", "确定", 20, 900, 500, 980, clickable = true)
|
||||
nodes += node("order", "提交订单", 20, if (singleHeading) 2000 else 1100, 500, if (singleHeading) 2080 else 1180, clickable = true)
|
||||
nodes += node("pay", "立即支付", 520, 1100, 1020, 1180, clickable = true)
|
||||
return UiSnapshot(PDD, ACTIVITY, nodes)
|
||||
}
|
||||
|
||||
fun leaveAgentAndOpenBrowser() {
|
||||
inAgent = false
|
||||
browser = true
|
||||
}
|
||||
|
||||
override fun clickFresh(target: SnapshotNode): FreshActionResult {
|
||||
clicked += target.label
|
||||
clickedPaths += target.path
|
||||
@@ -1161,14 +1481,17 @@ class PurchaseRehearsalExecutorTest {
|
||||
if (result == FreshActionResult.SUCCESS || openPddOnFailedClick) browser = false
|
||||
return result
|
||||
}
|
||||
"选择规格", "免拼购买" -> if (entryActionHasEffect) panel = true
|
||||
in sizes -> {
|
||||
"选择规格", "免拼购买" -> {
|
||||
if (entryActionChangesPageWithoutPanel) entryActionChanged = true
|
||||
if (entryActionHasEffect) panel = true
|
||||
}
|
||||
in (horizontalSizePages.orEmpty().flatten() + sizes) -> {
|
||||
sizeClickCount++
|
||||
val result = sizeClickResults.removeFirstOrNull() ?: FreshActionResult.SUCCESS
|
||||
if (result == FreshActionResult.SUCCESS || sizeSelectsOnFailedClick) size = target.label
|
||||
return result
|
||||
}
|
||||
in colors -> color = target.label
|
||||
in (horizontalColorPages?.flatten() ?: colors) -> color = target.label
|
||||
"增加数量" -> quantity++
|
||||
"减少数量" -> quantity--
|
||||
}
|
||||
@@ -1185,7 +1508,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
}
|
||||
return FreshClickOutcome(result, forcedEntryClickReason)
|
||||
}
|
||||
if (target.label in sizes && forcedSizeClickReason != null) {
|
||||
if (target.label in (horizontalSizePages?.flatten() ?: sizes) && forcedSizeClickReason != null) {
|
||||
sizeClickCount++
|
||||
val result = when (forcedSizeClickReason) {
|
||||
FreshClickReason.ROOT_UNAVAILABLE, FreshClickReason.TARGET_NOT_FOUND -> FreshActionResult.NOT_FOUND
|
||||
@@ -1212,12 +1535,15 @@ class PurchaseRehearsalExecutorTest {
|
||||
if (specTapResult != FreshActionResult.SUCCESS || !specTapHasEffect) return specTapResult
|
||||
when {
|
||||
target.path in setOf("spec", "buy") -> panel = true
|
||||
target.label in sizes -> size = target.label
|
||||
target.label in (horizontalSizePages?.flatten() ?: sizes) -> size = target.label
|
||||
target.label in colors -> color = target.label
|
||||
}
|
||||
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
|
||||
@@ -1237,6 +1563,28 @@ class PurchaseRehearsalExecutorTest {
|
||||
return swipePurchase(direction, durationMs)
|
||||
}
|
||||
|
||||
override fun swipeSpecRow(target: SnapshotNode, direction: SwipeDirection): Boolean {
|
||||
horizontalSpecDirections += direction
|
||||
horizontalSpecAnchorPaths += target.path
|
||||
val dimension = if (target.label in (horizontalSizePages?.flatten() ?: emptyList())) "size" else "color"
|
||||
horizontalSpecDimensions += dimension
|
||||
if (!horizontalSpecSwipeSucceeds) return false
|
||||
if (dimension == "size") {
|
||||
horizontalSizePage = when (direction) {
|
||||
SwipeDirection.LEFT -> (horizontalSizePage + 1).coerceAtMost((horizontalSizePages?.lastIndex ?: 0))
|
||||
SwipeDirection.RIGHT -> (horizontalSizePage - 1).coerceAtLeast(0)
|
||||
else -> horizontalSizePage
|
||||
}
|
||||
} else {
|
||||
horizontalColorPage = when (direction) {
|
||||
SwipeDirection.LEFT -> (horizontalColorPage + 1).coerceAtMost((horizontalColorPages?.lastIndex ?: 0))
|
||||
SwipeDirection.RIGHT -> (horizontalColorPage - 1).coerceAtLeast(0)
|
||||
else -> horizontalColorPage
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
override fun pullDownGoodsPage(): Boolean {
|
||||
pullDownCount++
|
||||
if (!pullDownSucceeds) return false
|
||||
|
||||
@@ -16,19 +16,22 @@ class PurchaseRetryPolicyTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `retry confirmation explains new task current archive and no payment`() {
|
||||
fun `ordinary retry uses same task new attempt and no payment`() {
|
||||
val message = PurchaseRetryPolicy.confirmationMessage()
|
||||
assertTrue(message.contains("保留原任务"))
|
||||
assertTrue(message.contains("当前商品档案"))
|
||||
assertTrue(message.contains("最新采购规则"))
|
||||
assertTrue(message.contains("新采购任务"))
|
||||
assertTrue(PurchaseRetryPolicy.usesInPlaceReset(continuing = false))
|
||||
assertTrue(message.contains("复用原采购任务"))
|
||||
assertTrue(message.contains("新一次执行"))
|
||||
assertTrue(message.contains("当前有效采购规则"))
|
||||
assertTrue(message.contains("任务快照保持不变"))
|
||||
assertTrue(message.contains("待付款订单"))
|
||||
assertTrue(message.contains("不会支付"))
|
||||
assertFalse(message.contains("新采购任务"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `continue confirmation remains a new purchase task`() {
|
||||
val message = PurchaseRetryPolicy.confirmationMessage(continuing = true)
|
||||
assertFalse(PurchaseRetryPolicy.usesInPlaceReset(continuing = true))
|
||||
assertTrue(message.contains("新采购任务"))
|
||||
assertTrue(message.contains("替代商品"))
|
||||
assertTrue(message.contains("不会支付"))
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package cn.ilapage.goauto.agent
|
||||
|
||||
import cn.ilapage.goauto.agent.automation.NodeBounds
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseScrollCandidate
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseScrollLocator
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class PurchaseScrollLocatorTest {
|
||||
private val outer = PurchaseScrollCandidate("0/1", "RecyclerView", NodeBounds(0, 1038, 1080, 2079))
|
||||
private val inner = PurchaseScrollCandidate("0/1/0", "RecyclerView", NodeBounds(36, 1149, 1080, 2007))
|
||||
|
||||
@Test fun nestedContainersWithNearbyCentersResolveOuter() {
|
||||
assertEquals(outer, PurchaseScrollLocator.locate(outer, listOf(inner, outer)))
|
||||
}
|
||||
|
||||
@Test fun pathDisambiguatesIdenticalBounds() {
|
||||
val nested = outer.copy(path = "0/1/0")
|
||||
assertEquals(outer, PurchaseScrollLocator.locate(outer, listOf(nested, outer)))
|
||||
}
|
||||
|
||||
@Test fun changedPathRequiresUniqueFullBoundsMatch() {
|
||||
val moved = outer.copy(path = "0/2")
|
||||
assertEquals(moved, PurchaseScrollLocator.locate(outer, listOf(inner, moved)))
|
||||
assertNull(PurchaseScrollLocator.locate(outer, listOf(moved, moved.copy(path = "0/3"))))
|
||||
}
|
||||
|
||||
@Test fun reusedPathWithDifferentGeometryIsRejected() {
|
||||
assertNull(PurchaseScrollLocator.locate(outer, listOf(inner.copy(path = outer.path))))
|
||||
}
|
||||
|
||||
@Test fun classAndAllEdgesAreValidated() {
|
||||
assertNull(PurchaseScrollLocator.locate(outer, listOf(outer.copy(className = "ScrollView"))))
|
||||
assertNull(PurchaseScrollLocator.locate(outer, listOf(
|
||||
outer.copy(bounds = NodeBounds(0, 1138, 1080, 1979)),
|
||||
)))
|
||||
}
|
||||
}
|
||||
+18
@@ -83,6 +83,24 @@ class AgentDiagnosticStoreMigrationTest {
|
||||
assertEquals(1, rowCount(db))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun v2MigrationAddsPurchaseFailureColumnsWithoutDroppingRows() = withDatabase { db ->
|
||||
db.createStatement().use { statement ->
|
||||
statement.execute(CREATE_V1_TABLE_SQL)
|
||||
statement.execute(
|
||||
"INSERT INTO agent_diagnostic " +
|
||||
"(task_id, stage, reason, attempt, elapsed_ms, agent_version, created_at) " +
|
||||
"VALUES (153, 'SPEC_PANEL_ENTRY', 'PURCHASE_FAILURE', 2, 120, '0.9.99', 1000)",
|
||||
)
|
||||
}
|
||||
AgentDiagnosticSchema.migrationStatements(1, 3, columnNames(db)).forEach { sql ->
|
||||
db.createStatement().use { it.execute(sql) }
|
||||
}
|
||||
assertTrue(columnNames(db).containsAll(AgentDiagnosticSchema.purchaseFailureColumns.keys))
|
||||
assertEquals(1, rowCount(db))
|
||||
assertTrue(AgentDiagnosticSchema.migrationStatements(3, 3, columnNames(db)).isEmpty())
|
||||
}
|
||||
|
||||
private fun migrateV1ToV2(db: Connection) {
|
||||
AgentDiagnosticSchema.v2MigrationStatements(1, 2, columnNames(db)).forEach { sql ->
|
||||
db.createStatement().use { it.execute(sql) }
|
||||
|
||||
@@ -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: 21ad1681d12a40e7042567f03716d899d49b737e
|
||||
synchronized_at: 2026-09-05T07:16:58Z
|
||||
wiki_revision: 670a5592a6db8301cd115295bf820f7f4b6e06d7
|
||||
synchronized_at: 2026-09-07T03:21:47Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 业务规则与术语
|
||||
@@ -257,13 +257,13 @@ synchronized_at: 2026-09-05T07:16:58Z
|
||||
## Agent 受控重试采购
|
||||
|
||||
- 当前设备只可重试自身最近 30 天内、服务端标记 `retryable=true` 的正式采购失败任务;列表和详情都只能发起单任务重试,不支持多选、批量或自动重试。
|
||||
- 普通“重试采购”调用既有 `AgentRetry → BatchRetry → Create`:原失败任务及其商品、目标规格、执行规格、价格和执行记录保持不可变;服务端根据当前 SYB、虾皮/PDD 档案、当前采购规则和当前设备创建不同 `purchase_task.id` 的新任务。
|
||||
- 新 SYB 采购任务继续遵循 #215 的强制当次规格探测,首趟不得直接使用历史任务的规格决策;当前档案、规则、价格、设备或能力门禁不通过时拒绝创建,旧任务保持失败状态。
|
||||
- 普通“重试采购”调用就地 `/reset`:复用原 `purchase_task.id`,只新增 attempt;新 attempt 使用当前有效采购规则和最新版 Agent 代码,商品、目标规格、执行规格、数量、价格、地址及既有规格决策等业务快照保持不变。
|
||||
- 就地重试不恢复已经消耗的真机规格探测资格;目标规格存在但对应执行规格为空时必须拒绝,不能进入正式采购阶段。需要按替代商品或已变化业务规格重新决策时,必须走明确的新任务流程。
|
||||
- 已出现 `order_submit_started` 证据,或存在不可逆时间、订单提交请求、PDD 订单号、下单时间的任务一律拒绝重试,并提示走既有“授权重新采购”流程,防止重复下单。
|
||||
- `requestId` 按“来源任务 + 请求”幂等;相同请求重放返回同一新任务,不重复创建。
|
||||
- Android 只在服务端 `retryable=true` 且状态为 `failed` 时显示普通“重试采购”。确认和成功反馈必须说明旧任务保留、新任务读取当前档案和规则、可能创建待付款订单且系统不会支付。
|
||||
- 历史兼容的就地 `/reset` 服务端入口不得把“目标规格存在但对应执行规格为空”的任务恢复到正式采购阶段;此类异常快照必须拒绝,并提示创建新任务。Android 普通重试不再调用该入口。
|
||||
- 替代商品匹配完成后的“继续采购”和 Admin 批量重试继续使用同一新任务语义;取消订单、修改既有订单和支付仍禁止。真机重试可能进入创建待付款订单流程,执行前必须再次取得人工授权。
|
||||
- `requestId` 按“任务 + 重置请求”幂等;相同请求重放返回同一 task ID 和 attempt,不重复递增。
|
||||
- Android 只在服务端 `retryable=true` 且状态为 `failed` 时显示普通“重试采购”。确认和成功反馈必须说明复用原任务、新增一次执行、使用当前规则但业务快照不变、可能创建待付款订单且系统不会支付。
|
||||
- `/reset` 不得把“目标规格存在但对应执行规格为空”的任务恢复到正式采购阶段;此类异常快照必须拒绝,并提示使用明确的新任务流程。
|
||||
- 替代商品匹配完成后的“继续采购”继续调用 `AgentRetry → BatchRetry → Create`,按当前档案创建不同 task ID;Admin 批量重试也保持新任务语义。取消订单、修改既有订单和支付仍禁止。真机重试可能进入创建待付款订单流程,执行前必须再次取得人工授权。
|
||||
|
||||
## Agent 状态页手动检查任务
|
||||
|
||||
@@ -341,7 +341,7 @@ synchronized_at: 2026-09-05T07:16:58Z
|
||||
|
||||
## SYB 采购强制当次规格探测(#215)
|
||||
|
||||
- 每个新 SYB 采购任务固定执行“首趟只读探测 → 服务端确定性优先/必要时 AI → 固化任务级精确规格 → 第二趟正式采购”。首趟只打开一次浏览器商品链接;匹配期间当前设备保留给同一任务,不领取其他采购或采集任务;第二趟复用 PDD 当前页,不再次打开链接,也不严格核验标题、goodsId 或页面指纹,但仍要求 PDD 包名与商品/规格/订单页面结构安全证据。已有长期映射只作商品档案事实,不直接进入任务执行规格。
|
||||
- 每个新 SYB 采购任务固定执行“首趟只读探测 → 服务端确定性优先/必要时 AI → 固化任务级精确规格 → 第二趟正式采购”。首趟只打开一次浏览器商品链接;匹配期间当前设备保留给同一任务,不领取其他采购或采集任务。连续进入第二趟且当前仍有 PDD 商品页或规格面板强证据时复用当前页、不再次打开链接;手动同任务重试,或当前处于 Agent、其他应用及缺少上述强证据时,重新打开任务固化的商品 URL。复用或重开均不严格核验标题、goodsId 或页面指纹,但仍要求 PDD 包名与商品/规格/订单页面结构安全证据。已有长期映射只作商品档案事实,不直接进入任务执行规格。
|
||||
- 首趟候选与 `taskId`、`taskAttemptId`、`deviceId`、规则快照哈希和幂等结果哈希关联;第二趟失败不得回到首趟循环探测。备货 `stock/direct_select` 没有 SYB 目标规格,继续使用用户逐字选择的档案规格,不进入本规则。
|
||||
- 候选和 Provider 结果仅保存颜色、尺码原始标签及结构化决策,不保存控件树、整屏截图、账号、地址、订单或支付数据;付款仍永久禁止。
|
||||
|
||||
@@ -431,3 +431,16 @@ synchronized_at: 2026-09-05T07:16:58Z
|
||||
- 服务端只接受同一 `installId`、未过期且尚未使用的恢复码完成重新注册,成功后签发新 Token 并使恢复码失效。过期、重复使用、installId 不符或停用均明确失败;不能通过清空数据、直接改库或“吊销 Token”恢复原任务归属。
|
||||
|
||||
- 自 #223 起,任务创建时把与冻结 SYB 目标对应的已确认商品规格映射保存为不可变的探测指导快照,但仍不得跳过首趟真机探测。探测完成后,每个角色先验证快照映射能否按既有规范化规则唯一对应当次候选,能对应时固化当次候选原文;不能对应时只对该未解决角色执行确定性匹配,仍无结果才调用 AI。已解决角色不重复交给 AI,任一最终值仍必须逐字属于当次候选;历史映射失效、规范化后歧义或角色不符时不得复用。候选完整但无法决策时提示“已采集到当前规格,但未能确定颜色或尺码映射”,不再误报候选不存在。
|
||||
|
||||
## 采购商品深链入口(#232)
|
||||
|
||||
- Android 采购新任务(含探测与人工重试)打开商品时,优先使用指定 PDD 包名的 ACTION_VIEW,并设置 NEW_TASK 与 CLEAR_TASK,以清理旧任务栈后交付该任务链接。清理的是 Activity 返回历史,不是清除应用数据或强制停止进程。
|
||||
- 直接启动失败(包括无可处理 Activity)时回退既有浏览器入口;启动请求被接受后仍由执行器验证稳定商品页面,停留首页不会仅因启动成功而判定完成。
|
||||
- 清栈不用于改地址后返回、下单后回到 PDD、订单核查;采集入口继续使用既有浏览器流程,避免绕过规则中的浏览器步骤。
|
||||
- 当前通用商品页面结构验证不能独立证明页面 goods_id;解析结果携带的任务 goodsId 不是页面回读证据。首次直接深链上线仍需以实际目标商品真机核对,ADB 实验不替代 Agent 上下文验证。
|
||||
## SYB 批量采购设备偏好(#233)
|
||||
|
||||
- SYB 商品页“批量创建采购任务”按当前登录用户 ID、当前浏览器来源与 API 环境记住最近一次设备选择;主动清空也记忆为不指定设备。只保存设备 ID,不保存 Token、账号凭据或订单信息。
|
||||
- 弹窗打开后先按现有在线/可选/采购能力条件加载设备,再恢复选择并以相同设备预检。记忆设备当前不可用时保留偏好并提示用户重新选择或明确清空,不静默切换设备或自动领取。
|
||||
- 预检加载中、失败或记忆设备不可用时不能提交;过期预检结果不覆盖新的设备选择。服务端原有设备及采购资格校验不变。
|
||||
- 偏好只作用于此入口,不影响采集、其他创建入口和采购重试。浏览器存储失败时仍允许手动操作;刷新或关闭再打开浏览器可恢复,清理浏览器数据或沿用现有退出登录清理存储行为后需重新选择。
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Android-Agent-API-Contract
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Android-Agent-API-Contract.-
|
||||
wiki_revision: e5a443e16e288143c2c890c674038c531abe3f16
|
||||
synchronized_at: 2026-09-05T07:17:37Z
|
||||
wiki_revision: f3242938de4ac55c47f5c6eebd69184024e6a0ea
|
||||
synchronized_at: 2026-09-05T09:04:42Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# MVP 共享 API 契约
|
||||
@@ -535,11 +535,11 @@ Admin 列表与详情由 #35 实现;#67 增加 `shopeeOrderNoSnapshot` 的列
|
||||
| `POST` | `/api/agent/v1/purchase-tasks/{taskId}/order-submit-started` | 创建订单前先落不可逆标记;演练任务和 `spec_probe` attempt 永远拒绝 |
|
||||
| `POST` | `/api/agent/v1/purchase-tasks/{taskId}/result` | 请求体携带 `taskAttemptId` 和 `requestId`;幂等提交演练、规格探测、订单或失败结果 |
|
||||
|
||||
自 #215 起,新建 SYB 采购任务不再从 PDD 档案创建持久匹配工作项,也不在首次派发前调用外部 AI;部署前已存在的 `purchase_spec_match_work_item` 继续按原状态兼容处理。新任务首次 `start` 固定得到 `phase=spec_probe`,Android 通过浏览器打开任务链接一次并经既有结果字段回传当次候选;匹配成功后的第二次 `start` 才得到 `phase=purchase` 和服务端固化的精确 PDD 原始标签。第二阶段直接复用首趟保留的 PDD 页面,不再次打开浏览器链接,也不以标题、goodsId 或页面指纹做严格同页校验;仍必须通过 PDD 包名和商品/规格/订单页面结构安全证据。Android 不接收 AI 配置或自由决策权限。
|
||||
自 #215 起,新建 SYB 采购任务不再从 PDD 档案创建持久匹配工作项,也不在首次派发前调用外部 AI;部署前已存在的 `purchase_spec_match_work_item` 继续按原状态兼容处理。新任务首次 `start` 固定得到 `phase=spec_probe`,Android 通过浏览器打开任务链接一次并经既有结果字段回传当次候选;匹配成功后的第二次 `start` 才得到 `phase=purchase` 和服务端固化的精确 PDD 原始标签。连续第二阶段仅在当前 PDD 页面仍有商品页或规格面板强证据时复用首趟页面、不再次打开浏览器链接;手动同任务重试,或当前为 Agent、其他应用及缺少上述强证据时,Android 重新打开任务 `urlSnapshot`。两种路径都不以标题、goodsId 或页面指纹做严格同页校验,仍必须通过 PDD 包名和商品/规格/订单页面结构安全证据。Android 不接收 AI 配置或自由决策权限。
|
||||
|
||||
结果提交至少关联 `taskId`、`taskAttemptId`、`deviceId`、规则快照哈希和结构化结果。相同 attempt 的相同结果重复提交返回同一事实;不同内容拒绝覆盖。每个新 SYB 采购任务的第一趟只读遍历当次 PDD 规格面板并提交颜色、尺码原始候选,随后释放数据库租约和已知账号运行守卫并进入 `spec_probe_pending`,但服务端调度与 Agent 必须把当前设备保留给同一采购流程:`next` 返回该等待任务,Agent 只轮询等待,不领取其他采购或采集任务。服务端只以任务冻结的 SYB 目标和当次候选先做繁简、空白/全半角/大小写及公斤/斤的唯一确定性匹配,仍无唯一结果才调用 AI。AI 的颜色和尺码必须逐字属于当次对应候选,否则按无匹配失败。第二趟只会收到服务端固化的精确 PDD 原始标签;Agent 复用首趟仍打开的页面,只在已打开的规格面板内做有限纵向滑动,每次重新读取节点并按完整规范化文字精确点击,连续没有新证据或达到上限即停止。尺码的任务目标与页面值在选择边界使用同一安全尾价规范化;不改写任务快照,规范化为空、仍含货币符号或多个原始候选折叠为同一值时安全失败。
|
||||
|
||||
任务 payload 的必传布尔字段 `specResolutionAllowed` 是 Android 是否可以提交规格探测的唯一资格事实。新建 `taskType=syb_order` 任务必须由声明 `purchase.spec-probe.v1` 的规则创建,初始 `SpecDecisionRequestID` 为空且 `specSource=unresolved`,首趟返回 `true`;当次决策固化后返回 `false`。`stock`、`direct_select`、已固化规格决策、能力缺失及其他组合均返回 `false`。历史兼容的就地 `/reset` 保留 `SpecDecisionRequestID`、目标规格、映射规格和规格决策快照,不能恢复探测资格;映射不完整时必须拒绝,不能进入正式采购阶段。普通 Agent 重试创建新任务并重新取得一次探测资格。Android 不得根据映射是否非空、错误文字或本地判断扩大资格。
|
||||
任务 payload 的必传布尔字段 `specResolutionAllowed` 是 Android 是否可以提交规格探测的唯一资格事实。新建 `taskType=syb_order` 任务必须由声明 `purchase.spec-probe.v1` 的规则创建,初始 `SpecDecisionRequestID` 为空且 `specSource=unresolved`,首趟返回 `true`;当次决策固化后返回 `false`。`stock`、`direct_select`、已固化规格决策、能力缺失及其他组合均返回 `false`。普通 Agent 重试使用就地 `/reset`,保留 `SpecDecisionRequestID`、目标规格、映射规格和规格决策快照,不能恢复探测资格;映射不完整时必须拒绝,不能进入正式采购阶段。只有替代商品“继续采购”或 Admin 批量重试创建的新任务才按 #215 重新取得一次探测资格。Android 不得根据映射是否非空、错误文字或本地判断扩大资格。
|
||||
|
||||
Android 规格失败使用五个稳定阶段:`PURCHASE_SPEC_TARGET_NOT_VISIBLE`、`PURCHASE_SPEC_TARGET_AMBIGUOUS`、`PURCHASE_SPEC_SAFE_TARGET_MISSING`、`PURCHASE_SPEC_CLICK_FAILED` 和 `PURCHASE_SPEC_SELECTION_UNCONFIRMED`。`PURCHASE_SPEC_CLICK_FAILED` 的 `errorMessage` 只允许稳定子原因 `root_unavailable`、`target_stale`、`no_clickable_ancestor`、`action_click_false` 或 `unknown`;其他阶段的消息不得包含规格原文、坐标、控件树或截图。只有 `PURCHASE_SPEC_TARGET_NOT_VISIBLE && specResolutionAllowed=true` 可以提交规格探测,其他四态直接提交真实失败,服务端原样保留稳定阶段/子原因。旧 Agent 在资格已用尽后再次提交 `spec_probe_completed` 时,服务端以 `PURCHASE_SPEC_REPROBE_REJECTED` fail-closed,释放租约并保留第一次规格决策,不再冒充新的选择根因或再次派发。无匹配、候选不完整、歧义或 Provider 异常同样使任务失败。`order_result_unknown` 只允许管理员或采购员人工解除,永不自动重派。
|
||||
|
||||
@@ -617,9 +617,9 @@ Content-Type: application/json
|
||||
- 响应返回 `taskId`、`attemptNumber`、`status` 和可选的 `replayed`,不返回规则快照、URL、Token、控件树或截图。
|
||||
- 设备离线、任务非终态、设备忙、规则不可用或同商品存在活动任务时返回明确冲突,不支持离线排队。
|
||||
|
||||
## Agent 受控采购重试(#95、#157、#217)
|
||||
## Agent 受控采购重试(#95、#157、#217、#225)
|
||||
|
||||
普通失败任务的“重试采购”和替代商品匹配完成后的“继续采购”统一调用新任务接口:
|
||||
替代商品匹配完成后的“继续采购”调用新任务接口;普通失败任务的“重试采购”使用下方同任务重置接口:
|
||||
|
||||
```http
|
||||
POST /api/agent/v1/purchase-tasks/{taskId}/retry
|
||||
@@ -647,11 +647,11 @@ Content-Type: application/json
|
||||
- 来源任务不得存在 `irreversibleAt`、`orderSubmitRequestId`、PDD 订单号或下单时间。已有任何不可逆证据时返回 `PURCHASE_RETRY_UNSAFE`,提示走“授权重新采购”,不得创建新任务。
|
||||
- `AgentRetry → BatchRetry → Create` 保留来源失败任务并创建不同 `purchase_task.id` 的新任务;新任务重新读取当前 SYB、虾皮/PDD 档案、当前采购规则、价格保护和设备能力,重新生成地址后缀,不继承来源任务的旧规格决策。
|
||||
- 新 SYB 任务按 #215 固定从 `spec_probe` 开始。相同 `requestId` 重放返回同一新任务且 `replayed=true`;不同 requestId 再次请求受同一 SYB 商品最新任务和设备并发门禁约束。
|
||||
- Android 只在服务端 `retryable=true` 且状态为 `failed` 时显示普通“重试采购”;确认文案和成功反馈必须说明原任务保留、新任务使用当前档案与规则、可能产生待付款订单且系统不会支付。
|
||||
- 本新任务接口只供替代商品“继续采购”;确认和成功反馈必须说明保留来源任务、按当前替代商品档案创建新任务、可能产生待付款订单且系统不会支付。
|
||||
- 规则无效、当前档案或价格不合格、设备离线/忙、能力不匹配、任务状态变化或同一 SYB 商品已有更新任务时,服务端明确拒绝且不得部分创建。
|
||||
- Admin 批量重试继续使用相同的新任务语义;替代商品“继续采购”仍在 AgentRetry 前额外验证替换分项与继续采购资格。
|
||||
|
||||
历史兼容的就地重置接口仍保留,但 Android 普通重试不再调用:
|
||||
普通失败任务的“重试采购”调用同任务重置接口:
|
||||
|
||||
```http
|
||||
POST /api/agent/v1/purchase-tasks/{taskId}/reset
|
||||
@@ -661,8 +661,8 @@ Content-Type: application/json
|
||||
{"requestId":"<uuid>"}
|
||||
```
|
||||
|
||||
- `/reset` 只允许安全失败、无不可逆证据且不存在更新任务的原任务;它保留原业务快照并刷新当前规则。
|
||||
- 目标颜色存在但映射颜色为空,或目标尺码存在但映射尺码为空时,必须返回 `PURCHASE_SPEC_MAPPING_REQUIRED`,不得创建 `purchase` attempt 或下发正式采购 payload。
|
||||
- `/reset` 只允许当前设备的安全失败任务且不得存在不可逆证据或更新任务;它复用原 `purchase_task.id`、新增 attempt、刷新当前有效规则,并保持商品、目标/执行规格、数量、价格、地址和规格决策等业务快照不变。
|
||||
- 目标颜色存在但映射颜色为空,或目标尺码存在但映射尺码为空时,必须返回 `PURCHASE_SPEC_MAPPING_REQUIRED`,不得创建 `purchase` attempt 或下发正式采购 payload。相同 `requestId` 重放返回同一 task ID 和 attempt,不重复递增;Android 必须明确提示复用原任务和新的 attempt 序号。
|
||||
- 两个入口都不执行支付。真机调用可能创建待付款订单,必须先取得人工授权。
|
||||
|
||||
## Agent 任务记录范围与同步(#99)
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: SYB-ERP-Interface-Contract
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/SYB-ERP-Interface-Contract.-
|
||||
wiki_revision: ad9adc22e69e48c9a8fd87d7121066eecca55fd6
|
||||
synchronized_at: 2026-09-04T11:30:50Z
|
||||
wiki_revision: df0fe874c7f3f3e9c031f2f793f8f6a0511ace25
|
||||
synchronized_at: 2026-09-07T06:25:27Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 12 顺云宝(SYB)ERP 接口契约
|
||||
@@ -228,6 +228,14 @@ Admin 默认 `max_matches = 10000`,可以在配置中调整;上限针对整
|
||||
读取完整明细并按既有 upsert 保存,但本次同步仍记为失败、明确提示当天未形成
|
||||
稳定快照且不推进游标,下一次继续覆盖今天。任何尝试都不得突破 `max_matches`;
|
||||
网络/业务错误、非法 ID 或不完整明细不属于可放宽的快照漂移。
|
||||
`[必须,#235]` 当天跨页重复 ID 纳入上述最多 3 次列表快照尝试(含首次),
|
||||
不增加另一层重试次数。发现跨页重复后丢弃本次列表,从预检总数和第一页重新开始,
|
||||
使用全新 ID 集合;最后一次仍重复时直接失败,不得去重后按成功或降级数据保存。
|
||||
已经完成的历史日期保持其已有结果,不重复拉取;历史日期重复不适用此恢复。
|
||||
每页先检查非法 ID 和页内重复,再检查跨页重叠;同页同时存在页内重复和跨页重叠时
|
||||
仍作为硬错误停止。原有总数漂移/短页的合法唯一列表降级保存条件保持不变。
|
||||
重复诊断只记录日期、当天尝试序号、首次/当前页码与行号、start、pageSize、
|
||||
expectedTotal 和已获取唯一数量,不记录真实重复 ID、原始响应或个人数据。
|
||||
|
||||
### 4.4 统一日期范围同步与覆盖游标
|
||||
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
package sybimport
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func freezePaginationToday(t *testing.T) {
|
||||
t.Helper()
|
||||
previous := syncNow
|
||||
syncNow = func() time.Time { return time.Date(2026, 8, 29, 12, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*60*60)) }
|
||||
t.Cleanup(func() { syncNow = previous })
|
||||
}
|
||||
|
||||
func TestTodayCrossPageOverlapRestartsWithIndependentIDs(t *testing.T) {
|
||||
freezePaginationToday(t)
|
||||
f := &fakeSYB{perDay: map[string]int{"2026-08-29": 4}}
|
||||
f.pageIDs = func(_ string, start, call int) []int64 {
|
||||
if call == 2 {
|
||||
return []int64{1001, 1002}
|
||||
}
|
||||
if call > 2 {
|
||||
return []int64{int64(2000 + start), int64(2001 + start)}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
rows, err := loadDailyListWithRecovery(context.Background(), newSyncClient(t, f), "2026-08-29", 2, 4, 100)
|
||||
if err != nil || len(rows) != 4 || f.listTotalCalls != 2 {
|
||||
t.Fatalf("rows=%d totals=%d err=%v", len(rows), f.listTotalCalls, err)
|
||||
}
|
||||
if got := strings.Join(f.listCalls, ","); got != "2026-08-29:0,2026-08-29:2,2026-08-29:0,2026-08-29:2" {
|
||||
t.Fatalf("did not restart at first page: %s", got)
|
||||
}
|
||||
for index, row := range rows {
|
||||
if row.ID != int64(2000+index) {
|
||||
t.Fatal("rows leaked from abandoned attempt")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTodayPersistentOverlapPreservesYesterdayButDoesNotImportToday(t *testing.T) {
|
||||
freezePaginationToday(t)
|
||||
f := &fakeSYB{perDay: map[string]int{"2026-08-28": 2, "2026-08-29": 4}}
|
||||
f.pageIDs = func(date string, start, _ int) []int64 {
|
||||
if date == "2026-08-29" && start == 2 {
|
||||
return []int64{1001, 1002}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
db := newSyncTestDB(t)
|
||||
report, err := Sync(context.Background(), db, newSyncClient(t, f), SyncConfig{PageSize: 2, MaxMatches: 100}, "2026-08-28", "2026-08-29")
|
||||
if err == nil {
|
||||
t.Fatal("overlap was treated as successful sync")
|
||||
}
|
||||
for _, token := range []string{"连续 3 次", "跨页重复", "firstPage=1", "firstRow=2", "page=2", "row=1", "start=2", "pageSize=2", "expectedTotal=4", "unique=2"} {
|
||||
if !strings.Contains(err.Error(), token) {
|
||||
t.Fatalf("missing %s in %v", token, err)
|
||||
}
|
||||
}
|
||||
if strings.Contains(err.Error(), "1001") || strings.Contains(err.Error(), "已保存") {
|
||||
t.Fatalf("unsafe diagnosis/degraded save: %v", err)
|
||||
}
|
||||
if len(f.listCalls) != 7 || f.detailCalls != 1 || report.OrderCount != 2 {
|
||||
t.Fatalf("unexpected retry/import boundary: pages=%d details=%d orders=%d", len(f.listCalls), f.detailCalls, report.OrderCount)
|
||||
}
|
||||
var count int64
|
||||
if e := db.Table("syb_product").Count(&count).Error; e != nil || count != 2 {
|
||||
t.Fatalf("yesterday not preserved: count=%d err=%v", count, e)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaginationHardErrorsDoNotUseOverlapRecovery(t *testing.T) {
|
||||
freezePaginationToday(t)
|
||||
for _, tc := range []struct {
|
||||
name, date, message string
|
||||
page int
|
||||
ids []int64
|
||||
calls int
|
||||
}{
|
||||
{"same page", "2026-08-29", "同页重复", 0, []int64{1000, 1000}, 1},
|
||||
{"mixed overlap and same page", "2026-08-29", "同页重复", 2, []int64{1001, 1001}, 2},
|
||||
{"overlap and invalid ID", "2026-08-29", "非法 id", 2, []int64{1001, 0}, 2},
|
||||
{"historical overlap", "2026-08-28", "跨页重复", 2, []int64{1001, 1002}, 2},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
f := &fakeSYB{perDay: map[string]int{tc.date: 4}}
|
||||
f.pageIDs = func(_ string, start, _ int) []int64 {
|
||||
if start == tc.page {
|
||||
return tc.ids
|
||||
}
|
||||
return nil
|
||||
}
|
||||
rows, err := loadDailyListWithRecovery(context.Background(), newSyncClient(t, f), tc.date, 2, 4, 100)
|
||||
if rows != nil || err == nil || !strings.Contains(err.Error(), tc.message) || len(f.listCalls) != tc.calls || f.listTotalCalls != 0 {
|
||||
t.Fatalf("unexpected recovery: rows=%d pages=%d totals=%d err=%v", len(rows), len(f.listCalls), f.listTotalCalls, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -358,7 +358,7 @@ func loadDailyListWithRecovery(ctx context.Context, client *sybclient.Client, da
|
||||
}
|
||||
var drift *snapshotDriftError
|
||||
if !errors.As(err, &drift) {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("今天第 %d/%d 次拉取失败: %w", attempt, maxTodaySnapshotAttempts, err)
|
||||
}
|
||||
last = drift
|
||||
}
|
||||
@@ -370,7 +370,8 @@ func loadDailyListWithRecovery(ctx context.Context, client *sybclient.Client, da
|
||||
|
||||
func loadDailyList(ctx context.Context, client *sybclient.Client, date string, pageSize, expectedTotal int) ([]sybclient.StockRow, error) {
|
||||
rows := make([]sybclient.StockRow, 0, expectedTotal)
|
||||
seen := make(map[int64]struct{}, expectedTotal)
|
||||
type position struct{ page, row int }
|
||||
seen := make(map[int64]position, expectedTotal)
|
||||
|
||||
for start := 0; start < expectedTotal; start += pageSize {
|
||||
pageIndex := start/pageSize + 1
|
||||
@@ -386,14 +387,27 @@ func loadDailyList(ctx context.Context, client *sybclient.Client, date string, p
|
||||
if pageCount != len(page) {
|
||||
return nil, fmt.Errorf("%s 货运单列表第 %d 页响应条数不自洽:total=%d,list=%d", date, pageIndex, pageCount, len(page))
|
||||
}
|
||||
for _, row := range page {
|
||||
// Validate the whole page first: a same-page duplicate must not be
|
||||
// hidden behind an earlier, recoverable cross-page overlap.
|
||||
pageSeen := make(map[int64]int, len(page))
|
||||
for index, row := range page {
|
||||
if row.ID <= 0 {
|
||||
return nil, fmt.Errorf("%s 货运单列表包含非法 id=%d", date, row.ID)
|
||||
}
|
||||
if _, duplicate := seen[row.ID]; duplicate {
|
||||
return nil, fmt.Errorf("%s 货运单列表重复返回 id=%d", date, row.ID)
|
||||
if firstRow, duplicate := pageSeen[row.ID]; duplicate {
|
||||
return nil, fmt.Errorf("%s 货运单列表同页重复 [firstPage=%d,firstRow=%d,page=%d,row=%d,start=%d,pageSize=%d,expectedTotal=%d,unique=%d]",
|
||||
date, pageIndex, firstRow, pageIndex, index+1, start, pageSize, expectedTotal, len(rows))
|
||||
}
|
||||
seen[row.ID] = struct{}{}
|
||||
pageSeen[row.ID] = index + 1
|
||||
}
|
||||
for index, row := range page {
|
||||
if first, duplicate := seen[row.ID]; duplicate {
|
||||
// No rows/valid flag: overlapping pages are never eligible for
|
||||
// the existing final-attempt degraded save, even after deduping.
|
||||
return nil, &snapshotDriftError{message: fmt.Sprintf("%s 货运单列表跨页重复 [firstPage=%d,firstRow=%d,page=%d,row=%d,start=%d,pageSize=%d,expectedTotal=%d,unique=%d]",
|
||||
date, first.page, first.row, pageIndex, index+1, start, pageSize, expectedTotal, len(rows))}
|
||||
}
|
||||
seen[row.ID] = position{page: pageIndex, row: index + 1}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
if len(page) != expectedPageCount {
|
||||
|
||||
@@ -60,6 +60,9 @@ type fakeSYB struct {
|
||||
totalOverride map[int]int
|
||||
shortPageAtIndex int
|
||||
detailDropID int64
|
||||
listCalls []string
|
||||
pageIDs func(date string, start, call int) []int64
|
||||
detailCalls int
|
||||
// shopNames 按货运单序号轮换;留空表示全部用「测试店铺」。
|
||||
shopNames []string
|
||||
// detailShopName 非空时,明细响应里的 shopName 用它覆盖,
|
||||
@@ -96,6 +99,7 @@ func (f *fakeSYB) server(t *testing.T) *httptest.Server {
|
||||
start := int(body["start"].(float64))
|
||||
pageSize := int(body["length"].(float64))
|
||||
total := f.perDay[date]
|
||||
f.listCalls = append(f.listCalls, fmt.Sprintf("%s:%d", date, start))
|
||||
|
||||
rows := []map[string]any{}
|
||||
for i := start; i < total && len(rows) < pageSize; i++ {
|
||||
@@ -108,9 +112,18 @@ func (f *fakeSYB) server(t *testing.T) *httptest.Server {
|
||||
if f.shortPageAtIndex > 0 && start/pageSize+1 == f.shortPageAtIndex && len(rows) > 0 {
|
||||
rows = rows[:len(rows)-1]
|
||||
}
|
||||
if f.pageIDs != nil {
|
||||
if ids := f.pageIDs(date, start, len(f.listCalls)); ids != nil {
|
||||
rows = nil
|
||||
for _, id := range ids {
|
||||
rows = append(rows, map[string]any{"id": id, "code": "TEST", "shopName": f.shopFor(0)})
|
||||
}
|
||||
}
|
||||
}
|
||||
writeEnvelope(w, map[string]any{"list": rows, "total": len(rows)})
|
||||
})
|
||||
mux.HandleFunc("/am/stock/detail/listByStock", func(w http.ResponseWriter, r *http.Request) {
|
||||
f.detailCalls++
|
||||
var body struct {
|
||||
IDs []int64 `json:"ids"`
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ const getters = {
|
||||
visitedViews: state => state.tagsView.visitedViews,
|
||||
cachedViews: state => state.tagsView.cachedViews,
|
||||
token: state => state.user.token,
|
||||
userId: state => state.user.userId,
|
||||
avatar: state => state.user.avatar,
|
||||
name: state => state.user.name,
|
||||
introduction: state => state.user.introduction,
|
||||
|
||||
@@ -5,6 +5,7 @@ import storage from '@/utils/storage'
|
||||
|
||||
const state = {
|
||||
token: getToken(),
|
||||
userId: null,
|
||||
name: '',
|
||||
avatar: '',
|
||||
introduction: '',
|
||||
@@ -16,6 +17,10 @@ const state = {
|
||||
const mutations = {
|
||||
SET_TOKEN: (state, token) => {
|
||||
state.token = token
|
||||
if (!token) state.userId = null
|
||||
},
|
||||
SET_USER_ID: (state, userId) => {
|
||||
state.userId = Number(userId) || null
|
||||
},
|
||||
SET_INTRODUCTION: (state, introduction) => {
|
||||
state.introduction = introduction
|
||||
@@ -63,13 +68,14 @@ const actions = {
|
||||
return resolve()
|
||||
}
|
||||
|
||||
const { roles, name, avatar, introduction, permissions } = response.data
|
||||
const { roles, name, avatar, introduction, permissions, userId } = response.data
|
||||
|
||||
// roles must be a non-empty array
|
||||
if (!roles || roles.length <= 0) {
|
||||
return reject('getInfo: roles must be a non-null array!')
|
||||
}
|
||||
commit('SET_PERMISSIONS', permissions)
|
||||
commit('SET_USER_ID', userId)
|
||||
commit('SET_ROLES', roles)
|
||||
commit('SET_NAME', name)
|
||||
commit('SET_AVATAR', avatar)
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
function preferenceKey(userId) {
|
||||
const id = Number(userId)
|
||||
if (!Number.isSafeInteger(id) || id <= 0) return null
|
||||
return `goauto:purchase-device:${encodeURIComponent(process.env.VUE_APP_BASE_API || '/')}:user:${id}`
|
||||
}
|
||||
|
||||
export function readPurchaseDevice(userId) {
|
||||
try {
|
||||
const key = preferenceKey(userId)
|
||||
if (!key) return null
|
||||
const id = JSON.parse(localStorage.getItem(key) || 'null')
|
||||
return Number.isSafeInteger(id) && id > 0 ? id : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function rememberPurchaseDevice(userId, deviceId) {
|
||||
try {
|
||||
const key = preferenceKey(userId)
|
||||
if (!key) return false
|
||||
const id = deviceId == null || deviceId === '' ? null : Number(deviceId)
|
||||
if (id !== null && (!Number.isSafeInteger(id) || id <= 0)) return false
|
||||
localStorage.setItem(key, JSON.stringify(id))
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -37,8 +37,16 @@
|
||||
<el-alert title="每条 SYB 商品分别创建一个采购任务和一个待付款 PDD 订单;系统永不支付。创建前服务端会再次逐条检查。" type="warning" :closable="false" show-icon class="notice" />
|
||||
<div class="purchase-summary" aria-live="polite"><span>已选择 <strong>{{ purchaseDialog.selectedCount }}</strong> 条 SYB 明细</span><span class="success-text">其中 {{ purchaseDialog.eligibleCount }} 条适用于采购</span><span v-if="purchaseDialog.skippedCount" class="danger-text">预检后另有 {{ purchaseDialog.skippedCount }} 条不可创建</span></div>
|
||||
<el-form label-width="110px" class="purchase-settings">
|
||||
<el-form-item label="Android 设备"><el-select v-model="purchaseDialog.deviceId" clearable placeholder="不指定,由空闲设备领取" style="width:360px" @change="refreshPurchasePreview"><el-option v-for="device in purchaseDevices" :key="device.id" :label="`${device.name} · ${device.model}`" :value="device.id" /></el-select><span class="field-help">默认人工指定;留空时由符合能力的空闲设备领取。</span></el-form-item>
|
||||
<el-form-item label="Android 设备">
|
||||
<el-select v-model="purchaseDialog.deviceId" clearable placeholder="不指定,由空闲设备领取" style="width:360px" :disabled="purchaseDialog.saving" @change="changePurchaseDevice">
|
||||
<el-option v-if="purchaseDeviceUnavailable" :value="purchaseDialog.deviceId" label="上次选择的设备(当前不可用)" disabled />
|
||||
<el-option v-for="device in purchaseDevices" :key="device.id" :label="`${device.name} · ${device.model}`" :value="device.id" />
|
||||
</el-select>
|
||||
<span v-if="purchaseDeviceUnavailable" class="field-help danger-text" role="alert">上次选择的设备当前不可用,请重新选择,或清空后由空闲设备领取。</span>
|
||||
<span v-else class="field-help">记住当前用户在此浏览器的选择;留空时由符合能力的空闲设备领取。</span>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<el-alert v-if="purchaseDialog.previewError" :title="purchaseDialog.previewError" type="error" :closable="false" show-icon class="notice"><el-button link type="primary" @click="openPurchaseBatch">重试</el-button></el-alert>
|
||||
<el-table :data="purchaseDialog.items" border size="small" max-height="380" empty-text="没有可创建的商品">
|
||||
<el-table-column label="SYB 商品" min-width="150"><template #default="{ row }"><strong>{{ row.orderCode || `ID ${row.sybProductId}` }}</strong><div class="muted">{{ row.shopeeItemId || '未关联蝦皮商品' }}</div></template></el-table-column>
|
||||
<el-table-column label="商品" min-width="210"><template #default="{ row }"><div class="ellipsis">{{ row.productTitle || '—' }}</div><div class="muted">PDD {{ row.pddGoodsId || '未关联' }}</div></template></el-table-column>
|
||||
@@ -47,7 +55,7 @@
|
||||
</el-table>
|
||||
<p class="muted">如果某条商品在确认后状态发生变化,只跳过该条,不撤销其他成功任务。</p>
|
||||
</div>
|
||||
<template #footer><el-button :disabled="purchaseDialog.saving" @click="purchaseDialog.open = false">返回列表</el-button><el-button type="primary" :loading="purchaseDialog.saving" :disabled="purchaseDialog.loading || purchaseDialog.eligibleCount === 0" @click="submitPurchaseBatch">创建 {{ purchaseDialog.eligibleCount }} 个采购任务</el-button></template>
|
||||
<template #footer><el-button :disabled="purchaseDialog.saving" @click="purchaseDialog.open = false">返回列表</el-button><el-button type="primary" :loading="purchaseDialog.saving" :disabled="purchaseDialog.loading || purchaseDeviceUnavailable || !!purchaseDialog.previewError || purchaseDialog.eligibleCount === 0" @click="submitPurchaseBatch">创建 {{ purchaseDialog.eligibleCount }} 个采购任务</el-button></template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 批量创建采购任务结果 -->
|
||||
@@ -171,6 +179,7 @@ import { createPurchaseTasksBatch, matchPurchaseSpecsBatch, previewPurchaseTasks
|
||||
import { batchCreateCollectionTasks } from '@/api/goauto/collection-tasks'
|
||||
import { listCollectionRules } from '@/api/goauto/collection-rules'
|
||||
import { createRequestId } from '@/utils/request-id'
|
||||
import { readPurchaseDevice, rememberPurchaseDevice } from '@/utils/purchase-device-preference'
|
||||
import ShopeeProductDetailDrawer from '../shopee-products/ShopeeProductDetailDrawer.vue'
|
||||
import PddProductDetailDrawer from '../pdd-products/PddProductDetailDrawer.vue'
|
||||
|
||||
@@ -183,8 +192,9 @@ export default {
|
||||
loading: false, products: [], selectedProducts: [], total: 0,
|
||||
purchaseReadiness: {}, purchaseReadinessLoading: false, specMatchLoading: false, loadGeneration: 0,
|
||||
purchaseDevices: [],
|
||||
purchasePreviewGeneration: 0,
|
||||
shopOptions: [], shopOptionsLoaded: false, shopOptionsPromise: null,
|
||||
purchaseDialog: { open: false, loading: false, saving: false, ids: [], selectedCount: 0, deviceId: null, items: [], eligibleCount: 0, skippedCount: 0 },
|
||||
purchaseDialog: { open: false, loading: false, saving: false, ids: [], selectedCount: 0, deviceId: null, items: [], eligibleCount: 0, skippedCount: 0, previewError: '' },
|
||||
purchaseResult: { open: false, items: [], createdCount: 0, failedCount: 0 },
|
||||
specMatchResult: { open: false, items: [], selectedIDs: [], autoConfirmedCount: 0, pendingCount: 0, failedCount: 0, skippedCount: 0 },
|
||||
collectionBatch: this.emptyCollectionBatch(),
|
||||
@@ -198,6 +208,7 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
purchaseDeviceUnavailable() { return !!this.purchaseDialog.deviceId && !this.purchaseDevices.some(device => device.id === this.purchaseDialog.deviceId) },
|
||||
canPurchase() { const roles = this.$store.getters.roles || []; return roles.includes('admin') || roles.includes('purchaser') },
|
||||
processStageOptions() { return [{ value: 'manual_action', label: '待人工处理' }, { value: 'pdd_unlinked', label: '未关联 PDD' }, { value: 'pdd_pending', label: 'PDD 待采集' }, { value: 'pdd_collecting', label: 'PDD 采集中' }, { value: 'pdd_collection_failed', label: 'PDD 采集失败' }, { value: 'color_mapping', label: '规格待匹配' }, { value: 'purchase_ready', label: '可创建采购' }, { value: 'task_created', label: '已创建任务' }, { value: 'purchase_succeeded', label: '采购成功' }, { value: 'order_review', label: '待人工核对' }] },
|
||||
aiMatchCandidates() { return this.selectedProducts.filter(row => this.purchaseReady(row).aiMatchEligible === true) },
|
||||
@@ -353,23 +364,46 @@ export default {
|
||||
const candidates = this.purchaseCandidates
|
||||
if (!candidates.length) return
|
||||
const ids = candidates.map(item => item.id)
|
||||
this.purchaseDialog = { open: true, loading: true, saving: false, ids, selectedCount: this.selectedProducts.length, deviceId: null, items: [], eligibleCount: 0, skippedCount: 0 }
|
||||
this.purchaseDevices = []
|
||||
this.purchaseDialog = { open: true, loading: true, saving: false, ids, selectedCount: this.selectedProducts.length, deviceId: readPurchaseDevice(this.$store.getters.userId), items: [], eligibleCount: 0, skippedCount: 0, previewError: '' }
|
||||
const dialog = this.purchaseDialog
|
||||
try {
|
||||
const [preview, devices] = await Promise.all([
|
||||
previewPurchaseTasks({ sybProductIds: ids }),
|
||||
listDevices({ page: 1, pageSize: 100, status: 'online' })
|
||||
])
|
||||
this.applyPurchasePreview(preview.data)
|
||||
const devices = await listDevices({ page: 1, pageSize: 100, status: 'online' })
|
||||
if (this.purchaseDialog !== dialog || !dialog.open) return
|
||||
const required = ['purchase.live.v1', 'purchase.address-update.v1', 'purchase.order-create.v1', 'purchase.spec-probe.v1']
|
||||
this.purchaseDevices = devices.data.items.filter(device => device.selectable && required.every(capability => (device.capabilities || []).includes(capability)))
|
||||
} finally { this.purchaseDialog.loading = false }
|
||||
await this.refreshPurchasePreview()
|
||||
} catch {
|
||||
if (this.purchaseDialog === dialog) {
|
||||
dialog.previewError = '设备列表加载失败,请重试。'
|
||||
dialog.loading = false
|
||||
}
|
||||
}
|
||||
},
|
||||
applyPurchasePreview(data) { this.purchaseDialog.items = data.items; this.purchaseDialog.eligibleCount = data.eligibleCount; this.purchaseDialog.skippedCount = data.skippedCount },
|
||||
changePurchaseDevice() {
|
||||
if (!rememberPurchaseDevice(this.$store.getters.userId, this.purchaseDialog.deviceId)) ElMessage.warning('本次选择已生效,但未能保存设备偏好;下次可能需要重新选择。')
|
||||
return this.refreshPurchasePreview()
|
||||
},
|
||||
async refreshPurchasePreview() {
|
||||
this.purchaseDialog.loading = true
|
||||
try { const r = await previewPurchaseTasks({ sybProductIds: this.purchaseDialog.ids, deviceId: this.purchaseDialog.deviceId || undefined }); this.applyPurchasePreview(r.data) } finally { this.purchaseDialog.loading = false }
|
||||
const dialog = this.purchaseDialog
|
||||
const generation = ++this.purchasePreviewGeneration
|
||||
dialog.eligibleCount = 0
|
||||
dialog.items = []
|
||||
dialog.previewError = ''
|
||||
if (this.purchaseDeviceUnavailable) { dialog.loading = false; return }
|
||||
dialog.loading = true
|
||||
try {
|
||||
const r = await previewPurchaseTasks({ sybProductIds: dialog.ids, deviceId: dialog.deviceId || undefined })
|
||||
if (this.purchaseDialog === dialog && generation === this.purchasePreviewGeneration) this.applyPurchasePreview(r.data)
|
||||
} catch {
|
||||
if (this.purchaseDialog === dialog && generation === this.purchasePreviewGeneration) dialog.previewError = '采购预检失败,请重试。'
|
||||
} finally {
|
||||
if (this.purchaseDialog === dialog && generation === this.purchasePreviewGeneration) dialog.loading = false
|
||||
}
|
||||
},
|
||||
async submitPurchaseBatch() {
|
||||
if (this.purchaseDialog.loading || this.purchaseDialog.saving || this.purchaseDeviceUnavailable || this.purchaseDialog.previewError || !this.purchaseDialog.eligibleCount) return
|
||||
this.purchaseDialog.saving = true
|
||||
try {
|
||||
const r = await createPurchaseTasksBatch({ requestId: createRequestId(), sybProductIds: this.purchaseDialog.ids, deviceId: this.purchaseDialog.deviceId || undefined })
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
|
||||
const required = ['purchase.live.v1', 'purchase.address-update.v1', 'purchase.order-create.v1', 'purchase.spec-probe.v1']
|
||||
const routes = [{ path: '/workbench', component: 'Layout', menuName: 'Workbench', title: '工作台', visible: '0', children: [
|
||||
{ path: '/syb-products', component: '/goauto/syb-products/index', menuName: 'GoAutoSybProducts', title: 'SYB 商品', visible: '0' }
|
||||
] }]
|
||||
|
||||
async function mock(page: any) {
|
||||
const state = { userId: 1, available: true, previewFailed: false, previews: [] as any[], created: [] as any[] }
|
||||
await page.context().addCookies([{ name: 'Admin-Token', value: 'mock-token', domain: 'localhost', path: '/' }])
|
||||
await page.route('**/api/**', async (route: any) => {
|
||||
const url = new URL(route.request().url())
|
||||
const ok = (data: any) => route.fulfill({ json: { code: 200, data } })
|
||||
if (url.pathname.startsWith('/src/api/')) return route.continue()
|
||||
if (url.pathname.endsWith('/api/v1/getinfo')) return ok({ userId: state.userId, roles: ['admin'], name: '同名测试用户', avatar: '', introduction: '', permissions: [] })
|
||||
if (url.pathname.endsWith('/api/v1/menurole')) return ok(routes)
|
||||
if (url.pathname.endsWith('/api/admin/v1/syb-products')) return ok({ items: [
|
||||
{ id: 1, orderCode: 'TEST-1', shopeeItemId: '1001', shopeeProductId: 2, productTitle: '测试商品', targetColor: '黑色', targetSize: 'XL', quantity: 1, parseStatus: 'success' }
|
||||
], total: 1, page: 1, pageSize: 20 })
|
||||
if (url.pathname.endsWith('/api/admin/v1/devices')) return ok({ items: [
|
||||
{ id: 10, name: '测试设备A', model: 'A', selectable: state.available, capabilities: required },
|
||||
{ id: 20, name: '测试设备B', model: 'B', selectable: true, capabilities: required }
|
||||
], total: 2 })
|
||||
if (url.pathname.endsWith('/purchase-tasks/batch-preview')) {
|
||||
const body = route.request().postDataJSON()
|
||||
state.previews.push(body)
|
||||
if (state.previewFailed) return route.fulfill({ status: 500, json: { code: 500, message: '模拟预检失败' } })
|
||||
return ok({ items: [{ sybProductId: 1, eligible: true, processStage: 'purchase_ready', reason: '规格就绪', quantity: 1 }], eligibleCount: 1, skippedCount: 0 })
|
||||
}
|
||||
if (url.pathname.endsWith('/purchase-tasks/batch')) {
|
||||
state.created.push(route.request().postDataJSON())
|
||||
return ok({ items: [], createdCount: 1, failedCount: 0 })
|
||||
}
|
||||
return ok([])
|
||||
})
|
||||
return state
|
||||
}
|
||||
|
||||
async function open(page: any, reload = false) {
|
||||
if (reload) await page.reload()
|
||||
else await page.goto('/#/syb-products')
|
||||
await expect(page.locator('tbody').getByText('可创建采购', { exact: true })).toBeVisible()
|
||||
await page.locator('.el-table__body-wrapper tbody tr').first().locator('.el-checkbox').click()
|
||||
await page.getByRole('button', { name: /^创建采购\s*1$/ }).click()
|
||||
const dialog = page.getByRole('dialog', { name: '批量创建采购任务' })
|
||||
await expect(dialog).toBeVisible()
|
||||
await expect(dialog.locator('.el-loading-mask')).toBeHidden()
|
||||
return dialog
|
||||
}
|
||||
|
||||
async function choose(page: any, dialog: any, name: string) {
|
||||
await dialog.locator('.el-select').click()
|
||||
await page.getByRole('option', { name, exact: true }).click()
|
||||
await expect(dialog.locator('.el-loading-mask')).toBeHidden()
|
||||
}
|
||||
|
||||
test('设备选择跨弹窗和刷新恢复,预检与创建使用同一设备', async ({ page }) => {
|
||||
const state = await mock(page)
|
||||
let dialog = await open(page)
|
||||
await choose(page, dialog, '测试设备A · A')
|
||||
await dialog.getByRole('button', { name: '返回列表' }).click()
|
||||
await page.getByRole('button', { name: /^创建采购\s*1$/ }).click()
|
||||
await expect(dialog.getByText('测试设备A · A', { exact: true })).toBeVisible()
|
||||
await expect.poll(() => state.previews.at(-1)?.deviceId).toBe(10)
|
||||
dialog = await open(page, true)
|
||||
await expect(dialog.getByText('测试设备A · A', { exact: true })).toBeVisible()
|
||||
await dialog.getByRole('button', { name: '创建 1 个采购任务' }).click()
|
||||
await expect.poll(() => state.created.length).toBe(1)
|
||||
expect(state.created[0].deviceId).toBe(10)
|
||||
})
|
||||
|
||||
test('不可用设备不被静默替换,主动清空后保持不指定', async ({ page }) => {
|
||||
const state = await mock(page)
|
||||
let dialog = await open(page)
|
||||
await choose(page, dialog, '测试设备A · A')
|
||||
state.available = false
|
||||
dialog = await open(page, true)
|
||||
await expect(dialog.getByRole('alert').filter({ hasText: '上次选择的设备当前不可用' })).toBeVisible()
|
||||
await expect(dialog.getByRole('button', { name: /创建 .* 个采购任务/ })).toBeDisabled()
|
||||
await dialog.locator('.el-select').hover()
|
||||
await dialog.locator('.el-select__clear').click()
|
||||
await expect.poll(() => state.previews.at(-1)?.deviceId).toBeUndefined()
|
||||
dialog = await open(page, true)
|
||||
await expect(dialog.getByText('上次选择的设备(当前不可用)', { exact: true })).toHaveCount(0)
|
||||
await expect(dialog.getByText('不指定,由空闲设备领取', { exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
test('同名用户偏好按ID隔离,预检失败禁用创建', async ({ page }) => {
|
||||
const state = await mock(page)
|
||||
let dialog = await open(page)
|
||||
await choose(page, dialog, '测试设备A · A')
|
||||
state.userId = 2
|
||||
dialog = await open(page, true)
|
||||
await expect(dialog.getByText('不指定,由空闲设备领取', { exact: true })).toBeVisible()
|
||||
await choose(page, dialog, '测试设备B · B')
|
||||
state.userId = 1
|
||||
dialog = await open(page, true)
|
||||
await expect(dialog.getByText('测试设备A · A', { exact: true })).toBeVisible()
|
||||
state.previewFailed = true
|
||||
await choose(page, dialog, '测试设备B · B')
|
||||
await expect(dialog.getByText('采购预检失败,请重试。', { exact: true })).toBeVisible()
|
||||
await expect(dialog.getByRole('button', { name: /创建 .* 个采购任务/ })).toBeDisabled()
|
||||
expect(state.created).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('关闭重开后旧预检响应不覆盖新设备结果', async ({ page }) => {
|
||||
await mock(page)
|
||||
const dialog = await open(page)
|
||||
let held = false
|
||||
let finished = false
|
||||
let release: () => void = () => {}
|
||||
const gate = new Promise<void>(resolve => { release = resolve })
|
||||
await page.route('**/purchase-tasks/batch-preview', async route => {
|
||||
if (route.request().postDataJSON().deviceId !== 10 || held) return route.fallback()
|
||||
held = true
|
||||
await gate
|
||||
await route.fulfill({ json: { code: 200, data: { items: [], eligibleCount: 0, skippedCount: 1 } } })
|
||||
finished = true
|
||||
})
|
||||
await dialog.locator('.el-select').click()
|
||||
await page.getByRole('option', { name: '测试设备A · A', exact: true }).click()
|
||||
await expect.poll(() => held).toBe(true)
|
||||
await expect(dialog.getByRole('button', { name: /创建 .* 个采购任务/ })).toBeDisabled()
|
||||
await dialog.getByRole('button', { name: '返回列表' }).click()
|
||||
await page.getByRole('button', { name: /^创建采购\s*1$/ }).click()
|
||||
await expect(dialog.locator('.el-loading-mask')).toBeHidden()
|
||||
await choose(page, dialog, '测试设备B · B')
|
||||
release()
|
||||
await expect.poll(() => finished).toBe(true)
|
||||
await expect(dialog.getByText('测试设备B · B', { exact: true })).toBeVisible()
|
||||
await expect(dialog.getByRole('button', { name: '创建 1 个采购任务' })).toBeEnabled()
|
||||
})
|
||||
@@ -0,0 +1,32 @@
|
||||
import { readPurchaseDevice, rememberPurchaseDevice } from '@/utils/purchase-device-preference'
|
||||
|
||||
describe('purchase device preference', () => {
|
||||
beforeEach(() => { localStorage.clear() })
|
||||
afterEach(() => { jest.restoreAllMocks() })
|
||||
|
||||
it('isolates users and remembers an explicit empty selection', () => {
|
||||
expect(rememberPurchaseDevice(1, 10)).toBe(true)
|
||||
expect(rememberPurchaseDevice(2, 20)).toBe(true)
|
||||
expect(readPurchaseDevice(1)).toBe(10)
|
||||
expect(readPurchaseDevice(2)).toBe(20)
|
||||
expect(rememberPurchaseDevice(1, '')).toBe(true)
|
||||
expect(readPurchaseDevice(1)).toBeNull()
|
||||
expect(readPurchaseDevice(2)).toBe(20)
|
||||
})
|
||||
|
||||
it('does not share a fallback key when user identity is missing', () => {
|
||||
expect(rememberPurchaseDevice(null, 10)).toBe(false)
|
||||
expect(readPurchaseDevice(null)).toBeNull()
|
||||
expect(localStorage.length).toBe(0)
|
||||
})
|
||||
|
||||
it('ignores corrupt preferences and tolerates unavailable browser storage', () => {
|
||||
rememberPurchaseDevice(1, 10)
|
||||
localStorage.setItem(localStorage.key(0), '{broken')
|
||||
expect(readPurchaseDevice(1)).toBeNull()
|
||||
jest.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { throw new Error('disabled') })
|
||||
jest.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { throw new Error('disabled') })
|
||||
expect(readPurchaseDevice(1)).toBeNull()
|
||||
expect(rememberPurchaseDevice(1, 10)).toBe(false)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user