From 9c7357609cee7c73b57edd030136ed12ef9485b6 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Fri, 9 Oct 2026 17:19:43 +0800 Subject: [PATCH] fix(android): verify selection before address and honor submit prompt (#376) --- .../automation/PurchaseFinalSpecVerifier.kt | 126 ------ .../automation/PurchaseLiveAutomation.kt | 67 +++- .../automation/PurchaseRehearsalExecutor.kt | 28 +- .../automation/PurchaseSelectionEvidence.kt | 68 ++++ .../agent/service/AgentForegroundService.kt | 10 +- .../PurchaseFinalSpecConfirmationTest.kt | 364 ++++++++---------- .../agent/PurchaseLiveAutomationTest.kt | 102 +++++ .../agent/PurchaseRehearsalExecutorTest.kt | 25 ++ .../goauto/agent/SpecPanelRecognitionTest.kt | 4 +- .../goauto/agent/TruncatedSpecCardTest.kt | 15 +- 10 files changed, 446 insertions(+), 363 deletions(-) delete mode 100644 android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseFinalSpecVerifier.kt create mode 100644 android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseSelectionEvidence.kt diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseFinalSpecVerifier.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseFinalSpecVerifier.kt deleted file mode 100644 index f3ba36e..0000000 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseFinalSpecVerifier.kt +++ /dev/null @@ -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() - 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() - 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 - } -} diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseLiveAutomation.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseLiveAutomation.kt index c907af4..f654a70 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseLiveAutomation.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseLiveAutomation.kt @@ -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 { + 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 { 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 { it.bounds.top }.thenBy { it.bounds.left }) + .joinToString("") { it.label } + return listOf(SubmitTarget(row, chosen, text)) } private fun subtreeDescendants(node: SnapshotNode, snapshot: UiSnapshot): List { diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseRehearsalExecutor.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseRehearsalExecutor.kt index 9231143..dd08db6 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseRehearsalExecutor.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseRehearsalExecutor.kt @@ -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, @@ -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 { diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseSelectionEvidence.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseSelectionEvidence.kt new file mode 100644 index 0000000..c750b31 --- /dev/null +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseSelectionEvidence.kt @@ -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() + 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 + } + } +} diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/service/AgentForegroundService.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/service/AgentForegroundService.kt index 8e737fa..a6a9aa1 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/service/AgentForegroundService.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/service/AgentForegroundService.kt @@ -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) }, diff --git a/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseFinalSpecConfirmationTest.kt b/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseFinalSpecConfirmationTest.kt index aed9ef3..eeb722e 100644 --- a/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseFinalSpecConfirmationTest.kt +++ b/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseFinalSpecConfirmationTest.kt @@ -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 = listOf("黑色"), sizes: List = 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 = 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() - 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 = 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() 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" } } diff --git a/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseLiveAutomationTest.kt b/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseLiveAutomationTest.kt index d3b55e5..2f7d1f0 100644 --- a/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseLiveAutomationTest.kt +++ b/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseLiveAutomationTest.kt @@ -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 -> diff --git a/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseRehearsalExecutorTest.kt b/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseRehearsalExecutorTest.kt index 4c32e93..f04f720 100644 --- a/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseRehearsalExecutorTest.kt +++ b/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseRehearsalExecutorTest.kt @@ -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) diff --git a/android/app/src/test/java/cn/ilapage/goauto/agent/SpecPanelRecognitionTest.kt b/android/app/src/test/java/cn/ilapage/goauto/agent/SpecPanelRecognitionTest.kt index ce4f55b..3070b34 100644 --- a/android/app/src/test/java/cn/ilapage/goauto/agent/SpecPanelRecognitionTest.kt +++ b/android/app/src/test/java/cn/ilapage/goauto/agent/SpecPanelRecognitionTest.kt @@ -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 diff --git a/android/app/src/test/java/cn/ilapage/goauto/agent/TruncatedSpecCardTest.kt b/android/app/src/test/java/cn/ilapage/goauto/agent/TruncatedSpecCardTest.kt index 7b0757f..aa68900 100644 --- a/android/app/src/test/java/cn/ilapage/goauto/agent/TruncatedSpecCardTest.kt +++ b/android/app/src/test/java/cn/ilapage/goauto/agent/TruncatedSpecCardTest.kt @@ -196,7 +196,13 @@ class TruncatedSpecCardTest { var sizeSelected = false val clicks = mutableListOf() 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