fix(android): verify selection before address and honor submit prompt (#376)
This commit is contained in:
-126
@@ -1,126 +0,0 @@
|
||||
package cn.ilapage.goauto.agent.automation
|
||||
|
||||
/** Current-capture evidence only. No clicks, historical selection proof, or whole-screen text fallback. */
|
||||
internal object PurchaseFinalSpecVerifier {
|
||||
private fun normalize(value: String) = SpecValueNormalizer.normalizeFinalConfirmation(value)
|
||||
|
||||
fun verify(snapshot: UiSnapshot, input: PurchaseExecutionInput, diagnostic: (String) -> Unit) {
|
||||
val config = PurchaseRehearsalExecutor.DEFAULT_COLLECTOR
|
||||
val byPath = snapshot.nodes.associateBy(SnapshotNode::path)
|
||||
fun within(node: SnapshotNode, container: SnapshotNode): Boolean {
|
||||
val visited = mutableSetOf<String>()
|
||||
var current: SnapshotNode? = node
|
||||
while (current != null && visited.add(current.path)) {
|
||||
if (current.path == container.path) return true
|
||||
current = current.parentPath?.let(byPath::get)
|
||||
}
|
||||
return false
|
||||
}
|
||||
fun contained(node: SnapshotNode, container: SnapshotNode) =
|
||||
node.bounds.width > 0 && node.bounds.height > 0 &&
|
||||
node.bounds.left >= container.bounds.left && node.bounds.right <= container.bounds.right &&
|
||||
node.bounds.top >= container.bounds.top && node.bounds.bottom <= container.bounds.bottom
|
||||
|
||||
val original = PddScreenParser.parse(snapshot, config, input.goodsId, null, forFinalConfirmation = true)
|
||||
val fullPanel = boundedQuantityPanel(snapshot).takeIf { original.specPanelOpen }
|
||||
// #331/#335 sheet ancestry, as represented by SpecPanelFixtures.sheet and taskOptionDedupSheet.
|
||||
// The inner scroll list is not the full sheet: summary and quantity may be its siblings.
|
||||
val scoped = fullPanel?.let { panel ->
|
||||
val nodes = snapshot.nodes.filter { node ->
|
||||
(within(node, panel) && contained(node, panel)) ||
|
||||
(node.parentPath == null && node.label.isBlank() && !node.clickable)
|
||||
}
|
||||
PddScreenParser.parse(snapshot.copy(nodes = nodes), config, input.goodsId, null, forFinalConfirmation = true)
|
||||
.takeIf { it.specPanelOpen }
|
||||
}
|
||||
val optionScope = if (scoped != null) fullPanel else original.specPanelContainer
|
||||
val dimensions = (scoped ?: original).dimensions.groupBy(VisibleDimension::key).mapValues { (_, groups) ->
|
||||
val options = groups.flatMap(VisibleDimension::values).filter { option ->
|
||||
optionScope != null && within(option.node, optionScope) && contained(option.node, optionScope)
|
||||
}
|
||||
// A blank clickable wrapper and its single text child may survive optionBlock separately.
|
||||
// The #331 size fixture exposes exactly the same rectangle for both. Aggregate only a
|
||||
// same-value ancestor chain with that physical rectangle, never same-label siblings.
|
||||
options.groupBy { option ->
|
||||
options.filter { other -> other.node.bounds == option.node.bounds &&
|
||||
normalize(other.text) == normalize(option.text) && within(option.node, other.node)
|
||||
}.minBy { it.node.path.length }.node.path
|
||||
}.values.map { members ->
|
||||
members.first().let { first -> first.copy(
|
||||
available = members.all(VisibleSpecValue::available),
|
||||
node = first.node.copy(
|
||||
selected = members.any { it.node.selected },
|
||||
checked = members.any { it.node.checked },
|
||||
),
|
||||
) }
|
||||
}
|
||||
}
|
||||
val summaries = scoped?.finalSelectionSummaries.orEmpty().map { raw ->
|
||||
// Only the anchored recognized prefix and its optional colon are removed.
|
||||
val trimmed = raw.trim()
|
||||
val prefix = config.textAliases.selection.selectedPrefixes.firstOrNull(trimmed::startsWith)
|
||||
val value = if (prefix == null) trimmed else trimmed.removePrefix(prefix).trimStart().let {
|
||||
if (it.startsWith(':') || it.startsWith(':')) it.drop(1) else it
|
||||
}
|
||||
normalize(value)
|
||||
}.distinct()
|
||||
val targets = linkedMapOf("color" to normalize(input.mappedColor), "size" to normalize(input.mappedSize))
|
||||
.filterValues(String::isNotBlank)
|
||||
fun reject(dimension: String, stage: String, selected: Boolean = false, conflict: Boolean = false): Nothing {
|
||||
val detail = "finalSpec;dimension=$dimension;stage=$stage;summaryPresent=${if (summaries.isNotEmpty()) 1 else 0};" +
|
||||
"selectedFound=${if (selected) 1 else 0};conflict=${if (conflict) 1 else 0}"
|
||||
diagnostic(detail)
|
||||
throw PurchaseLiveException("PURCHASE_SPEC_NOT_MATCHED", "创建订单前规格复核失败;$detail")
|
||||
}
|
||||
if (targets.isEmpty()) reject("none", "missing_target")
|
||||
val selectedProof = mutableSetOf<String>()
|
||||
targets.forEach { (dimension, target) ->
|
||||
val options = dimensions[dimension].orEmpty()
|
||||
val selected = options.filter { it.node.selected || it.node.checked }
|
||||
// Parsed text already follows the established UI trailing-price separation contract.
|
||||
// The final-only normalizer removes no additional content from values, input or summary.
|
||||
val exact = options.filter { normalize(it.text) == target }
|
||||
if (selected.any { normalize(it.text) != target }) reject(dimension, "selected_conflict", selected = true, conflict = true)
|
||||
if (exact.size > 1) reject(dimension, "candidate_collision", selected.isNotEmpty(), conflict = true)
|
||||
if (exact.any { !it.available }) reject(dimension, "unavailable", selected.isNotEmpty(), conflict = true)
|
||||
if (exact.any { it.node.selected || it.node.checked }) selectedProof += dimension
|
||||
}
|
||||
if (summaries.size > 1) reject(targets.keys.first(), "summary_conflict", selectedProof.isNotEmpty(), conflict = true)
|
||||
val summary = summaries.singleOrNull()
|
||||
// Evidence only supports color then size with a space. Do not guess reverse order or comma separators.
|
||||
val expected = targets.values.joinToString(" ")
|
||||
if (summary != null && targets.size == 2) {
|
||||
val colors = (dimensions["color"].orEmpty().map { normalize(it.text) } + targets.getValue("color")).distinct()
|
||||
val sizes = (dimensions["size"].orEmpty().map { normalize(it.text) } + targets.getValue("size")).distinct()
|
||||
if (colors.any { color -> sizes.any { size ->
|
||||
"$color $size" == summary && (color != targets["color"] || size != targets["size"])
|
||||
} }) reject("color", "composition_ambiguity", selectedProof.isNotEmpty(), conflict = true)
|
||||
}
|
||||
targets.keys.forEach { dimension ->
|
||||
if (dimension !in selectedProof && summary != expected) reject(dimension, "missing_proof")
|
||||
}
|
||||
}
|
||||
|
||||
/** Existing #335 quantity-ancestor form, retaining identity as well as bounds for summary ownership. */
|
||||
private fun boundedQuantityPanel(snapshot: UiSnapshot): SnapshotNode? {
|
||||
val visible = snapshot.nodes.filter(SnapshotNode::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 byPath = visible.associateBy(SnapshotNode::path)
|
||||
var current = visible.singleOrNull {
|
||||
it.enabled && it.className == "android.widget.EditText" && it.label.toIntOrNull()?.let { value -> value > 0 } == true
|
||||
} ?: return null
|
||||
var panel: SnapshotNode? = null
|
||||
val visited = mutableSetOf(current.path)
|
||||
while (true) {
|
||||
val parent = current.parentPath?.let(byPath::get) ?: break
|
||||
if (!visited.add(parent.path)) return null
|
||||
val area = parent.bounds.width.toLong() * parent.bounds.height
|
||||
if (area <= 0 || area >= screenArea || parent.parentPath == null) break
|
||||
panel = parent
|
||||
current = parent
|
||||
}
|
||||
return panel
|
||||
}
|
||||
}
|
||||
+58
-9
@@ -3,6 +3,7 @@ package cn.ilapage.goauto.agent.automation
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
import org.json.JSONObject
|
||||
|
||||
data class ShippingAddressProof internal constructor(
|
||||
internal val expectedAddress: String,
|
||||
@@ -17,7 +18,28 @@ data class FinalConfirmationEvidence(
|
||||
val unitPriceCent: Long,
|
||||
val addressSuffix: String,
|
||||
val activityName: String,
|
||||
)
|
||||
val observedQuantity: Long? = null,
|
||||
val selectedSummaryPresent: Boolean = false,
|
||||
val summaryPromptPresent: Boolean = false,
|
||||
) {
|
||||
/** Local boundary evidence only; never used in the uploaded purchase result. */
|
||||
fun toLocalJson(): String = JSONObject()
|
||||
.put("goodsId", goodsId)
|
||||
.put("mappedColor", mappedColor)
|
||||
.put("mappedSize", mappedSize)
|
||||
.put("targetQuantity", quantity)
|
||||
.put("observedQuantity", observedQuantity ?: JSONObject.NULL)
|
||||
.put("selectedSummaryPresent", selectedSummaryPresent)
|
||||
.put("summaryPromptPresent", summaryPromptPresent)
|
||||
.put("unitPriceCent", unitPriceCent)
|
||||
.put("addressSuffix", addressSuffix)
|
||||
.put("activityName", activityName)
|
||||
.toString()
|
||||
}
|
||||
|
||||
internal fun observedPurchaseQuantity(snapshot: UiSnapshot): Long? = snapshot.nodes.filter {
|
||||
it.visible && it.enabled && it.className?.endsWith("EditText") == true && it.label.toLongOrNull() != null
|
||||
}.singleOrNull()?.label?.toLongOrNull()
|
||||
|
||||
data class PurchaseOrderEvidence(val orderNo: String, val submittedAt: String, val pddOrderAmountCent: Long? = null)
|
||||
data class PurchaseOrderReadFailure(
|
||||
@@ -315,18 +337,20 @@ class PurchaseLiveAutomation(
|
||||
val snapshot = driver.capture()
|
||||
pageProblem(snapshot)
|
||||
if (snapshot.packageName != PDD_PACKAGE) fail("PURCHASE_CONFIRMATION_LOST", "最终提交前页面已经变化,禁止创建订单")
|
||||
if (!hasFinalSavedAddressEvidence(snapshot, address.expectedAddress, address.suffix)) {
|
||||
if (!hasSavedAddressEvidence(snapshot, address.expectedAddress, address.suffix)) {
|
||||
fail("PURCHASE_ADDRESS_UPDATE_FAILED", "收货地址保存后复核失败,未创建订单")
|
||||
}
|
||||
val submit = finalSubmitTargets(snapshot)
|
||||
if (submit.size != 1) fail("PURCHASE_SUBMIT_TARGET_AMBIGUOUS", "创建订单按钮不是唯一目标,禁止创建订单")
|
||||
val screen = PddScreenParser.parse(snapshot, PurchaseRehearsalExecutor.DEFAULT_COLLECTOR, input.goodsId, null)
|
||||
PurchaseFinalSpecVerifier.verify(snapshot, input, panelDiagnostic)
|
||||
rejectSubmitSelectionPrompt(submit.single())
|
||||
val price = screen.priceCent ?: fail("RULE_NOT_MATCHED", "创建订单前没有读取到商品单价")
|
||||
if (price !in input.minUnitPriceCent..input.maxUnitPriceCent) fail("PURCHASE_PRICE_OUT_OF_RANGE", "当前商品单价超出允许范围", price)
|
||||
val quantities = snapshot.nodes.filter { it.visible && it.enabled && it.className?.endsWith("EditText") == true }.mapNotNull { it.label.toLongOrNull() }
|
||||
if (quantities.singleOrNull() != input.quantity) fail("PURCHASE_QUANTITY_MISMATCH", "创建订单前数量复核失败")
|
||||
return FinalConfirmationEvidence(input.goodsId, input.mappedColor, input.mappedSize, input.quantity, price, address.suffix, snapshot.activityName.orEmpty())
|
||||
val selection = PurchaseSelectionEvidence.observe(snapshot)
|
||||
return FinalConfirmationEvidence(
|
||||
input.goodsId, input.mappedColor, input.mappedSize, input.quantity, price, address.suffix, snapshot.activityName.orEmpty(),
|
||||
observedPurchaseQuantity(snapshot), selection.selectedSummaryPresent, selection.summaryPromptPresent,
|
||||
)
|
||||
}
|
||||
|
||||
fun submitOrderOnce() {
|
||||
@@ -337,8 +361,9 @@ class PurchaseLiveAutomation(
|
||||
if (snapshot.packageName != PDD_PACKAGE || targets.size != 1) {
|
||||
fail("PURCHASE_SUBMIT_TARGET_AMBIGUOUS", "最终页面或创建订单按钮已变化,禁止创建订单")
|
||||
}
|
||||
rejectSubmitSelectionPrompt(targets.single())
|
||||
submitAttempted = true
|
||||
when (driver.clickFresh(targets.single())) {
|
||||
when (driver.clickFresh(targets.single().chosen)) {
|
||||
FreshActionResult.SUCCESS -> Unit
|
||||
FreshActionResult.AMBIGUOUS -> fail("PURCHASE_SUBMIT_TARGET_AMBIGUOUS", "创建订单按钮不唯一")
|
||||
else -> fail("PURCHASE_ORDER_RESULT_UNKNOWN", "创建订单点击结果不明确,请人工检查")
|
||||
@@ -879,7 +904,24 @@ class PurchaseLiveAutomation(
|
||||
* (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> {
|
||||
private data class SubmitTarget(val row: SnapshotNode, val chosen: SnapshotNode, val text: String)
|
||||
|
||||
internal fun selectionPromptPresent(snapshot: UiSnapshot): Boolean =
|
||||
PurchaseSelectionEvidence.observe(snapshot).summaryPromptPresent ||
|
||||
finalSubmitTargets(snapshot).singleOrNull()?.let { isSelectionPrompt(it.text) } == true
|
||||
|
||||
private fun isSelectionPrompt(text: String): Boolean =
|
||||
Regex("^(选择|選擇).+(后|後)[,,](提交订单|提交訂單)$").matches(text.filterNot(Char::isWhitespace))
|
||||
|
||||
private fun rejectSubmitSelectionPrompt(target: SubmitTarget) {
|
||||
if (isSelectionPrompt(target.text)) {
|
||||
val detail = "finalSpec;stage=selection_prompt"
|
||||
panelDiagnostic(detail)
|
||||
fail("PURCHASE_SPEC_NOT_MATCHED", "创建订单前规格复核失败;$detail")
|
||||
}
|
||||
}
|
||||
|
||||
private fun finalSubmitTargets(snapshot: UiSnapshot): List<SubmitTarget> {
|
||||
fun hasArea(node: SnapshotNode) = node.bounds.width > 0 && node.bounds.height > 0
|
||||
val byPath = snapshot.nodes.associateBy { it.path }
|
||||
fun clickableAncestor(node: SnapshotNode): SnapshotNode? {
|
||||
@@ -948,7 +990,14 @@ class PurchaseLiveAutomation(
|
||||
"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)
|
||||
// Prompt evidence uses each visible leaf's own label exactly once. A nested
|
||||
// clickable button is a separate action and cannot supply text for this row.
|
||||
val text = (listOf(row) + subtreeDescendants(row, snapshot)).filter { node ->
|
||||
node.visible && node.label.isNotBlank() && clickableAncestor(node)?.path == row.path &&
|
||||
subtreeDescendants(node, snapshot).none { it.label.isNotBlank() }
|
||||
}.sortedWith(compareBy<SnapshotNode> { it.bounds.top }.thenBy { it.bounds.left })
|
||||
.joinToString("") { it.label }
|
||||
return listOf(SubmitTarget(row, chosen, text))
|
||||
}
|
||||
|
||||
private fun subtreeDescendants(node: SnapshotNode, snapshot: UiSnapshot): List<SnapshotNode> {
|
||||
|
||||
+23
-5
@@ -772,6 +772,11 @@ class PurchaseRehearsalExecutor(
|
||||
val summaryTokenMatched = !token.isNullOrBlank() && SpecValueNormalizer.summaryHasExactToken(screen.selectedSummary, token)
|
||||
if (!summaryTokenMatched) {
|
||||
if (screen.selectedSummary == null && proofUsable) {
|
||||
if (candidates.any { it.available && it.node.visible &&
|
||||
SpecValueNormalizer.normalizeFinalConfirmation(it.text) == SpecValueNormalizer.normalizeFinalConfirmation(target) &&
|
||||
!it.node.selected && !it.node.checked }) {
|
||||
return FinalSpecVerification(false, "target_visible_unselected", false, candidates.size, true, true, matchingNodes.size)
|
||||
}
|
||||
return FinalSpecVerification(true, "attempt_selection_state", false, candidates.size, true, true, matchingNodes.size)
|
||||
}
|
||||
return FinalSpecVerification(false, "summary_token_missing", false, candidates.size, proofRecorded, proofUsable, matchingNodes.size)
|
||||
@@ -803,12 +808,15 @@ class PurchaseRehearsalExecutor(
|
||||
target: String,
|
||||
proof: ExactSpecSelectionProof?,
|
||||
): FinalSpecVerification {
|
||||
var screen = currentScreen(input)
|
||||
var selectionPromptPresent = false
|
||||
fun captureScreen() = currentScreen(input) { snapshot -> selectionPromptPresent = hasSelectionPrompt(snapshot) }
|
||||
var screen = captureScreen()
|
||||
screen.problem?.let {
|
||||
return FinalSpecVerification(false, it.code, false, 0, proof != null, false)
|
||||
}
|
||||
if (selectionPromptPresent) return FinalSpecVerification(false, "selection_prompt", false, 0, proof != null, false)
|
||||
var verification = verifyExactSpecSelection(screen, dimension, target, proof)
|
||||
if (verification.confirmed || verification.targetMatchCount > 0 || verification.reason == "visible_selected_conflict") return verification
|
||||
if (verification.confirmed || verification.targetMatchCount > 0 || verification.reason in setOf("visible_selected_conflict", "selection_prompt", "target_visible_unselected")) return verification
|
||||
if (!screen.specPanelOpen) return verification.copy(reason = "relocation_panel_closed", relocationAttempted = true)
|
||||
|
||||
var signature = screen.dimensions.joinToString("|") { item ->
|
||||
@@ -832,7 +840,7 @@ class PurchaseRehearsalExecutor(
|
||||
}
|
||||
swipes++
|
||||
pause(300)
|
||||
screen = currentScreen(input)
|
||||
screen = captureScreen()
|
||||
screen.problem?.let {
|
||||
return verification.copy(
|
||||
reason = it.code,
|
||||
@@ -840,6 +848,9 @@ class PurchaseRehearsalExecutor(
|
||||
relocationSwipes = swipes,
|
||||
)
|
||||
}
|
||||
if (selectionPromptPresent) {
|
||||
return verification.copy(confirmed = false, reason = "selection_prompt", relocationAttempted = true, relocationSwipes = swipes)
|
||||
}
|
||||
if (!screen.specPanelOpen) {
|
||||
return verification.copy(
|
||||
reason = "relocation_panel_closed",
|
||||
@@ -851,7 +862,7 @@ class PurchaseRehearsalExecutor(
|
||||
relocationAttempted = true,
|
||||
relocationSwipes = swipes,
|
||||
)
|
||||
if (verification.confirmed || verification.targetMatchCount > 0 || verification.reason == "visible_selected_conflict") return verification
|
||||
if (verification.confirmed || verification.targetMatchCount > 0 || verification.reason in setOf("visible_selected_conflict", "selection_prompt", "target_visible_unselected")) return verification
|
||||
val refreshedSignature = screen.dimensions.joinToString("|") { item ->
|
||||
"${item.key}:${item.values.joinToString(",") { value -> "${value.text}:${value.available}" }}"
|
||||
}
|
||||
@@ -1129,6 +1140,9 @@ class PurchaseRehearsalExecutor(
|
||||
dimension to (normalizedTarget(dimension, rawTarget)
|
||||
?: return failure(SPEC_SAFE_TARGET_MISSING, "下发规格无法安全规范化"))
|
||||
}
|
||||
if (selected.isEmpty() && hasSelectionPrompt(driver.capture())) {
|
||||
return failure(SPEC_SELECTION_UNCONFIRMED, "最终规格复核未能确认精确选中状态 [reason=selection_prompt]")
|
||||
}
|
||||
selected.forEach { (dimension, target) ->
|
||||
val verification = verifyExactSpecSelectionWithRelocation(input, dimension, target, selectionProofs[dimension])
|
||||
if (!verification.confirmed) {
|
||||
@@ -1152,6 +1166,9 @@ class PurchaseRehearsalExecutor(
|
||||
return null
|
||||
}
|
||||
|
||||
private fun hasSelectionPrompt(snapshot: UiSnapshot): Boolean =
|
||||
PurchaseLiveAutomation(driver, pause, panelDiagnostic).selectionPromptPresent(snapshot)
|
||||
|
||||
private fun hasAllRecordedSelectionProofs(
|
||||
input: PurchaseExecutionInput,
|
||||
proofs: Map<String, ExactSpecSelectionProof>,
|
||||
@@ -1219,8 +1236,9 @@ class PurchaseRehearsalExecutor(
|
||||
return normalized.takeIf(SpecValueNormalizer::isSafeSize)
|
||||
}
|
||||
|
||||
private fun currentScreen(input: PurchaseExecutionInput): ParsedPddScreen {
|
||||
private fun currentScreen(input: PurchaseExecutionInput, snapshotObserved: (UiSnapshot) -> Unit = {}): ParsedPddScreen {
|
||||
val snapshot = driver.capture()
|
||||
snapshotObserved(snapshot)
|
||||
val screen = PddScreenParser.parse(snapshot, DEFAULT_COLLECTOR, input.goodsId, null, purchasePanelContext)
|
||||
purchasePanelContext = if (screen.isPddPackage && screen.problem == null && screen.specPanelOpen) {
|
||||
screen.specPanelContainer?.let {
|
||||
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
package cn.ilapage.goauto.agent.automation
|
||||
|
||||
/** Boolean-only summary observations within the #374 bounded quantity panel. */
|
||||
internal data class PurchaseSelectionEvidence(
|
||||
val selectedSummaryPresent: Boolean = false,
|
||||
val summaryPromptPresent: Boolean = false,
|
||||
) {
|
||||
companion object {
|
||||
fun observe(snapshot: UiSnapshot): PurchaseSelectionEvidence {
|
||||
val config = PurchaseRehearsalExecutor.DEFAULT_COLLECTOR
|
||||
val original = PddScreenParser.parse(snapshot, config, "", null)
|
||||
val panel = boundedQuantityPanel(snapshot).takeIf { original.specPanelOpen }
|
||||
?: return PurchaseSelectionEvidence()
|
||||
val byPath = snapshot.nodes.associateBy(SnapshotNode::path)
|
||||
fun within(node: SnapshotNode): Boolean {
|
||||
val visited = mutableSetOf<String>()
|
||||
var current: SnapshotNode? = node
|
||||
while (current != null && visited.add(current.path)) {
|
||||
if (current.path == panel.path) return true
|
||||
current = current.parentPath?.let(byPath::get)
|
||||
}
|
||||
return false
|
||||
}
|
||||
fun contained(node: SnapshotNode) = node.bounds.width > 0 && node.bounds.height > 0 &&
|
||||
node.bounds.left >= panel.bounds.left && node.bounds.right <= panel.bounds.right &&
|
||||
node.bounds.top >= panel.bounds.top && node.bounds.bottom <= panel.bounds.bottom
|
||||
val nodes = snapshot.nodes.filter { (within(it) && contained(it)) ||
|
||||
(it.parentPath == null && it.label.isBlank() && !it.clickable) }
|
||||
val scoped = PddScreenParser.parse(snapshot.copy(nodes = nodes), config, "", null, forFinalConfirmation = true)
|
||||
if (!scoped.specPanelOpen) return PurchaseSelectionEvidence()
|
||||
val quantity = nodes.singleOrNull { it.visible && it.enabled && it.className == "android.widget.EditText" && it.label.toIntOrNull()?.let { value -> value > 0 } == true }
|
||||
val firstOptionTop = scoped.dimensions.flatMap(VisibleDimension::values).minOfOrNull { it.node.bounds.top } ?: Int.MAX_VALUE
|
||||
val prompt = quantity != null && nodes.any { node ->
|
||||
node.visible && !node.clickable && node.className?.endsWith("TextView") == true &&
|
||||
node.bounds.bottom <= minOf(quantity.bounds.top, firstOptionTop) &&
|
||||
node.label.filterNot(Char::isWhitespace).let { it.startsWith("请选择") || it.startsWith("請選擇") }
|
||||
}
|
||||
val selected = scoped.finalSelectionSummaries.any { raw ->
|
||||
val compact = raw.filterNot(Char::isWhitespace)
|
||||
config.textAliases.selection.selectedPrefixes.any(compact::startsWith)
|
||||
}
|
||||
return PurchaseSelectionEvidence(selected, prompt)
|
||||
}
|
||||
|
||||
/** Retains #374's real ancestry and bounded full sheet, not the inner option list. */
|
||||
private fun boundedQuantityPanel(snapshot: UiSnapshot): SnapshotNode? {
|
||||
val visible = snapshot.nodes.filter(SnapshotNode::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 byPath = visible.associateBy(SnapshotNode::path)
|
||||
var current = visible.singleOrNull {
|
||||
it.enabled && it.className == "android.widget.EditText" && it.label.toIntOrNull()?.let { value -> value > 0 } == true
|
||||
} ?: return null
|
||||
var panel: SnapshotNode? = null
|
||||
val visited = mutableSetOf(current.path)
|
||||
while (true) {
|
||||
val parent = current.parentPath?.let(byPath::get) ?: break
|
||||
if (!visited.add(parent.path)) return null
|
||||
val area = parent.bounds.width.toLong() * parent.bounds.height
|
||||
if (area <= 0 || area >= screenArea || parent.parentPath == null) break
|
||||
panel = parent
|
||||
current = parent
|
||||
}
|
||||
return panel
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -864,15 +864,7 @@ class AgentForegroundService : Service() {
|
||||
panelDiagnostic = { evidence -> Log.i("GoAutoPurchasePanel", "task=${task.taskId};$evidence") },
|
||||
beforeOrderSubmit = { evidence ->
|
||||
val boundaryRequestId = UUID.randomUUID().toString()
|
||||
val finalEvidence = JSONObject()
|
||||
.put("goodsId", evidence.goodsId)
|
||||
.put("mappedColor", evidence.mappedColor)
|
||||
.put("mappedSize", evidence.mappedSize)
|
||||
.put("quantity", evidence.quantity)
|
||||
.put("unitPriceCent", evidence.unitPriceCent)
|
||||
.put("addressSuffix", evidence.addressSuffix)
|
||||
.put("activityName", evidence.activityName)
|
||||
.toString()
|
||||
val finalEvidence = evidence.toLocalJson()
|
||||
purchaseStore.markOrderSubmitStarted(task.taskId, task.taskAttemptId, boundaryRequestId, finalEvidence)
|
||||
api.markPurchaseOrderSubmitStarted(task.taskId, boundaryRequestId, token)
|
||||
},
|
||||
|
||||
+154
-210
@@ -3,224 +3,171 @@ package cn.ilapage.goauto.agent
|
||||
import cn.ilapage.goauto.agent.automation.*
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
import org.json.JSONObject
|
||||
|
||||
/** Final capture only; derived from the existing #331/#332 bounded purchase-sheet fixtures. */
|
||||
/** #376 replaces #374's final value-proof contract. All fixtures are synthetic. */
|
||||
class PurchaseFinalSpecConfirmationTest {
|
||||
@Test fun `bracket display variants and offscreen color are proven by full panel summary`() {
|
||||
val snapshot = sheet("已选 [香槟]RW圆领 L码[105-115斤]", colors = listOf("其他颜色"), sizes = listOf("L码[105-115斤]"))
|
||||
.selected("size/o0/t")
|
||||
accepts(snapshot, "【香槟】RW圆领", "L码【105-115斤】")
|
||||
@Test fun `local final evidence distinguishes target observed quantity and scoped summary flags`() {
|
||||
val json = JSONObject(check(sheet("已选 黑色 XL"), input().copy(quantity = 2)).toLocalJson())
|
||||
assertFalse(json.has("quantity"))
|
||||
assertEquals(2, json.getLong("targetQuantity"))
|
||||
assertEquals(1, json.getLong("observedQuantity"))
|
||||
assertTrue(json.getBoolean("selectedSummaryPresent"))
|
||||
assertFalse(json.getBoolean("summaryPromptPresent"))
|
||||
val prompt = check(sheet("请 选择:颜色"), input())
|
||||
assertFalse(prompt.selectedSummaryPresent)
|
||||
assertTrue(prompt.summaryPromptPresent)
|
||||
val external = sheet(null).addText("r/outside", "已选 黑色 XL", NodeBounds(20, 400, 800, 460))
|
||||
assertFalse(check(external, input()).selectedSummaryPresent)
|
||||
assertFalse(check(sheet("#黑色 XL#"), input()).selectedSummaryPresent)
|
||||
}
|
||||
|
||||
@Test fun `summary above scroll area is accepted for scrollable and non scrollable sheets`() {
|
||||
for (scrollable in listOf(true, false)) {
|
||||
accepts(sheet("已选 黑色 XL", scrollable = scrollable))
|
||||
}
|
||||
@Test fun `observed quantity is nullable for missing or multiple numeric editors`() {
|
||||
val single = sheet(null)
|
||||
assertEquals(1L, observedPurchaseQuantity(single))
|
||||
assertNull(observedPurchaseQuantity(single.mapNodes { if (it.className == "android.widget.EditText") it.copy(visible = false) else it }))
|
||||
val quantity = single.nodes.single { it.className == "android.widget.EditText" }
|
||||
assertNull(observedPurchaseQuantity(single.copy(nodes = single.nodes + quantity.copy(path = "r/second"))))
|
||||
val evidence = check(single, input()).copy(observedQuantity = null)
|
||||
val json = JSONObject(evidence.toLocalJson())
|
||||
assertTrue(json.has("observedQuantity"))
|
||||
assertTrue(json.isNull("observedQuantity"))
|
||||
}
|
||||
|
||||
@Test fun `checked only exact option and physical parent child duplicates provide selection proof`() {
|
||||
accepts(sheet(null).selected("color/o0/img", checked = true).selected("size/o0/t", checked = true))
|
||||
}
|
||||
|
||||
@Test fun `selected other color or size overrides matching summary and positive target`() {
|
||||
rejects(sheet("已选 黑色 XL", colors = listOf("黑色", "白色")).selected("color/o0").selected("color/o1"))
|
||||
rejects(sheet("已选 黑色 XL", sizes = listOf("XL", "L")).selected("size/o1/t"))
|
||||
}
|
||||
|
||||
@Test fun `selected bracket variants are not a conflict`() {
|
||||
accepts(sheet("已选 [白色] XL", colors = listOf("[白色]")).selected("color/o0").selected("size/o0/t"), "【白色】")
|
||||
}
|
||||
|
||||
@Test fun `distinct physical identical or normalized target options are collisions`() {
|
||||
for (colors in listOf(listOf("黑色", "黑色"), listOf("【白色】", "[白色]"))) {
|
||||
rejects(sheet("已选 ${colors[0]} XL", colors = colors).selected("color/o0"), colors[0])
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun `explicit unavailable target cannot be rescued by summary`() {
|
||||
rejects(sheet("已选 黑色 XL").mapNodes { if (it.path.contains("color/o0")) it.copy(enabled = false) else it })
|
||||
}
|
||||
|
||||
@Test fun `all required selected proofs take priority over a single non proving summary`() {
|
||||
accepts(sheet("已选 其他颜色 其他尺码").selected("color/o0/img").selected("size/o0/t"))
|
||||
rejects(sheet("已选 白色 L", colors = listOf("黑色", "白色"), sizes = listOf("XL", "L"))
|
||||
.selected("color/o0/img").selected("size/o0/t")) // Known alternative composition is a conflict.
|
||||
}
|
||||
|
||||
@Test fun `selected options retain established parser separation of trailing display price`() {
|
||||
accepts(sheet("已选 黑色 XL", colors = listOf("黑色 ¥19.60"), sizes = listOf("XL ¥19.60"))
|
||||
.selected("color/o0/img").selected("size/o0/t"))
|
||||
rejects(sheet("已选 黑色 XL", colors = listOf("黑色加绒 ¥19.60")).selected("color/o0/img"))
|
||||
rejects(sheet("已选 黑色 XL", colors = listOf("黑色 ¥19.60", "黑色 ¥20.00")).selected("color/o0/img"))
|
||||
}
|
||||
|
||||
@Test fun `unselected visible options alone and unrelated title cannot prove selection`() {
|
||||
rejects(sheet(null).addText("r/title", "示例黑色 XL 商品"))
|
||||
}
|
||||
|
||||
@Test fun `panel external prefixed summary is ignored even at same coordinates`() {
|
||||
rejects(sheet(null).addText("r/fake", "已选 黑色 XL"))
|
||||
accepts(sheet("已选 黑色 XL").addText("r/fake", "已选 白色 L"))
|
||||
}
|
||||
|
||||
@Test fun `structural ancestry overrides textual path prefix`() {
|
||||
rejects(sheet(null).addText("r/sheet/fake", "已选 黑色 XL", parent = "r"))
|
||||
}
|
||||
|
||||
@Test fun `whole summary rejects substring sizes colors extra dimensions quantity and unsupported separators`() {
|
||||
for (summary in listOf("已选 黑色 XL", "已选 黑色 加绒 L", "已选 黑色 L 2件", "已选 黑色,L", "已选 L 黑色")) {
|
||||
rejects(sheet(summary, sizes = listOf("L")), size = "L")
|
||||
}
|
||||
rejects(sheet("已选 [白色+黑色]超值两件装 XL", colors = emptyList()), "【白色】")
|
||||
}
|
||||
|
||||
@Test fun `known candidates making another full composition reject ambiguous summary`() {
|
||||
rejects(sheet("已选 黑色 加绒 XL", colors = listOf("黑色", "黑色 加绒"), sizes = listOf("加绒 XL", "XL")), size = "加绒 XL")
|
||||
rejects(sheet("已选 黑色 加绒 XL", colors = listOf("黑色", "黑色 加绒"), sizes = listOf("加绒 XL", "XL")), color = "黑色 加绒")
|
||||
}
|
||||
|
||||
@Test fun `summary permits invisible color despite another unselected visible color`() {
|
||||
accepts(sheet("已选 黑色 XL", colors = listOf("白色")))
|
||||
}
|
||||
|
||||
@Test fun `required single dimension must equal whole summary and empty pair is never proof`() {
|
||||
accepts(sheet("已选 黑色"), size = "")
|
||||
accepts(sheet("已选 XL"), color = "")
|
||||
rejects(sheet("已选 黑色 XL"), size = "")
|
||||
rejects(sheet("已选 黑色 XL"), color = "")
|
||||
rejects(sheet("已选 黑色 XL"), color = " ", size = "")
|
||||
}
|
||||
|
||||
@Test fun `anchored selected prefix variants preserve internal colons commas and whitespace`() {
|
||||
for (prefix in listOf("已选", "已选 ", "已选:", "已选: ", " 已选: ")) {
|
||||
accepts(sheet("$prefix 黑色:加绒,款 XL", colors = emptyList()), color = "黑色:加绒,款")
|
||||
}
|
||||
accepts(sheet("已选 黑色 加绒 XL", colors = emptyList()), color = "黑色 加绒")
|
||||
rejects(sheet("已选 黑色 加绒 XL", colors = emptyList()), color = "黑色加绒")
|
||||
rejects(sheet("已选 黑色:加绒 XL", colors = emptyList()), color = "黑色加绒")
|
||||
rejects(sheet("已选::黑色 XL"))
|
||||
accepts(sheet("已选::黑色 XL", colors = emptyList()), color = ":黑色")
|
||||
}
|
||||
|
||||
@Test fun `conflicting scoped summaries reject even both exact options selected`() {
|
||||
rejects(sheet("已选 黑色 XL").selected("color/o0").selected("size/o0/t").addText("r/sheet/second", "已选 白色 L"))
|
||||
accepts(sheet("已选 黑色 XL").addText("r/sheet/second", "已选:黑色 XL"))
|
||||
}
|
||||
|
||||
@Test fun `display normalization is narrow and content preserving`() {
|
||||
accepts(sheet("已选 [白色](加绒) RW12 XL", colors = emptyList()), color = "〔白色〕(加绒) RW12")
|
||||
rejects(sheet("已选 白色 RW12 XL", colors = emptyList()), color = "白色 rw12")
|
||||
rejects(sheet("已选 白色 1 XL", colors = emptyList()), color = "白色 ①")
|
||||
rejects(sheet("已选 白色 XL", colors = emptyList()), color = "白色 ¥19.6")
|
||||
}
|
||||
|
||||
@Test fun `existing structurally recognized unprefixed summary is usable`() {
|
||||
accepts(prefixlessSheet())
|
||||
rejects(sheet("黑色 XL")) // No quantity/header relationship in this fixture.
|
||||
rejects(sheet(null).addText("r/title", "黑色 XL"))
|
||||
rejects(prefixlessSheet().addText("r/sheet/body/info/second", "黑色 L"))
|
||||
}
|
||||
|
||||
@Test fun `same dimension groups retain earlier selected conflicts and physical candidates`() {
|
||||
val base = sheet("已选 黑色 XL").addText("r/sheet/body/list/extra/h", "颜色分类")
|
||||
.addText("r/sheet/body/list/extra/o", "白色")
|
||||
.mapNodes { when (it.path) {
|
||||
"r/sheet/body/list/extra/h" -> it.copy(bounds = NodeBounds(20, 1000, 500, 1040), parentPath = "r/sheet/body/list")
|
||||
"r/sheet/body/list/extra/o" -> it.copy(bounds = NodeBounds(20, 1045, 500, 1100), parentPath = "r/sheet/body/list", clickable = true, selected = true)
|
||||
"r/sheet/body/list" -> it.copy(bounds = NodeBounds(0, 980, 1080, 2036))
|
||||
else -> it
|
||||
} }
|
||||
rejects(base)
|
||||
rejects(base.mapNodes { if (it.path.endsWith("extra/o")) it.copy(text = "黑色") else it })
|
||||
}
|
||||
|
||||
@Test fun `collector default still collapses same raw labels and leaves summary behavior unchanged`() {
|
||||
val snapshot = sheet("已选 黑色 XL", colors = listOf("黑色", "黑色"))
|
||||
val config = PurchaseRehearsalExecutor.DEFAULT_COLLECTOR
|
||||
val regular = PddScreenParser.parse(snapshot, config, "374", null)
|
||||
val final = PddScreenParser.parse(snapshot, config, "374", null, forFinalConfirmation = true)
|
||||
assertEquals(1, regular.dimensions.single { it.key == "color" }.values.size)
|
||||
assertEquals(2, final.dimensions.single { it.key == "color" }.values.size)
|
||||
assertEquals(regular.selectedSummary, final.selectedSummary)
|
||||
assertTrue(regular.finalSelectionSummaries.isEmpty())
|
||||
}
|
||||
|
||||
@Test fun `independently bounded selected options survive absent full panel boundary`() {
|
||||
val snapshot = sheet(null).selected("color/o0/img").selected("size/o0/t")
|
||||
.mapNodes { if (it.path == "r/sheet") it.copy(bounds = NodeBounds(0, 0, 1080, 2216)) else it }
|
||||
PurchaseFinalSpecVerifier.verify(snapshot, input()) { fail(it) }
|
||||
val noSelections = snapshot.mapNodes { it.copy(selected = false, checked = false) }
|
||||
.addText("r/sheet/fake", "已选 黑色 XL")
|
||||
try {
|
||||
PurchaseFinalSpecVerifier.verify(noSelections, input()) {}
|
||||
fail("unproven full panel must not supply summary")
|
||||
} catch (failure: PurchaseLiveException) { assertEquals("PURCHASE_SPEC_NOT_MATCHED", failure.code) }
|
||||
}
|
||||
|
||||
@Test fun `price quantity and address checks remain enforced`() {
|
||||
rejects(sheet("已选 黑色 XL"), expectedCode = "PURCHASE_PRICE_OUT_OF_RANGE", input = input().copy(maxUnitPriceCent = 1))
|
||||
rejects(sheet("已选 黑色 XL"), expectedCode = "PURCHASE_QUANTITY_MISMATCH", input = input().copy(quantity = 2))
|
||||
rejects(sheet("已选 黑色 XL").mapNodes { if (it.path == "r/sheet/body/addr/a/detail") it.copy(text = "示例地址") else it }, expectedCode = "PURCHASE_ADDRESS_UPDATE_FAILED")
|
||||
}
|
||||
|
||||
private fun sheet(
|
||||
summary: String?, colors: List<String> = listOf("黑色"), sizes: List<String> = listOf("XL"), scrollable: Boolean = true,
|
||||
): UiSnapshot = SpecPanelFixtures.sheet(SpecPanelFixtures.Sheet(colorLabels = colors, sizeLabels = sizes, listScrollable = scrollable))
|
||||
.mapNodes { when (it.path) {
|
||||
"r/sheet/body/summary" -> it.copy(text = summary, visible = summary != null)
|
||||
"r/sheet/body/addr/a/detail" -> it.copy(text = ADDRESS)
|
||||
else -> it
|
||||
} }
|
||||
|
||||
/** Reuses the info-above-list shape from PddProductDetailCollectorTest.prefixlessPanel. */
|
||||
private fun prefixlessSheet(): UiSnapshot {
|
||||
val base = sheet("黑色 XL")
|
||||
val info = base.nodes.single { it.path == "r/sheet/body/qty" }.copy(
|
||||
path = "r/sheet/body/info", bounds = NodeBounds(0, 770, 1080, 1000), parentPath = "r/sheet/body",
|
||||
)
|
||||
return base.mapNodes { when {
|
||||
it.path == "r/sheet/body/summary" -> it.copy(path = "r/sheet/body/info/summary", parentPath = info.path)
|
||||
it.path == "r/sheet/body/qty" -> it.copy(path = "r/sheet/body/info/qty", parentPath = info.path)
|
||||
it.path.startsWith("r/sheet/body/qty/") -> it.copy(path = it.path.replace("body/qty/", "body/info/qty/"), parentPath = "r/sheet/body/info/qty")
|
||||
else -> it
|
||||
} }.let { it.copy(nodes = it.nodes + info) }
|
||||
}
|
||||
|
||||
private fun UiSnapshot.mapNodes(transform: (SnapshotNode) -> SnapshotNode) = copy(nodes = nodes.map(transform))
|
||||
private fun UiSnapshot.selected(suffix: String, checked: Boolean = false) = mapNodes {
|
||||
if (it.path.endsWith(suffix)) it.copy(selected = !checked, checked = checked) else it
|
||||
}
|
||||
private fun UiSnapshot.addText(path: String, text: String, parent: String = path.substringBeforeLast('/')) = copy(nodes = nodes + SnapshotNode(
|
||||
path, parent, text, null, null, "android.widget.TextView", NodeBounds(384, 791, 1068, 852),
|
||||
false, false, false, false, true, true,
|
||||
))
|
||||
private fun input(color: String = "黑色", size: String = "XL") = PurchaseExecutionInput(
|
||||
taskId = 374, executionMode = "live", phase = "purchase", url = "https://mobile.yangkeduo.com/goods.html?goods_id=374",
|
||||
goodsId = "374", mappedColor = color, mappedSize = size, quantity = 1,
|
||||
minUnitPriceCent = 1, maxUnitPriceCent = 3000, addressSuffix = "_cg374",
|
||||
)
|
||||
|
||||
private fun check(snapshot: UiSnapshot, input: PurchaseExecutionInput, diagnostics: MutableList<String> = mutableListOf()): FinalConfirmationEvidence {
|
||||
@Test fun `summary prompt is scoped to visible nonclickable header rather than option or external text`() {
|
||||
val normal = sheet(null)
|
||||
val driver = object : PurchaseUiDriver {
|
||||
override fun capture() = snapshot
|
||||
override fun clickFresh(target: SnapshotNode): FreshActionResult = error("final confirmation must be read only")
|
||||
override fun capture() = normal
|
||||
override fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long): Boolean = error("unexpected swipe")
|
||||
override fun clickFresh(target: SnapshotNode): FreshActionResult = error("unexpected click")
|
||||
override fun tapPurchaseFresh(target: SnapshotNode): FreshActionResult = error("unexpected tap")
|
||||
override fun inputFresh(target: SnapshotNode, value: String): FreshActionResult = error("unexpected input")
|
||||
override fun swipePurchase(direction: SwipeDirection, durationMs: Long): Boolean = error("unexpected swipe")
|
||||
override fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long): Boolean = error("unexpected scoped swipe")
|
||||
override fun backPurchase(): Boolean = error("unexpected back")
|
||||
}
|
||||
return PurchaseLiveAutomation(driver, pause = { error("unexpected pause") }, panelDiagnostic = diagnostics::add)
|
||||
.finalConfirmation(input, ShippingAddressProof(ADDRESS, "_cg374"))
|
||||
}
|
||||
private fun accepts(snapshot: UiSnapshot, color: String = "黑色", size: String = "XL") {
|
||||
val diagnostics = mutableListOf<String>()
|
||||
val result = try { check(snapshot, input(color, size), diagnostics) } catch (failure: PurchaseLiveException) {
|
||||
throw AssertionError("${failure.code}: ${diagnostics.lastOrNull()}", failure)
|
||||
val live = PurchaseLiveAutomation(driver, pause = {})
|
||||
for (prompt in listOf("请选择颜色", "请 选择:颜色", "請選擇: 顏色")) {
|
||||
assertTrue(live.selectionPromptPresent(sheet(prompt)))
|
||||
}
|
||||
assertEquals(color, result.mappedColor)
|
||||
val summaryBounds = NodeBounds(384, 791, 1068, 852)
|
||||
for ((index, snapshot) in listOf(
|
||||
normal.addText("r/external", "请选择颜色", summaryBounds),
|
||||
normal.addText("r/sheet/fake", "请选择颜色", summaryBounds).mapNodes { if (it.path.endsWith("fake")) it.copy(parentPath = "r") else it },
|
||||
sheet("请选择颜色").mapNodes { if (it.path.endsWith("body/summary")) it.copy(visible = false) else it },
|
||||
sheet("请选择颜色").mapNodes { if (it.path.endsWith("body/summary")) it.copy(clickable = true) else it },
|
||||
normal.addText("r/sheet/body/list/color/o0/prompt", "请选择颜色", NodeBounds(40, 1600, 300, 1640)),
|
||||
normal.mapNodes { if (it.path.startsWith("r/sheet/body/qty")) it.copy(bounds = it.bounds.copy(top = 1990, bottom = 2020)) else it }
|
||||
.addText("r/sheet/body/list/color/o0/prompt", "请选择颜色", NodeBounds(40, 1600, 300, 1640)),
|
||||
sheet("请选择颜色").mapNodes { if (it.path in listOf("r/sheet", "r/sheet/body")) it.copy(bounds = NodeBounds(0, 0, 1080, 2216)) else it },
|
||||
).withIndex()) {
|
||||
assertFalse("variant=$index", live.selectionPromptPresent(snapshot))
|
||||
assertFalse(PurchaseSelectionEvidence.observe(snapshot).summaryPromptPresent)
|
||||
}
|
||||
// A missing final button never adds a pre-address failure by itself.
|
||||
assertFalse(live.selectionPromptPresent(normal.mapNodes { if (it.path.startsWith("r/sheet/submit")) it.copy(visible = false) else it }))
|
||||
}
|
||||
private fun rejects(snapshot: UiSnapshot, color: String = "黑色", size: String = "XL", expectedCode: String = "PURCHASE_SPEC_NOT_MATCHED", input: PurchaseExecutionInput = input(color, size)) {
|
||||
@Test fun `normal button permits missing heading marketing summary and offscreen size`() {
|
||||
accepts(sheet("#示例活动#" ).mapNodes { if (it.path.endsWith("color/h")) it.copy(visible = false) else it })
|
||||
accepts(sheet("#示例活动#").mapNodes { if (it.path.contains("list/size")) it.copy(visible = false) else it })
|
||||
}
|
||||
|
||||
@Test fun `normal button permits wrong selected values unavailable targets and collisions`() {
|
||||
accepts(sheet(null).mapNodes {
|
||||
when {
|
||||
it.path.endsWith("color/o0") -> it.copy(selected = true, contentDescription = "白色")
|
||||
it.path.endsWith("size/o0/t") -> it.copy(selected = true, text = "L")
|
||||
else -> it
|
||||
}
|
||||
})
|
||||
accepts(sheet("已选 白色 L").mapNodes { if (it.path.contains("color/o0")) it.copy(enabled = false) else it })
|
||||
accepts(sheet(null).let { it.copy(nodes = it.nodes + it.nodes.filter { n -> n.path.contains("color/o0") }.map { n ->
|
||||
n.copy(path = n.path.replace("o0", "o1"), parentPath = n.parentPath?.replace("o0", "o1"), bounds = n.bounds.copy(left = 400, right = 700))
|
||||
}) })
|
||||
}
|
||||
|
||||
@Test fun `normal button permits mismatched quantity and summary prompt`() {
|
||||
accepts(sheet("请选择:颜色分类 尺码"), input().copy(quantity = 2))
|
||||
accepts(sheet("請選擇: 顏色 尺碼"))
|
||||
}
|
||||
|
||||
@Test fun `special button rejects simplified traditional whitespace and split prompt`() {
|
||||
for (prompt in listOf("选择颜色分类及尺码后,提交订单", "選擇顏色及尺碼後,提交訂單", " 选 择 颜色 后 , 提交 订单 ")) {
|
||||
rejects(sheet("已选 黑色 XL").button(prompt), "PURCHASE_SPEC_NOT_MATCHED")
|
||||
}
|
||||
rejects(splitButton(), "PURCHASE_SPEC_NOT_MATCHED")
|
||||
rejects(splitButton().mapNodes { if (it.path == "r/sheet/submit/l") it.copy(text = "选择颜色后,提交订单") else it }, "PURCHASE_SPEC_NOT_MATCHED")
|
||||
}
|
||||
|
||||
@Test fun `whole match ignores prefix suffix hidden external and independent child fragments`() {
|
||||
for (label in listOf("提示选择颜色后,提交订单", "选择颜色后,提交订单优惠", "选择颜色提交订单", "提交订单")) accepts(sheet(null).button(label))
|
||||
accepts(sheet(null).addText("r/outside", "选择颜色后,提交订单", NodeBounds(20, 10, 800, 70)))
|
||||
accepts(sheet(null).addText("r/sheet/submit/hidden", "选择颜色后,提交订单", NodeBounds(20, 2140, 800, 2170)).mapNodes {
|
||||
if (it.path.endsWith("hidden")) it.copy(visible = false) else it
|
||||
})
|
||||
accepts(sheet(null).mapNodes { when (it.path) {
|
||||
"r/sheet/submit" -> it.copy(text = "选择颜色后,提交订单")
|
||||
"r/sheet/submit/l/t" -> it.copy(text = "选择颜色后,提交订单", visible = false)
|
||||
else -> it
|
||||
} }) // Do not use a parent's aggregate label when its labelled child is hidden.
|
||||
accepts(splitButton().mapNodes { if (it.path.endsWith("/first")) it.copy(clickable = true) else it })
|
||||
accepts(splitButton().mapNodes { if (it.path.endsWith("/first")) it.copy(path = "r/outside", parentPath = "r") else it })
|
||||
}
|
||||
|
||||
@Test fun `address price and missing button still reject without actions`() {
|
||||
rejects(sheet(null), "PURCHASE_PRICE_OUT_OF_RANGE", input().copy(maxUnitPriceCent = 1))
|
||||
rejects(sheet(null).mapNodes { if (it.path.endsWith("addr/a/detail")) it.copy(text = "示例地址") else it }, "PURCHASE_ADDRESS_UPDATE_FAILED")
|
||||
rejects(sheet(null).mapNodes { if (it.path.startsWith("r/sheet/submit")) it.copy(visible = false) else it }, "PURCHASE_SUBMIT_TARGET_AMBIGUOUS")
|
||||
}
|
||||
|
||||
@Test fun `collector opt in retains independent candidates without changing regular parsing`() {
|
||||
val snapshot = SpecPanelFixtures.sheet(SpecPanelFixtures.Sheet(colorLabels = listOf("黑色", "黑色")))
|
||||
val config = PurchaseRehearsalExecutor.DEFAULT_COLLECTOR
|
||||
val regular = PddScreenParser.parse(snapshot, config, "376", null)
|
||||
val final = PddScreenParser.parse(snapshot, config, "376", null, forFinalConfirmation = true)
|
||||
assertEquals(1, regular.dimensions.single { it.key == "color" }.values.size)
|
||||
assertEquals(2, final.dimensions.single { it.key == "color" }.values.size)
|
||||
assertEquals(regular.selectedSummary, final.selectedSummary)
|
||||
}
|
||||
|
||||
private fun splitButton(): UiSnapshot = sheet(null).button("提交订单").mapNodes {
|
||||
if (it.path.endsWith("submit/l/t")) it.copy(bounds = NodeBounds(600, 2180, 1000, 2216)) else it
|
||||
}.addText("r/sheet/submit/l/first", "选择颜色后,", NodeBounds(20, 2180, 590, 2216))
|
||||
|
||||
private fun sheet(summary: String?): UiSnapshot = SpecPanelFixtures.sheet(SpecPanelFixtures.Sheet(colorLabels = listOf("黑色"), sizeLabels = listOf("XL")))
|
||||
.mapNodes { when (it.path) {
|
||||
"r/sheet/body/summary" -> it.copy(text = summary, visible = summary != null)
|
||||
"r/sheet/body/addr/a/detail" -> it.copy(text = ADDRESS)
|
||||
"r/sheet/submit/l/t" -> it.copy(text = "提交订单")
|
||||
else -> it
|
||||
} }
|
||||
private fun UiSnapshot.mapNodes(transform: (SnapshotNode) -> SnapshotNode) = copy(nodes = nodes.map(transform))
|
||||
private fun UiSnapshot.button(label: String) = mapNodes { if (it.path.endsWith("submit/l/t")) it.copy(text = label) else it }
|
||||
private fun UiSnapshot.addText(path: String, text: String, bounds: NodeBounds) = copy(nodes = nodes + SnapshotNode(
|
||||
path, path.substringBeforeLast('/'), text, null, null, "android.widget.TextView", bounds,
|
||||
false, false, false, false, true, true,
|
||||
))
|
||||
private fun input() = PurchaseExecutionInput(
|
||||
taskId = 376, executionMode = "live", phase = "purchase", url = "https://mobile.yangkeduo.com/goods.html?goods_id=376",
|
||||
goodsId = "376", mappedColor = "黑色", mappedSize = "XL", quantity = 1,
|
||||
minUnitPriceCent = 1, maxUnitPriceCent = 3000, addressSuffix = "_cg376",
|
||||
)
|
||||
private fun check(snapshot: UiSnapshot, input: PurchaseExecutionInput, diagnostics: MutableList<String> = mutableListOf()): FinalConfirmationEvidence {
|
||||
val driver = object : PurchaseUiDriver {
|
||||
override fun capture() = snapshot
|
||||
override fun backPurchase(): Boolean = error("unexpected back")
|
||||
override fun tapPurchaseFresh(target: SnapshotNode): FreshActionResult = error("unexpected tap")
|
||||
override fun clickFresh(target: SnapshotNode): FreshActionResult = error("unexpected click")
|
||||
override fun inputFresh(target: SnapshotNode, value: String): FreshActionResult = error("unexpected input")
|
||||
override fun swipePurchase(direction: SwipeDirection, durationMs: Long): Boolean = error("unexpected swipe")
|
||||
override fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long): Boolean = error("unexpected scoped swipe")
|
||||
}
|
||||
return PurchaseLiveAutomation(driver, pause = { error("unexpected pause") }, panelDiagnostic = diagnostics::add)
|
||||
.finalConfirmation(input, ShippingAddressProof(ADDRESS, "_cg376"))
|
||||
}
|
||||
private fun accepts(snapshot: UiSnapshot, input: PurchaseExecutionInput = input()) {
|
||||
assertEquals(input.mappedColor, check(snapshot, input).mappedColor)
|
||||
}
|
||||
private fun rejects(snapshot: UiSnapshot, expectedCode: String, input: PurchaseExecutionInput = input()) {
|
||||
val diagnostics = mutableListOf<String>()
|
||||
try {
|
||||
check(snapshot, input, diagnostics)
|
||||
@@ -228,14 +175,11 @@ class PurchaseFinalSpecConfirmationTest {
|
||||
} catch (failure: PurchaseLiveException) {
|
||||
assertEquals(expectedCode, failure.code)
|
||||
if (expectedCode == "PURCHASE_SPEC_NOT_MATCHED") {
|
||||
val detail = diagnostics.last()
|
||||
assertTrue(detail, detail.matches(Regex("finalSpec;dimension=(color|size|none);stage=[a-z_]+;summaryPresent=[01];selectedFound=[01];conflict=[01]")))
|
||||
assertTrue(failure.message.orEmpty(), failure.message.orEmpty().contains(detail))
|
||||
assertEquals("finalSpec;stage=selection_prompt", diagnostics.last())
|
||||
assertTrue(failure.message.orEmpty().contains(diagnostics.last()))
|
||||
assertFalse(failure.message.orEmpty().contains(ADDRESS))
|
||||
assertFalse(failure.message.orEmpty().contains("黑色"))
|
||||
assertFalse(failure.message.orEmpty().contains("XL"))
|
||||
}
|
||||
}
|
||||
}
|
||||
private companion object { const val ADDRESS = "示例区示例路374号_cg374" }
|
||||
private companion object { const val ADDRESS = "示例区示例路376号_cg376" }
|
||||
}
|
||||
|
||||
@@ -8,6 +8,9 @@ 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.PurchaseRuleParser
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseRehearsalExecutor
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseAgentCapabilities
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseExecutionOutcome
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseUiDriver
|
||||
import cn.ilapage.goauto.agent.automation.SnapshotNode
|
||||
import cn.ilapage.goauto.agent.automation.SwipeDirection
|
||||
@@ -18,6 +21,105 @@ import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class PurchaseLiveAutomationTest {
|
||||
@Test
|
||||
fun `pre address summary or button selection prompt stops executor before any address action`() {
|
||||
for (variant in listOf("summary", "button", "second-dimension", "after-swipe")) {
|
||||
val result = runPromptBoundary(prePrompt = variant)
|
||||
assertEquals(variant, "failed", result.outcome.resultType)
|
||||
assertEquals(variant, "PURCHASE_SPEC_SELECTION_UNCONFIRMED", result.outcome.errorCode)
|
||||
assertTrue(result.outcome.message, result.outcome.message.contains("reason=selection_prompt"))
|
||||
assertEquals(variant, 0, result.boundaries)
|
||||
assertEquals(variant, 0, result.driver.addressPathClicks)
|
||||
assertEquals(variant, 0, result.driver.inputCount)
|
||||
assertEquals(variant, 0, result.driver.submitClicks)
|
||||
assertEquals(variant, if (variant == "after-swipe") 1 else 0, result.preSwipes)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `final selection prompt is ordinary failure before boundary and unknown after boundary with zero clicks`() {
|
||||
val before = runPromptBoundary(finalPrompt = true)
|
||||
assertEquals("failed", before.outcome.resultType)
|
||||
assertEquals("PURCHASE_SPEC_NOT_MATCHED", before.outcome.errorCode)
|
||||
assertTrue(before.outcome.message.contains("finalSpec;stage=selection_prompt"))
|
||||
assertEquals(0, before.boundaries)
|
||||
assertEquals(0, before.driver.submitClicks)
|
||||
assertEquals(1, before.driver.inputCount)
|
||||
val after = runPromptBoundary(boundaryPrompt = true)
|
||||
assertEquals("order_result_unknown", after.outcome.resultType)
|
||||
assertEquals("PURCHASE_ORDER_RESULT_UNKNOWN", after.outcome.errorCode)
|
||||
assertEquals(1, after.boundaries)
|
||||
assertEquals(0, after.driver.submitClicks)
|
||||
assertEquals(0, after.driver.scopedSwipes + after.driver.genericSwipes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `post address wrong spec and quantity normal button complete executor once`() {
|
||||
val result = runPromptBoundary(wrongFinalValues = true)
|
||||
assertEquals("order_created", result.outcome.resultType)
|
||||
assertEquals(1, result.boundaries)
|
||||
assertEquals(1, result.driver.submitClicks)
|
||||
}
|
||||
|
||||
private data class PromptBoundaryResult(val outcome: PurchaseExecutionOutcome, val driver: LiveDriver, val boundaries: Int, val preSwipes: Int)
|
||||
|
||||
private fun runPromptBoundary(
|
||||
prePrompt: String? = null, finalPrompt: Boolean = false, boundaryPrompt: Boolean = false, wrongFinalValues: Boolean = false,
|
||||
): PromptBoundaryResult {
|
||||
val addressDriver = LiveDriver()
|
||||
var step = ""
|
||||
var boundaries = 0
|
||||
var verifyCaptures = 0
|
||||
var preSwipes = 0
|
||||
val selectedSheet = SpecPanelFixtures.sheet(SpecPanelFixtures.Sheet(colorLabels = listOf("黑色"), sizeLabels = listOf("XL"))).let { sheet ->
|
||||
sheet.copy(nodes = sheet.nodes.map { when {
|
||||
it.path.endsWith("body/summary") -> it.copy(text = "已选 黑色 XL")
|
||||
it.path.endsWith("qty/input") -> it.copy(text = "2")
|
||||
it.path.endsWith("submit/l/t") -> it.copy(text = "提交订单")
|
||||
it.path.endsWith("color/o0") || it.path.endsWith("size/o0/t") -> it.copy(selected = true)
|
||||
else -> it
|
||||
} })
|
||||
}
|
||||
val driver = object : PurchaseUiDriver by addressDriver {
|
||||
override fun capture(): UiSnapshot {
|
||||
if (step in listOf("updateShippingAddress", "createOrder", "readOrderResult")) {
|
||||
val snapshot = addressDriver.capture()
|
||||
if (step != "createOrder") return snapshot
|
||||
return snapshot.copy(nodes = snapshot.nodes.map { node -> when {
|
||||
node.path == "panel/submit" && (finalPrompt || (boundaryPrompt && boundaries > 0)) ->
|
||||
node.copy(text = "选择颜色分类及尺码后,提交订单")
|
||||
wrongFinalValues && node.path == "panel/selected" -> node.copy(text = "已选 白色 L")
|
||||
wrongFinalValues && node.path == "panel/quantity" -> node.copy(text = "9")
|
||||
else -> node
|
||||
} })
|
||||
}
|
||||
if (step != "verifyOrderSummary") return selectedSheet
|
||||
verifyCaptures++
|
||||
return selectedSheet.copy(nodes = selectedSheet.nodes.map { node -> when {
|
||||
prePrompt == "after-swipe" && node.path.contains("list/color") -> node.copy(visible = false)
|
||||
prePrompt == "after-swipe" && preSwipes == 0 && node.path.endsWith("body/summary") -> node.copy(text = "已选 白色 XL")
|
||||
(prePrompt == "summary" || (prePrompt == "after-swipe" && preSwipes > 0)) && node.path.endsWith("body/summary") -> node.copy(text = "请选择:颜色分类")
|
||||
(prePrompt == "button" || (prePrompt == "second-dimension" && verifyCaptures > 1)) && node.path.endsWith("submit/l/t") ->
|
||||
node.copy(text = "选择颜色分类及尺码后,提交订单")
|
||||
else -> node
|
||||
} })
|
||||
}
|
||||
override fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long): Boolean {
|
||||
if (step == "verifyOrderSummary") { preSwipes++; return true }
|
||||
return addressDriver.swipePurchaseIn(target, direction, durationMs)
|
||||
}
|
||||
}
|
||||
val rule = PurchaseRuleParser.parse("""{
|
||||
"schemaVersion":1,"ruleType":"pddPurchase",
|
||||
"requiredCapabilities":["purchase.live.v1","purchase.address-update.v1","purchase.order-create.v1"],
|
||||
"actions":[{"type":"openProduct"},{"type":"verifyProduct"},{"type":"openSpecPanel"},{"type":"selectSpec"},{"type":"setQuantity"},{"type":"verifyUnitPrice"},{"type":"verifyOrderSummary"},{"type":"updateShippingAddress"},{"type":"createOrder"},{"type":"readOrderResult"}]
|
||||
}""")
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { error("unexpected open") }, { null }, pause = {},
|
||||
stepChanged = { step = it }, beforeOrderSubmit = { boundaries++ },
|
||||
).execute(input().copy(reuseProbeProduct = true), rule, PurchaseAgentCapabilities.supported)
|
||||
return PromptBoundaryResult(outcome, addressDriver, boundaries, preSwipes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hint delayed address update preserves fresh payment back budget for late order evidence`() {
|
||||
val observationDriver = OrderObservationDriver { sample ->
|
||||
|
||||
@@ -723,6 +723,31 @@ class PurchaseRehearsalExecutorTest {
|
||||
assertEquals(1, driver.clicked.count { it == "黑色" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `visible unselected target without summary cannot reuse historical proof`() {
|
||||
val driver = FakePurchaseDriver(sizes = listOf("L"), hideSelectedSummaryAfterQuantitySet = true, hideSizeSelectedState = true)
|
||||
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
.execute(input().copy(mappedSize = "L"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
assertEquals("failed", outcome.resultType)
|
||||
assertEquals("PURCHASE_SPEC_SELECTION_UNCONFIRMED", outcome.errorCode)
|
||||
assertTrue(outcome.message, outcome.message.contains("reason=target_visible_unselected"))
|
||||
assertTrue(outcome.message, outcome.message.contains("proofRecorded=true"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `visible unselected normalized target blocks historical proof without relocation`() {
|
||||
val target = "L【建议105-115斤】"
|
||||
val driver = FakePurchaseDriver(
|
||||
sizes = listOf(target), hideSelectedSummaryAfterQuantitySet = true,
|
||||
finalSizesAfterQuantitySet = listOf("L[建议105-115斤]"),
|
||||
)
|
||||
val outcome = executor(driver, { driver.browser = true; true }, { null }, pause = {})
|
||||
.execute(input().copy(mappedSize = target), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
assertEquals("PURCHASE_SPEC_SELECTION_UNCONFIRMED", outcome.errorCode)
|
||||
assertTrue(outcome.message, outcome.message.contains("reason=target_visible_unselected"))
|
||||
assertFalse(driver.swipeInPaths.contains("scroll"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `final verification keeps current attempt proof when selected panel becomes unclassified`() {
|
||||
val driver = FakePurchaseDriver(panelBecomesUnknownAfterSizeProof = true)
|
||||
|
||||
@@ -502,7 +502,7 @@ class SpecPanelRecognitionTest {
|
||||
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"),
|
||||
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"),
|
||||
@@ -512,7 +512,7 @@ class SpecPanelRecognitionTest {
|
||||
|
||||
PurchaseLiveAutomation(driver, pause = {}).submitOrderOnce()
|
||||
|
||||
assertEquals(listOf("选择颜色分类及尺码后,提交订单"), driver.clicked)
|
||||
assertEquals(listOf("提交订单"), driver.clicked)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -196,7 +196,13 @@ class TruncatedSpecCardTest {
|
||||
var sizeSelected = false
|
||||
val clicks = mutableListOf<String>()
|
||||
override fun capture() = if (opened) snapshot.copy(nodes = snapshot.nodes.map { n ->
|
||||
if (n.path.contains("/size/o")) n.copy(selected = sizeSelected && n.path.startsWith("r/sheet/body/list/size/o2")) else n
|
||||
when {
|
||||
n.path.contains("/size/o") -> n.copy(selected = sizeSelected && n.path.startsWith("r/sheet/body/list/size/o2"))
|
||||
// #376: a completed fake selection must no longer expose PDD's incomplete-selection prompts.
|
||||
sizeSelected && n.label.startsWith("请选择") -> n.copy(text = "已选 黑色示例长裤【有抽绳】 有口袋不起球 2XL建议130-150斤")
|
||||
sizeSelected && n.label == "选择颜色分类及尺码后,提交订单" -> n.copy(text = "提交订单")
|
||||
else -> n
|
||||
}
|
||||
}) else SpecPanelFixtures.productDetailPage()
|
||||
override fun clickFresh(target: SnapshotNode): FreshActionResult {
|
||||
clicks += target.label
|
||||
@@ -242,7 +248,12 @@ class TruncatedSpecCardTest {
|
||||
}
|
||||
|
||||
@Test fun `existing final confirmation target first ordering is documented not changed`() {
|
||||
val screen = parse(sheet(otherSelected = true))
|
||||
val snapshot = sheet(otherSelected = true).let { sheet -> sheet.copy(nodes = sheet.nodes.map { node -> when {
|
||||
node.label.startsWith("请选择") -> node.copy(text = "已选 $full $size")
|
||||
node.label == "选择颜色分类及尺码后,提交订单" -> node.copy(text = "提交订单")
|
||||
else -> node
|
||||
} }) }
|
||||
val screen = parse(snapshot)
|
||||
val executor = executor(Driver(sheet()))
|
||||
val immediate = PurchaseRehearsalExecutor::class.java.declaredMethods.single { it.name == "isExactSpecSelected" }
|
||||
immediate.isAccessible = true
|
||||
|
||||
Reference in New Issue
Block a user