fix(agent): 验证采购规格点击并锁定面板滚动 (#214)

This commit is contained in:
QiuSW
2026-09-04 18:23:47 +08:00
parent 1ac50064bd
commit 683aa9c671
5 changed files with 372 additions and 100 deletions
@@ -333,31 +333,32 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
if (bounds.width() < 2 || bounds.height() < 2 || Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
return FreshActionResult.FAILED
}
val path = Path().apply {
moveTo(bounds.centerX().toFloat(), bounds.centerY().toFloat())
}
val completed = AtomicBoolean(false)
val latch = CountDownLatch(1)
val queued = dispatchGesture(
GestureDescription.Builder().addStroke(GestureDescription.StrokeDescription(path, 0, 80)).build(),
object : GestureResultCallback() {
override fun onCompleted(gestureDescription: GestureDescription?) {
completed.set(true)
latch.countDown()
}
return if (dispatchCenterTap(bounds)) FreshActionResult.SUCCESS else FreshActionResult.FAILED
}
override fun onCancelled(gestureDescription: GestureDescription?) {
latch.countDown()
}
},
null,
)
if (!queued) return FreshActionResult.FAILED
return if (latch.await(1_500, TimeUnit.MILLISECONDS) && completed.get()) {
FreshActionResult.SUCCESS
} else {
FreshActionResult.FAILED
override fun tapSpecFresh(target: SnapshotNode): FreshActionResult {
val width = resources.displayMetrics.widthPixels
val height = resources.displayMetrics.heightPixels
if (!PurchaseSpecGesturePolicy.isAllowed(target, width, height)) return FreshActionResult.FAILED
val root = rootInActiveWindow ?: return FreshActionResult.NOT_FOUND
val candidates = mutableListOf<AccessibilityNodeInfo>()
walk(root) { node ->
val bounds = Rect().also(node::getBoundsInScreen)
if (node.isVisibleToUser && node.isEnabled &&
node.preferredOrDescendantLabel() == target.label &&
node.className?.toString() == target.className &&
kotlin.math.abs(bounds.centerX() - target.bounds.centerX) <= 32 &&
kotlin.math.abs(bounds.centerY() - target.bounds.centerY) <= 32
) candidates += node
}
if (candidates.isEmpty()) return FreshActionResult.NOT_FOUND
if (candidates.size != 1) return FreshActionResult.AMBIGUOUS
val bounds = Rect().also(candidates.single()::getBoundsInScreen)
val freshTarget = target.copy(bounds = NodeBounds(bounds.left, bounds.top, bounds.right, bounds.bottom))
if (!PurchaseSpecGesturePolicy.isAllowed(freshTarget, width, height)) {
return FreshActionResult.FAILED
}
return if (dispatchCenterTap(bounds)) FreshActionResult.SUCCESS else FreshActionResult.FAILED
}
override fun inputFresh(target: SnapshotNode, value: String): FreshActionResult {
@@ -407,7 +408,7 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
) matches += node
}
if (matches.size != 1) return false
return swipeNode(matches.single(), direction, durationMs, preferScrollAction = false)
return swipeNode(matches.single(), direction, durationMs, preferScrollAction = true)
}
override fun backPurchase(): Boolean = performGlobalAction(GLOBAL_ACTION_BACK)
@@ -617,6 +618,28 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
return latch.await(1500, TimeUnit.MILLISECONDS) && completed.get()
}
private fun dispatchCenterTap(bounds: Rect): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N || bounds.width() < 2 || bounds.height() < 2) return false
val path = Path().apply { moveTo(bounds.centerX().toFloat(), bounds.centerY().toFloat()) }
val completed = AtomicBoolean(false)
val latch = CountDownLatch(1)
val queued = dispatchGesture(
GestureDescription.Builder().addStroke(GestureDescription.StrokeDescription(path, 0, 80)).build(),
object : GestureResultCallback() {
override fun onCompleted(gestureDescription: GestureDescription?) {
completed.set(true)
latch.countDown()
}
override fun onCancelled(gestureDescription: GestureDescription?) {
latch.countDown()
}
},
null,
)
return queued && latch.await(1_500, TimeUnit.MILLISECONDS) && completed.get()
}
private fun walk(node: AccessibilityNodeInfo, visit: (AccessibilityNodeInfo) -> Unit) {
visit(node)
for (index in 0 until node.childCount) node.getChild(index)?.let { walk(it, visit) }
@@ -11,6 +11,12 @@ interface PurchaseUiDriver {
reason = FreshClickReason.UNKNOWN,
)
fun tapPurchaseFresh(target: SnapshotNode): FreshActionResult
/**
* Reacquires and center-taps a target that the purchase parser has already
* accepted as either a safe spec entry or one exact spec value. Callers
* must still verify the semantic postcondition from a fresh snapshot.
*/
fun tapSpecFresh(target: SnapshotNode): FreshActionResult = FreshActionResult.FAILED
fun inputFresh(target: SnapshotNode, value: String): FreshActionResult
fun swipePurchase(direction: SwipeDirection, durationMs: Long): Boolean
fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long): Boolean
@@ -20,6 +26,21 @@ interface PurchaseUiDriver {
fun bringPddToForeground(): Boolean = false
}
internal object PurchaseSpecGesturePolicy {
private val forbiddenLabels = listOf(
"提交订单", "创建订单", "确认订单", "确认购买", "支付", "付款",
"收货地址", "修改地址", "保存地址",
)
fun isAllowed(target: SnapshotNode, screenWidth: Int, screenHeight: Int): Boolean {
val label = target.label.replace(" ", "")
return target.visible && target.enabled && label.isNotBlank() &&
forbiddenLabels.none(label::contains) &&
target.bounds.width >= 2 && target.bounds.height >= 2 &&
target.bounds.centerX in 0 until screenWidth && target.bounds.centerY in 0 until screenHeight
}
}
data class PurchaseExecutionInput(
val taskId: Long,
val executionMode: String,
@@ -297,6 +318,7 @@ class PurchaseRehearsalExecutor(
entryReadyWaitPolls++
screen = currentScreen(input)
}
val beforeSignature = specActionSignature(screen)
val click = driver.clickFreshDetailed(requireNotNull(target))
when (click.result) {
// The parser already narrowed to a single semantic candidate; the
@@ -306,18 +328,91 @@ class PurchaseRehearsalExecutor(
SPEC_ENTRY_TARGET_AMBIGUOUS,
"规格入口点击目标不唯一 [${specEntryEvidence(screen, 1, entryReadyWaitPolls)}]",
)
else -> Unit
}
var wait = waitForSpecPanel(input, beforeSignature)
wait.failure?.let { return it }
if (wait.opened) return null
if (wait.changed) {
return failure(
SPEC_PANEL_EVIDENCE_NOT_MATCHED,
"规格入口点击后页面已变化,但规格面板强证据不足 [${panelEvidence(wait.screen)}]",
)
}
when (driver.tapSpecFresh(requireNotNull(target))) {
FreshActionResult.AMBIGUOUS -> return failure(
SPEC_ENTRY_TARGET_AMBIGUOUS,
"规格入口手势目标不唯一 [${specEntryEvidence(screen, 1, entryReadyWaitPolls)}]",
)
FreshActionResult.SUCCESS -> Unit
else -> return failure(SPEC_ENTRY_CLICK_FAILED, click.reason.specEntrySubreason())
}
repeat(30) {
screen = currentScreen(input)
panelDiagnostic(panelEvidence(screen))
if (screen.reviewPageOpen) return leaveUnexpectedReviewPage(input)
screen.problem?.let { return failure(it.code, it.message) }
if (screen.specPanelOpen) return null
pause(100)
wait = waitForSpecPanel(input, specActionSignature(wait.screen))
wait.failure?.let { return it }
if (wait.opened) return null
if (wait.changed) {
return failure(
SPEC_PANEL_EVIDENCE_NOT_MATCHED,
"规格入口手势后页面已变化,但规格面板强证据不足 [${panelEvidence(wait.screen)}]",
)
}
return failure(SPEC_PANEL_NOT_OPENED, "点击后未识别到商品规格面板 [${panelEvidence(screen)}]")
return failure(
SPEC_ENTRY_CLICK_NO_EFFECT,
"规格入口无障碍点击和受控手势后页面均无变化 [${panelEvidence(wait.screen)}]",
)
}
private data class SpecPanelWait(
val screen: ParsedPddScreen,
val opened: Boolean,
val changed: Boolean,
val failure: PurchaseExecutionOutcome? = null,
)
private data class SpecActionNodeSignature(
val path: String,
val className: String?,
val bounds: NodeBounds,
val clickable: Boolean,
val scrollable: Boolean,
val selected: Boolean,
val checked: Boolean,
val enabled: Boolean,
val visible: Boolean,
)
private fun waitForSpecPanel(input: PurchaseExecutionInput, beforeSignature: List<SpecActionNodeSignature>): SpecPanelWait {
var last = currentScreen(input)
var changed = false
repeat(SPEC_POST_CLICK_VERIFY_POLLS) {
last = currentScreen(input)
panelDiagnostic(panelEvidence(last))
if (last.reviewPageOpen) {
return SpecPanelWait(last, false, true, leaveUnexpectedReviewPage(input))
}
last.problem?.let { return SpecPanelWait(last, false, true, failure(it.code, it.message)) }
if (last.specPanelOpen) return SpecPanelWait(last, true, true)
changed = changed || specActionSignature(last) != beforeSignature
pause(SPEC_SELECTION_POLL_MILLIS)
}
return SpecPanelWait(last, false, changed)
}
/** In-memory only; the signature is never logged, persisted, or uploaded. */
private fun specActionSignature(screen: ParsedPddScreen): List<SpecActionNodeSignature> = screen.sourceNodes.map { node ->
SpecActionNodeSignature(
node.path,
node.className,
node.bounds,
node.clickable,
node.scrollable,
node.selected,
node.checked,
node.enabled,
node.visible,
)
}
private fun panelEvidence(screen: ParsedPddScreen): String =
@@ -386,44 +481,39 @@ class PurchaseRehearsalExecutor(
target: String,
selectionProofs: MutableMap<String, ExactSpecSelectionProof>,
): PurchaseExecutionOutcome? {
var lastClickReason = FreshClickReason.UNKNOWN
var clickSucceeded = false
repeat(SPEC_SELECTION_CLICK_ATTEMPTS) {
val lookup = locateExactSpec(input, dimension, target)
lookup.failure?.let { return it }
val outcome = driver.clickFreshDetailed(requireNotNull(lookup.node))
lastClickReason = outcome.reason
if (outcome.result == FreshActionResult.AMBIGUOUS) {
return failure(SPEC_TARGET_AMBIGUOUS, "精确规格匹配到多个控件")
}
clickSucceeded = clickSucceeded || outcome.result == FreshActionResult.SUCCESS
val initial = currentScreen(input)
initial.problem?.let { return failure(it.code, it.message) }
if (rememberExactSelection(initial, dimension, target, selectionProofs)) return null
// PDD rerenders the remaining options after a color selection. A
// stale node can report NOT_FOUND/FAILED even when the exact option
// was selected. Always verify the observable selected state before
// deciding whether to refind and retry the same exact target.
val verifyPolls = if (outcome.result == FreshActionResult.SUCCESS) {
SPEC_SELECTION_SUCCESS_VERIFY_POLLS
} else {
SPEC_SELECTION_FAILED_VERIFY_POLLS
}
repeat(verifyPolls) {
val refreshed = currentScreen(input)
refreshed.problem?.let { return failure(it.code, it.message) }
if (isExactSpecSelected(refreshed, dimension, target)) {
exactSpecSelectionProof(refreshed, dimension, target)?.let { proof ->
selectionProofs[dimension] = proof
}
return null
}
pause(SPEC_SELECTION_POLL_MILLIS)
}
val lookup = locateExactSpec(input, dimension, target)
lookup.failure?.let { return it }
val outcome = driver.clickFreshDetailed(requireNotNull(lookup.node))
if (outcome.result == FreshActionResult.AMBIGUOUS) {
return failure(SPEC_TARGET_AMBIGUOUS, "精确规格匹配到多个控件")
}
var verification = waitForExactSelection(input, dimension, target, selectionProofs, SPEC_SELECTION_SUCCESS_VERIFY_POLLS)
verification.failure?.let { return it }
if (verification.confirmed) return null
if (clickSucceeded) {
// Reacquire the exact option after PDD has had a chance to rerender.
// The fallback is still bounded to one center gesture and the same
// unique, server-provided exact value.
val refreshedLookup = locateExactSpec(input, dimension, target)
refreshedLookup.failure?.let { return it }
val gesture = driver.tapSpecFresh(requireNotNull(refreshedLookup.node))
if (gesture == FreshActionResult.AMBIGUOUS) {
return failure(SPEC_TARGET_AMBIGUOUS, "精确规格手势目标不唯一")
}
if (gesture == FreshActionResult.SUCCESS) {
verification = waitForExactSelection(input, dimension, target, selectionProofs, SPEC_SELECTION_SUCCESS_VERIFY_POLLS)
verification.failure?.let { return it }
if (verification.confirmed) return null
return failure(SPEC_SELECTION_UNCONFIRMED, "无障碍点击和受控手势后未能确认精确选中状态")
}
if (outcome.result == FreshActionResult.SUCCESS) {
return failure(SPEC_SELECTION_UNCONFIRMED, "点击规格后未能确认精确选中状态")
}
val subreason = when (lastClickReason) {
val subreason = when (outcome.reason) {
FreshClickReason.ROOT_UNAVAILABLE -> SPEC_CLICK_ROOT_UNAVAILABLE
FreshClickReason.TARGET_NOT_FOUND -> SPEC_CLICK_TARGET_STALE
FreshClickReason.NO_CLICKABLE_ANCESTOR -> SPEC_CLICK_NO_CLICKABLE_ANCESTOR
@@ -433,6 +523,38 @@ class PurchaseRehearsalExecutor(
return failure(SPEC_CLICK_FAILED, subreason)
}
private data class ExactSelectionWait(
val confirmed: Boolean,
val failure: PurchaseExecutionOutcome? = null,
)
private fun waitForExactSelection(
input: PurchaseExecutionInput,
dimension: String,
target: String,
selectionProofs: MutableMap<String, ExactSpecSelectionProof>,
polls: Int,
): ExactSelectionWait {
repeat(polls) {
val refreshed = currentScreen(input)
refreshed.problem?.let { return ExactSelectionWait(false, failure(it.code, it.message)) }
if (rememberExactSelection(refreshed, dimension, target, selectionProofs)) return ExactSelectionWait(true)
pause(SPEC_SELECTION_POLL_MILLIS)
}
return ExactSelectionWait(false)
}
private fun rememberExactSelection(
screen: ParsedPddScreen,
dimension: String,
target: String,
selectionProofs: MutableMap<String, ExactSpecSelectionProof>,
): Boolean {
if (!isExactSpecSelected(screen, dimension, target)) return false
exactSpecSelectionProof(screen, dimension, target)?.let { proof -> selectionProofs[dimension] = proof }
return true
}
private fun isExactSpecSelected(screen: ParsedPddScreen, dimension: String, target: String): Boolean {
val candidates = screen.dimensions.filter { it.key == dimension }.flatMap { it.values }
if (candidates.any { it.text == target && (it.node.selected || it.node.checked) }) return true
@@ -527,38 +649,40 @@ class PurchaseRehearsalExecutor(
* that direction early.
*/
private fun locateExactSpec(input: PurchaseExecutionInput, dimension: String, target: String): SpecLookup {
fun inspect(): Pair<SpecLookup?, String> {
data class Inspection(val lookup: SpecLookup?, val signature: String, val container: SnapshotNode?)
fun inspect(): Inspection {
val screen = currentScreen(input)
screen.problem?.let { return SpecLookup(failure = failure(it.code, it.message)) to "" }
screen.problem?.let { return Inspection(SpecLookup(failure = failure(it.code, it.message)), "", null) }
if (!screen.specPanelOpen) {
return SpecLookup(failure = failure("RULE_NOT_MATCHED", "商品规格面板已经关闭")) to ""
return Inspection(SpecLookup(failure = failure("RULE_NOT_MATCHED", "商品规格面板已经关闭")), "", null)
}
val dimensionValues = screen.dimensions.filter { it.key == dimension }.flatMap { it.values }
val exact = dimensionValues.filter { it.text == target }
if (exact.size > 1) {
return SpecLookup(failure = failure(SPEC_TARGET_AMBIGUOUS, "精确规格匹配到多个控件")) to ""
return Inspection(SpecLookup(failure = failure(SPEC_TARGET_AMBIGUOUS, "精确规格匹配到多个控件")), "", screen.specPanelContainer)
}
if (exact.size == 1 && exact.single().available) return SpecLookup(node = exact.single().node) to ""
if (exact.size == 1 && exact.single().available) return Inspection(SpecLookup(node = exact.single().node), "", screen.specPanelContainer)
if (exact.size == 1) {
return SpecLookup(failure = failure(SPEC_SAFE_TARGET_MISSING, "精确规格当前不可安全点击")) to ""
return Inspection(SpecLookup(failure = failure(SPEC_SAFE_TARGET_MISSING, "精确规格当前不可安全点击")), "", screen.specPanelContainer)
}
val signature = screen.dimensions.joinToString("|") { item ->
"${item.key}:${item.values.joinToString(",") { value -> "${value.text}:${value.available}" }}"
}
return null to signature
return Inspection(null, signature, screen.specPanelContainer)
}
var inspected = inspect()
inspected.first?.let { return it }
var currentSignature = inspected.second
inspected.lookup?.let { return it }
var currentSignature = inspected.signature
for ((direction, limit) in listOf(SwipeDirection.DOWN to 3, SwipeDirection.UP to 6)) {
for (attempt in 0 until limit) {
if (!driver.swipePurchase(direction, 350)) break
val container = inspected.container ?: break
if (!driver.swipePurchaseIn(container, direction, 350)) break
pause(300)
inspected = inspect()
inspected.first?.let { return it }
if (inspected.second == currentSignature) break
currentSignature = inspected.second
inspected.lookup?.let { return it }
if (inspected.signature == currentSignature) break
currentSignature = inspected.signature
}
}
return SpecLookup(failure = failure(SPEC_TARGET_NOT_VISIBLE, "有界搜索后未找到精确规格"))
@@ -690,7 +814,7 @@ class PurchaseRehearsalExecutor(
private const val OPEN_PRODUCT_POLL_MILLIS = 100L
private const val SPEC_ENTRY_READY_WAIT_POLLS = 20
private const val SPEC_ENTRY_READY_POLL_MILLIS = 100L
private const val SPEC_SELECTION_CLICK_ATTEMPTS = 3
private const val SPEC_POST_CLICK_VERIFY_POLLS = 30
private const val SPEC_SELECTION_SUCCESS_VERIFY_POLLS = 20
private const val SPEC_SELECTION_FAILED_VERIFY_POLLS = 5
private const val SPEC_SELECTION_POLL_MILLIS = 100L
@@ -699,7 +823,8 @@ class PurchaseRehearsalExecutor(
private const val SPEC_ENTRY_TARGET_AMBIGUOUS = "PURCHASE_SPEC_ENTRY_TARGET_AMBIGUOUS"
private const val SPEC_ENTRY_CLICK_FAILED = "PURCHASE_SPEC_ENTRY_CLICK_FAILED"
private const val SPEC_ENTRY_OPENED_REVIEW = "PURCHASE_SPEC_ENTRY_OPENED_REVIEW"
private const val SPEC_PANEL_NOT_OPENED = "PURCHASE_SPEC_PANEL_NOT_OPENED"
private const val SPEC_ENTRY_CLICK_NO_EFFECT = "PURCHASE_SPEC_ENTRY_CLICK_NO_EFFECT"
private const val SPEC_PANEL_EVIDENCE_NOT_MATCHED = "PURCHASE_SPEC_PANEL_EVIDENCE_NOT_MATCHED"
private const val SPEC_TARGET_NOT_VISIBLE = "PURCHASE_SPEC_TARGET_NOT_VISIBLE"
private const val SPEC_TARGET_AMBIGUOUS = "PURCHASE_SPEC_TARGET_AMBIGUOUS"
private const val SPEC_SAFE_TARGET_MISSING = "PURCHASE_SPEC_SAFE_TARGET_MISSING"
@@ -8,6 +8,7 @@ import cn.ilapage.goauto.agent.automation.PurchaseAgentCapabilities
import cn.ilapage.goauto.agent.automation.PurchaseExecutionInput
import cn.ilapage.goauto.agent.automation.PurchaseRehearsalExecutor
import cn.ilapage.goauto.agent.automation.PurchaseRuleParser
import cn.ilapage.goauto.agent.automation.PurchaseSpecGesturePolicy
import cn.ilapage.goauto.agent.automation.PurchaseUiDriver
import cn.ilapage.goauto.agent.automation.RuleValidationException
import cn.ilapage.goauto.agent.automation.SnapshotNode
@@ -21,6 +22,22 @@ import org.junit.Assert.assertTrue
import org.junit.Test
class PurchaseRehearsalExecutorTest {
@Test
fun `spec gesture policy excludes irreversible and out of bounds targets`() {
fun target(label: String, bounds: NodeBounds = NodeBounds(20, 100, 300, 180)) = SnapshotNode(
"target", null, label, null, null, "android.widget.TextView", bounds,
clickable = true, scrollable = false, selected = false, checked = false, enabled = true, visible = true,
)
assertTrue(PurchaseSpecGesturePolicy.isAllowed(target("选择规格"), 1080, 2200))
assertTrue(PurchaseSpecGesturePolicy.isAllowed(target("XL"), 1080, 2200))
assertFalse(PurchaseSpecGesturePolicy.isAllowed(target("提交订单"), 1080, 2200))
assertFalse(PurchaseSpecGesturePolicy.isAllowed(target("确认购买"), 1080, 2200))
assertFalse(PurchaseSpecGesturePolicy.isAllowed(target("立即支付"), 1080, 2200))
assertFalse(PurchaseSpecGesturePolicy.isAllowed(target("修改地址"), 1080, 2200))
assertFalse(PurchaseSpecGesturePolicy.isAllowed(target("XL", NodeBounds(-20, 100, -2, 180)), 1080, 2200))
}
@Test
fun `shared current live rule matches Android contract`() {
val raw = requireNotNull(javaClass.classLoader?.getResource("purchase-rule-live-v1.json")) {
@@ -130,6 +147,8 @@ class PurchaseRehearsalExecutorTest {
assertEquals("rehearsal_completed", outcome.resultType)
assertTrue(driver.clicked.contains("XL"))
assertEquals(3, driver.upSwipeCount)
assertTrue(driver.swipeInPaths.isNotEmpty())
assertTrue(driver.swipeInPaths.all { it == "scroll" })
}
@Test
@@ -147,18 +166,31 @@ class PurchaseRehearsalExecutorTest {
}
@Test
fun `rerendered exact size is refound and selected on the second attempt`() {
fun `rerendered exact size falls back to one verified center gesture`() {
val driver = FakePurchaseDriver(
sizeClickResults = mutableListOf(FreshActionResult.NOT_FOUND, FreshActionResult.SUCCESS),
sizeClickResults = mutableListOf(FreshActionResult.NOT_FOUND),
specTapResult = FreshActionResult.SUCCESS,
)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("rehearsal_completed", outcome.resultType)
assertEquals(2, driver.sizeClickCount)
assertEquals(1, driver.sizeClickCount)
assertEquals(1, driver.specTapCount)
assertEquals("XL", driver.size)
}
@Test
fun `already selected exact size is not clicked again`() {
val driver = FakePurchaseDriver(initialSize = "XL")
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("rehearsal_completed", outcome.resultType)
assertEquals(0, driver.sizeClickCount)
assertEquals(0, driver.specTapCount)
}
@Test
fun `long exact size can be verified by one unambiguous short summary token`() {
val target = "XL【建议121-140斤】"
@@ -280,16 +312,17 @@ class PurchaseRehearsalExecutorTest {
}
@Test
fun `persistent exact size click failure stops after three attempts`() {
fun `persistent exact size click failure stops after one action and one bounded gesture`() {
val driver = FakePurchaseDriver(
sizeClickResults = MutableList(3) { FreshActionResult.FAILED },
sizeClickResults = mutableListOf(FreshActionResult.FAILED),
)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("PURCHASE_SPEC_CLICK_FAILED", outcome.errorCode)
assertEquals("action_click_false", outcome.message)
assertEquals(3, driver.sizeClickCount)
assertEquals(1, driver.sizeClickCount)
assertEquals(1, driver.specTapCount)
assertEquals(null, driver.size)
}
@@ -434,14 +467,44 @@ class PurchaseRehearsalExecutorTest {
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("PURCHASE_SPEC_PANEL_NOT_OPENED", outcome.errorCode)
assertEquals("PURCHASE_SPEC_PANEL_EVIDENCE_NOT_MATCHED", outcome.errorCode)
assertEquals(
"点击后未识别到商品规格面板 [type=UNKNOWN;scrollables=0;headings=0;options=0;summary=false;quantity=false;orderAction=false;pageEvidence=true]",
"规格入口点击后页面已变化,但规格面板强证据不足 [type=UNKNOWN;scrollables=0;headings=0;options=0;summary=false;quantity=false;orderAction=false;pageEvidence=true]",
outcome.message,
)
assertEquals(0, driver.specTapCount)
assertFalse(outcome.message.orEmpty().contains("确认款式"))
}
@Test
fun `unchanged spec entry action uses one verified center gesture`() {
val driver = FakePurchaseDriver(
entryActionHasEffect = false,
specTapResult = FreshActionResult.SUCCESS,
)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("rehearsal_completed", outcome.resultType)
assertEquals(1, driver.specTapCount)
assertTrue(driver.panel)
}
@Test
fun `unchanged spec entry after action and gesture returns no effect`() {
val driver = FakePurchaseDriver(
entryActionHasEffect = false,
specTapResult = FreshActionResult.SUCCESS,
specTapHasEffect = false,
)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("PURCHASE_SPEC_ENTRY_CLICK_NO_EFFECT", outcome.errorCode)
assertEquals(1, driver.specTapCount)
assertFalse(driver.panel)
}
@Test
fun `missing spec entry emits scalar source counts without node text`() {
val diagnostics = mutableListOf<String>()
@@ -817,6 +880,10 @@ class PurchaseRehearsalExecutorTest {
private val sizeClickResults: MutableList<FreshActionResult> = mutableListOf(),
private val forcedSizeClickReason: FreshClickReason? = null,
private val forcedEntryClickReason: FreshClickReason? = null,
private val initialSize: String? = null,
private val entryActionHasEffect: Boolean = true,
private val specTapResult: FreshActionResult = FreshActionResult.FAILED,
private val specTapHasEffect: Boolean = true,
private val sizeSelectsOnFailedClick: Boolean = false,
private val selectedSizeSummaryOverride: String? = null,
private val hideSizeSelectedState: Boolean = false,
@@ -838,7 +905,7 @@ class PurchaseRehearsalExecutorTest {
var browser = false
var panel = false
var color: String? = null
var size: String? = null
var size: String? = initialSize
var quantity = 1L
var swipeCount = 0
var upSwipeCount = 0
@@ -846,6 +913,8 @@ class PurchaseRehearsalExecutorTest {
var sizeClickCount = 0
var pullDownCount = 0
var backCount = 0
var specTapCount = 0
val swipeInPaths = mutableListOf<String>()
private var soldOut = soldOut
private var allSpecsUnavailable = allSpecsUnavailable
private var productEvidenceLost = false
@@ -964,7 +1033,9 @@ class PurchaseRehearsalExecutorTest {
clicked += target.label
clickedPaths += target.path
if (target.path == "buy") {
if (openReviewOnBottomClick) reviewPage = true else panel = true
if (entryActionHasEffect) {
if (openReviewOnBottomClick) reviewPage = true else panel = true
}
return FreshActionResult.SUCCESS
}
when (target.label) {
@@ -974,7 +1045,7 @@ class PurchaseRehearsalExecutorTest {
if (result == FreshActionResult.SUCCESS || openPddOnFailedClick) browser = false
return result
}
"选择规格", "免拼购买" -> panel = true
"选择规格", "免拼购买" -> if (entryActionHasEffect) panel = true
in sizes -> {
sizeClickCount++
val result = sizeClickResults.removeFirstOrNull() ?: FreshActionResult.SUCCESS
@@ -1020,6 +1091,17 @@ class PurchaseRehearsalExecutorTest {
override fun tapPurchaseFresh(target: SnapshotNode): FreshActionResult = clickFresh(target)
override fun tapSpecFresh(target: SnapshotNode): FreshActionResult {
specTapCount++
if (specTapResult != FreshActionResult.SUCCESS || !specTapHasEffect) return specTapResult
when {
target.path in setOf("spec", "buy") -> panel = true
target.label in sizes -> size = target.label
target.label in colors -> color = target.label
}
return FreshActionResult.SUCCESS
}
override fun inputFresh(target: SnapshotNode, value: String): FreshActionResult {
quantity = value.toLong()
return FreshActionResult.SUCCESS
@@ -1031,8 +1113,10 @@ class PurchaseRehearsalExecutorTest {
return purchaseSwipeSucceeds
}
override fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long): Boolean =
swipePurchase(direction, durationMs)
override fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long): Boolean {
swipeInPaths += target.path
return swipePurchase(direction, durationMs)
}
override fun pullDownGoodsPage(): Boolean {
pullDownCount++