fix(android): verify selection before address and honor submit prompt (#376)

This commit is contained in:
QiuSW
2026-10-09 17:19:43 +08:00
parent 3ee8dfc0aa
commit 9c7357609c
10 changed files with 446 additions and 363 deletions
@@ -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
}
}
@@ -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> {
@@ -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 {
@@ -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)
},
@@ -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