Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ab5a96ca4 |
@@ -31,7 +31,3 @@
|
||||
/android/**/build/
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
|
||||
# Third-party capture and device dumps: contain credentials, tokens and personal data.
|
||||
*.har
|
||||
/xml_data/
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
- 不保存原始控件树和整屏设备截图;允许保存按规格值节点边界裁剪的商品图片区域,且裁剪结果不得包含账号、地址、订单、支付及其他个人数据。除此之外只保存结构化任务日志、错误码、任务规则快照和采集结果。
|
||||
- 一台设备同一时刻只执行一个任务;手机离线时当前采集任务失败,默认不重试、不自动换机。
|
||||
- Android Agent 端:找不到控件、验证码、风控、人机验证或登录失效时明确失败,不使用 OCR/VLM。
|
||||
- 服务端顺云宝(SYB)登录与 yeeke(mmt.yeeke.com 对裹)登录:允许调用配置的线上自建 OCR 服务识别登录验证码(SYB 见 #48;yeeke 于 2026-09-23 经用户在 #336 明确批准)。验证码图片会离开本项目发送到该服务,更换服务地址前必须重新评估;OCR 请求和日志不得记录账号、密码、验证码原文或 token。此例外只适用于上述两个服务端登录流程,不扩大到 Agent 端或任何 PDD 相关流程。
|
||||
- 服务端顺云宝(SYB)登录:允许调用配置的线上 OCR 服务识别登录验证码(见 #48)。验证码图片会离开本项目发送到该服务,更换服务地址前必须重新评估。此例外只适用于 SYB 登录,不扩大到 Agent 端或任何 PDD 相关流程。
|
||||
- 规格匹配:Agent 本地不得自行猜测规格或点击相近候选,只执行服务端下发的精确规格;人工映射缺失、商品无规格数据或目标规格定位不到时,由服务端 AI 匹配接口决策(见 #46),AI 无结果时明确失败。
|
||||
- SKU 数据不完整仍须提交并允许在任务详情查看,状态记为 `completed_partial`。
|
||||
- 规则创建即生效;删除后不能创建新任务,但已有任务继续使用自身规则快照。
|
||||
|
||||
@@ -11,8 +11,8 @@ android {
|
||||
applicationId = "cn.ilapage.goauto.agent"
|
||||
minSdk = 23
|
||||
targetSdk = 34
|
||||
versionCode = 76
|
||||
versionName = "0.9.63"
|
||||
versionCode = 72
|
||||
versionName = "0.9.59"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
|
||||
+11
-288
@@ -90,28 +90,7 @@ data class VisibleSpecValue(
|
||||
val rawText: String = text,
|
||||
)
|
||||
data class VisibleDimension(val key: String, val name: String, val values: List<VisibleSpecValue>)
|
||||
enum class SpecPanelType {
|
||||
UNKNOWN,
|
||||
NORMAL_SCROLLABLE,
|
||||
NON_SCROLLABLE_CONFIRMATION,
|
||||
QUICK_CONFIRMATION,
|
||||
ORDER_CONFIRMATION,
|
||||
/**
|
||||
* #331 fallback: only reached when no stronger panel type matched. It
|
||||
* requires an address entry, a payment-method entry and one quantity input
|
||||
* box, plus at least one auxiliary signal. It is recognition only and never
|
||||
* relaxes any click target (the final order-submit click stays strict).
|
||||
*/
|
||||
REQUIRED_EVIDENCE,
|
||||
}
|
||||
|
||||
/** #331 bounded wait for a spec panel that is still loading after an entry click or an address save. */
|
||||
object SpecPanelStabilityPolicy {
|
||||
/** Upper bound for waiting until the required evidence is present and the structure is stable. */
|
||||
const val WAIT_BOUND_MS = 5_000L
|
||||
/** Sampling interval used after an address save returns to the purchase panel. */
|
||||
const val ADDRESS_SAVE_POLL_MS = 200L
|
||||
}
|
||||
enum class SpecPanelType { UNKNOWN, NORMAL_SCROLLABLE, NON_SCROLLABLE_CONFIRMATION, QUICK_CONFIRMATION, ORDER_CONFIRMATION }
|
||||
|
||||
object PddSoldOutRecoveryDefaults {
|
||||
const val EXACT_TEXT = "商品已售罄"
|
||||
@@ -155,54 +134,7 @@ data class ParsedPddScreen(
|
||||
val hasCloseControl: Boolean = false,
|
||||
val hasPaymentArea: Boolean = false,
|
||||
val purchaseContextMatched: Boolean = false,
|
||||
// #331 evidence flags. Presence only: address text is never kept here.
|
||||
val hasAddressEntry: Boolean = false,
|
||||
val hasPaymentEntry: Boolean = false,
|
||||
val hasQuantityInput: Boolean = false,
|
||||
val hasQuantityAdjustControls: Boolean = false,
|
||||
val quantityInputCount: Int = 0,
|
||||
/** #331: address entry, payment entry and quantity input share one bounded panel container. */
|
||||
val requiredEvidenceSameContainer: Boolean = false,
|
||||
) {
|
||||
/** The three #331 required spec-panel items are all visible. */
|
||||
val hasRequiredPanelEvidence: Boolean get() = hasAddressEntry && hasPaymentEntry && hasQuantityInput
|
||||
|
||||
/** Boolean/count-only evidence string for diagnostics; contains no page text. */
|
||||
fun requiredEvidenceSummary(): String =
|
||||
"addr=${hasAddressEntry.flag()};pay=${hasPaymentEntry.flag()};qin=$quantityInputCount;" +
|
||||
"adj=${hasQuantityAdjustControls.flag()};summary=${hasSelectionSummary.flag()};" +
|
||||
"close=${hasCloseControl.flag()};submit=${hasOrderSubmitAction.flag()};same=${requiredEvidenceSameContainer.flag()}"
|
||||
|
||||
/**
|
||||
* #331 purchaser-readable hints for missing required items. Only emitted
|
||||
* when the page shows at least one required item or parsed options, so an
|
||||
* unrelated page does not produce misleading environment hints. No page
|
||||
* text or personal data is included.
|
||||
*/
|
||||
fun requiredEvidenceHints(): List<String> {
|
||||
val partial = hasAddressEntry || hasPaymentEntry || hasQuantityInput || panelOptionCount > 0
|
||||
if (!partial) return emptyList()
|
||||
return buildList {
|
||||
if (!hasPaymentEntry) add(MISSING_PAYMENT_ENTRY_HINT)
|
||||
if (!hasAddressEntry) add(MISSING_ADDRESS_ENTRY_HINT)
|
||||
if (!hasQuantityInput) add(MISSING_QUANTITY_INPUT_HINT)
|
||||
if (hasRequiredPanelEvidence && !requiredEvidenceSameContainer) add(NOT_SAME_CONTAINER_HINT)
|
||||
}
|
||||
}
|
||||
|
||||
/** Hints joined for a failure message with a leading separator; empty when there is nothing to hint. */
|
||||
fun requiredEvidenceHintSuffix(): String =
|
||||
requiredEvidenceHints().takeIf { it.isNotEmpty() }?.joinToString(";", prefix = ";") ?: ""
|
||||
|
||||
private fun Boolean.flag(): Int = if (this) 1 else 0
|
||||
|
||||
companion object {
|
||||
const val MISSING_PAYMENT_ENTRY_HINT = "未找到支付入口:请确认 PDD 默认支付方式为微信支付"
|
||||
const val MISSING_ADDRESS_ENTRY_HINT = "未找到收货地址入口:请确认 PDD 已设置默认收货地址"
|
||||
const val MISSING_QUANTITY_INPUT_HINT = "未找到购买数量输入框"
|
||||
const val NOT_SAME_CONTAINER_HINT = "收货地址入口、支付入口和购买数量输入框不在同一规格面板内"
|
||||
}
|
||||
|
||||
fun isTransientSoldOut(
|
||||
exactText: String,
|
||||
fallbackTopText: String = "相似商品",
|
||||
@@ -254,13 +186,6 @@ object PddScreenParser {
|
||||
// purchase/order/payment controls must never become collection click targets.
|
||||
private val nonConfigurableClickDenylist = listOf("提交订单", "确认订单", "支付", "付款")
|
||||
|
||||
/** Masked CN mobile number shown in the purchase address row, e.g. 138****5678. Presence only. */
|
||||
val MASKED_PHONE_PATTERN = Regex("(?<![0-9])[0-9]{3}\\*{4}[0-9]{4}(?![0-9])")
|
||||
/** An address/payment entry must be clickable itself or through a nearby row container. */
|
||||
private const val ENTRY_CLICKABLE_ANCESTOR_MAX_DEPTH = 6
|
||||
/** Whole-sheet containers are clickable too; a real entry row stays well below this share of the screen height. */
|
||||
private const val ENTRY_ROW_MAX_HEIGHT_PERCENT = 30
|
||||
|
||||
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 ->
|
||||
@@ -278,8 +203,6 @@ object PddScreenParser {
|
||||
}
|
||||
}
|
||||
val labels = visible.map(SnapshotNode::label)
|
||||
val visibleByPath = visible.associateBy(SnapshotNode::path)
|
||||
val sourceByPath = visibleNodes.associateBy(SnapshotNode::path)
|
||||
val textAliases = config.textAliases
|
||||
val problem = PddPageClassifier.classify(snapshot.packageName, snapshot.activityName, labels)
|
||||
val compactLabels = labels.map { it.replace(" ", "") }
|
||||
@@ -317,23 +240,7 @@ object PddScreenParser {
|
||||
val decreaseControls = visible.filter { it.clickable && it.label.replace(" ", "") in textAliases.specPanel.quantityDecreaseAliases }
|
||||
val increaseControls = visible.filter { it.clickable && it.label.replace(" ", "") in textAliases.specPanel.quantityIncreaseAliases }
|
||||
val hasQuantityControls = quantityInputs.size == 1 && decreaseControls.size == 1 && increaseControls.size == 1
|
||||
// #331: the unique input box is required; the +/- buttons are only auxiliary.
|
||||
val hasQuantityInput = quantityInputs.size == 1 && quantityInputs.single().enabled
|
||||
val hasQuantityAdjustControls = decreaseControls.size == 1 && increaseControls.size == 1
|
||||
val hasPaymentArea = compactLabels.any { label -> textAliases.specPanel.paymentAreaAliases.any(label::contains) }
|
||||
val addressEntryRows = addressEntryRows(visibleNodes, screenHeight)
|
||||
val paymentEntryRows = paymentEntryRows(visibleNodes, screenHeight, textAliases.specPanel.paymentAreaAliases)
|
||||
val hasAddressEntry = addressEntryRows.isNotEmpty()
|
||||
val hasPaymentEntry = paymentEntryRows.isNotEmpty()
|
||||
// #331: the three required items must sit in one panel container, not
|
||||
// merely somewhere on screen (e.g. an address dialog, a payment dialog
|
||||
// and an unrelated input box). Same area bound idea as boundedScrollables.
|
||||
val requiredEvidenceSameContainer = hasAddressEntry && hasPaymentEntry && hasQuantityInput &&
|
||||
addressEntryRows.any { address ->
|
||||
paymentEntryRows.any { payment ->
|
||||
sharesBoundedPanelContainer(listOf(address, payment, quantityInputs.single()), sourceByPath, screenArea)
|
||||
}
|
||||
}
|
||||
val hasQuickBuy = visible.any { node ->
|
||||
node.label.replace(" ", "").let { compact -> textAliases.specPanel.quickBuyAliases.any(compact::contains) } &&
|
||||
node.bounds.centerY.toDouble() >= screenHeight * 0.75
|
||||
@@ -369,50 +276,27 @@ object PddScreenParser {
|
||||
headings.forEachIndexed { index, heading ->
|
||||
val lower = headings.getOrNull(index + 1)?.bounds?.top ?: Int.MAX_VALUE
|
||||
val dimensionKey = dimensionKey(heading.label, config, any { it.key == "color" })
|
||||
fun inValueRegion(node: SnapshotNode) =
|
||||
node.bounds.top >= heading.bounds.bottom && node.bounds.bottom <= lower &&
|
||||
node.bounds.width > 0 && node.bounds.height > 0 && node.label.length <= 80
|
||||
val candidates = panelVisible.asSequence()
|
||||
.filter { it.clickable && inValueRegion(it) }
|
||||
val values = panelVisible.asSequence()
|
||||
.filter { it.clickable && it.bounds.top >= heading.bounds.bottom && it.bounds.bottom <= lower }
|
||||
.filter { it.bounds.width > 0 && it.bounds.height > 0 && it.label.length <= 80 }
|
||||
.filterNot { isExactHeadingLabel(it.label, config) }
|
||||
.filterNot { node ->
|
||||
isExcludedOptionLabel(node.label) ||
|
||||
descendants(node, visibleNodes).any { descendant -> isExcludedOptionLabel(descendant.label) }
|
||||
}
|
||||
.toList()
|
||||
// #332/task 535: one option block may expose several clickable
|
||||
// nodes (outer block, image, inner text with a badge such as
|
||||
// "零差评"). Nodes nested in the same labelled option block are
|
||||
// collapsed into one value named by the outer block. The click
|
||||
// node is still chosen by safeOptionRank among the members.
|
||||
val values = candidates
|
||||
.groupBy { candidate ->
|
||||
optionBlock(candidate, candidates, visibleByPath, sourceByPath, ::inValueRegion)?.path ?: candidate.path
|
||||
}
|
||||
.map { (blockPath, members) ->
|
||||
val block = visibleByPath[blockPath]
|
||||
val chosen = members.minBy { option -> safeOptionRank(option, visibleNodes) }
|
||||
val selected = members.any(SnapshotNode::selected) || block?.selected == true
|
||||
val checked = members.any(SnapshotNode::checked) || block?.checked == true
|
||||
Triple(chosen.copy(selected = selected, checked = checked), block?.label ?: chosen.label, members)
|
||||
}
|
||||
.asSequence()
|
||||
.map { (option, blockLabel, members) ->
|
||||
val rawText = blockLabel
|
||||
.map {
|
||||
val rawText = it.label
|
||||
val normalizedText = when (dimensionKey) {
|
||||
"color" -> SpecValueNormalizer.normalizeColor(rawText)
|
||||
"size" -> SpecValueNormalizer.normalizeSize(rawText)
|
||||
else -> rawText
|
||||
}
|
||||
val stateText = (listOf(option.label) + descendants(option, visibleNodes).map(SnapshotNode::label)).joinToString(" ")
|
||||
val stateText = (listOf(it.label) + descendants(it, visibleNodes).map(SnapshotNode::label)).joinToString(" ")
|
||||
VisibleSpecValue(
|
||||
normalizedText,
|
||||
option.enabled && !stateText.containsUnavailableWord(),
|
||||
option,
|
||||
if (dimensionKey == "color") {
|
||||
(listOf(option) + members.filter { it.path != option.path })
|
||||
.firstNotNullOfOrNull { colorImageBoundsForOption(it, visibleNodes) }
|
||||
} else null,
|
||||
it.enabled && !stateText.containsUnavailableWord(),
|
||||
it,
|
||||
if (dimensionKey == "color") colorImageBoundsForOption(it, visibleNodes) else null,
|
||||
rawText,
|
||||
)
|
||||
}
|
||||
@@ -455,14 +339,6 @@ object PddScreenParser {
|
||||
panelScrollable == null && headings.size >= 2 && dimensions.size >= 2 &&
|
||||
dimensions.sumOf { it.values.size } >= 2 &&
|
||||
hasQuantityControls && hasOrderSubmitAction -> SpecPanelType.NON_SCROLLABLE_CONFIRMATION
|
||||
// #331 fallback, evaluated only after every stronger branch failed.
|
||||
// Required: address entry + payment entry + one enabled quantity
|
||||
// input. Auxiliary (at least one): grouped options, selection
|
||||
// summary, close control, +/- controls or the lower submit action.
|
||||
requiredEvidenceSameContainer && (
|
||||
dimensions.any { it.values.isNotEmpty() } || hasSelectionSummary || hasClose ||
|
||||
hasQuantityAdjustControls || hasOrderSubmitAction
|
||||
) -> SpecPanelType.REQUIRED_EVIDENCE
|
||||
else -> SpecPanelType.UNKNOWN
|
||||
}
|
||||
val panelOpen = specPanelType != SpecPanelType.UNKNOWN
|
||||
@@ -563,12 +439,6 @@ object PddScreenParser {
|
||||
purchaseContextMatched = continuedPurchasePanel,
|
||||
hasQuantityControls = hasQuantityControls,
|
||||
hasOrderSubmitAction = hasOrderSubmitAction,
|
||||
hasAddressEntry = hasAddressEntry,
|
||||
hasPaymentEntry = hasPaymentEntry,
|
||||
hasQuantityInput = hasQuantityInput,
|
||||
hasQuantityAdjustControls = hasQuantityAdjustControls,
|
||||
quantityInputCount = quantityInputs.size,
|
||||
requiredEvidenceSameContainer = requiredEvidenceSameContainer,
|
||||
explicitSpecEntryCount = explicitSpecEntries.size,
|
||||
nestedSpecEntryCount = nestedSpecEntries.size,
|
||||
bottomPurchaseEntryCount = bottomSpecEntries.size,
|
||||
@@ -583,93 +453,6 @@ object PddScreenParser {
|
||||
return compact in excludedExactOptionLabels || excludedOptionWords.any { compact.contains(it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Outermost labelled clickable ancestor inside the value region whose label
|
||||
* prefixes every candidate nested in it. Rows that contain several distinct
|
||||
* options never qualify because their members do not share its label.
|
||||
*/
|
||||
private fun optionBlock(
|
||||
candidate: SnapshotNode,
|
||||
candidates: List<SnapshotNode>,
|
||||
visibleByPath: Map<String, SnapshotNode>,
|
||||
sourceByPath: Map<String, SnapshotNode>,
|
||||
inValueRegion: (SnapshotNode) -> Boolean,
|
||||
): SnapshotNode? {
|
||||
fun compact(value: String) = value.replace(Regex("\\s+"), "")
|
||||
var block: SnapshotNode? = null
|
||||
var parentPath = candidate.parentPath
|
||||
while (parentPath != null) {
|
||||
val raw = sourceByPath[parentPath] ?: break
|
||||
val resolved = visibleByPath[parentPath]
|
||||
// Only a block with its own label names the option; a blank row
|
||||
// container resolved from its first child never merges options.
|
||||
if (raw.clickable && raw.label.isNotBlank() && resolved != null && inValueRegion(resolved)) {
|
||||
val outer = compact(resolved.label)
|
||||
val members = candidates.filter { it.path == resolved.path || it.path.startsWith("${resolved.path}/") }
|
||||
if (outer.isEmpty() || members.any { !compact(it.label).startsWith(outer) }) break
|
||||
block = resolved
|
||||
}
|
||||
parentPath = raw.parentPath
|
||||
}
|
||||
return block
|
||||
}
|
||||
|
||||
/** The nearby clickable row holding [node], or null when there is none or it is not a plausible entry row. */
|
||||
private fun clickableEntryRow(node: SnapshotNode, sourceByPath: Map<String, SnapshotNode>, screenHeight: Int): SnapshotNode? {
|
||||
var current: SnapshotNode? = node
|
||||
var depth = 0
|
||||
while (current != null && depth <= ENTRY_CLICKABLE_ANCESTOR_MAX_DEPTH) {
|
||||
if (current.clickable) {
|
||||
val plausible = current.visible && current.enabled && current.bounds.width > 0 && current.bounds.height > 0 &&
|
||||
(screenHeight <= 0 || current.bounds.height * 100 <= screenHeight * ENTRY_ROW_MAX_HEIGHT_PERCENT)
|
||||
return current.takeIf { plausible }
|
||||
}
|
||||
current = current.parentPath?.let(sourceByPath::get)
|
||||
depth++
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Address rows: a masked phone inside a nearby clickable row. The address text is never read out. */
|
||||
private fun addressEntryRows(source: List<SnapshotNode>, screenHeight: Int): List<SnapshotNode> {
|
||||
val byPath = source.associateBy(SnapshotNode::path)
|
||||
return source.filter { node ->
|
||||
node.visible && node.bounds.width > 0 && node.bounds.height > 0 && MASKED_PHONE_PATTERN.containsMatchIn(node.label)
|
||||
}.mapNotNull { clickableEntryRow(it, byPath, screenHeight) }.distinctBy(SnapshotNode::path)
|
||||
}
|
||||
|
||||
/** Payment-method rows via the configurable payment aliases inside a nearby clickable row. */
|
||||
private fun paymentEntryRows(source: List<SnapshotNode>, screenHeight: Int, aliases: List<String>): List<SnapshotNode> {
|
||||
val byPath = source.associateBy(SnapshotNode::path)
|
||||
return source.filter { node ->
|
||||
node.visible && node.bounds.width > 0 && node.bounds.height > 0 &&
|
||||
node.label.replace(Regex("\\s+"), "").let { compact -> aliases.any(compact::contains) }
|
||||
}.mapNotNull { clickableEntryRow(it, byPath, screenHeight) }.distinctBy(SnapshotNode::path)
|
||||
}
|
||||
|
||||
/**
|
||||
* #331: true when the lowest common ancestor of [nodes] is a real panel
|
||||
* container: it is not a window root (a node without parent) and, like
|
||||
* boundedScrollables, it does not cover the whole screen. Items taken from
|
||||
* separate dialogs/pages only meet at the root or a full-screen wrapper.
|
||||
*/
|
||||
private fun sharesBoundedPanelContainer(nodes: List<SnapshotNode>, sourceByPath: Map<String, SnapshotNode>, screenArea: Long): Boolean {
|
||||
fun chain(node: SnapshotNode): List<SnapshotNode> {
|
||||
val result = mutableListOf(node)
|
||||
var current = node.parentPath?.let(sourceByPath::get)
|
||||
while (current != null) {
|
||||
result += current
|
||||
current = current.parentPath?.let(sourceByPath::get)
|
||||
}
|
||||
return result
|
||||
}
|
||||
val others = nodes.drop(1).map { node -> chain(node).map(SnapshotNode::path).toSet() }
|
||||
val common = chain(nodes.first()).firstOrNull { candidate -> others.all { candidate.path in it } } ?: return false
|
||||
val parent = common.parentPath?.let(sourceByPath::get) ?: return false
|
||||
if (parent.path == common.path || common.bounds.width <= 0 || common.bounds.height <= 0) return false
|
||||
return screenArea == 0L || common.bounds.width.toLong() * common.bounds.height < screenArea
|
||||
}
|
||||
|
||||
private fun orderSubmitCandidatesAncestorFree(candidate: SnapshotNode, source: List<SnapshotNode>, aliases: List<String>): Boolean {
|
||||
val candidatePrefix = "${candidate.path}/"
|
||||
return source.none { node ->
|
||||
@@ -1602,41 +1385,6 @@ class PddProductDetailCollector(
|
||||
.count()
|
||||
}
|
||||
|
||||
/**
|
||||
* #334: the panel already shows its topmost color heading (the first
|
||||
* parsed dimension is "color") with values visible, so no restore swipe
|
||||
* is required. Swiping an already-top panel drags the bottom sheet
|
||||
* further and can make the color/size headings vanish.
|
||||
*/
|
||||
private fun isSpecPanelAtTop(screen: ParsedPddScreen): Boolean {
|
||||
val first = screen.dimensions.firstOrNull() ?: return false
|
||||
return first.key == "color" && first.values.isNotEmpty()
|
||||
}
|
||||
|
||||
private fun recordSpecPanelTopSwipe(
|
||||
reason: AgentDiagnosticReason,
|
||||
screen: ParsedPddScreen,
|
||||
swipeCount: Int,
|
||||
headingCountBefore: Int?,
|
||||
headingCountAfter: Int?,
|
||||
) {
|
||||
if (taskId <= 0) return
|
||||
diagnostic(
|
||||
AgentDiagnosticEvent(
|
||||
taskId = taskId,
|
||||
// A dedicated PAGE_STABILITY event on purpose: SPEC_PANEL_ENTRY
|
||||
// already carries the panel-open/restore diagnostics elsewhere,
|
||||
// and several tests assert a single SPEC_PANEL_ENTRY event per run.
|
||||
stage = AgentDiagnosticStage.PAGE_STABILITY,
|
||||
reason = reason,
|
||||
attempt = swipeCount,
|
||||
targetClassName = "type=${screen.specPanelType.name};headBefore=${headingCountBefore ?: -1};" +
|
||||
"headAfter=${headingCountAfter ?: -1};dim=${screen.dimensions.sumOf { it.values.size }}",
|
||||
clickableAncestorDepth = screen.panelHeadingCount,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun moveSpecPanelToTop(
|
||||
goodsId: String,
|
||||
config: PddCollectorConfig,
|
||||
@@ -1646,40 +1394,17 @@ class PddProductDetailCollector(
|
||||
): PddCollectorResult? {
|
||||
var previous: List<String>? = null
|
||||
var stable = 0
|
||||
var priorHeadingCount = -1
|
||||
var priorDimensionCount = -1
|
||||
var swipes = 0
|
||||
repeat(config.limits.getValue("specVerticalSwipes")) { pass ->
|
||||
repeat(config.limits.getValue("specVerticalSwipes")) {
|
||||
if (now() > deadline) return failure("RULE_NOT_MATCHED", "采集超过规则总超时")
|
||||
val screen = parse(goodsId, config, evidence)
|
||||
screen.problem?.let { return failure(it.code, it.message) }
|
||||
if (!screen.pageEvidenceMatched) return failure("RULE_NOT_MATCHED", "采集期间离开 PDD 商品详情页")
|
||||
if (pass == 0 && isSpecPanelAtTop(screen)) {
|
||||
recordSpecPanelTopSwipe(AgentDiagnosticReason.SPEC_PANEL_TOP_ALREADY, screen, swipes, null, screen.panelHeadingCount)
|
||||
return null
|
||||
}
|
||||
val headingCount = screen.panelHeadingCount
|
||||
val dimensionCount = screen.dimensions.sumOf { it.values.size }
|
||||
// A normal scroll-to-top can legitimately drop the panel's
|
||||
// heading count (lower dimensions scroll out of view) without
|
||||
// losing any values, so heading count alone must never trigger
|
||||
// this. Only a full loss of every collected spec value, or the
|
||||
// panel no longer being recognized as open, indicates the sheet
|
||||
// was dragged too far.
|
||||
val panelNoLongerRecognized = !screen.specPanelOpen || screen.specPanelType == SpecPanelType.UNKNOWN
|
||||
if (pass > 0 && priorDimensionCount > 0 && (dimensionCount == 0 || panelNoLongerRecognized)) {
|
||||
recordSpecPanelTopSwipe(AgentDiagnosticReason.SPEC_PANEL_TOP_COLLAPSED, screen, swipes, priorHeadingCount, headingCount)
|
||||
return failure("SPEC_PANEL_TOP_COLLAPSED", "回顶滑动导致规格标题消失,已停止滑动")
|
||||
}
|
||||
priorHeadingCount = headingCount
|
||||
priorDimensionCount = dimensionCount
|
||||
val signature = viewportSignature(screen)
|
||||
stable = if (previous != null && signature == previous) stable + 1 else 0
|
||||
previous = signature
|
||||
if (stable >= config.limits.getValue("stableEdgeReads")) return null
|
||||
val anchor = screen.dimensions.flatMap { it.values }.firstOrNull()?.node ?: specPanelContainer ?: return null
|
||||
if (!driver.swipeSpec(SwipeDirection.DOWN, anchor)) return null
|
||||
swipes++
|
||||
pause(350)
|
||||
}
|
||||
return null
|
||||
@@ -1847,8 +1572,6 @@ class PddProductDetailCollector(
|
||||
"type=${it.specPanelType.name};scroll=${it.panelScrollableCount};head=${it.panelHeadingCount};" +
|
||||
"option=${it.panelOptionCount};summary=${it.hasSelectionSummary.toInt()};" +
|
||||
"quantity=${it.hasQuantityControls.toInt()};submit=${it.hasOrderSubmitAction.toInt()};" +
|
||||
"addr=${it.hasAddressEntry.toInt()};pay=${it.hasPaymentEntry.toInt()};qin=${it.quantityInputCount};" +
|
||||
"adj=${it.hasQuantityAdjustControls.toInt()};close=${it.hasCloseControl.toInt()};" +
|
||||
"changed=${screenChanged.toInt()};" +
|
||||
"recovery=${when {
|
||||
recoverySucceeded -> "success"
|
||||
|
||||
+13
-240
@@ -40,8 +40,6 @@ class PurchaseLiveException(val code: String, message: String, val actualUnitPri
|
||||
class PurchaseLiveAutomation(
|
||||
private val driver: PurchaseUiDriver,
|
||||
private val pause: (Long) -> Unit = Thread::sleep,
|
||||
/** Boolean/count-only diagnostics; never receives address text. */
|
||||
private val panelDiagnostic: (String) -> Unit = {},
|
||||
) {
|
||||
private var submitAttempted = false
|
||||
var lastOrderReadFailure: PurchaseOrderReadFailure? = null
|
||||
@@ -525,7 +523,7 @@ class PurchaseLiveAutomation(
|
||||
val save = uniqueClickable(stable, stable.nodes.filter { it.visible && it.enabled && it.label == "保存" })
|
||||
if (save.size != 1) fail("PURCHASE_ADDRESS_UPDATE_FAILED", "地址保存按钮不唯一,未创建订单")
|
||||
click(save.single(), "保存地址")
|
||||
val savedEvidence = waitForStableAddressEditorExit(expected, suffix)
|
||||
val savedEvidence = waitForStableAddressEditorExit()
|
||||
if (!hasFinalSavedAddressEvidence(savedEvidence, expected, suffix)) {
|
||||
if (isPurchaseConfirmationPanel(savedEvidence)) {
|
||||
restoreFinalEvidenceInCurrentPanel(savedEvidence, expected, suffix)
|
||||
@@ -542,7 +540,11 @@ class PurchaseLiveAutomation(
|
||||
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 PURCHASE_CONFIRMATION_PANEL_TYPES
|
||||
return screen.specPanelType in setOf(
|
||||
SpecPanelType.NORMAL_SCROLLABLE,
|
||||
SpecPanelType.NON_SCROLLABLE_CONFIRMATION,
|
||||
SpecPanelType.ORDER_CONFIRMATION,
|
||||
)
|
||||
}
|
||||
|
||||
private fun restoreFinalEvidenceInCurrentPanel(
|
||||
@@ -596,14 +598,14 @@ class PurchaseLiveAutomation(
|
||||
private fun hasFinalSavedAddressEvidence(snapshot: UiSnapshot, expected: String, suffix: String): Boolean =
|
||||
hasSavedAddressEvidence(snapshot, expected, suffix) && finalSubmitTargets(snapshot).size == 1
|
||||
|
||||
private fun waitForStableAddressEditorExit(expected: String, suffix: String): UiSnapshot {
|
||||
private fun waitForStableAddressEditorExit(): UiSnapshot {
|
||||
var consecutiveExits = 0
|
||||
repeat(50) {
|
||||
val snapshot = driver.capture()
|
||||
pageProblem(snapshot)
|
||||
if (snapshot.packageName == PDD_PACKAGE && shippingAddressEditors(snapshot).isEmpty()) {
|
||||
consecutiveExits++
|
||||
if (consecutiveExits >= 2) return waitForSettledPanelAfterAddressSave(snapshot, expected, suffix)
|
||||
if (consecutiveExits >= 2) return snapshot
|
||||
} else {
|
||||
consecutiveExits = 0
|
||||
}
|
||||
@@ -612,68 +614,6 @@ class PurchaseLiveAutomation(
|
||||
fail("PURCHASE_ADDRESS_SAVE_TIMEOUT", "地址保存超时,未创建订单")
|
||||
}
|
||||
|
||||
/**
|
||||
* #331: after the editor closes PDD may still be rebuilding the purchase
|
||||
* sheet. Accept immediately when the final evidence or a stronger panel type
|
||||
* is already present. A sheet recognized only through the required-evidence
|
||||
* fallback must also be structurally stable for two consecutive samples. A
|
||||
* stable page without any purchase-sheet marker (quantity input or payment
|
||||
* entry), e.g. the address list, keeps the existing caller path. A partially
|
||||
* loaded sheet is waited for up to [SpecPanelStabilityPolicy.WAIT_BOUND_MS]
|
||||
* without pressing Back and then fails explicitly.
|
||||
*/
|
||||
private fun waitForSettledPanelAfterAddressSave(initial: UiSnapshot, expected: String, suffix: String): UiSnapshot {
|
||||
var snapshot = initial
|
||||
var previousSignature: String? = null
|
||||
var waitedMs = 0L
|
||||
var samples = 0
|
||||
while (true) {
|
||||
samples++
|
||||
val screen = PddScreenParser.parse(snapshot, PurchaseRehearsalExecutor.DEFAULT_COLLECTOR, "", null)
|
||||
val editorGone = snapshot.packageName == PDD_PACKAGE && shippingAddressEditors(snapshot).isEmpty()
|
||||
val signature = structureSignature(snapshot)
|
||||
val stable = editorGone && signature == previousSignature
|
||||
val outcome = when {
|
||||
editorGone && hasFinalSavedAddressEvidence(snapshot, expected, suffix) -> "final_evidence"
|
||||
editorGone && screen.specPanelType in STRONG_PURCHASE_PANEL_TYPES -> "recognized"
|
||||
stable && screen.specPanelType == SpecPanelType.REQUIRED_EVIDENCE -> "stable_required"
|
||||
stable && !screen.hasQuantityInput && !screen.hasPaymentEntry -> "non_panel"
|
||||
else -> null
|
||||
}
|
||||
if (outcome != null) {
|
||||
addressSaveDiagnostic(outcome, waitedMs, samples, screen)
|
||||
return snapshot
|
||||
}
|
||||
if (waitedMs >= SpecPanelStabilityPolicy.WAIT_BOUND_MS) {
|
||||
addressSaveDiagnostic("timeout", waitedMs, samples, screen)
|
||||
fail(
|
||||
"PURCHASE_ADDRESS_SAVE_TIMEOUT",
|
||||
"地址保存后规格面板未完整加载,未按返回键,未创建订单 [waitedMs=$waitedMs;samples=$samples;${screen.requiredEvidenceSummary()}]${screen.requiredEvidenceHintSuffix()}",
|
||||
)
|
||||
}
|
||||
previousSignature = signature
|
||||
pause(SpecPanelStabilityPolicy.ADDRESS_SAVE_POLL_MS)
|
||||
waitedMs += SpecPanelStabilityPolicy.ADDRESS_SAVE_POLL_MS
|
||||
snapshot = driver.capture()
|
||||
pageProblem(snapshot)
|
||||
}
|
||||
}
|
||||
|
||||
private fun addressSaveDiagnostic(outcome: String, waitedMs: Long, samples: Int, screen: ParsedPddScreen) {
|
||||
panelDiagnostic(
|
||||
"addressSaveWait;outcome=$outcome;waitedMs=$waitedMs;samples=$samples;backPressed=false;" +
|
||||
"type=${screen.specPanelType};${screen.requiredEvidenceSummary()}",
|
||||
)
|
||||
}
|
||||
|
||||
/** In-memory structure only (path, class, bounds, flags); no text is included. */
|
||||
private fun structureSignature(snapshot: UiSnapshot): String = snapshot.nodes
|
||||
.filter { it.visible }
|
||||
.joinToString("|") { node ->
|
||||
listOf(node.path, node.className.orEmpty(), node.bounds.left, node.bounds.top, node.bounds.right, node.bounds.bottom, node.clickable, node.scrollable, node.enabled)
|
||||
.joinToString(":")
|
||||
}
|
||||
|
||||
private fun normalizeAddressText(value: String): String = value.filterNot(Char::isWhitespace)
|
||||
|
||||
private fun hasExactTaskSuffix(value: String, suffix: String): Boolean =
|
||||
@@ -776,159 +716,10 @@ class PurchaseLiveAutomation(
|
||||
pause(500)
|
||||
}
|
||||
|
||||
/**
|
||||
* #335 (3rd revision, reviewer cross-check on real dumps — Samsung sample): a
|
||||
* two-step "row, then leaf" pick, not a single whole-panel leaf scan. A whole-panel
|
||||
* scan can pick a labelled leaf that lives in a DIFFERENT clickable row than the real
|
||||
* bottom bar — on the Samsung sample the bottom bar is a label-less 31px FrameLayout
|
||||
* whose only text node is zero-size, so a naive scan falls through to the next
|
||||
* lowest labelled leaf, which sits in the PAYMENT-METHOD row above it, and the climb
|
||||
* from there lands the click on "change payment method" instead of the order button.
|
||||
*
|
||||
* Step 1 — row: among visible/enabled/non-zero-size CLICKABLE nodes inside the
|
||||
* recognized panel's container (#331 judgment) with height ≤ [SUBMIT_ROW_MAX_HEIGHT_PERCENT]
|
||||
* of screen height (same guard as 2bc624f, keeps a full-sheet/full-body wrapper from
|
||||
* ever being "the row"), pick the one with the lowest bottom edge; ties go to the
|
||||
* rightmost. This row — and only this row — may hold the order button. If its own
|
||||
* subtree matches a payment-method alias (`textAliases.specPanel.paymentAreaAliases`,
|
||||
* e.g. 微信支付/先用后付/支付方式) or [SUBMIT_TARGET_BLOCKED_MARKERS], fail explicitly;
|
||||
* never fall back to a higher row — a higher row is never the order button either.
|
||||
*
|
||||
* Step 2 — leaf: within that row's own subtree (or the row itself), among
|
||||
* visible/non-zero-size nodes with a NON-BLANK OWN LABEL whose nearest clickable
|
||||
* ancestor is EXACTLY that row (not some nested sub-button, and not a higher row),
|
||||
* pick the bottom-right-most one. [PurchaseUiDriver.clickFresh] re-finds its target
|
||||
* live by `preferredOrDescendantLabel() == target.label && className && center±32`
|
||||
* and only then climbs to the nearest clickable ancestor and clicks it (see
|
||||
* `GoAutoAccessibilityService.clickFreshDetailed`); a label-less container's
|
||||
* [SnapshotNode.label] is `""` but its live `preferredOrDescendantLabel()` digs into a
|
||||
* child, so the re-find would always miss (task 594: TARGET_NOT_FOUND right after the
|
||||
* irreversible boundary). Aiming at the labelled leaf — the same convention already
|
||||
* used by `ImageSearchCandidatePolicy.clickTargetInside` — lets clickFresh's own
|
||||
* climb-to-ancestor logic land the tap back on the row. If the row has no such leaf
|
||||
* (Samsung: only a zero-size text node), fail explicitly (`bottom_row_unlabelled`) —
|
||||
* never fall back to a higher row.
|
||||
*/
|
||||
private fun finalSubmitTargets(snapshot: UiSnapshot): List<SnapshotNode> {
|
||||
fun hasArea(node: SnapshotNode) = node.bounds.width > 0 && node.bounds.height > 0
|
||||
val byPath = snapshot.nodes.associateBy { it.path }
|
||||
fun clickableAncestor(node: SnapshotNode): SnapshotNode? {
|
||||
var current: SnapshotNode? = node
|
||||
while (current != null && !current.clickable) current = current.parentPath?.let(byPath::get)
|
||||
return current
|
||||
}
|
||||
val screen = PddScreenParser.parse(snapshot, PurchaseRehearsalExecutor.DEFAULT_COLLECTOR, "", null)
|
||||
if (screen.specPanelType !in PURCHASE_CONFIRMATION_PANEL_TYPES) {
|
||||
panelDiagnostic("bottomSubmit;outcome=not_panel;type=${screen.specPanelType}")
|
||||
return emptyList()
|
||||
}
|
||||
val container = panelContainerBounds(snapshot)
|
||||
if (container == null) {
|
||||
panelDiagnostic("bottomSubmit;outcome=no_container;type=${screen.specPanelType}")
|
||||
return emptyList()
|
||||
}
|
||||
val screenHeight = snapshot.nodes.filter { it.visible }.maxOfOrNull { it.bounds.bottom }?.coerceAtLeast(1) ?: 1
|
||||
|
||||
// Step 1: the bottom-most clickable row.
|
||||
val rowCandidates = snapshot.nodes.filter { node ->
|
||||
node.visible && node.enabled && node.clickable && hasArea(node) &&
|
||||
node.bounds.height.toLong() * 100 <= screenHeight.toLong() * SUBMIT_ROW_MAX_HEIGHT_PERCENT &&
|
||||
inside(node.bounds, container)
|
||||
}.distinctBy { it.path }
|
||||
if (rowCandidates.isEmpty()) {
|
||||
panelDiagnostic("bottomSubmit;outcome=no_rows;type=${screen.specPanelType}")
|
||||
return emptyList()
|
||||
}
|
||||
val rowMaxBottom = rowCandidates.maxOf { it.bounds.bottom }
|
||||
val rowBottomTied = rowCandidates.filter { it.bounds.bottom == rowMaxBottom }
|
||||
val rowTie = rowBottomTied.size > 1
|
||||
val row = rowBottomTied.maxBy { it.bounds.right }
|
||||
|
||||
val rowSubtreeLabels = (listOf(row.label) + subtreeDescendants(row, snapshot).map(SnapshotNode::label)).joinToString("")
|
||||
val paymentAreaAliases = PurchaseRehearsalExecutor.DEFAULT_COLLECTOR.textAliases.specPanel.paymentAreaAliases
|
||||
if (paymentAreaAliases.any(rowSubtreeLabels::contains) || SUBMIT_TARGET_BLOCKED_MARKERS.any(rowSubtreeLabels::contains)) {
|
||||
panelDiagnostic(
|
||||
"bottomSubmit;outcome=row_blocked;tie=${rowTie.diagFlag()};class=${row.className};" +
|
||||
"w=${row.bounds.width};h=${row.bounds.height}",
|
||||
)
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
// Step 2: the bottom-right-most labelled leaf whose nearest clickable ancestor is exactly this row.
|
||||
val leafCandidates = (listOf(row) + subtreeDescendants(row, snapshot)).filter { node ->
|
||||
node.visible && node.enabled && hasArea(node) && node.label.isNotEmpty() &&
|
||||
clickableAncestor(node)?.path == row.path
|
||||
}.distinctBy { it.path }
|
||||
if (leafCandidates.isEmpty()) {
|
||||
panelDiagnostic(
|
||||
"bottomSubmit;outcome=bottom_row_unlabelled;tie=${rowTie.diagFlag()};class=${row.className};" +
|
||||
"w=${row.bounds.width};h=${row.bounds.height}",
|
||||
)
|
||||
return emptyList()
|
||||
}
|
||||
val leafMaxBottom = leafCandidates.maxOf { it.bounds.bottom }
|
||||
val leafBottomTied = leafCandidates.filter { it.bounds.bottom == leafMaxBottom }
|
||||
val leafTie = leafBottomTied.size > 1
|
||||
val chosen = leafBottomTied.maxBy { it.bounds.right }
|
||||
|
||||
val hasPrice = PRICE_PRESENCE.containsMatchIn(rowSubtreeLabels)
|
||||
val legacyMarker = FINAL_SUBMIT_MARKERS.any { rowSubtreeLabels == it || rowSubtreeLabels.startsWith(it) }
|
||||
panelDiagnostic(
|
||||
"bottomSubmit;outcome=ok;rowTie=${rowTie.diagFlag()};leafTie=${leafTie.diagFlag()};" +
|
||||
"leafClass=${chosen.className};rowClass=${row.className};w=${row.bounds.width};h=${row.bounds.height};" +
|
||||
"price=${hasPrice.diagFlag()};legacyMarker=${legacyMarker.diagFlag()};type=${screen.specPanelType}",
|
||||
)
|
||||
return listOf(chosen)
|
||||
}
|
||||
|
||||
private fun subtreeDescendants(node: SnapshotNode, snapshot: UiSnapshot): List<SnapshotNode> {
|
||||
val prefix = "${node.path}/"
|
||||
return snapshot.nodes.filter { it.path.startsWith(prefix) }
|
||||
}
|
||||
|
||||
/**
|
||||
* #335 follow-up: the submit click target must belong to the recognized spec panel,
|
||||
* not just be somewhere on screen (e.g. an underlying goods-detail-page bottom bar
|
||||
* that is still in the accessibility tree behind the panel). [ParsedPddScreen
|
||||
* .specPanelContainer] (PddScreenParser's `panelScrollable`) is not usable for this:
|
||||
* it is the inner *scrollable dimension list* used only for heading/option parsing,
|
||||
* and in a real PDD sheet the address/payment/submit rows sit OUTSIDE it as
|
||||
* structural siblings, not descendants. Instead this climbs from the panel's unique
|
||||
* quantity input — present for every recognized #331 panel type used here — to the
|
||||
* LARGEST ancestor that still does not cover the whole screen, i.e. the whole
|
||||
* bottom-sheet container. That is the same "does not cover the whole screen" bounded
|
||||
* notion #331 already uses for `sharesBoundedPanelContainer`/`boundedScrollables`,
|
||||
* just walked from the quantity input instead of recomputed from private internals.
|
||||
* A candidate then belongs to the panel when its center point falls inside that
|
||||
* container's bounds (geometric containment, not path prefix, since PDD's own
|
||||
* accessibility tree can place the submit bar as a structural sibling of the panel
|
||||
* container that is still visually part of the sheet). Returns null (no target) when
|
||||
* the quantity input is missing or not unique, so the caller fails explicitly instead
|
||||
* of guessing a container.
|
||||
*/
|
||||
private fun panelContainerBounds(snapshot: UiSnapshot): NodeBounds? {
|
||||
val byPath = snapshot.nodes.associateBy { it.path }
|
||||
val visible = snapshot.nodes.filter { it.visible }
|
||||
val screenWidth = visible.maxOfOrNull { it.bounds.right } ?: return null
|
||||
val screenHeight = visible.maxOfOrNull { it.bounds.bottom } ?: return null
|
||||
val screenArea = screenWidth.toLong() * screenHeight
|
||||
val quantity = snapshot.nodes.singleOrNull { node ->
|
||||
node.visible && node.enabled && node.className == "android.widget.EditText" &&
|
||||
node.label.toIntOrNull()?.let { it > 0 } == true
|
||||
} ?: return null
|
||||
var lastBounded: SnapshotNode? = null
|
||||
var current: SnapshotNode = quantity
|
||||
while (true) {
|
||||
val parent = current.parentPath?.let(byPath::get) ?: break
|
||||
val parentArea = parent.bounds.width.toLong() * parent.bounds.height
|
||||
if (parentArea <= 0 || parentArea >= screenArea) break
|
||||
lastBounded = parent
|
||||
current = parent
|
||||
}
|
||||
return lastBounded?.bounds
|
||||
}
|
||||
|
||||
private fun Boolean.diagFlag(): Int = if (this) 1 else 0
|
||||
private fun finalSubmitTargets(snapshot: UiSnapshot): List<SnapshotNode> = uniqueClickable(
|
||||
snapshot,
|
||||
snapshot.nodes.filter { node -> node.visible && node.enabled && FINAL_SUBMIT_MARKERS.any { node.label == it || node.label.startsWith(it) } },
|
||||
)
|
||||
|
||||
private fun orderConfirmationReady(snapshot: UiSnapshot): Boolean {
|
||||
if (snapshot.packageName != PDD_PACKAGE) return false
|
||||
@@ -1002,27 +793,9 @@ class PurchaseLiveAutomation(
|
||||
private companion object {
|
||||
const val PDD_PACKAGE = "com.xunmeng.pinduoduo"
|
||||
const val WECHAT_PACKAGE = "com.tencent.mm"
|
||||
val MASKED_PHONE = PddScreenParser.MASKED_PHONE_PATTERN
|
||||
val STRONG_PURCHASE_PANEL_TYPES = setOf(
|
||||
SpecPanelType.NORMAL_SCROLLABLE,
|
||||
SpecPanelType.NON_SCROLLABLE_CONFIRMATION,
|
||||
SpecPanelType.ORDER_CONFIRMATION,
|
||||
)
|
||||
val PURCHASE_CONFIRMATION_PANEL_TYPES = STRONG_PURCHASE_PANEL_TYPES + SpecPanelType.REQUIRED_EVIDENCE
|
||||
val MASKED_PHONE = Regex("(?<![0-9])[0-9]{3}\\*{4}[0-9]{4}(?![0-9])")
|
||||
val FINAL_SUBMIT_MARKERS = listOf("提交订单", "现在买,仅", "确认购买")
|
||||
// #335: presence-only signal for diagnostics; never records the actual price digits.
|
||||
val PRICE_PRESENCE = Regex("[¥¥][0-9]")
|
||||
// #335: a bottom-edge candidate taller than this share of screen height is a
|
||||
// whole-sheet/whole-body wrapper, never the order button itself.
|
||||
const val SUBMIT_ROW_MAX_HEIGHT_PERCENT = 30
|
||||
val PAYMENT_MARKERS = listOf("立即支付", "确认支付", "输入支付密码")
|
||||
// #335: broader than PAYMENT_MARKERS on purpose — this list is used ONLY by
|
||||
// finalSubmitTargets' bottom-node subtree check, never by pageProblem/order-result
|
||||
// parsing. "付款" alone must NOT join PAYMENT_MARKERS: UNPAID_MARKERS' "待付款"
|
||||
// contains "付款" and PAYMENT_MARKERS gates the global pageProblem() payment guard,
|
||||
// so adding it there would misclassify every ordinary unpaid-order page as a
|
||||
// forbidden payment page.
|
||||
val SUBMIT_TARGET_BLOCKED_MARKERS = listOf("立即支付", "确认支付", "去支付", "付款", "输入支付密码")
|
||||
val UNPAID_MARKERS = listOf("待付款", "待支付", "去支付")
|
||||
val ORDER_DETAIL_ENTRY_MARKERS = setOf("查看订单", "订单详情")
|
||||
val ORDER_CONTEXT_MARKERS = listOf("订单编号", "订单号", "下单时间", "创建时间")
|
||||
|
||||
+7
-58
@@ -100,7 +100,7 @@ class PurchaseRehearsalExecutor(
|
||||
var addressProof: ShippingAddressProof? = null
|
||||
var irreversibleStarted = false
|
||||
val specSelectionProofs = mutableMapOf<String, ExactSpecSelectionProof>()
|
||||
val live = PurchaseLiveAutomation(driver, pause, panelDiagnostic)
|
||||
val live = PurchaseLiveAutomation(driver, pause)
|
||||
for (action in rule.actions) {
|
||||
// The immediate phase-two handoff can reuse the PDD page retained by
|
||||
// spec_probe. A later manual retry may start from Agent (or another
|
||||
@@ -394,7 +394,7 @@ class PurchaseRehearsalExecutor(
|
||||
if (wait.changed) {
|
||||
return failure(
|
||||
SPEC_PANEL_EVIDENCE_NOT_MATCHED,
|
||||
"规格入口点击后页面已变化,但规格面板强证据不足 [${panelEvidence(wait.screen)}]${wait.screen.requiredEvidenceHintSuffix()}",
|
||||
"规格入口点击后页面已变化,但规格面板强证据不足 [${panelEvidence(wait.screen)}]",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -415,7 +415,7 @@ class PurchaseRehearsalExecutor(
|
||||
if (wait.changed) {
|
||||
return failure(
|
||||
SPEC_PANEL_EVIDENCE_NOT_MATCHED,
|
||||
"规格入口手势后页面已变化,但规格面板强证据不足 [${panelEvidence(wait.screen)}]${wait.screen.requiredEvidenceHintSuffix()}",
|
||||
"规格入口手势后页面已变化,但规格面板强证据不足 [${panelEvidence(wait.screen)}]",
|
||||
)
|
||||
}
|
||||
return failure(
|
||||
@@ -446,51 +446,17 @@ class PurchaseRehearsalExecutor(
|
||||
private fun waitForSpecPanel(input: PurchaseExecutionInput, beforeSignature: List<SpecActionNodeSignature>): SpecPanelWait {
|
||||
var last = currentScreen(input)
|
||||
var changed = false
|
||||
var polls = 0
|
||||
var waitedMs = 0L
|
||||
var requiredEvidenceSeen = false
|
||||
var previousStructure: List<SpecActionNodeSignature>? = null
|
||||
fun waitDiagnostic(outcome: String) {
|
||||
if (requiredEvidenceSeen) {
|
||||
panelDiagnostic(
|
||||
"specPanelWait;outcome=$outcome;waitedMs=$waitedMs;samples=$polls;backPressed=false;" +
|
||||
last.requiredEvidenceSummary() + last.requiredEvidenceHintSuffix(),
|
||||
)
|
||||
}
|
||||
}
|
||||
while (true) {
|
||||
repeat(SPEC_POST_CLICK_VERIFY_POLLS) {
|
||||
last = currentScreen(input)
|
||||
panelDiagnostic(panelEvidence(last))
|
||||
if (last.reviewPageOpen) {
|
||||
return SpecPanelWait(last, false, true, leaveUnexpectedReviewPage(input))
|
||||
}
|
||||
last.problem?.let { return SpecPanelWait(last, false, true, failure(it.code, it.message)) }
|
||||
val structure = specActionSignature(last)
|
||||
// Stronger panel types keep their immediate acceptance. The #331
|
||||
// fallback type additionally needs two identical consecutive
|
||||
// structures so a still-loading sheet is not acted on too early.
|
||||
if (last.specPanelOpen && (last.specPanelType != SpecPanelType.REQUIRED_EVIDENCE || structure == previousStructure)) {
|
||||
waitDiagnostic("opened")
|
||||
return SpecPanelWait(last, true, true)
|
||||
}
|
||||
changed = changed || structure != beforeSignature
|
||||
requiredEvidenceSeen = requiredEvidenceSeen ||
|
||||
last.hasAddressEntry || last.hasPaymentEntry || last.hasQuantityInput
|
||||
previousStructure = structure
|
||||
if (last.specPanelOpen) return SpecPanelWait(last, true, true)
|
||||
changed = changed || specActionSignature(last) != beforeSignature
|
||||
pause(SPEC_SELECTION_POLL_MILLIS)
|
||||
waitedMs += SPEC_SELECTION_POLL_MILLIS
|
||||
polls++
|
||||
// A partially loaded purchase sheet may take longer than the plain
|
||||
// entry verification window; it gets the bounded #331 wait. No Back
|
||||
// is pressed while waiting; the caller fails explicitly on timeout.
|
||||
val limitReached = if (requiredEvidenceSeen) {
|
||||
waitedMs >= SpecPanelStabilityPolicy.WAIT_BOUND_MS
|
||||
} else {
|
||||
polls >= SPEC_POST_CLICK_VERIFY_POLLS
|
||||
}
|
||||
if (limitReached) break
|
||||
}
|
||||
waitDiagnostic("timeout")
|
||||
return SpecPanelWait(last, false, changed)
|
||||
}
|
||||
|
||||
@@ -512,8 +478,7 @@ class PurchaseRehearsalExecutor(
|
||||
private fun panelEvidence(screen: ParsedPddScreen): String =
|
||||
"type=${screen.specPanelType};scrollables=${screen.panelScrollableCount};headings=${screen.panelHeadingCount};" +
|
||||
"options=${screen.panelOptionCount};summary=${screen.hasSelectionSummary};quantity=${screen.hasQuantityControls};" +
|
||||
"orderAction=${screen.hasOrderSubmitAction};pageEvidence=${screen.pageEvidenceMatched};" +
|
||||
screen.requiredEvidenceSummary()
|
||||
"orderAction=${screen.hasOrderSubmitAction};pageEvidence=${screen.pageEvidenceMatched}"
|
||||
|
||||
private fun specEntryEvidence(screen: ParsedPddScreen, candidateCount: Int, entryReadyWaitPolls: Int = 0): String =
|
||||
"specEntryCandidates=$candidateCount;explicit=${screen.explicitSpecEntryCount};" +
|
||||
@@ -609,11 +574,9 @@ class PurchaseRehearsalExecutor(
|
||||
verification = waitForExactSelection(input, dimension, target, selectionProofs, SPEC_SELECTION_SUCCESS_VERIFY_POLLS)
|
||||
verification.failure?.let { return it }
|
||||
if (verification.confirmed) return null
|
||||
panelDiagnostic(selectionFailureEvidence(currentScreen(input), dimension, target))
|
||||
return failure(SPEC_SELECTION_UNCONFIRMED, "无障碍点击和受控手势后未能确认精确选中状态")
|
||||
}
|
||||
if (outcome.result == FreshActionResult.SUCCESS) {
|
||||
panelDiagnostic(selectionFailureEvidence(currentScreen(input), dimension, target))
|
||||
return failure(SPEC_SELECTION_UNCONFIRMED, "点击规格后未能确认精确选中状态")
|
||||
}
|
||||
val subreason = when (outcome.reason) {
|
||||
@@ -626,19 +589,6 @@ class PurchaseRehearsalExecutor(
|
||||
return failure(SPEC_CLICK_FAILED, subreason)
|
||||
}
|
||||
|
||||
/**
|
||||
* #332/task 535: parsed option texts and selected flags of one dimension.
|
||||
* Spec values are product attributes, not personal data; each is truncated.
|
||||
*/
|
||||
private fun selectionFailureEvidence(screen: ParsedPddScreen, dimension: String, target: String): String {
|
||||
fun clip(value: String) = value.replace(Regex("[\r\n\t;|]+"), " ").take(SELECTION_DIAGNOSTIC_TEXT_CHARS)
|
||||
val values = screen.dimensions.filter { it.key == dimension }.flatMap { it.values }
|
||||
return "specSelectionUnconfirmed;dimension=$dimension;target=${clip(target)};valueCount=${values.size};values=" +
|
||||
values.joinToString("|") { value ->
|
||||
"${clip(value.text)}:${(value.node.selected || value.node.checked).toString().first()}"
|
||||
}
|
||||
}
|
||||
|
||||
private data class ExactSelectionWait(
|
||||
val confirmed: Boolean,
|
||||
val failure: PurchaseExecutionOutcome? = null,
|
||||
@@ -1225,7 +1175,6 @@ class PurchaseRehearsalExecutor(
|
||||
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
|
||||
private const val SELECTION_DIAGNOSTIC_TEXT_CHARS = 40
|
||||
private const val SOLD_OUT_PANEL_CLOSE_MILLIS = 300L
|
||||
private const val SPEC_ENTRY_NOT_FOUND = "PURCHASE_SPEC_ENTRY_NOT_FOUND"
|
||||
private const val SPEC_ENTRY_TARGET_AMBIGUOUS = "PURCHASE_SPEC_ENTRY_TARGET_AMBIGUOUS"
|
||||
|
||||
-95
@@ -1,95 +0,0 @@
|
||||
package cn.ilapage.goauto.agent.automation
|
||||
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* #334: a spec probe that produced zero color/size dimensions, or that never
|
||||
* collected any dimensions because the collector itself failed (e.g. an
|
||||
* over-swiped spec panel losing its headings — see
|
||||
* [PddProductDetailCollector.moveSpecPanelToTop]), must fail explicitly
|
||||
* rather than being reported as a normal `spec_probe_completed` empty
|
||||
* result or a generic "no matchable spec" outcome.
|
||||
*
|
||||
* This lives outside [PurchaseRehearsalExecutor] on purpose: the executor's
|
||||
* `probeSpecs` callback is opaque by contract (any JSON payload the caller
|
||||
* wants to hand to the server), and its own unit tests exercise that
|
||||
* contract with canned payloads unrelated to this device bug. The checks
|
||||
* instead run once, at the point where
|
||||
* [cn.ilapage.goauto.agent.service.AgentForegroundService] turns the real
|
||||
* collector result into the outcome that gets persisted and reported.
|
||||
* `collectPurchaseProbe` encodes a failed collector run as a
|
||||
* `probeFailureCode`/`probeFailureMessage` JSON payload (still routed
|
||||
* through the same opaque `String?` probe callback) instead of `null`, so
|
||||
* [demote] can tell it apart from a genuinely empty-but-successful probe.
|
||||
*/
|
||||
object PurchaseSpecProbePolicy {
|
||||
const val EMPTY_PROBE_CODE = "PURCHASE_SPEC_PROBE_EMPTY"
|
||||
const val EMPTY_PROBE_MESSAGE = "规格探测未读取到任何颜色或尺码"
|
||||
const val FAILED_PROBE_CODE = "PURCHASE_SPEC_PROBE_FAILED"
|
||||
|
||||
/** JSON field names used by [collectPurchaseProbeFailurePayload]-style encodings. */
|
||||
const val PROBE_FAILURE_CODE_FIELD = "probeFailureCode"
|
||||
const val PROBE_FAILURE_MESSAGE_FIELD = "probeFailureMessage"
|
||||
|
||||
/** Purchaser-readable messages for known collector failure codes; anything else falls back to a generic message that still includes the raw code. */
|
||||
private val knownCollectorFailureMessages = mapOf(
|
||||
"SPEC_PANEL_TOP_COLLAPSED" to "规格探测时规格面板被拖动,规格标题消失",
|
||||
)
|
||||
|
||||
/** True when the probe JSON's `dimensions` array has no entries, or entries with no values. */
|
||||
fun isDimensionsEmpty(probedSpecsJson: String?): Boolean {
|
||||
if (probedSpecsJson.isNullOrBlank()) return true
|
||||
return try {
|
||||
val dimensions = JSONObject(probedSpecsJson).optJSONArray("dimensions") ?: return true
|
||||
var total = 0
|
||||
for (i in 0 until dimensions.length()) {
|
||||
total += dimensions.optJSONObject(i)?.optJSONArray("values")?.length() ?: 0
|
||||
}
|
||||
total == 0
|
||||
} catch (_: Exception) {
|
||||
// Malformed payload cannot be trusted as a real, non-empty probe result.
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/** Extracts a collector failure code from a probe JSON payload built by [encodeCollectorFailure], or null when the payload is not a failure encoding. */
|
||||
fun extractCollectorFailureCode(probedSpecsJson: String?): String? {
|
||||
if (probedSpecsJson.isNullOrBlank()) return null
|
||||
return try {
|
||||
JSONObject(probedSpecsJson).optString(PROBE_FAILURE_CODE_FIELD, "").takeIf(String::isNotBlank)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/** Builds the opaque probe JSON payload used to carry an explicit collector failure through the `probeSpecs: () -> String?` callback. */
|
||||
fun encodeCollectorFailure(goodsId: String, collectorCode: String, collectorMessage: String): String =
|
||||
JSONObject()
|
||||
.put("goodsId", goodsId)
|
||||
.put(PROBE_FAILURE_CODE_FIELD, collectorCode)
|
||||
.put(PROBE_FAILURE_MESSAGE_FIELD, collectorMessage)
|
||||
.toString()
|
||||
|
||||
private fun failureMessageFor(collectorCode: String): String =
|
||||
knownCollectorFailureMessages[collectorCode] ?: "商品规格探测失败:$collectorCode"
|
||||
|
||||
/**
|
||||
* Demotes a `spec_probe_completed` outcome that either carries an
|
||||
* encoded collector failure, or collected zero dimensions, into an
|
||||
* explicit failure. Any other outcome (including real failures reported
|
||||
* some other way, or a probe that did collect dimensions) is returned
|
||||
* unchanged.
|
||||
*/
|
||||
fun demote(outcome: PurchaseExecutionOutcome): PurchaseExecutionOutcome {
|
||||
if (outcome.resultType != "spec_probe_completed") return outcome
|
||||
extractCollectorFailureCode(outcome.probedSpecs)?.let { collectorCode ->
|
||||
return PurchaseExecutionOutcome("failed", FAILED_PROBE_CODE, failureMessageFor(collectorCode))
|
||||
}
|
||||
if (!isDimensionsEmpty(outcome.probedSpecs)) return outcome
|
||||
return PurchaseExecutionOutcome("failed", EMPTY_PROBE_CODE, EMPTY_PROBE_MESSAGE)
|
||||
}
|
||||
|
||||
/** @see demote */
|
||||
@Deprecated("Use demote(outcome), which also handles encoded collector failures.", ReplaceWith("demote(outcome)"))
|
||||
fun demoteIfEmpty(outcome: PurchaseExecutionOutcome): PurchaseExecutionOutcome = demote(outcome)
|
||||
}
|
||||
@@ -30,10 +30,6 @@ enum class AgentDiagnosticReason {
|
||||
SPEC_PANEL_RESTORE_LIMIT,
|
||||
SPEC_PANEL_RESTORE_CONTAINER_UNAVAILABLE,
|
||||
SPEC_PANEL_RESTORE_GESTURE_FAILED,
|
||||
/** #334: the spec panel already showed the topmost color heading; the top-restore swipe was skipped. */
|
||||
SPEC_PANEL_TOP_ALREADY,
|
||||
/** #334: a top-restore swipe made spec headings or dimensions vanish (the bottom sheet was dragged); swiping stopped. */
|
||||
SPEC_PANEL_TOP_COLLAPSED,
|
||||
SPEC_PANEL_EVIDENCE_NOT_MATCHED,
|
||||
SPEC_ENTRY_CLICK_NO_EFFECT,
|
||||
SIZE_FOUND,
|
||||
|
||||
+1
-12
@@ -38,7 +38,6 @@ import cn.ilapage.goauto.agent.automation.PageEvidence
|
||||
import cn.ilapage.goauto.agent.automation.NodeSelector
|
||||
import cn.ilapage.goauto.agent.automation.CollectionRule
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseAgentCapabilities
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseSpecProbePolicy
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseExecutionInput
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseExecutionOutcome
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseLiveAutomation
|
||||
@@ -592,7 +591,7 @@ class AgentForegroundService : Service() {
|
||||
stateStore.update("BUSY", "正在执行${taskLabel}任务 #${task.taskId}", tokenStored = true)
|
||||
updateNotification("$taskLabel #${task.taskId}")
|
||||
|
||||
val rawOutcome = if (!snapshotHashValid) {
|
||||
val outcome = if (!snapshotHashValid) {
|
||||
PurchaseExecutionOutcome("failed", "PURCHASE_RULE_INVALID", "采购规则快照哈希无效")
|
||||
} else {
|
||||
var parseFailure: PurchaseExecutionOutcome? = null
|
||||
@@ -653,9 +652,6 @@ class AgentForegroundService : Service() {
|
||||
}
|
||||
}
|
||||
}
|
||||
// #334: a spec probe that read zero colors/sizes must fail explicitly
|
||||
// instead of being reported as a normal, empty spec_probe_completed.
|
||||
val outcome = PurchaseSpecProbePolicy.demote(rawOutcome)
|
||||
val requestId = UUID.randomUUID().toString()
|
||||
val payload = purchaseResultPayload(requestId, task.taskAttemptId, outcome)
|
||||
purchaseStore.completeAndEnqueue(task.taskId, task.taskAttemptId, requestId, payload)
|
||||
@@ -695,13 +691,6 @@ class AgentForegroundService : Service() {
|
||||
collector = collector,
|
||||
)
|
||||
val result = PddProductDetailCollector(accessibility).collect(task.pddGoodsId, rule)
|
||||
// #334: a collector failure (e.g. SPEC_PANEL_TOP_COLLAPSED) must not
|
||||
// collapse into a generic "spec probe failed" outcome. Carry the
|
||||
// real code/message through the opaque probeSpecs callback so
|
||||
// PurchaseSpecProbePolicy.demote can surface it explicitly.
|
||||
if (!result.successful) {
|
||||
return PurchaseSpecProbePolicy.encodeCollectorFailure(task.pddGoodsId, result.code, result.message)
|
||||
}
|
||||
val payload = result.payload ?: return null
|
||||
return JSONObject()
|
||||
.put("goodsId", task.pddGoodsId)
|
||||
|
||||
+17
-133
@@ -974,95 +974,6 @@ class PddProductDetailCollectorTest {
|
||||
assertTrue(driver.captureCount > driver.clickCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `spec panel already at top skips the restore swipe and still collects colors and sizes`() {
|
||||
// Default fixture state already renders the color heading first (at
|
||||
// top). #334: moveSpecPanelToTop must not issue any DOWN swipe in
|
||||
// that case, since dragging an already-top panel can lose headings
|
||||
// on a real device.
|
||||
val driver = FakeCollectorDriver(
|
||||
colorPages = listOf(listOf("红色", "蓝色")),
|
||||
sizePages = listOf(listOf("S", "M")),
|
||||
prices = mapOf("红色" to 1099L, "蓝色" to 1299L),
|
||||
)
|
||||
var clock = 0L
|
||||
val result = PddProductDetailCollector(driver, { clock }, { clock += it }).collect(GOODS_ID, rule())
|
||||
|
||||
assertTrue(result.successful)
|
||||
val payload = requireNotNull(result.payload)
|
||||
assertEquals(listOf("红色", "蓝色"), payload.colorPrices.map { it.color })
|
||||
assertEquals(listOf("S", "M"), payload.dimensions.first { it.key == "size" }.values)
|
||||
assertEquals(0, driver.swipes.count { it.first == SwipeDirection.DOWN })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `spec panel not at top restores with the original swipe behavior before collecting`() {
|
||||
// The color heading is hidden until one DOWN (top-restore) swipe has
|
||||
// happened, simulating a panel scrolled past its headings.
|
||||
val driver = FakeCollectorDriver(
|
||||
colorPages = listOf(listOf("红色", "蓝色")),
|
||||
sizePages = listOf(listOf("S", "M")),
|
||||
prices = mapOf("红色" to 1099L, "蓝色" to 1299L),
|
||||
specPanelHidesColorInitially = true,
|
||||
)
|
||||
var clock = 0L
|
||||
val result = PddProductDetailCollector(driver, { clock }, { clock += it }).collect(GOODS_ID, rule())
|
||||
|
||||
assertTrue(result.successful)
|
||||
val payload = requireNotNull(result.payload)
|
||||
assertEquals(listOf("红色", "蓝色"), payload.colorPrices.map { it.color })
|
||||
assertEquals(listOf("S", "M"), payload.dimensions.first { it.key == "size" }.values)
|
||||
assertTrue(driver.swipes.count { it.first == SwipeDirection.DOWN } >= 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `spec headings vanishing after a restore swipe stops swiping and fails explicitly`() {
|
||||
// The panel starts not-at-top (color heading hidden) so the restore
|
||||
// swipe runs; after that swipe every heading and value disappears,
|
||||
// simulating the bottom sheet being dragged off screen. #334
|
||||
// requires the collector to stop and fail instead of reporting an
|
||||
// empty successful result.
|
||||
val driver = FakeCollectorDriver(
|
||||
colorPages = listOf(listOf("红色", "蓝色")),
|
||||
sizePages = listOf(listOf("S", "M")),
|
||||
prices = mapOf("红色" to 1099L, "蓝色" to 1299L),
|
||||
specPanelHidesColorInitially = true,
|
||||
specPanelCollapsesAfterTopSwipe = true,
|
||||
)
|
||||
var clock = 0L
|
||||
val result = PddProductDetailCollector(driver, { clock }, { clock += it }).collect(GOODS_ID, rule())
|
||||
|
||||
assertFalse(result.successful)
|
||||
assertEquals("SPEC_PANEL_TOP_COLLAPSED", result.code)
|
||||
assertEquals(1, driver.swipes.count { it.first == SwipeDirection.DOWN })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a lower heading scrolling out of view after a restore swipe does not fail collection`() {
|
||||
// Three-dimension panel not at top (color heading is present but has
|
||||
// no values yet, so it does not become dimensions.first()). After the
|
||||
// restore swipe, color values return but the third, lower "容量"
|
||||
// heading scrolls out of view — a normal scroll-to-top effect. Total
|
||||
// heading count drops, but color and size values are present the
|
||||
// whole time, so this must NOT be treated as a collapsed panel.
|
||||
val driver = FakeCollectorDriver(
|
||||
colorPages = listOf(listOf("红色", "蓝色")),
|
||||
sizePages = listOf(listOf("S", "M")),
|
||||
prices = mapOf("红色" to 1099L, "蓝色" to 1299L),
|
||||
extraDimension = true,
|
||||
specPanelHidesColorInitially = true,
|
||||
specPanelDropsExtraDimensionAfterTopSwipe = true,
|
||||
)
|
||||
var clock = 0L
|
||||
val result = PddProductDetailCollector(driver, { clock }, { clock += it }).collect(GOODS_ID, rule())
|
||||
|
||||
assertTrue(result.successful)
|
||||
val payload = requireNotNull(result.payload)
|
||||
assertEquals(listOf("红色", "蓝色"), payload.colorPrices.map { it.color })
|
||||
assertEquals(listOf("S", "M"), payload.dimensions.first { it.key == "size" }.values)
|
||||
assertTrue(driver.swipes.count { it.first == SwipeDirection.DOWN } >= 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `duplicate sizes after price cleanup reject the whole size dimension`() {
|
||||
val driver = FakeCollectorDriver(
|
||||
@@ -1133,9 +1044,7 @@ class PddProductDetailCollectorTest {
|
||||
assertTrue(result.successful)
|
||||
assertEquals(listOf("A色", "B色", "C色", "F色", "E色", "D色"), driver.clickedLabels)
|
||||
assertEquals(colors, requireNotNull(result.payload).dimensions.first { it.key == "color" }.values)
|
||||
// #334: the panel already shows the color heading at top, so
|
||||
// moveSpecPanelToTop must not issue a needless DOWN restore swipe.
|
||||
assertTrue(driver.swipes.none { it.first == SwipeDirection.DOWN })
|
||||
assertTrue(driver.swipes.any { it.first == SwipeDirection.DOWN && it.second != null })
|
||||
assertTrue(driver.swipes.any { it.first == SwipeDirection.RIGHT && it.second != null })
|
||||
assertTrue(driver.swipes.any { it.first == SwipeDirection.UP && it.second != null })
|
||||
}
|
||||
@@ -1848,21 +1757,6 @@ class PddProductDetailCollectorTest {
|
||||
private val selectedSummaryPrefix: String = "已选",
|
||||
private val imageColorCards: Boolean = false,
|
||||
private val navigateAwayAfterColorClick: Boolean = false,
|
||||
// #334: simulates a spec panel that is not scrolled to top yet: the
|
||||
// color heading is present but has no values yet (so it is not the
|
||||
// first parsed dimension), until one DOWN (top-restore) swipe has
|
||||
// happened, after which color values render normally.
|
||||
private val specPanelHidesColorInitially: Boolean = false,
|
||||
// #334: simulates a bottom sheet dragged past its headings by a
|
||||
// restore swipe — once at least one DOWN swipe has happened, every
|
||||
// spec heading and value disappears from the panel.
|
||||
private val specPanelCollapsesAfterTopSwipe: Boolean = false,
|
||||
// #334: simulates a normal scroll-to-top on a multi-dimension panel
|
||||
// where a lower, non-spec-value-bearing heading (here the extra
|
||||
// "容量" dimension) scrolls out of view once the color heading is
|
||||
// restored to top. Heading count drops but color/size values are
|
||||
// untouched, so this must NOT be treated as a collapsed panel.
|
||||
private val specPanelDropsExtraDimensionAfterTopSwipe: Boolean = false,
|
||||
) : PddCollectorDriver {
|
||||
var captureCount = 0
|
||||
var clickCount = 0
|
||||
@@ -1876,7 +1770,6 @@ class PddProductDetailCollectorTest {
|
||||
private var previousSelected: String? = null
|
||||
private var horizontalPage = 0
|
||||
private var verticalPage = 0
|
||||
private var downSwipeCount = 0
|
||||
private var priceRead = 0
|
||||
private var panelOpen = !startWithPanelClosed
|
||||
private var reviewOpen = false
|
||||
@@ -1939,16 +1832,12 @@ class PddProductDetailCollectorTest {
|
||||
node("scroll", "", 0, 380, 1080, 1900, scrollable = scrollablePanel),
|
||||
)
|
||||
val continuationPage = hideDimensionHeadingsAfterFirstVerticalPage && verticalPage > 0
|
||||
val panelCollapsedNow = specPanelCollapsesAfterTopSwipe && downSwipeCount >= 1
|
||||
val hideColorNow = specPanelHidesColorInitially && downSwipeCount == 0
|
||||
val visibleColors = colorVerticalPages?.get(verticalPage.coerceAtMost(colorVerticalPages.lastIndex))
|
||||
?: colorPages[horizontalPage.coerceAtMost(colorPages.lastIndex)]
|
||||
val colorRowCount = (visibleColors.size + rowSize - 1) / rowSize
|
||||
val sizeHeadingTop = maxOf(700, 470 + colorRowCount * 90 + 20)
|
||||
if (!continuationPage && !panelCollapsedNow) {
|
||||
if (!continuationPage) {
|
||||
nodes += node("scroll/color-heading", "颜色分类", 20, 400, 300, 450, parentPath = "scroll")
|
||||
}
|
||||
if (!continuationPage && !hideColorNow && !panelCollapsedNow) {
|
||||
visibleColors
|
||||
.filterNot { hideSelectedColorOption && it == selected }
|
||||
.forEachIndexed { index, color ->
|
||||
@@ -1988,23 +1877,21 @@ class PddProductDetailCollectorTest {
|
||||
)
|
||||
}
|
||||
}
|
||||
if (!continuationPage && !panelCollapsedNow) nodes += node("scroll/size-heading", sizeHeadingLabel, 20, sizeHeadingTop, 300, sizeHeadingTop + 50, parentPath = "scroll")
|
||||
if (!panelCollapsedNow) {
|
||||
sizePages[verticalPage.coerceAtMost(sizePages.lastIndex)].forEachIndexed { index, size ->
|
||||
nodes += node(
|
||||
"scroll/size-$size-$captureCount",
|
||||
size,
|
||||
30 + index * 230,
|
||||
sizeHeadingTop + 70,
|
||||
220 + index * 230,
|
||||
sizeHeadingTop + 140,
|
||||
clickable = true,
|
||||
selected = size == initialSelectedSize,
|
||||
parentPath = "scroll",
|
||||
)
|
||||
}
|
||||
if (!continuationPage) nodes += node("scroll/size-heading", sizeHeadingLabel, 20, sizeHeadingTop, 300, sizeHeadingTop + 50, parentPath = "scroll")
|
||||
sizePages[verticalPage.coerceAtMost(sizePages.lastIndex)].forEachIndexed { index, size ->
|
||||
nodes += node(
|
||||
"scroll/size-$size-$captureCount",
|
||||
size,
|
||||
30 + index * 230,
|
||||
sizeHeadingTop + 70,
|
||||
220 + index * 230,
|
||||
sizeHeadingTop + 140,
|
||||
clickable = true,
|
||||
selected = size == initialSelectedSize,
|
||||
parentPath = "scroll",
|
||||
)
|
||||
}
|
||||
if (extraDimension && !(specPanelDropsExtraDimensionAfterTopSwipe && downSwipeCount >= 1)) {
|
||||
if (extraDimension) {
|
||||
nodes += node("scroll/capacity-heading", "容量", 20, 900, 300, 950, parentPath = "scroll")
|
||||
nodes += node("scroll/capacity", "大容量", 30, 970, 220, 1040, clickable = true, parentPath = "scroll")
|
||||
}
|
||||
@@ -2079,10 +1966,7 @@ class PddProductDetailCollectorTest {
|
||||
SwipeDirection.LEFT -> horizontalPage = (horizontalPage + 1).coerceAtMost(colorPages.lastIndex)
|
||||
SwipeDirection.UP -> verticalPage = (verticalPage + 1)
|
||||
.coerceAtMost(maxOf(sizePages.lastIndex, colorVerticalPages?.lastIndex ?: 0))
|
||||
SwipeDirection.DOWN -> {
|
||||
verticalPage = (verticalPage - 1).coerceAtLeast(0)
|
||||
downSwipeCount++
|
||||
}
|
||||
SwipeDirection.DOWN -> verticalPage = (verticalPage - 1).coerceAtLeast(0)
|
||||
else -> Unit
|
||||
}
|
||||
return true
|
||||
|
||||
@@ -197,12 +197,7 @@ class PurchaseLiveAutomationTest {
|
||||
val automation = PurchaseLiveAutomation(driver, pause = {})
|
||||
val address = automation.updateShippingAddress("_cg11")
|
||||
assertEquals("广东省广州市天园街道骏景花园骏晖轩1202_cg11", address.expectedAddress)
|
||||
// #335: the confirmation-page fixture now wraps the masked phone in a clickable
|
||||
// row (required so the page satisfies #331 REQUIRED_EVIDENCE panel recognition,
|
||||
// which finalSubmitTargets depends on). That changes address entry activation
|
||||
// from a bare TAP to a CLICK through the row's path, same as production PDD rows.
|
||||
assertEquals(0, driver.addressTaps)
|
||||
assertEquals(1, driver.addressPathClicks)
|
||||
assertEquals(1, driver.addressTaps)
|
||||
assertEquals("editor-address", driver.lastInputTargetPath)
|
||||
val final = automation.finalConfirmation(input(), address)
|
||||
assertEquals("_cg11", final.addressSuffix)
|
||||
@@ -877,50 +872,38 @@ class PurchaseLiveAutomationTest {
|
||||
"unknown" -> UiSnapshot("example.untrusted", "example.untrusted.UnknownActivity", emptyList())
|
||||
"pdd-home" -> UiSnapshot(PDD, "com.xunmeng.pinduoduo.ui.activity.MainFrameActivity", listOf(node("home", "拼多多首页")))
|
||||
else -> {
|
||||
// #335: the confirmation page must satisfy PddScreenParser's spec-panel
|
||||
// recognition (#331 REQUIRED_EVIDENCE fallback: address entry + payment
|
||||
// entry + one quantity input, all sharing the "panel" container) so the
|
||||
// new bottom-most-clickable-node submit rule has a recognized panel to
|
||||
// operate on. "panel/submit" keeps the legacy exact "提交订单" label so
|
||||
// existing click-driven state transitions below stay unchanged.
|
||||
val nodes = mutableListOf(
|
||||
node("root", "", bounds = NodeBounds(0, 0, 1080, 2200)),
|
||||
node("panel", "", scrollable = true, parentPath = "root", bounds = NodeBounds(0, 400, 1080, 2100)),
|
||||
node("panel/price", "¥20.00", parentPath = "panel", bounds = NodeBounds(20, 420, 300, 470)),
|
||||
node("panel/selected", "已选 黑色 XL", parentPath = "panel", bounds = NodeBounds(20, 480, 700, 530)),
|
||||
node("panel/quantity", "2", className = "android.widget.EditText", parentPath = "panel", bounds = NodeBounds(400, 560, 600, 620)),
|
||||
node("panel/payment-row", "", clickable = true, parentPath = "panel", bounds = NodeBounds(20, 640, 1060, 710)),
|
||||
node("panel/payment-row/label", "微信支付", parentPath = "panel/payment-row", bounds = NodeBounds(40, 650, 300, 700)),
|
||||
node("panel/submit", "提交订单", clickable = true, parentPath = "panel", bounds = NodeBounds(20, 1900, 1060, 2080)),
|
||||
node("panel", "", scrollable = true, bounds = NodeBounds(0, 400, 1080, 2100)),
|
||||
node("price", "¥20.00"), node("selected", "已选 黑色 XL"),
|
||||
node("quantity", "2", className = "android.widget.EditText"),
|
||||
node("submit-parent", "", clickable = true), node("submit", "提交订单", parentPath = "submit-parent"),
|
||||
)
|
||||
if (duplicatePanels) nodes += node("panel2", "", scrollable = true, bounds = NodeBounds(0, 500, 1080, 2000))
|
||||
if (addressVisible) {
|
||||
when {
|
||||
duplicateSemanticAddressCards -> {
|
||||
nodes += node("panel/address-layer-a", "", clickable = true, parentPath = "panel", bounds = NodeBounds(0, 820, 1080, 900))
|
||||
nodes += node("panel/address-layer-a/phone", "138****5678", parentPath = "panel/address-layer-a", bounds = NodeBounds(20, 830, 400, 860))
|
||||
nodes += node("panel/address-layer-a/detail", "广东省广州市天园街道骏景花园", parentPath = "panel/address-layer-a", bounds = NodeBounds(20, 862, 900, 898))
|
||||
nodes += node("panel/address-layer-b", "", clickable = true, parentPath = "panel", bounds = NodeBounds(0, 910, 1080, 990))
|
||||
nodes += node("panel/address-layer-b/phone", "138****5678", parentPath = "panel/address-layer-b", bounds = NodeBounds(20, 920, 400, 950))
|
||||
nodes += node("panel/address-layer-b/detail", "广东省广州市天园街道骏景花园", parentPath = "panel/address-layer-b", bounds = NodeBounds(20, 952, 900, 988))
|
||||
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("panel/address-card-a", "", clickable = true, parentPath = "panel", bounds = NodeBounds(0, 820, 1080, 900))
|
||||
nodes += node("panel/address-card-a/phone", "138****5678", parentPath = "panel/address-card-a", bounds = NodeBounds(20, 830, 400, 860))
|
||||
nodes += node("panel/address-card-a/detail", "广东省广州市天园街道一号", parentPath = "panel/address-card-a", bounds = NodeBounds(20, 862, 900, 898))
|
||||
nodes += node("panel/address-card-b", "", clickable = true, parentPath = "panel", bounds = NodeBounds(0, 910, 1080, 990))
|
||||
nodes += node("panel/address-card-b/phone", "138****5678", parentPath = "panel/address-card-b", bounds = NodeBounds(20, 920, 400, 950))
|
||||
nodes += node("panel/address-card-b/detail", "广东省广州市天园街道二号", parentPath = "panel/address-card-b", bounds = NodeBounds(20, 952, 900, 988))
|
||||
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("panel/address-card", "", clickable = true, parentPath = "panel", bounds = NodeBounds(0, 820, 1080, 900))
|
||||
nodes += node("panel/address-card/phone-a", "138****5678", parentPath = "panel/address-card", bounds = NodeBounds(20, 830, 400, 860))
|
||||
nodes += node("panel/address-card/phone-b", "138****5678", parentPath = "panel/address-card", bounds = NodeBounds(20, 862, 440, 892))
|
||||
}
|
||||
else -> {
|
||||
nodes += node("panel/address-row", "", clickable = true, parentPath = "panel", bounds = NodeBounds(0, 820, 1080, 900))
|
||||
nodes += node("panel/address-row/phone", "138****5678", parentPath = "panel/address-row", bounds = NodeBounds(20, 830, 400, 890))
|
||||
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
|
||||
@@ -928,10 +911,10 @@ class PurchaseLiveAutomationTest {
|
||||
when {
|
||||
hideConfirmationSuffix -> nodes += node("address", addressBody)
|
||||
splitConfirmationAddress -> {
|
||||
nodes += node("address-body", addressBody, bounds = NodeBounds(20, 1000, 900, 1080))
|
||||
nodes += node("address-suffix", addressSuffix, bounds = NodeBounds(20, 1090, 300, 1150))
|
||||
nodes += node("address-body", addressBody, bounds = NodeBounds(20, 700, 900, 780))
|
||||
nodes += node("address-suffix", addressSuffix, bounds = NodeBounds(20, 780, 300, 840))
|
||||
if (duplicateConfirmationSuffix) {
|
||||
nodes += node("address-suffix-2", addressSuffix, bounds = NodeBounds(500, 1090, 780, 1150))
|
||||
nodes += node("address-suffix-2", addressSuffix, bounds = NodeBounds(500, 780, 780, 840))
|
||||
}
|
||||
}
|
||||
else -> nodes += node("address", address)
|
||||
|
||||
@@ -781,8 +781,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
|
||||
assertEquals("PURCHASE_SPEC_PANEL_EVIDENCE_NOT_MATCHED", outcome.errorCode)
|
||||
assertEquals(
|
||||
"规格入口点击后页面已变化,但规格面板强证据不足 [type=UNKNOWN;scrollables=0;headings=0;options=0;summary=false;quantity=false;orderAction=false;pageEvidence=true;" +
|
||||
"addr=0;pay=0;qin=0;adj=0;summary=0;close=0;submit=0;same=0]",
|
||||
"规格入口点击后页面已变化,但规格面板强证据不足 [type=UNKNOWN;scrollables=0;headings=0;options=0;summary=false;quantity=false;orderAction=false;pageEvidence=true]",
|
||||
outcome.message,
|
||||
)
|
||||
assertEquals(0, driver.specTapCount)
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
package cn.ilapage.goauto.agent
|
||||
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseExecutionOutcome
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseSpecProbePolicy
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class PurchaseSpecProbePolicyTest {
|
||||
|
||||
@Test
|
||||
fun `zero dimensions is treated as empty`() {
|
||||
assertTrue(PurchaseSpecProbePolicy.isDimensionsEmpty("""{"dimensions":[]}"""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dimensions with no values is treated as empty`() {
|
||||
assertTrue(
|
||||
PurchaseSpecProbePolicy.isDimensionsEmpty(
|
||||
"""{"dimensions":[{"key":"color","name":"颜色","values":[]},{"key":"size","name":"尺码","values":[]}]}""",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `null blank or malformed payload is treated as empty`() {
|
||||
assertTrue(PurchaseSpecProbePolicy.isDimensionsEmpty(null))
|
||||
assertTrue(PurchaseSpecProbePolicy.isDimensionsEmpty(""))
|
||||
assertTrue(PurchaseSpecProbePolicy.isDimensionsEmpty("not json"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dimensions with at least one value is not empty`() {
|
||||
assertFalse(
|
||||
PurchaseSpecProbePolicy.isDimensionsEmpty(
|
||||
"""{"dimensions":[{"key":"color","name":"颜色","values":["红色"]}]}""",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `demote replaces an empty spec_probe_completed outcome with an explicit failure`() {
|
||||
val outcome = PurchaseExecutionOutcome(
|
||||
"spec_probe_completed",
|
||||
message = "商品规格已回传,等待服务端匹配",
|
||||
probedSpecs = """{"dimensions":[]}""",
|
||||
)
|
||||
|
||||
val demoted = PurchaseSpecProbePolicy.demote(outcome)
|
||||
|
||||
assertEquals("failed", demoted.resultType)
|
||||
assertEquals("PURCHASE_SPEC_PROBE_EMPTY", demoted.errorCode)
|
||||
assertEquals("规格探测未读取到任何颜色或尺码", demoted.message)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `demote leaves a non-empty probe result unchanged`() {
|
||||
val outcome = PurchaseExecutionOutcome(
|
||||
"spec_probe_completed",
|
||||
message = "商品规格已回传,等待服务端匹配",
|
||||
probedSpecs = """{"dimensions":[{"key":"color","name":"颜色","values":["红色"]}]}""",
|
||||
)
|
||||
|
||||
val demoted = PurchaseSpecProbePolicy.demote(outcome)
|
||||
|
||||
assertEquals(outcome, demoted)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `demote leaves other outcome types unchanged`() {
|
||||
val outcome = PurchaseExecutionOutcome("failed", "PURCHASE_SPEC_NOT_MATCHED", "商品规格探测失败")
|
||||
|
||||
val demoted = PurchaseSpecProbePolicy.demote(outcome)
|
||||
|
||||
assertEquals(outcome, demoted)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `extractCollectorFailureCode reads the encoded collector failure code`() {
|
||||
val payload = PurchaseSpecProbePolicy.encodeCollectorFailure("719834019024", "SPEC_PANEL_TOP_COLLAPSED", "回顶滑动导致规格标题消失,已停止滑动")
|
||||
|
||||
assertEquals("SPEC_PANEL_TOP_COLLAPSED", PurchaseSpecProbePolicy.extractCollectorFailureCode(payload))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `extractCollectorFailureCode returns null for a normal probe payload`() {
|
||||
assertEquals(
|
||||
null,
|
||||
PurchaseSpecProbePolicy.extractCollectorFailureCode(
|
||||
"""{"dimensions":[{"key":"color","name":"颜色","values":["红色"]}]}""",
|
||||
),
|
||||
)
|
||||
assertEquals(null, PurchaseSpecProbePolicy.extractCollectorFailureCode(null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `demote surfaces an encoded SPEC_PANEL_TOP_COLLAPSED collector failure with a purchaser-readable message`() {
|
||||
val payload = PurchaseSpecProbePolicy.encodeCollectorFailure("719834019024", "SPEC_PANEL_TOP_COLLAPSED", "回顶滑动导致规格标题消失,已停止滑动")
|
||||
val outcome = PurchaseExecutionOutcome("spec_probe_completed", message = "商品规格已回传,等待服务端匹配", probedSpecs = payload)
|
||||
|
||||
val demoted = PurchaseSpecProbePolicy.demote(outcome)
|
||||
|
||||
assertEquals("failed", demoted.resultType)
|
||||
assertEquals("PURCHASE_SPEC_PROBE_FAILED", demoted.errorCode)
|
||||
assertEquals("规格探测时规格面板被拖动,规格标题消失", demoted.message)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `demote falls back to a generic message for an unrecognized collector failure code`() {
|
||||
val payload = PurchaseSpecProbePolicy.encodeCollectorFailure("719834019024", "RULE_NOT_MATCHED", "采集期间离开 PDD 商品详情页")
|
||||
val outcome = PurchaseExecutionOutcome("spec_probe_completed", message = "商品规格已回传,等待服务端匹配", probedSpecs = payload)
|
||||
|
||||
val demoted = PurchaseSpecProbePolicy.demote(outcome)
|
||||
|
||||
assertEquals("failed", demoted.resultType)
|
||||
assertEquals("PURCHASE_SPEC_PROBE_FAILED", demoted.errorCode)
|
||||
assertTrue(demoted.message.contains("RULE_NOT_MATCHED"))
|
||||
}
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
package cn.ilapage.goauto.agent
|
||||
|
||||
import cn.ilapage.goauto.agent.automation.FreshActionResult
|
||||
import cn.ilapage.goauto.agent.automation.PddScreenParser
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseAgentCapabilities
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseExecutionInput
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseRehearsalExecutor
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseRuleParser
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseUiDriver
|
||||
import cn.ilapage.goauto.agent.automation.SnapshotNode
|
||||
import cn.ilapage.goauto.agent.automation.SwipeDirection
|
||||
import cn.ilapage.goauto.agent.automation.UiSnapshot
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/** #332 spec-option dedup (task 535) and the selection-failure diagnostic. */
|
||||
class SpecOptionDedupTest {
|
||||
private fun parse(snapshot: UiSnapshot) =
|
||||
PddScreenParser.parse(snapshot, PurchaseRehearsalExecutor.DEFAULT_COLLECTOR, "719834019024", null)
|
||||
|
||||
@Test
|
||||
fun `nested clickable nodes of one option block form one value`() {
|
||||
val screen = parse(SpecPanelFixtures.taskOptionDedupSheet())
|
||||
val colors = screen.dimensions.single { it.key == "color" }.values
|
||||
val sizes = screen.dimensions.single { it.key == "size" }.values
|
||||
|
||||
assertEquals(listOf("兰条纹", "白条纹"), colors.map { it.text })
|
||||
assertTrue(colors.single { it.text == "兰条纹" }.node.selected)
|
||||
assertFalse(colors.single { it.text == "白条纹" }.node.selected)
|
||||
assertEquals(5, sizes.size)
|
||||
assertEquals(listOf("2XL建议130-150斤"), sizes.filter { it.node.selected }.map { it.rawText })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unconfirmed selection reports parsed values selected flags and target`() {
|
||||
// The click never changes the page, so the white option stays unselected.
|
||||
val driver = object : PurchaseUiDriver {
|
||||
var opened = false
|
||||
override fun capture(): UiSnapshot =
|
||||
if (opened) SpecPanelFixtures.taskOptionDedupSheet() else SpecPanelFixtures.productDetailPage()
|
||||
override fun clickFresh(target: SnapshotNode): FreshActionResult {
|
||||
if (target.label.startsWith("请选择")) opened = true
|
||||
return FreshActionResult.SUCCESS
|
||||
}
|
||||
override fun tapPurchaseFresh(target: SnapshotNode) = FreshActionResult.FAILED
|
||||
override fun inputFresh(target: SnapshotNode, value: String) = FreshActionResult.FAILED
|
||||
override fun swipePurchase(direction: SwipeDirection, durationMs: Long) = false
|
||||
override fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long) = false
|
||||
override fun backPurchase() = false
|
||||
}
|
||||
val diagnostics = mutableListOf<String>()
|
||||
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { true }, { null }, pause = {}, panelDiagnostic = diagnostics::add)
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("PURCHASE_SPEC_SELECTION_UNCONFIRMED", outcome.errorCode)
|
||||
val evidence = diagnostics.last { it.startsWith("specSelectionUnconfirmed;") }
|
||||
assertTrue(evidence, evidence.contains("dimension=color;target=白条纹;valueCount=2;values=兰条纹:t|白条纹:f"))
|
||||
assertFalse(evidence.contains("****"))
|
||||
}
|
||||
|
||||
private fun input() = PurchaseExecutionInput(
|
||||
taskId = 332,
|
||||
executionMode = "rehearsal",
|
||||
phase = "purchase",
|
||||
url = "https://mobile.yangkeduo.com/goods.html?goods_id=719834019024",
|
||||
goodsId = "719834019024",
|
||||
mappedColor = "白条纹",
|
||||
mappedSize = "",
|
||||
quantity = 1,
|
||||
minUnitPriceCent = 1_000,
|
||||
maxUnitPriceCent = 3_000,
|
||||
)
|
||||
|
||||
private fun rule() = """{
|
||||
"schemaVersion":1,
|
||||
"ruleType":"pddPurchase",
|
||||
"requiredCapabilities":["purchase.rehearsal.v1"],
|
||||
"actions":[
|
||||
{"type":"openProduct"},{"type":"verifyProduct"},{"type":"openSpecPanel"},{"type":"selectSpec"},
|
||||
{"type":"setQuantity"},{"type":"verifyUnitPrice"},{"type":"verifyOrderSummary"}
|
||||
]
|
||||
}"""
|
||||
}
|
||||
@@ -1,317 +0,0 @@
|
||||
package cn.ilapage.goauto.agent
|
||||
|
||||
import cn.ilapage.goauto.agent.automation.NodeBounds
|
||||
import cn.ilapage.goauto.agent.automation.SnapshotNode
|
||||
import cn.ilapage.goauto.agent.automation.UiSnapshot
|
||||
|
||||
/**
|
||||
* Sanitized spec-panel fixtures (#332 option dedup, #331 recognition). The layout mirrors the structure of real
|
||||
* PDD purchase sheets, but every name, phone number and address is fake.
|
||||
*/
|
||||
internal object SpecPanelFixtures {
|
||||
const val PDD = "com.xunmeng.pinduoduo"
|
||||
const val ACTIVITY = "com.xunmeng.pinduoduo.activity.NewPageActivity"
|
||||
const val FAKE_PHONE = "139****0000"
|
||||
|
||||
class Tree(private val screenBottom: Int) {
|
||||
val nodes = mutableListOf<SnapshotNode>()
|
||||
|
||||
init {
|
||||
add("r", "", NodeBounds(0, 0, 1080, screenBottom), className = "android.widget.FrameLayout")
|
||||
}
|
||||
|
||||
fun add(
|
||||
path: String,
|
||||
text: String,
|
||||
bounds: NodeBounds,
|
||||
className: String = "android.widget.TextView",
|
||||
clickable: Boolean = false,
|
||||
scrollable: Boolean = false,
|
||||
selected: Boolean = false,
|
||||
visible: Boolean = true,
|
||||
enabled: Boolean = true,
|
||||
description: String? = null,
|
||||
) {
|
||||
val parent = path.substringBeforeLast('/', "").ifEmpty { null }
|
||||
nodes += SnapshotNode(
|
||||
path, parent, text, description, null, className, bounds,
|
||||
clickable, scrollable, selected, false, enabled, visible,
|
||||
)
|
||||
}
|
||||
|
||||
fun snapshot(packageName: String = PDD, activity: String = ACTIVITY) = UiSnapshot(packageName, activity, nodes.toList())
|
||||
}
|
||||
|
||||
/** Options for the purchase sheet shaped like the two unselected real samples. */
|
||||
data class Sheet(
|
||||
val screenBottom: Int = 2216,
|
||||
val address: Boolean = true,
|
||||
val payment: Boolean = true,
|
||||
val quantityInput: Boolean = true,
|
||||
val adjustButtons: Boolean = true,
|
||||
val close: Boolean = true,
|
||||
val summary: Boolean = true,
|
||||
val sizeDimension: Boolean = true,
|
||||
val listScrollable: Boolean = true,
|
||||
/** "visible" = normal submit bar; "hidden" = Samsung-like 31px bar whose [0,0][0,0] label is not visible; "none". */
|
||||
val submit: String = "visible",
|
||||
val offset: Int = 0,
|
||||
val colorLabels: List<String> = listOf("黑色 示例款", "卡其色 示例款"),
|
||||
val sizeLabels: List<String> = listOf("S 建议75-90斤", "M 建议90-105斤", "L 建议105-115斤"),
|
||||
)
|
||||
|
||||
fun sheet(options: Sheet = Sheet()): UiSnapshot {
|
||||
val o = options.offset
|
||||
val t = Tree(options.screenBottom)
|
||||
t.add("r/sheet", "", NodeBounds(0, 84, 1080, options.screenBottom), "android.widget.LinearLayout", clickable = true)
|
||||
t.add("r/sheet/top", "", NodeBounds(0, 219, 1080, 330), "android.widget.ViewSwitcher", clickable = true)
|
||||
t.add("r/sheet/top/tags", "#示例标签#七天退换", NodeBounds(193, 240, 886, 308))
|
||||
if (options.close) {
|
||||
t.add("r/sheet/close", "", NodeBounds(975, 237, 1050, 312), "android.widget.ImageView", clickable = true, description = "关闭")
|
||||
}
|
||||
t.add("r/sheet/body", "", NodeBounds(0, 330, 1080, options.screenBottom), "android.view.ViewGroup", clickable = true)
|
||||
if (options.address) {
|
||||
// Address row: clickable row five levels above the masked phone text.
|
||||
t.add("r/sheet/body/addr", "", NodeBounds(0, 330 + o, 1080, 484 + o), "android.view.ViewGroup", clickable = true)
|
||||
t.add("r/sheet/body/addr/a", "", NodeBounds(0, 340 + o, 1080, 470 + o), "android.widget.LinearLayout")
|
||||
t.add("r/sheet/body/addr/a/b", "", NodeBounds(36, 340 + o, 1044, 400 + o), "android.widget.LinearLayout")
|
||||
t.add("r/sheet/body/addr/a/b/c", "", NodeBounds(36, 340 + o, 1044, 400 + o), "android.widget.LinearLayout")
|
||||
t.add("r/sheet/body/addr/a/b/c/tag", "7天无理由退货", NodeBounds(138, 348 + o, 394, 394 + o))
|
||||
t.add("r/sheet/body/addr/a/b/c/phone", "测试,$FAKE_PHONE,示例省示例市", NodeBounds(412, 346 + o, 993, 395 + o))
|
||||
t.add("r/sheet/body/addr/a/detail", "示例区示例街道示例路1号", NodeBounds(132, 408 + o, 937, 457 + o))
|
||||
}
|
||||
t.add("r/sheet/body/image", "", NodeBounds(36, 638, 360, 962), "android.widget.FrameLayout", clickable = true, description = "商品主图")
|
||||
t.add("r/sheet/body/price", "¥19.6", NodeBounds(384, 640, 523, 707))
|
||||
if (options.summary) t.add("r/sheet/body/summary", "请选择: 颜色分类 尺码", NodeBounds(384, 791, 1068, 852))
|
||||
t.add("r/sheet/body/qty", "", NodeBounds(384, 888, 633, 963), "android.widget.LinearLayout")
|
||||
if (options.adjustButtons) {
|
||||
t.add("r/sheet/body/qty/dec", "", NodeBounds(384, 888, 462, 963), "android.widget.ImageView", clickable = true, description = "减少数量")
|
||||
t.add("r/sheet/body/qty/inc", "", NodeBounds(555, 888, 633, 963), "android.widget.ImageView", clickable = true, description = "增加数量")
|
||||
}
|
||||
if (options.quantityInput) {
|
||||
t.add("r/sheet/body/qty/input", "1", NodeBounds(468, 888, 549, 963), "android.widget.EditText", clickable = true)
|
||||
}
|
||||
val list = "r/sheet/body/list"
|
||||
t.add(list, "", NodeBounds(0, 1136, 1080, 2036), "androidx.recyclerview.widget.RecyclerView", scrollable = options.listScrollable)
|
||||
t.add("$list/color", "", NodeBounds(0, 1136, 1080, 1686), "android.widget.LinearLayout")
|
||||
t.add("$list/color/h", "颜色分类", NodeBounds(36, 1161, 216, 1222))
|
||||
options.colorLabels.forEachIndexed { index, label ->
|
||||
val step = if (options.colorLabels.size > 3) 258 else 346
|
||||
val w = if (options.colorLabels.size > 3) 240 else 316
|
||||
val left = 36 + index * step
|
||||
val block = "$list/color/o$index"
|
||||
t.add(block, "", NodeBounds(left, 1247, left + w, 1650), "android.view.ViewGroup", clickable = true, description = label)
|
||||
t.add("$block/img", "", NodeBounds(left, 1247, left + w, 1563), "android.widget.ImageView", clickable = true, description = label)
|
||||
t.add("$block/big", "", NodeBounds(left, 1247, left + 111, 1358), "android.widget.ImageView", clickable = true, description = "打开大图")
|
||||
t.add("$block/l", "", NodeBounds(left, 1563, left + w, 1650), "android.widget.LinearLayout")
|
||||
t.add("$block/l/t", label, NodeBounds(left, 1563, left + w, 1650), clickable = true)
|
||||
}
|
||||
if (options.sizeDimension) {
|
||||
t.add("$list/size", "", NodeBounds(0, 1686, 1080, 2036), "android.widget.LinearLayout")
|
||||
t.add("$list/size/row", "", NodeBounds(36, 1686, 1044, 1767), "android.widget.LinearLayout", clickable = true)
|
||||
t.add("$list/size/row/h", "尺码", NodeBounds(36, 1700, 126, 1753))
|
||||
t.add("$list/size/row/hint", "查看尺码建议", NodeBounds(150, 1702, 402, 1751))
|
||||
options.sizeLabels.forEachIndexed { index, label ->
|
||||
val top = 1779 + (index / 2) * 85
|
||||
val left = 36 + (index % 2) * 420
|
||||
t.add("$list/size/o$index", "", NodeBounds(left, top, left + 364, top + 80), "android.view.ViewGroup", clickable = true)
|
||||
t.add("$list/size/o$index/t", label, NodeBounds(left, top, left + 364, top + 80), clickable = true)
|
||||
}
|
||||
}
|
||||
if (options.payment) {
|
||||
t.add("r/sheet/pay", "", NodeBounds(0, 2036, 1080, 2135), "android.view.ViewGroup", clickable = true)
|
||||
t.add("r/sheet/pay/l", "", NodeBounds(94, 2057, 930, 2114), "android.widget.LinearLayout")
|
||||
t.add("r/sheet/pay/l/t", "使用#微信支付,更换先用后付可0元下单", NodeBounds(112, 2057, 930, 2114))
|
||||
}
|
||||
when (options.submit) {
|
||||
"visible" -> {
|
||||
t.add("r/sheet/submit", "", NodeBounds(0, 2135, 1080, options.screenBottom), "android.widget.FrameLayout", clickable = true)
|
||||
t.add("r/sheet/submit/l", "", NodeBounds(145, 2135, 934, options.screenBottom), "android.widget.LinearLayout")
|
||||
t.add("r/sheet/submit/l/t", "选择颜色分类及尺码后,提交订单", NodeBounds(157, 2179, 922, options.screenBottom))
|
||||
}
|
||||
"hidden" -> {
|
||||
t.add("r/sheet/submit", "", NodeBounds(0, options.screenBottom - 31, 1080, options.screenBottom), "android.widget.FrameLayout", clickable = true)
|
||||
t.add("r/sheet/submit/l", "", NodeBounds(0, 0, 0, 0), "android.widget.LinearLayout", visible = false)
|
||||
t.add("r/sheet/submit/l/t", "选择颜色分类及尺码后,提交订单", NodeBounds(0, 0, 0, 0), visible = false)
|
||||
}
|
||||
}
|
||||
return t.snapshot()
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal fallback sheet: no bounded scrollable, a single heading, no
|
||||
* summary, no +/- and no readable submit. Only the #331 required-evidence
|
||||
* fallback can recognize it. (Not the goods 8580 shape; see [sample8580ShapedSheet].)
|
||||
*/
|
||||
fun liveShapedSheet(options: Sheet = Sheet()): UiSnapshot = sheet(
|
||||
options.copy(summary = false, listScrollable = false, sizeDimension = false, adjustButtons = false, submit = "hidden"),
|
||||
)
|
||||
|
||||
/**
|
||||
* Shape of the 13 goods 8580 failures reported in #331:
|
||||
* `type=UNKNOWN;scrollables=1;headings=2;options=9;summary=false;quantity=true;orderAction=false;pageEvidenceMatched=true`.
|
||||
* One bounded scrollable, two headings, nine options, +/- and input present,
|
||||
* but no selection summary and no readable submit action.
|
||||
*/
|
||||
fun sample8580ShapedSheet(options: Sheet = Sheet()): UiSnapshot = sheet(
|
||||
options.copy(
|
||||
summary = false,
|
||||
submit = "hidden",
|
||||
colorLabels = listOf("黑色 示例款", "卡其色 示例款", "白色 示例款", "灰色 示例款"),
|
||||
sizeLabels = listOf("S 示例", "M 示例", "L 示例", "XL 示例", "2XL 示例"),
|
||||
),
|
||||
)
|
||||
|
||||
/**
|
||||
* #331 counter-example: a masked-phone row inside an address dialog, a
|
||||
* payment row inside a separate payment dialog and a quantity input in a
|
||||
* third place, together with a parsed option group. Every item exists but
|
||||
* they do not share one panel container.
|
||||
*/
|
||||
fun scatteredRequiredItemsPage(fullScreenWrapper: Boolean = false): UiSnapshot {
|
||||
val t = Tree(2216)
|
||||
val root = if (fullScreenWrapper) {
|
||||
t.add("r/content", "", NodeBounds(0, 0, 1080, 2216), "android.widget.FrameLayout")
|
||||
"r/content"
|
||||
} else "r"
|
||||
t.add("$root/addrDialog", "", NodeBounds(60, 200, 1020, 700), "android.widget.FrameLayout")
|
||||
t.add("$root/addrDialog/title", "选择收货地址", NodeBounds(360, 220, 720, 290))
|
||||
t.add("$root/addrDialog/row", "", NodeBounds(60, 320, 1020, 470), "android.view.ViewGroup", clickable = true)
|
||||
t.add("$root/addrDialog/row/phone", "测试,$FAKE_PHONE,示例省示例市", NodeBounds(100, 340, 980, 400))
|
||||
t.add("$root/payDialog", "", NodeBounds(60, 800, 1020, 1200), "android.widget.FrameLayout")
|
||||
t.add("$root/payDialog/row", "", NodeBounds(60, 900, 1020, 1010), "android.view.ViewGroup", clickable = true)
|
||||
t.add("$root/payDialog/row/t", "微信支付", NodeBounds(120, 920, 400, 990))
|
||||
t.add("$root/other", "", NodeBounds(0, 1250, 1080, 2216), "android.widget.LinearLayout")
|
||||
t.add("$root/other/close", "", NodeBounds(975, 1260, 1050, 1335), "android.widget.ImageView", clickable = true, description = "关闭")
|
||||
t.add("$root/other/input", "1", NodeBounds(468, 1350, 549, 1425), "android.widget.EditText", clickable = true)
|
||||
t.add("$root/other/h", "颜色分类", NodeBounds(36, 1500, 216, 1560))
|
||||
t.add("$root/other/o0", "黑色 示例款", NodeBounds(36, 1600, 352, 1700), clickable = true)
|
||||
t.add("$root/other/o1", "白色 示例款", NodeBounds(382, 1600, 698, 1700), clickable = true)
|
||||
return t.snapshot()
|
||||
}
|
||||
|
||||
/** Task 535 layout: the selected option block exposes an outer block, an image and an inner text with a badge. */
|
||||
fun taskOptionDedupSheet(): UiSnapshot {
|
||||
val t = Tree(2328)
|
||||
t.add("r/sheet", "", NodeBounds(0, 120, 1080, 2328), "android.widget.LinearLayout", clickable = true)
|
||||
t.add("r/sheet/close", "", NodeBounds(975, 273, 1050, 348), "android.widget.ImageView", clickable = true, description = "关闭")
|
||||
t.add("r/sheet/body", "", NodeBounds(0, 366, 1080, 2328), "android.view.ViewGroup", clickable = true)
|
||||
t.add("r/sheet/body/addr", "", NodeBounds(0, 366, 1080, 520), "android.view.ViewGroup", clickable = true)
|
||||
t.add("r/sheet/body/addr/a", "", NodeBounds(0, 376, 1080, 510), "android.widget.LinearLayout")
|
||||
t.add("r/sheet/body/addr/a/phone", "测试,$FAKE_PHONE,示例省示例市", NodeBounds(412, 382, 993, 431))
|
||||
t.add("r/sheet/body/addr/a/detail", "示例区示例路2号", NodeBounds(132, 444, 993, 493))
|
||||
t.add("r/sheet/body/price", "快抢光 ¥23.99", NodeBounds(396, 568, 722, 635))
|
||||
t.add("r/sheet/body/selected", "#快抢光#兰条纹 2XL建议130-150斤", NodeBounds(396, 719, 1053, 781))
|
||||
t.add("r/sheet/body/qty", "", NodeBounds(396, 817, 614, 895), "android.widget.LinearLayout")
|
||||
t.add("r/sheet/body/qty/dec", "", NodeBounds(396, 817, 462, 895), "android.widget.ImageView", clickable = true, description = "减少数量")
|
||||
t.add("r/sheet/body/qty/input", "1", NodeBounds(463, 817, 547, 895), "android.widget.EditText", clickable = true)
|
||||
t.add("r/sheet/body/qty/inc", "", NodeBounds(548, 817, 614, 895), "android.widget.ImageView", clickable = true, description = "增加数量")
|
||||
val list = "r/sheet/body/list"
|
||||
t.add(list, "", NodeBounds(0, 933, 1080, 2079), "androidx.recyclerview.widget.RecyclerView", scrollable = true)
|
||||
t.add("$list/color", "", NodeBounds(0, 933, 1080, 1483), "android.widget.LinearLayout")
|
||||
t.add("$list/color/h", "颜色分类", NodeBounds(36, 958, 216, 1019))
|
||||
val blue = "$list/color/o0"
|
||||
t.add(blue, "", NodeBounds(36, 1044, 352, 1447), "android.view.ViewGroup", clickable = true, selected = true, description = " 兰条纹")
|
||||
t.add("$blue/img", "", NodeBounds(36, 1044, 352, 1360), "android.widget.ImageView", clickable = true, selected = true, description = " 兰条纹")
|
||||
t.add("$blue/big", "", NodeBounds(36, 1044, 147, 1155), "android.widget.ImageView", clickable = true, selected = true, description = "打开大图")
|
||||
t.add("$blue/l", "", NodeBounds(36, 1339, 352, 1447), "android.widget.LinearLayout", selected = true)
|
||||
t.add("$blue/l/t", " 兰条纹\n 零差评", NodeBounds(36, 1339, 352, 1447), clickable = true, selected = true)
|
||||
val white = "$list/color/o1"
|
||||
t.add(white, "", NodeBounds(382, 1044, 698, 1447), "android.view.ViewGroup", clickable = true, description = "白条纹")
|
||||
t.add("$white/img", "", NodeBounds(382, 1044, 698, 1360), "android.widget.ImageView", clickable = true, description = "白条纹")
|
||||
t.add("$white/big", "", NodeBounds(382, 1044, 493, 1155), "android.widget.ImageView", clickable = true, description = "打开大图")
|
||||
t.add("$white/badge", "快要抢光", NodeBounds(521, 1044, 698, 1093))
|
||||
t.add("$white/l", "", NodeBounds(382, 1339, 698, 1447), "android.widget.LinearLayout")
|
||||
t.add("$white/l/t", "白条纹", NodeBounds(382, 1339, 698, 1447), clickable = true)
|
||||
t.add("$list/size", "", NodeBounds(0, 1483, 1080, 1938), "android.widget.LinearLayout")
|
||||
t.add("$list/size/row", "", NodeBounds(36, 1483, 1044, 1564), "android.widget.LinearLayout", clickable = true)
|
||||
t.add("$list/size/row/h", "尺码", NodeBounds(36, 1497, 126, 1550))
|
||||
listOf(
|
||||
Triple("L建议100-115斤", NodeBounds(36, 1576, 371, 1661), false),
|
||||
Triple("XL建议115-130斤", NodeBounds(401, 1576, 762, 1661), false),
|
||||
Triple("2XL建议130-150斤", NodeBounds(36, 1691, 430, 1776), true),
|
||||
Triple("3XL建议150-170斤", NodeBounds(460, 1691, 853, 1776), false),
|
||||
Triple("4XL建议170-190斤", NodeBounds(36, 1806, 431, 1891), false),
|
||||
).forEachIndexed { index, (label, bounds, selected) ->
|
||||
t.add("$list/size/o$index", "", bounds, "android.view.ViewGroup", clickable = true)
|
||||
t.add("$list/size/o$index/t", label, bounds, clickable = true, selected = selected)
|
||||
}
|
||||
t.add("r/sheet/pay", "", NodeBounds(0, 2079, 1080, 2178), "android.view.ViewGroup", clickable = true)
|
||||
t.add("r/sheet/pay/t", "使用#微信支付,更换先用后付可0元下单", NodeBounds(112, 2100, 930, 2157))
|
||||
t.add("r/sheet/submit", "", NodeBounds(0, 2181, 1080, 2328), "android.widget.FrameLayout", clickable = true)
|
||||
t.add("r/sheet/submit/t", "提交订单 ¥23.99", NodeBounds(365, 2225, 715, 2284))
|
||||
return t.snapshot()
|
||||
}
|
||||
|
||||
fun productDetailPage(): UiSnapshot {
|
||||
val t = Tree(2216)
|
||||
t.add("r/pager", "示例商品标题连衣裙夏季新款示例", NodeBounds(0, 84, 1080, 1100), "androidx.viewpager.widget.ViewPager")
|
||||
t.add("r/price", "¥19.6", NodeBounds(36, 1120, 300, 1200))
|
||||
t.add("r/sales", "已拼1万+件", NodeBounds(700, 1120, 1044, 1200))
|
||||
t.add("r/deliver", "配送至 示例省示例市", NodeBounds(36, 1300, 1044, 1360))
|
||||
t.add("r/spec", "请选择 颜色分类 尺码", NodeBounds(36, 1400, 1044, 1480), clickable = true)
|
||||
t.add("r/review", "商品评价(2000+)", NodeBounds(36, 1500, 1044, 1580), clickable = true)
|
||||
t.add("r/single", "单独购买", NodeBounds(420, 2080, 720, 2200), clickable = true)
|
||||
t.add("r/group", "发起拼单", NodeBounds(740, 2080, 1060, 2200), clickable = true)
|
||||
return t.snapshot()
|
||||
}
|
||||
|
||||
fun reviewPage(): UiSnapshot {
|
||||
val t = Tree(2216)
|
||||
t.add("r/title", "商品评价", NodeBounds(400, 120, 680, 200))
|
||||
t.add("r/all", "全部", NodeBounds(36, 260, 150, 320), clickable = true)
|
||||
t.add("r/media", "有图/视频", NodeBounds(170, 260, 360, 320), clickable = true)
|
||||
t.add("r/body", "质量不错,微信支付很方便", NodeBounds(36, 400, 1044, 500))
|
||||
return t.snapshot()
|
||||
}
|
||||
|
||||
fun addressListPage(): UiSnapshot {
|
||||
val t = Tree(2216)
|
||||
t.add("r/title", "收货地址", NodeBounds(400, 120, 680, 200))
|
||||
listOf(0, 1).forEach { index ->
|
||||
val top = 300 + index * 260
|
||||
t.add("r/card$index", "", NodeBounds(0, top, 1080, top + 240), "android.view.ViewGroup", clickable = true)
|
||||
t.add("r/card$index/phone", "测试 $FAKE_PHONE", NodeBounds(36, top + 20, 700, top + 80))
|
||||
t.add("r/card$index/detail", "示例省示例市示例路${index + 1}号", NodeBounds(36, top + 100, 900, top + 160))
|
||||
t.add("r/card$index/edit", "修改", NodeBounds(950, top + 20, 1044, top + 80), clickable = true)
|
||||
}
|
||||
return t.snapshot()
|
||||
}
|
||||
|
||||
fun addressEditPage(): UiSnapshot {
|
||||
val t = Tree(2216)
|
||||
t.add("r/title", "修改收货地址", NodeBounds(360, 120, 720, 200))
|
||||
t.add("r/name", "测试收货人", NodeBounds(180, 190, 900, 270), "android.widget.EditText", clickable = true)
|
||||
t.add("r/phone", "13900000000", NodeBounds(180, 290, 900, 370), "android.widget.EditText", clickable = true)
|
||||
t.add("r/detailLabel", "详细地址", NodeBounds(20, 400, 160, 460))
|
||||
t.add("r/detail", "示例路1号", NodeBounds(180, 390, 900, 480), "android.widget.EditText", clickable = true)
|
||||
t.add("r/save", "保存", NodeBounds(36, 2080, 1044, 2180), clickable = true)
|
||||
return t.snapshot()
|
||||
}
|
||||
|
||||
fun paymentMethodDialog(): UiSnapshot {
|
||||
val t = Tree(2216)
|
||||
t.add("r/title", "选择支付方式", NodeBounds(360, 1200, 720, 1280))
|
||||
t.add("r/wechat", "", NodeBounds(0, 1300, 1080, 1420), "android.view.ViewGroup", clickable = true)
|
||||
t.add("r/wechat/t", "微信支付", NodeBounds(120, 1330, 400, 1390))
|
||||
t.add("r/later", "", NodeBounds(0, 1440, 1080, 1560), "android.view.ViewGroup", clickable = true)
|
||||
t.add("r/later/t", "先用后付", NodeBounds(120, 1470, 400, 1530))
|
||||
t.add("r/ok", "确定", NodeBounds(36, 2080, 1044, 2180), clickable = true)
|
||||
return t.snapshot()
|
||||
}
|
||||
|
||||
fun postSubmitPaymentPage(): UiSnapshot {
|
||||
val t = Tree(2216)
|
||||
t.add("r/amount", "¥19.60", NodeBounds(360, 300, 720, 400))
|
||||
t.add("r/wechat", "", NodeBounds(0, 600, 1080, 720), "android.view.ViewGroup", clickable = true)
|
||||
t.add("r/wechat/t", "微信支付", NodeBounds(120, 630, 400, 690))
|
||||
t.add("r/pay", "立即支付", NodeBounds(36, 2080, 1044, 2180), clickable = true)
|
||||
return t.snapshot(activity = "com.xunmeng.pinduoduo.app_pay.core.PayActivity")
|
||||
}
|
||||
|
||||
fun quantityOnlyPage(): UiSnapshot {
|
||||
val t = Tree(2216)
|
||||
t.add("r/input", "1", NodeBounds(468, 888, 549, 963), "android.widget.EditText", clickable = true)
|
||||
return t.snapshot()
|
||||
}
|
||||
}
|
||||
@@ -1,879 +0,0 @@
|
||||
package cn.ilapage.goauto.agent
|
||||
|
||||
import cn.ilapage.goauto.agent.SpecPanelFixtures.Sheet
|
||||
import cn.ilapage.goauto.agent.automation.FreshActionResult
|
||||
import cn.ilapage.goauto.agent.automation.NodeBounds
|
||||
import cn.ilapage.goauto.agent.automation.ParsedPddScreen
|
||||
import cn.ilapage.goauto.agent.automation.PddScreenParser
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseAgentCapabilities
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseExecutionInput
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseLiveAutomation
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseLiveException
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseRehearsalExecutor
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseRuleParser
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseUiDriver
|
||||
import cn.ilapage.goauto.agent.automation.SnapshotNode
|
||||
import cn.ilapage.goauto.agent.automation.SpecPanelStabilityPolicy
|
||||
import cn.ilapage.goauto.agent.automation.SpecPanelType
|
||||
import cn.ilapage.goauto.agent.automation.SwipeDirection
|
||||
import cn.ilapage.goauto.agent.automation.UiSnapshot
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/** #331 spec-panel recognition, same-container check, hints, bounded waits and strict submit targets. */
|
||||
class SpecPanelRecognitionTest {
|
||||
private fun parse(snapshot: UiSnapshot) =
|
||||
PddScreenParser.parse(snapshot, PurchaseRehearsalExecutor.DEFAULT_COLLECTOR, "719834019024", null)
|
||||
|
||||
// --- Recognition -------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `both sanitized unselected sheet samples are recognized as spec panels`() {
|
||||
val first = parse(SpecPanelFixtures.sheet())
|
||||
val samsung = parse(SpecPanelFixtures.sheet(Sheet(screenBottom = 2020, submit = "hidden")))
|
||||
|
||||
listOf(first, samsung).forEach { screen ->
|
||||
assertTrue(screen.specPanelOpen)
|
||||
assertTrue(screen.hasAddressEntry)
|
||||
assertTrue(screen.hasPaymentEntry)
|
||||
assertTrue(screen.hasQuantityInput)
|
||||
}
|
||||
// The zero-size submit label is not visible, so the submit action is absent.
|
||||
assertFalse(samsung.hasOrderSubmitAction)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `live shaped samples are recognized only through the required evidence fallback`() {
|
||||
listOf(2216, 2020).forEach { bottom ->
|
||||
val screen = parse(SpecPanelFixtures.liveShapedSheet(Sheet(screenBottom = bottom)))
|
||||
|
||||
assertEquals(SpecPanelType.REQUIRED_EVIDENCE, screen.specPanelType)
|
||||
assertTrue(screen.specPanelOpen)
|
||||
assertEquals(0, screen.panelScrollableCount)
|
||||
assertFalse(screen.hasSelectionSummary)
|
||||
assertFalse(screen.hasQuantityControls)
|
||||
assertFalse(screen.hasQuantityAdjustControls)
|
||||
assertFalse(screen.hasOrderSubmitAction)
|
||||
assertEquals(listOf("黑色 示例款", "卡其色 示例款"), screen.dimensions.single().values.map { it.text })
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `zero size submit label sample is still recognized because submit is auxiliary`() {
|
||||
val screen = parse(SpecPanelFixtures.liveShapedSheet(Sheet(screenBottom = 2020)))
|
||||
|
||||
assertFalse(screen.hasOrderSubmitAction)
|
||||
assertEquals(SpecPanelType.REQUIRED_EVIDENCE, screen.specPanelType)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `missing address entry is not recognized`() {
|
||||
val screen = parse(SpecPanelFixtures.liveShapedSheet(Sheet(address = false)))
|
||||
assertFalse(screen.hasAddressEntry)
|
||||
assertEquals(SpecPanelType.UNKNOWN, screen.specPanelType)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `missing payment entry is not recognized`() {
|
||||
val screen = parse(SpecPanelFixtures.liveShapedSheet(Sheet(payment = false)))
|
||||
assertFalse(screen.hasPaymentEntry)
|
||||
assertEquals(SpecPanelType.UNKNOWN, screen.specPanelType)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `missing quantity input is not recognized`() {
|
||||
val screen = parse(SpecPanelFixtures.liveShapedSheet(Sheet(quantityInput = false)))
|
||||
assertFalse(screen.hasQuantityInput)
|
||||
assertEquals(SpecPanelType.UNKNOWN, screen.specPanelType)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `required items without any auxiliary evidence are not recognized`() {
|
||||
val t = SpecPanelFixtures.Tree(2216)
|
||||
t.add("r/panel", "", NodeBounds(0, 300, 1080, 2216), "android.widget.LinearLayout")
|
||||
t.add("r/panel/addr", "", NodeBounds(0, 330, 1080, 484), "android.view.ViewGroup", clickable = true)
|
||||
t.add("r/panel/addr/phone", "测试,${SpecPanelFixtures.FAKE_PHONE}", NodeBounds(412, 346, 993, 395))
|
||||
t.add("r/panel/qty", "1", NodeBounds(468, 888, 549, 963), "android.widget.EditText", clickable = true)
|
||||
t.add("r/panel/pay", "", NodeBounds(0, 2036, 1080, 2135), "android.view.ViewGroup", clickable = true)
|
||||
t.add("r/panel/pay/t", "使用微信支付", NodeBounds(112, 2057, 930, 2114))
|
||||
|
||||
val screen = parse(t.snapshot())
|
||||
|
||||
assertTrue(screen.hasRequiredPanelEvidence)
|
||||
assertTrue(screen.requiredEvidenceSameContainer)
|
||||
assertEquals(SpecPanelType.UNKNOWN, screen.specPanelType)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `quantity input alone is not recognized`() {
|
||||
val screen = parse(SpecPanelFixtures.quantityOnlyPage())
|
||||
assertTrue(screen.hasQuantityInput)
|
||||
assertEquals(SpecPanelType.UNKNOWN, screen.specPanelType)
|
||||
assertFalse(screen.specPanelOpen)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `non panel pages are never recognized as spec panels`() {
|
||||
mapOf(
|
||||
"product" to SpecPanelFixtures.productDetailPage(),
|
||||
"review" to SpecPanelFixtures.reviewPage(),
|
||||
"addressList" to SpecPanelFixtures.addressListPage(),
|
||||
"addressEdit" to SpecPanelFixtures.addressEditPage(),
|
||||
"paymentDialog" to SpecPanelFixtures.paymentMethodDialog(),
|
||||
"paymentPage" to SpecPanelFixtures.postSubmitPaymentPage(),
|
||||
).forEach { (name, snapshot) ->
|
||||
val screen = parse(snapshot)
|
||||
assertEquals(name, SpecPanelType.UNKNOWN, screen.specPanelType)
|
||||
assertFalse(name, screen.specPanelOpen)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `whole sheet clickable containers do not count as an address row`() {
|
||||
val t = SpecPanelFixtures.Tree(2216)
|
||||
t.add("r/sheet", "", NodeBounds(0, 84, 1080, 2216), "android.widget.LinearLayout", clickable = true)
|
||||
t.add("r/sheet/phone", "测试,${SpecPanelFixtures.FAKE_PHONE}", NodeBounds(412, 346, 993, 395))
|
||||
assertFalse(parse(t.snapshot()).hasAddressEntry)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `single dimension sheet without adjust buttons is recognized`() {
|
||||
val screen = parse(SpecPanelFixtures.sheet(Sheet(sizeDimension = false, adjustButtons = false, summary = false, listScrollable = false, submit = "none")))
|
||||
|
||||
assertEquals(SpecPanelType.REQUIRED_EVIDENCE, screen.specPanelType)
|
||||
assertEquals(listOf("color"), screen.dimensions.map { it.key })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `required evidence diagnostics hold booleans and counts only`() {
|
||||
val screen = parse(SpecPanelFixtures.liveShapedSheet())
|
||||
val summary = screen.requiredEvidenceSummary()
|
||||
|
||||
assertEquals("addr=1;pay=1;qin=1;adj=0;summary=0;close=1;submit=0;same=1", summary)
|
||||
assertFalse(summary.contains("****"))
|
||||
assertFalse(summary.contains("示例"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `goods 8580 shaped sheet is recognized through the required evidence fallback`() {
|
||||
val screen = parse(SpecPanelFixtures.sample8580ShapedSheet())
|
||||
|
||||
// Shape of the 13 live failures: scrollables=1;headings=2;options=9;
|
||||
// summary=false;quantity=true;orderAction=false (was UNKNOWN before #331).
|
||||
assertEquals(1, screen.panelScrollableCount)
|
||||
assertEquals(2, screen.panelHeadingCount)
|
||||
assertEquals(9, screen.panelOptionCount)
|
||||
assertFalse(screen.hasSelectionSummary)
|
||||
assertTrue(screen.hasQuantityControls)
|
||||
assertFalse(screen.hasOrderSubmitAction)
|
||||
assertEquals(SpecPanelType.REQUIRED_EVIDENCE, screen.specPanelType)
|
||||
assertTrue(screen.requiredEvidenceSameContainer)
|
||||
}
|
||||
|
||||
// --- Same panel container --------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `required items inside one panel container are recognized`() {
|
||||
listOf(SpecPanelFixtures.liveShapedSheet(), SpecPanelFixtures.sample8580ShapedSheet()).forEach { snapshot ->
|
||||
val screen = parse(snapshot)
|
||||
assertTrue(screen.requiredEvidenceSameContainer)
|
||||
assertEquals(SpecPanelType.REQUIRED_EVIDENCE, screen.specPanelType)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `address dialog payment dialog and a separate quantity input are not a spec panel`() {
|
||||
listOf(false, true).forEach { wrapper ->
|
||||
val screen = parse(SpecPanelFixtures.scatteredRequiredItemsPage(fullScreenWrapper = wrapper))
|
||||
|
||||
// Every required item exists somewhere on screen, plus auxiliary
|
||||
// options and a close control, but not inside one panel container.
|
||||
assertTrue(screen.hasAddressEntry)
|
||||
assertTrue(screen.hasPaymentEntry)
|
||||
assertTrue(screen.hasQuantityInput)
|
||||
assertTrue(screen.dimensions.any { it.values.isNotEmpty() })
|
||||
assertFalse(screen.requiredEvidenceSameContainer)
|
||||
assertEquals(SpecPanelType.UNKNOWN, screen.specPanelType)
|
||||
assertFalse(screen.specPanelOpen)
|
||||
assertEquals(listOf(ParsedPddScreen.NOT_SAME_CONTAINER_HINT), screen.requiredEvidenceHints())
|
||||
}
|
||||
}
|
||||
|
||||
// --- Purchaser hints for missing required items --------------------------
|
||||
|
||||
@Test
|
||||
fun `missing required items produce purchaser hints`() {
|
||||
val noPay = parse(SpecPanelFixtures.sample8580ShapedSheet(Sheet(payment = false)))
|
||||
val noAddr = parse(SpecPanelFixtures.sample8580ShapedSheet(Sheet(address = false)))
|
||||
val noQty = parse(SpecPanelFixtures.sample8580ShapedSheet(Sheet(quantityInput = false)))
|
||||
|
||||
assertEquals(listOf("未找到支付入口:请确认 PDD 默认支付方式为微信支付"), noPay.requiredEvidenceHints())
|
||||
assertEquals(listOf("未找到收货地址入口:请确认 PDD 已设置默认收货地址"), noAddr.requiredEvidenceHints())
|
||||
assertEquals(listOf("未找到购买数量输入框"), noQty.requiredEvidenceHints())
|
||||
assertTrue(parse(SpecPanelFixtures.sample8580ShapedSheet()).requiredEvidenceHints().isEmpty())
|
||||
// Unrelated pages without any required item or option do not get environment hints.
|
||||
assertTrue(parse(SpecPanelFixtures.reviewPage()).requiredEvidenceHints().isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `spec panel evidence failure message carries the missing payment hint`() {
|
||||
val driver = SpecEntryDriver(listOf(SpecPanelFixtures.sample8580ShapedSheet(Sheet(payment = false))))
|
||||
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { true }, { null }, pause = {})
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("PURCHASE_SPEC_PANEL_EVIDENCE_NOT_MATCHED", outcome.errorCode)
|
||||
assertTrue(outcome.message.orEmpty().contains("未找到支付入口:请确认 PDD 默认支付方式为微信支付"))
|
||||
assertFalse(outcome.message.orEmpty().contains("****"))
|
||||
assertEquals(0, driver.backs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `spec panel evidence failure message carries the missing address and quantity hints`() {
|
||||
mapOf(
|
||||
Sheet(address = false) to "未找到收货地址入口:请确认 PDD 已设置默认收货地址",
|
||||
Sheet(quantityInput = false) to "未找到购买数量输入框",
|
||||
).forEach { (options, hint) ->
|
||||
val driver = SpecEntryDriver(listOf(SpecPanelFixtures.sample8580ShapedSheet(options)))
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { true }, { null }, pause = {})
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("PURCHASE_SPEC_PANEL_EVIDENCE_NOT_MATCHED", outcome.errorCode)
|
||||
assertTrue(outcome.message.orEmpty(), outcome.message.orEmpty().contains(hint))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `address save timeout message carries the missing address hint`() {
|
||||
val loading = SpecPanelFixtures.liveShapedSheet(Sheet(address = false))
|
||||
val driver = AddressSaveDriver(listOf(loading)) { loading }
|
||||
|
||||
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg35") }
|
||||
.exceptionOrNull() as PurchaseLiveException
|
||||
|
||||
assertEquals("PURCHASE_ADDRESS_SAVE_TIMEOUT", error.code)
|
||||
assertTrue(error.message.orEmpty().contains("未找到收货地址入口:请确认 PDD 已设置默认收货地址"))
|
||||
}
|
||||
|
||||
// --- Bounded wait after the first spec-entry click -----------------------
|
||||
|
||||
@Test
|
||||
fun `loading sheet is accepted after required evidence becomes complete and stable without back`() {
|
||||
val loading = SpecPanelFixtures.liveShapedSheet(Sheet(address = false))
|
||||
val ready = SpecPanelFixtures.liveShapedSheet()
|
||||
val driver = SpecEntryDriver(listOf(loading, loading, loading, ready, ready))
|
||||
val diagnostics = mutableListOf<String>()
|
||||
val steps = mutableListOf<String>()
|
||||
|
||||
PurchaseRehearsalExecutor(driver, { true }, { null }, pause = {}, stepChanged = steps::add, panelDiagnostic = diagnostics::add)
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertTrue(steps.contains("selectSpec"))
|
||||
assertEquals(0, driver.backs)
|
||||
assertTrue(diagnostics.any { it.startsWith("specPanelWait;outcome=opened;") && it.contains("backPressed=false") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `always incomplete sheet fails explicitly at the wait bound without back`() {
|
||||
val loading = SpecPanelFixtures.liveShapedSheet(Sheet(address = false))
|
||||
val driver = SpecEntryDriver(listOf(loading))
|
||||
val diagnostics = mutableListOf<String>()
|
||||
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { true }, { null }, pause = {}, panelDiagnostic = diagnostics::add)
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("PURCHASE_SPEC_PANEL_EVIDENCE_NOT_MATCHED", outcome.errorCode)
|
||||
assertEquals(0, driver.backs)
|
||||
val timeout = diagnostics.single { it.startsWith("specPanelWait;outcome=timeout;") }
|
||||
assertTrue(timeout.contains("waitedMs=${SpecPanelStabilityPolicy.WAIT_BOUND_MS};"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fallback sheet whose structure keeps changing is never accepted early`() {
|
||||
val first = SpecPanelFixtures.liveShapedSheet(Sheet(screenBottom = 2216))
|
||||
val second = SpecPanelFixtures.liveShapedSheet(Sheet(screenBottom = 2200))
|
||||
assertEquals(SpecPanelType.REQUIRED_EVIDENCE, parse(second).specPanelType)
|
||||
val driver = SpecEntryDriver(listOf(first, second), cycle = true)
|
||||
val diagnostics = mutableListOf<String>()
|
||||
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { true }, { null }, pause = {}, panelDiagnostic = diagnostics::add)
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertEquals("PURCHASE_SPEC_PANEL_EVIDENCE_NOT_MATCHED", outcome.errorCode)
|
||||
assertEquals(0, driver.backs)
|
||||
assertFalse(diagnostics.any { it.startsWith("specPanelWait;outcome=opened") })
|
||||
assertTrue(diagnostics.any { it.contains("waitedMs=${SpecPanelStabilityPolicy.WAIT_BOUND_MS};") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `stronger panel type is still accepted immediately`() {
|
||||
val driver = SpecEntryDriver(listOf(SpecPanelFixtures.sheet()))
|
||||
val diagnostics = mutableListOf<String>()
|
||||
val steps = mutableListOf<String>()
|
||||
|
||||
PurchaseRehearsalExecutor(driver, { true }, { null }, pause = {}, stepChanged = steps::add, panelDiagnostic = diagnostics::add)
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
|
||||
assertTrue(steps.contains("selectSpec"))
|
||||
assertFalse(diagnostics.any { it.startsWith("specPanelWait;") })
|
||||
}
|
||||
|
||||
// --- Bounded wait after the address save ---------------------------------
|
||||
|
||||
@Test
|
||||
fun `address save waits for the loading sheet without pressing back`() {
|
||||
val loading = SpecPanelFixtures.liveShapedSheet(Sheet(address = false))
|
||||
val driver = AddressSaveDriver(listOf(loading, loading, loading)) { expected -> readySheet(expected) }
|
||||
val diagnostics = mutableListOf<String>()
|
||||
|
||||
val proof = PurchaseLiveAutomation(driver, pause = {}, panelDiagnostic = diagnostics::add).updateShippingAddress("_cg31")
|
||||
|
||||
assertEquals("_cg31", proof.suffix)
|
||||
assertEquals(0, driver.backs)
|
||||
val done = diagnostics.single { it.startsWith("addressSaveWait;") }
|
||||
assertTrue(done.contains("outcome=final_evidence"))
|
||||
assertTrue(done.contains("waitedMs=${2 * SpecPanelStabilityPolicy.ADDRESS_SAVE_POLL_MS};"))
|
||||
assertTrue(done.contains("backPressed=false"))
|
||||
assertFalse(diagnostics.any { it.contains("_cg31") || it.contains("示例") || it.contains("****") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `address save with a sheet that never completes fails at the bound without back`() {
|
||||
val loading = SpecPanelFixtures.liveShapedSheet(Sheet(address = false))
|
||||
val driver = AddressSaveDriver(listOf(loading)) { loading }
|
||||
val diagnostics = mutableListOf<String>()
|
||||
|
||||
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}, panelDiagnostic = diagnostics::add).updateShippingAddress("_cg32") }
|
||||
.exceptionOrNull() as PurchaseLiveException
|
||||
|
||||
assertEquals("PURCHASE_ADDRESS_SAVE_TIMEOUT", error.code)
|
||||
assertEquals(0, driver.backs)
|
||||
assertTrue(diagnostics.single().contains("outcome=timeout;waitedMs=${SpecPanelStabilityPolicy.WAIT_BOUND_MS};"))
|
||||
assertFalse(error.message.orEmpty().contains("示例"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `address save with an unstable fallback sheet does not decide early`() {
|
||||
val first = SpecPanelFixtures.liveShapedSheet(Sheet(screenBottom = 2216))
|
||||
val second = SpecPanelFixtures.liveShapedSheet(Sheet(screenBottom = 2200))
|
||||
val driver = AddressSaveDriver(listOf(first, second), cycle = true) { first }
|
||||
val diagnostics = mutableListOf<String>()
|
||||
|
||||
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}, panelDiagnostic = diagnostics::add).updateShippingAddress("_cg33") }
|
||||
.exceptionOrNull() as PurchaseLiveException
|
||||
|
||||
assertEquals("PURCHASE_ADDRESS_SAVE_TIMEOUT", error.code)
|
||||
assertEquals(0, driver.backs)
|
||||
assertTrue(diagnostics.single().contains("outcome=timeout"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `address save with a stable fallback sheet is accepted as purchase panel without back`() {
|
||||
val sheet = SpecPanelFixtures.liveShapedSheet()
|
||||
val driver = AddressSaveDriver(listOf(sheet)) { sheet }
|
||||
val diagnostics = mutableListOf<String>()
|
||||
|
||||
// The stable sheet has no saved suffix and no scroll region, so the
|
||||
// existing in-panel restore path fails closed; it must not press Back.
|
||||
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}, panelDiagnostic = diagnostics::add).updateShippingAddress("_cg34") }
|
||||
.exceptionOrNull() as PurchaseLiveException
|
||||
|
||||
assertEquals(0, driver.backs)
|
||||
assertTrue(diagnostics.single().contains("outcome=stable_required;waitedMs=${SpecPanelStabilityPolicy.ADDRESS_SAVE_POLL_MS};"))
|
||||
assertNotEquals("PURCHASE_ADDRESS_UPDATE_FAILED", error.code)
|
||||
}
|
||||
|
||||
// --- Strict final submit click -------------------------------------------
|
||||
|
||||
// #335: the order button's text keeps changing across PDD builds (促销文案/价格文案),
|
||||
// so the click target is now the bottom-most clickable, enabled, non-zero-size node of
|
||||
// a *recognized* spec panel, not a text match. These fixtures build on
|
||||
// [SpecPanelFixtures.sheet] (address + payment + quantity present, so #331 recognizes
|
||||
// the panel) with `submit = "none"` and then splice in the bottom-row scenario under
|
||||
// test, mirroring how [readySheet] already extends a base fixture elsewhere in this file.
|
||||
|
||||
@Test
|
||||
fun `an unrecognized page never yields a submit target`() {
|
||||
// #335: the button's own text no longer has to match any known marker; this used
|
||||
// to fail as "labelMismatch" and must now succeed instead (see the dedicated
|
||||
// wording tests below). Only "the page is not a recognized spec panel" still blocks.
|
||||
val driver = StaticDriver(SpecPanelFixtures.addressListPage())
|
||||
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}).submitOrderOnce() }.exceptionOrNull() as? PurchaseLiveException
|
||||
|
||||
assertEquals("PURCHASE_SUBMIT_TARGET_AMBIGUOUS", error?.code)
|
||||
assertTrue(driver.clicked.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a bottom node whose own label carries a payment word is never a click target`() {
|
||||
val recognizedBase = SpecPanelFixtures.sheet(Sheet(submit = "none"))
|
||||
val snapshot = recognizedBase.copy(
|
||||
nodes = recognizedBase.nodes + node("r/submit", "立即支付", NodeBounds(0, 2135, 1080, 2216), clickable = true, parent = "r"),
|
||||
)
|
||||
val driver = StaticDriver(snapshot)
|
||||
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}).submitOrderOnce() }.exceptionOrNull() as? PurchaseLiveException
|
||||
|
||||
// The project-wide page-problem guard (pageProblem) rejects any visible payment
|
||||
// wording before the submit-target logic even runs; either way, nothing is clicked.
|
||||
assertEquals("PURCHASE_PAYMENT_FORBIDDEN", error?.code)
|
||||
assertTrue(driver.clicked.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a bottom node whose subtree carries a payment word is never a click target`() {
|
||||
val recognizedBase = SpecPanelFixtures.sheet(Sheet(submit = "none"))
|
||||
val snapshot = recognizedBase.copy(
|
||||
nodes = recognizedBase.nodes + listOf(
|
||||
node("r/submit", "", NodeBounds(0, 2135, 1080, 2216), clickable = true, parent = "r"),
|
||||
node("r/submit/t", "确认支付 ¥23.99", NodeBounds(157, 2150, 922, 2210), parent = "r/submit"),
|
||||
),
|
||||
)
|
||||
val driver = StaticDriver(snapshot)
|
||||
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}).submitOrderOnce() }.exceptionOrNull() as? PurchaseLiveException
|
||||
|
||||
assertEquals("PURCHASE_PAYMENT_FORBIDDEN", error?.code)
|
||||
assertTrue(driver.clicked.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a bottom node whose own label is go-pay is never a click target`() {
|
||||
// "去支付" is not in PAYMENT_MARKERS (so the global pageProblem guard does not
|
||||
// trip on it — a real 待付款/去支付 order-result page must stay usable), but it
|
||||
// must still block the submit-target logic via SUBMIT_TARGET_BLOCKED_MARKERS.
|
||||
val recognizedBase = SpecPanelFixtures.sheet(Sheet(submit = "none"))
|
||||
val snapshot = recognizedBase.copy(
|
||||
nodes = recognizedBase.nodes + node("r/submit", "去支付", NodeBounds(0, 2135, 1080, 2216), clickable = true, parent = "r"),
|
||||
)
|
||||
val driver = StaticDriver(snapshot)
|
||||
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}).submitOrderOnce() }.exceptionOrNull() as? PurchaseLiveException
|
||||
|
||||
assertEquals("PURCHASE_SUBMIT_TARGET_AMBIGUOUS", error?.code)
|
||||
assertTrue(driver.clicked.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a bottom node whose own label is pay-fee is never a click target`() {
|
||||
// "付款" alone is not in PAYMENT_MARKERS either (UNPAID_MARKERS' "待付款" contains
|
||||
// it, so adding it to PAYMENT_MARKERS would misclassify every unpaid-order page),
|
||||
// but it must still block the submit-target logic here.
|
||||
val recognizedBase = SpecPanelFixtures.sheet(Sheet(submit = "none"))
|
||||
val snapshot = recognizedBase.copy(
|
||||
nodes = recognizedBase.nodes + node("r/submit", "付款", NodeBounds(0, 2135, 1080, 2216), clickable = true, parent = "r"),
|
||||
)
|
||||
val driver = StaticDriver(snapshot)
|
||||
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}).submitOrderOnce() }.exceptionOrNull() as? PurchaseLiveException
|
||||
|
||||
assertEquals("PURCHASE_SUBMIT_TARGET_AMBIGUOUS", error?.code)
|
||||
assertTrue(driver.clicked.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an invisible payment word in the bottom node subtree still blocks the click`() {
|
||||
// The project-wide pageProblem guard only scans *visible* labels; an invisible
|
||||
// descendant would slip past it, so the submit-target logic must catch it on its
|
||||
// own (#335 safety condition 3 covers "自身或子树文字" regardless of visibility).
|
||||
val recognizedBase = SpecPanelFixtures.sheet(Sheet(submit = "none"))
|
||||
val hiddenPaymentText = SnapshotNode(
|
||||
"r/submit/hidden", "r/submit", "输入支付密码", null, null, "android.widget.TextView",
|
||||
NodeBounds(0, 0, 0, 0), false, false, false, false, true, false,
|
||||
)
|
||||
val snapshot = recognizedBase.copy(
|
||||
nodes = recognizedBase.nodes + listOf(
|
||||
node("r/submit", "", NodeBounds(0, 2135, 1080, 2216), clickable = true, parent = "r"),
|
||||
node("r/submit/t", "提交订单", NodeBounds(157, 2150, 922, 2210), parent = "r/submit"),
|
||||
hiddenPaymentText,
|
||||
),
|
||||
)
|
||||
val driver = StaticDriver(snapshot)
|
||||
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}).submitOrderOnce() }.exceptionOrNull() as? PurchaseLiveException
|
||||
|
||||
assertEquals("PURCHASE_SUBMIT_TARGET_AMBIGUOUS", error?.code)
|
||||
assertTrue(driver.clicked.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `zero size bottom node is ignored in favor of the next non-zero labelled node`() {
|
||||
// The click target is the labelled LEAF node (#335 2nd revision); the button
|
||||
// carries its own label directly here (no separate text child) to make the
|
||||
// assertion check the real click target.
|
||||
val base = SpecPanelFixtures.sheet(Sheet(submit = "none"))
|
||||
val snapshot = base.copy(
|
||||
nodes = base.nodes + listOf(
|
||||
node("r/real-submit", "选择颜色分类及尺码后,提交订单", NodeBounds(0, 2135, 1080, 2200), clickable = true, parent = "r"),
|
||||
// Zero-*area* (right==left) and lower on screen: must never win despite
|
||||
// having its own non-blank label and a clickable ancestor.
|
||||
node("r/decoy", "¥0.0", NodeBounds(500, 2200, 500, 2216), clickable = true, parent = "r"),
|
||||
),
|
||||
)
|
||||
val driver = StaticDriver(snapshot)
|
||||
|
||||
PurchaseLiveAutomation(driver, pause = {}).submitOrderOnce()
|
||||
|
||||
assertEquals(listOf("选择颜色分类及尺码后,提交订单"), driver.clicked)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `two side by side bottom buttons pick the rightmost and are reported as a tie`() {
|
||||
val base = SpecPanelFixtures.sheet(Sheet(submit = "none"))
|
||||
val diagnostics = mutableListOf<String>()
|
||||
val snapshot = base.copy(
|
||||
nodes = base.nodes + listOf(
|
||||
// Avoid quickBuyAliases ("现在买") in either label: it would flip specPanelType
|
||||
// to QUICK_CONFIRMATION, which is intentionally outside the recognized set here.
|
||||
node("r/a", "限时优惠 ¥19.6", NodeBounds(0, 2000, 540, 2216), clickable = true, parent = "r"),
|
||||
node("r/b", "大促价,¥16.9", NodeBounds(540, 2000, 1080, 2216), clickable = true, parent = "r"),
|
||||
),
|
||||
)
|
||||
val driver = StaticDriver(snapshot)
|
||||
|
||||
PurchaseLiveAutomation(driver, pause = {}, panelDiagnostic = diagnostics::add).submitOrderOnce()
|
||||
|
||||
// r/b is the rightmost of the tied bottom row, so it is the one clicked.
|
||||
assertEquals(listOf("大促价,¥16.9"), driver.clicked)
|
||||
assertTrue(diagnostics.any { it.contains("outcome=ok") && it.contains("rowTie=1") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unique visible submit button is still clickable exactly once regardless of its wording`() {
|
||||
val base = SpecPanelFixtures.sheet(Sheet(submit = "none"))
|
||||
val snapshot = base.copy(
|
||||
nodes = base.nodes + node("r/submit", "提交订单", NodeBounds(0, 2135, 1080, 2216), clickable = true, parent = "r"),
|
||||
)
|
||||
val driver = StaticDriver(snapshot)
|
||||
|
||||
PurchaseLiveAutomation(driver, pause = {}).submitOrderOnce()
|
||||
|
||||
assertEquals(listOf("提交订单"), driver.clicked)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `task 570 promo priced bottom row picks the bottom-right-most labelled leaf`() {
|
||||
// #335 origin: goods 8580 task 570, the bottom button's text was split across
|
||||
// three sibling text nodes ("大促价," / "仅 ¥30.8" / "¥39.9") instead of one
|
||||
// "提交订单"-prefixed label. This checks finalSubmitTargets' own choice at the
|
||||
// SnapshotNode level (fast, no re-find simulation); the full live re-find +
|
||||
// climb-to-clickable-ancestor path is covered separately below with
|
||||
// ReFindingDriver, which is what actually caught the task 594 regression.
|
||||
val base = SpecPanelFixtures.sheet(Sheet(submit = "none"))
|
||||
val snapshot = base.copy(
|
||||
nodes = base.nodes + listOf(
|
||||
node("r/submit", "", NodeBounds(0, 2181, 1080, 2216), clickable = true, parent = "r"),
|
||||
node("r/submit/l1", "大促价,", NodeBounds(40, 2190, 300, 2210), parent = "r/submit"),
|
||||
node("r/submit/l2", "仅 ¥30.8", NodeBounds(320, 2190, 600, 2210), parent = "r/submit"),
|
||||
node("r/submit/l3", "¥39.9", NodeBounds(620, 2190, 780, 2210), parent = "r/submit"),
|
||||
),
|
||||
)
|
||||
val driver = StaticDriver(snapshot)
|
||||
|
||||
PurchaseLiveAutomation(driver, pause = {}).submitOrderOnce()
|
||||
|
||||
// #335 2nd revision: the target is the labelled leaf (bottom edge tied across
|
||||
// l1/l2/l3, rightmost wins), NOT the label-less "r/submit" container — a
|
||||
// label-less target can never be re-found live (task 594).
|
||||
assertEquals(listOf("¥39.9"), driver.clicked)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `task 570 leaf target is re-found live and the click lands on the clickable container`() {
|
||||
// #335 2nd revision (task 594 real-device failure): StaticDriver's clickFresh just
|
||||
// records target.label directly, so it cannot catch a target that a REAL driver
|
||||
// could never re-find. ReFindingDriver instead emulates
|
||||
// GoAutoAccessibilityService.clickFreshDetailed's real semantics: find the live
|
||||
// node by (label, className, center within ±32), then climb to the nearest live
|
||||
// clickable ancestor and click THAT.
|
||||
val base = SpecPanelFixtures.sheet(Sheet(screenBottom = 2400, submit = "none"))
|
||||
val snapshot = base.copy(
|
||||
nodes = base.nodes + listOf(
|
||||
node("r/sheet/submit", "", NodeBounds(0, 2181, 1080, 2328), clickable = true, parent = "r/sheet"),
|
||||
node("r/sheet/submit/l1", "大促价,", NodeBounds(364, 2225, 568, 2284), parent = "r/sheet/submit"),
|
||||
node("r/sheet/submit/l2", "仅 ¥30.8", NodeBounds(568, 2225, 748, 2284), parent = "r/sheet/submit"),
|
||||
node("r/sheet/submit/l3", "¥39.9", NodeBounds(748, 2222, 896, 2287), parent = "r/sheet/submit"),
|
||||
),
|
||||
)
|
||||
val driver = ReFindingDriver(snapshot)
|
||||
|
||||
PurchaseLiveAutomation(driver, pause = {}).submitOrderOnce()
|
||||
|
||||
// finalSubmitTargets aims at "r/sheet/submit/l3" ("¥39.9", the bottom-right-most
|
||||
// labelled leaf); the real click must land on its clickable ancestor, the
|
||||
// FrameLayout "r/sheet/submit" — not on the leaf itself.
|
||||
assertEquals("r/sheet/submit", driver.clickedPath)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Samsung 31px bottom bar with only a zero-size label fails explicitly instead of clicking the payment row above it`() {
|
||||
// #335 (3rd revision): reviewer cross-check on real dumps found that on this
|
||||
// Samsung sample the bottom-most row is a 31px FrameLayout whose only text node
|
||||
// is [0,0][0,0] (invisible), while the payment-method row directly above it has a
|
||||
// real, visible label ("使用#微信支付,更换先用后付可0元下单"). A naive whole-panel
|
||||
// leaf scan falls through to that payment row's text and the climb lands the click
|
||||
// on "change payment method" — which must never happen. SpecPanelFixtures'
|
||||
// `submit = "hidden"` branch already models exactly this shape.
|
||||
val snapshot = SpecPanelFixtures.sheet(Sheet(submit = "hidden"))
|
||||
val diagnostics = mutableListOf<String>()
|
||||
val driver = StaticDriver(snapshot)
|
||||
val error = runCatching {
|
||||
PurchaseLiveAutomation(driver, pause = {}, panelDiagnostic = diagnostics::add).submitOrderOnce()
|
||||
}.exceptionOrNull() as? PurchaseLiveException
|
||||
|
||||
assertEquals("PURCHASE_SUBMIT_TARGET_AMBIGUOUS", error?.code)
|
||||
assertTrue(driver.clicked.isEmpty())
|
||||
assertTrue(diagnostics.any { it.contains("outcome=bottom_row_unlabelled") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a payment-method row as the bottom-most clickable row is never a submit target`() {
|
||||
// Without a submit region at all, the payment-method row ("使用#微信支付,更换先用
|
||||
// 后付可0元下单") is itself the bottom-most clickable row. It must be rejected by
|
||||
// its own alias match, not merely skipped in favor of something else.
|
||||
val snapshot = SpecPanelFixtures.sheet(Sheet(submit = "none"))
|
||||
val diagnostics = mutableListOf<String>()
|
||||
val driver = StaticDriver(snapshot)
|
||||
val error = runCatching {
|
||||
PurchaseLiveAutomation(driver, pause = {}, panelDiagnostic = diagnostics::add).submitOrderOnce()
|
||||
}.exceptionOrNull() as? PurchaseLiveException
|
||||
|
||||
assertEquals("PURCHASE_SUBMIT_TARGET_AMBIGUOUS", error?.code)
|
||||
assertTrue(driver.clicked.isEmpty())
|
||||
assertTrue(diagnostics.any { it.contains("outcome=row_blocked") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a clickable node outside the recognized panel container is never chosen even if it is bottom most`() {
|
||||
// A hand-built #331 REQUIRED_EVIDENCE panel ("r/panel", bounded, not full-screen)
|
||||
// with its own valid submit row, plus an unrelated clickable bar further down the
|
||||
// same accessibility tree but OUTSIDE "r/panel" — e.g. an underlying goods-detail
|
||||
// page's "单独购买" bar still present behind the sheet. It is visually and
|
||||
// structurally lower on screen (bottom=2400 vs the panel submit's bottom=2100),
|
||||
// so a naive whole-screen "bottom-most clickable node" scan would wrongly pick it.
|
||||
val t = SpecPanelFixtures.Tree(2400)
|
||||
t.add("r/panel", "", NodeBounds(0, 300, 1080, 2100), "android.view.ViewGroup")
|
||||
t.add("r/panel/close", "", NodeBounds(975, 320, 1050, 390), "android.widget.ImageView", clickable = true, description = "关闭")
|
||||
t.add("r/panel/addr", "", NodeBounds(0, 340, 1080, 460), "android.view.ViewGroup", clickable = true)
|
||||
t.add("r/panel/addr/phone", "测试,${SpecPanelFixtures.FAKE_PHONE},示例省示例市", NodeBounds(40, 360, 900, 440))
|
||||
t.add("r/panel/pay", "", NodeBounds(0, 500, 1080, 600), "android.view.ViewGroup", clickable = true)
|
||||
t.add("r/panel/pay/t", "微信支付", NodeBounds(40, 520, 400, 580))
|
||||
t.add("r/panel/qty", "1", NodeBounds(400, 650, 600, 720), "android.widget.EditText", clickable = true)
|
||||
t.add("r/panel/submit", "提交订单", NodeBounds(0, 2020, 1080, 2100), "android.widget.FrameLayout", clickable = true)
|
||||
// Outside "r/panel": lower on screen, but not part of the recognized panel.
|
||||
t.add("r/outsideBar", "单独购买", NodeBounds(0, 2200, 1080, 2400), "android.widget.FrameLayout", clickable = true)
|
||||
val snapshot = t.snapshot()
|
||||
|
||||
val screen = PddScreenParser.parse(snapshot, PurchaseRehearsalExecutor.DEFAULT_COLLECTOR, "", null)
|
||||
assertEquals(SpecPanelType.REQUIRED_EVIDENCE, screen.specPanelType)
|
||||
|
||||
val driver = StaticDriver(snapshot)
|
||||
PurchaseLiveAutomation(driver, pause = {}).submitOrderOnce()
|
||||
|
||||
assertEquals(listOf("提交订单"), driver.clicked)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no determinable panel container fails explicitly instead of guessing`() {
|
||||
// Recognized (NORMAL_SCROLLABLE, via the headed-scrollable + selection-summary
|
||||
// branch, which does not require a quantity input at all) but with NO quantity
|
||||
// input anywhere: panelContainerBounds cannot determine a container, so
|
||||
// finalSubmitTargets must fail explicitly rather than fall back to a whole-screen
|
||||
// scan that could pick an unrelated bottom-most clickable node.
|
||||
val recognizedNoQuantity = SpecPanelFixtures.sheet(
|
||||
Sheet(quantityInput = false, adjustButtons = false, submit = "none"),
|
||||
)
|
||||
val screen = PddScreenParser.parse(recognizedNoQuantity, PurchaseRehearsalExecutor.DEFAULT_COLLECTOR, "", null)
|
||||
assertEquals(SpecPanelType.NORMAL_SCROLLABLE, screen.specPanelType)
|
||||
|
||||
val snapshot = recognizedNoQuantity.copy(
|
||||
nodes = recognizedNoQuantity.nodes + node("r/submit", "提交订单", NodeBounds(0, 2135, 1080, 2216), clickable = true, parent = "r"),
|
||||
)
|
||||
val driver = StaticDriver(snapshot)
|
||||
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}).submitOrderOnce() }.exceptionOrNull() as? PurchaseLiveException
|
||||
|
||||
assertEquals("PURCHASE_SUBMIT_TARGET_AMBIGUOUS", error?.code)
|
||||
assertTrue(driver.clicked.isEmpty())
|
||||
}
|
||||
|
||||
// --- Helpers -------------------------------------------------------------
|
||||
|
||||
private fun readySheet(expected: String): UiSnapshot {
|
||||
// #335 (3rd revision): [SpecPanelFixtures.liveShapedSheet] hardcodes a "hidden"
|
||||
// 31px submit placeholder (its own separate `submit`-shape fixture concern,
|
||||
// unrelated to this address-save-wait test). With the row-then-leaf submit-target
|
||||
// rule that placeholder would tie with "r/final" on the bottom edge and can win
|
||||
// the rightmost tie-break, so this helper builds the same shape directly with
|
||||
// `submit = "none"` instead, leaving "r/final" as the only, unambiguous bottom row.
|
||||
val base = SpecPanelFixtures.sheet(
|
||||
Sheet(summary = false, listScrollable = false, sizeDimension = false, adjustButtons = false, submit = "none"),
|
||||
)
|
||||
val extra = listOf(
|
||||
node("r/sheet/body/addr/a/saved", expected, NodeBounds(132, 460, 937, 480), parent = "r/sheet/body/addr/a"),
|
||||
node("r/final", "", NodeBounds(600, 2150, 1060, 2216), clickable = true, parent = "r"),
|
||||
node("r/final/t", "提交订单", NodeBounds(620, 2160, 1040, 2210), parent = "r/final"),
|
||||
)
|
||||
return base.copy(nodes = base.nodes + extra)
|
||||
}
|
||||
|
||||
private fun input() = PurchaseExecutionInput(
|
||||
taskId = 331,
|
||||
executionMode = "rehearsal",
|
||||
phase = "purchase",
|
||||
url = "https://mobile.yangkeduo.com/goods.html?goods_id=719834019024",
|
||||
goodsId = "719834019024",
|
||||
mappedColor = "黑色 示例款",
|
||||
mappedSize = "",
|
||||
quantity = 1,
|
||||
minUnitPriceCent = 1_000,
|
||||
maxUnitPriceCent = 3_000,
|
||||
)
|
||||
|
||||
private fun rule() = """{
|
||||
"schemaVersion":1,
|
||||
"ruleType":"pddPurchase",
|
||||
"requiredCapabilities":["purchase.rehearsal.v1"],
|
||||
"actions":[
|
||||
{"type":"openProduct"},{"type":"verifyProduct"},{"type":"openSpecPanel"},{"type":"selectSpec"},
|
||||
{"type":"setQuantity"},{"type":"verifyUnitPrice"},{"type":"verifyOrderSummary"}
|
||||
]
|
||||
}"""
|
||||
|
||||
private open class BaseDriver : PurchaseUiDriver {
|
||||
var backs = 0
|
||||
val clicked = mutableListOf<String>()
|
||||
override fun capture(): UiSnapshot = UiSnapshot(null, null, emptyList())
|
||||
override fun clickFresh(target: SnapshotNode): FreshActionResult {
|
||||
clicked += target.label
|
||||
return FreshActionResult.SUCCESS
|
||||
}
|
||||
override fun tapPurchaseFresh(target: SnapshotNode) = FreshActionResult.FAILED
|
||||
override fun inputFresh(target: SnapshotNode, value: String) = FreshActionResult.FAILED
|
||||
override fun swipePurchase(direction: SwipeDirection, durationMs: Long) = false
|
||||
override fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long) = false
|
||||
override fun backPurchase(): Boolean {
|
||||
backs++
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private class StaticDriver(private val snapshot: UiSnapshot) : BaseDriver() {
|
||||
override fun capture() = snapshot
|
||||
}
|
||||
|
||||
/**
|
||||
* #335 (2nd revision): emulates `GoAutoAccessibilityService.clickFreshDetailed`'s real
|
||||
* re-find semantics against a fixed, static "live" node list — match by (own label,
|
||||
* className, center within ±32 — every fixture node here already carries its own
|
||||
* label so this is equivalent to `preferredOrDescendantLabel()`), then climb to the
|
||||
* nearest clickable ancestor and click THAT, exactly like the real accessibility
|
||||
* service. Exposes the PATH of the node that actually received the click, so a test
|
||||
* can assert the tap landed on the container, not the labelled leaf `finalSubmitTargets`
|
||||
* aimed at.
|
||||
*/
|
||||
private class ReFindingDriver(private val liveSnapshot: UiSnapshot) : BaseDriver() {
|
||||
var clickedPath: String? = null
|
||||
override fun capture() = liveSnapshot
|
||||
|
||||
override fun clickFresh(target: SnapshotNode): FreshActionResult {
|
||||
val byPath = liveSnapshot.nodes.associateBy { it.path }
|
||||
val matches = liveSnapshot.nodes.filter { candidate ->
|
||||
candidate.label == target.label && candidate.className == target.className &&
|
||||
kotlin.math.abs(candidate.bounds.centerX - target.bounds.centerX) <= 32 &&
|
||||
kotlin.math.abs(candidate.bounds.centerY - target.bounds.centerY) <= 32
|
||||
}
|
||||
if (matches.isEmpty()) return FreshActionResult.NOT_FOUND
|
||||
if (matches.size != 1) return FreshActionResult.AMBIGUOUS
|
||||
var node = matches.single()
|
||||
if (target.clickable && !node.clickable) return FreshActionResult.NOT_FOUND
|
||||
while (!node.clickable) {
|
||||
node = node.parentPath?.let(byPath::get) ?: return FreshActionResult.FAILED
|
||||
}
|
||||
clickedPath = node.path
|
||||
clicked += node.label
|
||||
return FreshActionResult.SUCCESS
|
||||
}
|
||||
}
|
||||
|
||||
/** Product page first; after the spec-entry click it serves [panels] in order (the last repeats unless [cycle]). */
|
||||
private class SpecEntryDriver(private val panels: List<UiSnapshot>, private val cycle: Boolean = false) : BaseDriver() {
|
||||
private var opened = false
|
||||
var panelCaptures = 0
|
||||
|
||||
override fun capture(): UiSnapshot {
|
||||
if (!opened) return SpecPanelFixtures.productDetailPage()
|
||||
val index = if (cycle) panelCaptures % panels.size else minOf(panelCaptures, panels.lastIndex)
|
||||
panelCaptures++
|
||||
return panels[index]
|
||||
}
|
||||
|
||||
override fun clickFresh(target: SnapshotNode): FreshActionResult {
|
||||
clicked += target.label
|
||||
if (target.label.startsWith("请选择")) opened = true
|
||||
return FreshActionResult.SUCCESS
|
||||
}
|
||||
}
|
||||
|
||||
/** Address flow driver: sheet -> address list -> editor -> [afterSave] samples -> [settled]. */
|
||||
private class AddressSaveDriver(
|
||||
private val afterSave: List<UiSnapshot>,
|
||||
private val cycle: Boolean = false,
|
||||
private val settled: (String) -> UiSnapshot,
|
||||
) : BaseDriver() {
|
||||
private var page = "sheet"
|
||||
private var address = "示例路1号-old"
|
||||
private var afterSaveCaptures = 0
|
||||
|
||||
override fun capture(): UiSnapshot = when (page) {
|
||||
"sheet" -> SpecPanelFixtures.sheet()
|
||||
"list" -> SpecPanelFixtures.Tree(2216).apply {
|
||||
add("r/title", "收货地址", NodeBounds(400, 120, 680, 200))
|
||||
add("r/modify", "", NodeBounds(900, 300, 1044, 380), "android.view.ViewGroup", clickable = true)
|
||||
add("r/modify/t", "修改", NodeBounds(920, 310, 1030, 370))
|
||||
}.snapshot()
|
||||
"edit" -> SpecPanelFixtures.Tree(2216).apply {
|
||||
add("r/title", "修改收货地址", NodeBounds(360, 120, 720, 200))
|
||||
add("r/detailLabel", "详细地址", NodeBounds(20, 400, 160, 460))
|
||||
add("r/detail", address, NodeBounds(180, 390, 900, 480), "android.widget.EditText", clickable = true)
|
||||
add("r/save", "", NodeBounds(36, 2080, 1044, 2180), "android.view.ViewGroup", clickable = true)
|
||||
add("r/save/t", "保存", NodeBounds(400, 2100, 700, 2160))
|
||||
}.snapshot()
|
||||
else -> {
|
||||
val index = afterSaveCaptures++
|
||||
when {
|
||||
cycle -> afterSave[index % afterSave.size]
|
||||
index < afterSave.size -> afterSave[index]
|
||||
else -> settled(address)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun clickFresh(target: SnapshotNode): FreshActionResult {
|
||||
clicked += target.label
|
||||
when (target.label) {
|
||||
"修改" -> page = "edit"
|
||||
"保存" -> page = "saved"
|
||||
}
|
||||
return FreshActionResult.SUCCESS
|
||||
}
|
||||
|
||||
override fun clickAddressEntryFresh(target: SnapshotNode): cn.ilapage.goauto.agent.automation.FreshClickOutcome {
|
||||
page = "list"
|
||||
return cn.ilapage.goauto.agent.automation.FreshClickOutcome(
|
||||
FreshActionResult.SUCCESS,
|
||||
cn.ilapage.goauto.agent.automation.FreshClickReason.SUCCESS,
|
||||
1,
|
||||
1,
|
||||
)
|
||||
}
|
||||
|
||||
override fun inputFresh(target: SnapshotNode, value: String): FreshActionResult {
|
||||
address = value
|
||||
return FreshActionResult.SUCCESS
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
fun node(path: String, text: String, bounds: NodeBounds, clickable: Boolean = false, parent: String?) = SnapshotNode(
|
||||
path, parent, text, null, null, "android.widget.TextView", bounds, clickable, false, false, false, true, true,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Architecture-and-Code-Map
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Architecture-and-Code-Map.-
|
||||
wiki_revision: 504e1ffaa2cf1989f55048ac7067d8af50fe3bf3
|
||||
synchronized_at: 2026-09-22T02:56:22Z
|
||||
wiki_revision: 8e2cfa74bc7cf228278221e0f7ef488ac1b596d3
|
||||
synchronized_at: 2026-09-21T08:14:31Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 架构与代码地图
|
||||
@@ -513,10 +513,3 @@ Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态
|
||||
- order_writeback.go 的任务资格去掉 order_created 限制;validOrderWritebackTarget 统一有效 stock/detail 边界。OrderWritebackViews 一次批量读取关联而非逐行查询,RequestOrderWriteback 和 worker 执行前再次检查。队列结构、租约、外部写入协议不变。
|
||||
- web/src/views/goauto/purchase-tasks/index.vue 沿用原详情/弹窗/resolve-unknown API,新增缺字段补录入口、整数分转换、完整性展示及失败保留输入,不增加数据库状态或新的权限资源。旧未知结果请求不带金额继续兼容。
|
||||
- 回归:server go test -p 1 ./app/goauto/purchase ./app/goauto/clientapi ./app/goauto/sybclient;Web 生产构建及 tests/e2e/purchase-order-writeback.spec.ts 的6项隔离浏览器测试。真实SYB接口用fake/httptest替代,未执行付款、采购或线上写入。
|
||||
|
||||
## 规格面板识别回退与选项去重(#331/#332)
|
||||
|
||||
- `automation/PddProductDetailCollector.kt`:`SpecPanelType.REQUIRED_EVIDENCE` 回退分支(地址入口/支付入口/唯一数量输入框 + 同一面板容器 + ≥1 辅助证据);`ParsedPddScreen` 增加 `hasAddressEntry`、`hasPaymentEntry`、`hasQuantityInput`、`hasQuantityAdjustControls`、`quantityInputCount` 与 `requiredEvidenceSummary()`;`SpecPanelStabilityPolicy`(等待上限 5000ms,地址返回采样 200ms);规格值按选项块合并(`optionBlock`)。
|
||||
- `automation/PurchaseRehearsalExecutor.kt`:`waitForSpecPanel` 对回退类型要求连续两次结构一致;`selectionFailureEvidence` 选中失败诊断。
|
||||
- `automation/PurchaseLiveAutomation.kt`:`waitForSettledPanelAfterAddressSave` 保存地址后稳定等待(不按返回键);`finalSubmitTargets` 拒绝零尺寸文字/容器;新增构造参数 `panelDiagnostic`(仅布尔/计数)。
|
||||
- 测试:`SpecPanelRecognitionTest`、`SpecOptionDedupTest`、`SpecPanelFixtures`(脱敏构造样本)。
|
||||
|
||||
@@ -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: 7bb3997cfbe96343f63d4f6ef80201146c27c142
|
||||
synchronized_at: 2026-09-22T02:56:27Z
|
||||
wiki_revision: 6cb34c99ccdcf4b64b01cb9a25ff1549d8aeeec9
|
||||
synchronized_at: 2026-09-21T08:14:37Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 业务规则与术语
|
||||
@@ -666,17 +666,3 @@ SYB 回填与资料完整性独立:正式SYB任务已有单号且仍关联有
|
||||
实现368f2c2,2026-09-19,尚未发布。采购管理现有“状态”下拉框增加“已回填 SYB”,用户选择后点击原查询按钮,按PDD单号回填队列成功记录筛选;不是新的采购任务状态,也不表示物流回填成功或已付款。
|
||||
|
||||
筛选后仍展示原采购状态与独立SYB单号回填状态,已成功任务不因查询而允许重复回填。清空或重置恢复原列表,查询回到第一页,其他条件继续叠加。沿用既有下拉框、空/加载/失败状态及权限,不新增页面、审批、数据字段或外部动作。
|
||||
|
||||
## 规格面板识别与选项去重(#331/#332)
|
||||
|
||||
实现 `0edc78a`(#332)、`d8382e6`(#331),2026-09-22 已合并 main 并发布 Server(Android 0.9.60 仅安装到设备 8);真机采购效果未验证。
|
||||
|
||||
- 规格面板识别在原有判定之后新增回退类型 `REQUIRED_EVIDENCE`:**必须**同时存在收货地址入口(打码手机号所在可点击行)、支付方式修改入口(支付别名所在可点击行)和唯一可用的数量输入框,三者须位于同一规格面板容器内;另需至少一项辅助证据(规格选项、已选/请选择摘要、关闭、加减数量、提交订单)。加减按钮不再是必备条件。原有已识别的面板分类不变。
|
||||
- 地址入口只判断存在与否,不读取、不记录、不上传地址内容;诊断只输出布尔值与计数。
|
||||
- 点击规格入口后、保存地址返回后,若面板仅靠回退类型识别,须连续两次采样结构一致才继续,上限 5 秒;等待期间不按返回键,超时明确失败。稳定且明显不是采购面板的页面(如地址列表)沿用原返回路径。
|
||||
- 提交订单点击保持严格并进一步收紧:必须唯一、可点击、文字匹配,且文字节点及其可点击容器尺寸非零。
|
||||
- 规格选项解析:同一选项块(外层可点击块与其内部文字)只计一个值,文字优先取外层块;不做“零差评/快要抢光”等标签清洗。选中确认失败时诊断列出该维度解析值(截断)、选中状态和目标值。
|
||||
- 缺少必备证据时,失败信息附带可读提示,例如“未找到支付入口:请确认 PDD 默认支付方式为微信支付”“未找到收货地址入口:请确认 PDD 已设置默认收货地址”“未找到购买数量输入框”。
|
||||
|
||||
### 采购手机环境前提
|
||||
执行采购的 Android 手机必须:PDD App 已登录、微信已登录、PDD 已设置默认收货地址、PDD 默认支付方式为微信支付。规格面板的地址与支付证据依赖该前提;不为支付宝等其他支付方式补充识别别名(有意决定)。条件不满足时 Agent 明确失败,不自动登录、不切换支付方式、不填写地址。
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Deployment-and-Operations
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Deployment-and-Operations.-
|
||||
wiki_revision: cc642ebfa65f429100779e40560a59174ab877a0
|
||||
synchronized_at: 2026-09-22T02:56:36Z
|
||||
wiki_revision: 03ea269058b50fea2be7842b9c284018987c82d9
|
||||
synchronized_at: 2026-09-21T08:14:47Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 部署与运维
|
||||
@@ -142,14 +142,3 @@ Provider 故障日志只允许记录调用关联 ID、操作类型、耗时、
|
||||
- 管理员为密钥显式勾选“采购管理 → 回填(writeback)”。建议 HTTPS;若使用项目既有客户端密钥 HTTP 例外,密钥和订单事实会明文传输,风险边界不扩大到 Cookie、JWT 或 Device Token。
|
||||
- popup 关闭不停止运行;重新打开可查看持久状态。设置在运行时只读。停止会保留已确认批次并阻止后续请求;重启或超时仅以原批次 UUID 重放未确认批次。
|
||||
- 排错只查看阶段、已读、可提交、已写入、冲突、未完整扫描原因、脱敏错误码和批次状态;不得记录客户端密钥、地址全文、订单号、Cookie、原始 HTML 或真实订单载荷。没有明确列表末尾证据时只能报告未完整扫描。
|
||||
|
||||
## 采购手机准备清单(#331)
|
||||
|
||||
采购员配置或更换采购用 Android 手机时逐项确认:
|
||||
1. 拼多多 App 已登录;
|
||||
2. 微信已登录;
|
||||
3. 拼多多已设置默认收货地址;
|
||||
4. 拼多多默认支付方式为微信支付;
|
||||
5. GoAuto Agent 已启动,无障碍服务已开启,Admin 设备列表显示在线并已归属对应采购员账号。
|
||||
|
||||
任一项不满足时采购会明确失败并提示缺失项(如“未找到支付入口:请确认 PDD 默认支付方式为微信支付”);Agent 不代为登录、切换支付方式或填写地址。安装新版 Agent 后需手动启动一次 App,心跳恢复后设备才显示在线。
|
||||
|
||||
@@ -647,25 +647,12 @@ func (s *Service) resolveProbedSpecs(ctx context.Context, taskID uint64, attempt
|
||||
request := aimatching.MatchRequest{TargetColor: task.TargetColorSnapshot, TargetSize: task.TargetSizeSnapshot, Colors: candidates.Colors, Sizes: candidates.Sizes}
|
||||
decision := SpecDecisionRequest{RequestID: uuid.NewString(), TaskAttemptID: attemptID, Source: aimatching.SourceAI}
|
||||
if !complete {
|
||||
// #334: a probe that read zero colors and zero sizes (both dimensions
|
||||
// empty) is a distinct, more specific failure than "candidates present
|
||||
// but none matched the target" — it usually means the spec panel was
|
||||
// over-swiped off the device screen. The Agent now fails this case
|
||||
// explicitly before submitting, but resolveProbedSpecs keeps the same
|
||||
// explicit code as a defense in depth for any spec_probe_completed
|
||||
// result that still arrives empty (e.g. older Agent builds).
|
||||
reason := "规格探测结果没有包含所需的可选颜色或尺码"
|
||||
code, message := "PURCHASE_SPEC_NOT_MATCHED", "没有找到可采购的 PDD 颜色或尺码"
|
||||
if len(candidates.Colors) == 0 && len(candidates.Sizes) == 0 {
|
||||
reason = "规格探测未读取到任何颜色或尺码"
|
||||
code, message = "PURCHASE_SPEC_PROBE_EMPTY", "规格探测未读取到任何颜色或尺码"
|
||||
}
|
||||
snapshot, marshalErr := json.Marshal(aimatching.NoMatchDecision(request, aimatching.SourceAI, reason))
|
||||
snapshot, marshalErr := json.Marshal(aimatching.NoMatchDecision(request, aimatching.SourceAI, "规格探测结果没有包含所需的可选颜色或尺码"))
|
||||
if marshalErr != nil {
|
||||
return TaskPayload{}, internal(marshalErr)
|
||||
}
|
||||
decision.NoMatch, decision.Decision = true, snapshot
|
||||
decision.FailureCode, decision.FailureMessage = code, message
|
||||
decision.FailureCode, decision.FailureMessage = "PURCHASE_SPEC_NOT_MATCHED", "没有找到可采购的 PDD 颜色或尺码"
|
||||
} else {
|
||||
matched, matchErr := s.resolveProbedMatch(ctx, task, request)
|
||||
valid := matchErr == nil && (matched.Source == "manual_mapping" || matched.Source == aimatching.SourceExact || matched.Source == aimatching.SourceAI) &&
|
||||
|
||||
@@ -592,47 +592,6 @@ func TestLiveProbeUsesAccurateMessageWhenCompleteCandidatesCannotBeMatched(t *te
|
||||
}
|
||||
}
|
||||
|
||||
// #334: a probe with zero colors and zero sizes is a distinct, more specific
|
||||
// failure ("规格探测未读取到任何颜色或尺码") than the generic "candidates
|
||||
// present but none matched" message, so operators can tell an over-swiped
|
||||
// device probe apart from an ordinary spec mismatch.
|
||||
func TestLiveProbeWithNoDimensionsFailsWithExplicitEmptyProbeCode(t *testing.T) {
|
||||
db := testDB(t)
|
||||
f := seed(t, db, liveCaps(), false)
|
||||
if err := db.Model(&models.SYBProduct{}).Where("id = ?", f.syb.ID).Update("target_color", "象牙白").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
matcher := &liveProbeMatcher{err: errors.New("AI should not be called for an empty probe")}
|
||||
s := testService(db)
|
||||
s.Matcher = matcher
|
||||
task, err := createLive(t, s, f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = s.Claim(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first, err := s.Start(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
probe := ResultRequest{RequestID: uuid.NewString(), TaskAttemptID: first.TaskAttemptID, ResultType: "spec_probe_completed", ProbedSpecs: []byte(`{"dimensions":[]}`)}
|
||||
resolved, err := s.SubmitResult(context.Background(), task.ID, probe, f.token)
|
||||
if err != nil || matcher.calls != 0 || resolved.Status != models.PurchaseTaskStatusFailed {
|
||||
t.Fatalf("empty probe did not fail closed without calling AI: %+v calls=%d err=%v", resolved, matcher.calls, err)
|
||||
}
|
||||
var saved models.PurchaseTask
|
||||
if err = db.First(&saved, task.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if saved.ErrorCode == nil || *saved.ErrorCode != "PURCHASE_SPEC_PROBE_EMPTY" {
|
||||
t.Fatalf("empty probe did not use the explicit empty-probe code: %+v", saved)
|
||||
}
|
||||
if saved.ErrorMessage == nil || *saved.ErrorMessage != "规格探测未读取到任何颜色或尺码" {
|
||||
t.Fatalf("empty probe did not use the explicit empty-probe message: %+v", saved)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecondSpecProbeFailsClosedWithoutClearingDecision(t *testing.T) {
|
||||
db := testDB(t)
|
||||
f := seed(t, db, liveCaps(), false)
|
||||
|
||||
Reference in New Issue
Block a user