Compare commits

..
6 changed files with 179 additions and 26 deletions
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId = "cn.ilapage.goauto.agent"
minSdk = 23
targetSdk = 34
versionCode = 75
versionName = "0.9.62"
versionCode = 78
versionName = "0.9.65"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -491,8 +491,11 @@ class PurchaseLiveAutomation(
restoreFinalEvidenceInCurrentPanel(savedEvidence, expected, suffix)
} else {
if (!driver.backPurchase()) fail("PURCHASE_ADDRESS_UPDATE_FAILED", "地址保存后无法返回订单页面,未创建订单")
waitFor("PURCHASE_ADDRESS_SAVE_TIMEOUT", "地址保存后无法返回订单页面,未创建订单") {
hasFinalSavedAddressEvidence(it, expected, suffix)
val returned = waitFor("PURCHASE_ADDRESS_SAVE_TIMEOUT", "地址保存后无法返回订单页面,未创建订单") {
hasFinalSavedAddressEvidence(it, expected, suffix) || isPurchaseConfirmationPanel(it)
}
if (!hasFinalSavedAddressEvidence(returned, expected, suffix)) {
restoreFinalEvidenceInCurrentPanel(returned, expected, suffix)
}
}
}
@@ -521,9 +524,10 @@ class PurchaseLiveAutomation(
if (!isPurchaseConfirmationPanel(snapshot)) {
fail("PURCHASE_ADDRESS_SAVE_TIMEOUT", "地址保存后采购面板发生变化,未创建订单")
}
val panels = purchasePanelScrollTargets(snapshot)
val panels = savedAddressPanelScrollTargets(snapshot)
if (panels.size != 1) {
fail("PURCHASE_ADDRESS_ENTRY_AMBIGUOUS", "地址保存后没有找到唯一的规格面板滚动区域,未创建订单")
val reason = if (panels.isEmpty()) "未找到可用的规格面板滚动区域" else "找到多个规格面板滚动区域"
fail("PURCHASE_ADDRESS_ENTRY_AMBIGUOUS", "地址保存后${reason},未创建订单")
}
val panel = panels.single()
val signature = viewportSignature(snapshot, panel)
@@ -591,6 +595,20 @@ class PurchaseLiveAutomation(
fail(code, message)
}
private fun savedAddressPanelScrollTargets(snapshot: UiSnapshot): List<SnapshotNode> {
val panel = PddScreenParser.parse(snapshot, PurchaseRehearsalExecutor.DEFAULT_COLLECTOR, "", null)
.specPanelContainer ?: return purchasePanelScrollTargets(snapshot)
val screenWidth = snapshot.nodes.maxOfOrNull { it.bounds.right }?.coerceAtLeast(1) ?: 1
val screenHeight = snapshot.nodes.maxOfOrNull { it.bounds.bottom }?.coerceAtLeast(1) ?: 1
return snapshot.nodes.filter { node ->
node.visible && node.enabled && node.scrollable &&
node.bounds.width * 100 >= screenWidth * 60 &&
node.bounds.height * 100 >= screenHeight * 20 &&
node.bounds.left >= panel.bounds.left && node.bounds.right <= panel.bounds.right &&
node.bounds.top >= panel.bounds.top && node.bounds.bottom <= panel.bounds.bottom
}.distinctBy { it.path }
}
private fun purchasePanelScrollTargets(snapshot: UiSnapshot): List<SnapshotNode> {
val screenWidth = snapshot.nodes.maxOfOrNull { it.bounds.right }?.coerceAtLeast(1) ?: 1
val screenHeight = snapshot.nodes.maxOfOrNull { it.bounds.bottom }?.coerceAtLeast(1) ?: 1
@@ -96,6 +96,7 @@ class PurchaseRehearsalExecutor(
) {
private var purchasePanelContext: PurchasePanelContext? = null
private var pageIdentity: Pair<String?, String?> = null to null
private var entryRecoveryReason = "not_checked"
fun execute(input: PurchaseExecutionInput, rule: PurchaseRule, supportedCapabilities: Set<String>): PurchaseExecutionOutcome {
purchasePanelContext = null
@@ -400,7 +401,7 @@ class PurchaseRehearsalExecutor(
if (refreshed.specPanelOpen) return null
val recoveryTarget = recoverableSpecEntry(screen, refreshed, action, beforeIdentity)
if (recoveryTarget == null) {
panelDiagnostic("entryRecovery=rejected;pageChanged=${wait.changed};${panelEvidence(refreshed)}")
panelDiagnostic("entryRecovery=rejected;reason=$entryRecoveryReason;pageChanged=${wait.changed};${panelEvidence(refreshed)}")
return failure(
SPEC_PANEL_EVIDENCE_NOT_MATCHED,
"规格入口点击后页面已变化,但规格面板强证据不足 [${panelEvidence(wait.screen)}]",
@@ -443,18 +444,38 @@ class PurchaseRehearsalExecutor(
action: PurchaseAction,
identity: Pair<String?, String?>,
): SnapshotNode? {
if (identity.first == null || identity.second == null || identity != pageIdentity ||
!after.isPddPackage || !after.pageEvidenceMatched || after.problem != null || after.reviewPageOpen ||
after.specPanelOpen || after.hasCloseControl || after.hasPaymentArea || after.hasOrderSubmitAction ||
after.hasQuantityControls || after.hasSelectionSummary ||
before.summary.title.isNullOrBlank() || before.summary.title != after.summary.title ||
before.specEntrySource == null || before.specEntrySource != after.specEntrySource ||
before.specEntry?.label != after.specEntry?.label ||
after.explicitSpecEntryCount > 1 || after.nestedSpecEntryCount > 1
) return null
entryRecoveryReason = when {
identity.first == null || identity.second == null -> "identity_missing"
identity != pageIdentity -> "identity_changed"
!after.isPddPackage || !after.pageEvidenceMatched -> "product_context_missing"
after.problem != null || after.reviewPageOpen -> "page_problem"
after.specPanelOpen -> "panel_open"
after.hasCloseControl -> "close_control"
after.hasPaymentArea -> "payment_area"
after.hasOrderSubmitAction -> "order_action"
after.hasQuantityControls -> "quantity_controls"
after.hasSelectionSummary -> "selection_summary"
before.summary.title.isNullOrBlank() -> "title_missing"
before.summary.title != after.summary.title -> "title_changed"
after.specEntry == null -> "entry_missing"
before.specEntrySource == null -> "entry_source_missing"
before.specEntrySource != after.specEntrySource -> "entry_source_changed"
before.specEntry?.label != after.specEntry.label -> "entry_label_changed"
after.explicitSpecEntryCount > 1 || after.nestedSpecEntryCount > 1 -> "entry_ambiguous"
else -> "eligible"
}
if (entryRecoveryReason != "eligible") return null
val anchor = after.specEntry ?: return null
if (action.textAliases?.let { specEntryMatchesAliases(after, anchor, it) } == false) return null
return (after.specEntryClickTarget ?: anchor).takeIf { it.visible && it.enabled }
if (action.textAliases?.let { specEntryMatchesAliases(after, anchor, it) } == false) {
entryRecoveryReason = "rule_alias_mismatch"
return null
}
val target = after.specEntryClickTarget ?: anchor
if (!target.visible || !target.enabled) {
entryRecoveryReason = "entry_unavailable"
return null
}
return target
}
private data class SpecPanelWait(
@@ -582,6 +603,7 @@ class PurchaseRehearsalExecutor(
): PurchaseExecutionOutcome? {
val initial = currentScreen(input)
initial.problem?.let { return failure(it.code, it.message) }
unavailableExactSpec(initial, dimension, target)?.let { return it }
if (rememberExactSelection(initial, dimension, target, selectionProofs)) return null
val lookup = locateExactSpec(input, dimension, target)
@@ -832,6 +854,18 @@ class PurchaseRehearsalExecutor(
private data class SpecLookup(val node: SnapshotNode? = null, val failure: PurchaseExecutionOutcome? = null)
private fun unavailableExactSpec(screen: ParsedPddScreen, dimension: String, target: String): PurchaseExecutionOutcome? {
if (!screen.specPanelOpen) return null
val exact = screen.dimensions.filter { it.key == dimension }.flatMap { it.values }.filter { it.text == target }
return if (exact.size == 1 && !exact.single().available) unavailableSpec(dimension) else null
}
private fun unavailableSpec(dimension: String): PurchaseExecutionOutcome {
val role = if (dimension == "color") "颜色" else "尺码"
panelDiagnostic("specLookup=unavailable;dimension=$dimension;reason=exact_target_unavailable")
return failure(SPEC_SAFE_TARGET_MISSING, "目标${role}已售罄或当前不可选,未创建订单")
}
/**
* Searches only parsed, selectable values in the already-open spec panel.
* A short downward pass first restores the top when a previous action left
@@ -868,7 +902,7 @@ class PurchaseRehearsalExecutor(
}
if (exact.size == 1 && exact.single().available) return Inspection(SpecLookup(node = exact.single().node), "", screen.specPanelContainer)
if (exact.size == 1) {
return Inspection(SpecLookup(failure = failure(SPEC_SAFE_TARGET_MISSING, "精确规格当前不可安全点击")), "", screen.specPanelContainer)
return Inspection(SpecLookup(failure = unavailableSpec(dimension)), "", screen.specPanelContainer)
}
val signature = screen.dimensions.joinToString("|") { item ->
"${item.key}:${item.values.joinToString(",") { value -> "${value.text}:${value.available}" }}"
@@ -933,7 +967,7 @@ class PurchaseRehearsalExecutor(
val lookup = when {
exact.size > 1 -> SpecLookup(failure = failure(SPEC_TARGET_AMBIGUOUS, "精确规格匹配到多个控件"))
exact.size == 1 && exact.single().available -> SpecLookup(node = exact.single().node)
exact.size == 1 -> SpecLookup(failure = failure(SPEC_SAFE_TARGET_MISSING, "精确规格当前不可安全点击"))
exact.size == 1 -> SpecLookup(failure = unavailableSpec(dimension))
else -> null
}
val rows = specValueRows(values)
@@ -141,6 +141,72 @@ class PurchaseLiveAutomationTest {
assertEquals(0, driver.submitClicks)
}
@Test
fun `saved address back into short spec panel restores without aspect ratio restriction`() {
val driver = LiveDriver(savedTransitionWithoutLegacyContext = true, backReturnsToSpecPanel = true,
savedPanelBounds = NodeBounds(0, 400, 1080, 1325))
val automation = PurchaseLiveAutomation(driver, pause = {})
val address = automation.updateShippingAddress("_cg81")
assertEquals("_cg81", automation.finalConfirmation(input().copy(addressSuffix = "_cg81"), address).addressSuffix)
assertEquals(1, driver.backCount)
assertEquals(1, driver.scopedSwipes)
assertEquals(0, driver.submitClicks)
}
@Test
fun `short saved spec panel still stops when unchanged`() {
val driver = LiveDriver(saveReturnsToSpecPanel = true, savedSpecPanelRecoveryStuck = true,
savedPanelBounds = NodeBounds(0, 400, 1080, 1325))
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg81") }
.exceptionOrNull() as PurchaseLiveException
assertEquals("PURCHASE_ADDRESS_SAVE_TIMEOUT", error.code)
assertEquals(0, driver.backCount)
assertTrue(driver.scopedSwipes in 1..5)
assertEquals(0, driver.submitClicks)
}
@Test
fun `missing and duplicate saved panel scroll regions fail distinctly without gestures`() {
for (count in listOf(0, 2)) {
val driver = LiveDriver(saveReturnsToSpecPanel = true, savedPanelScrollCount = count)
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg81") }
.exceptionOrNull() as PurchaseLiveException
assertEquals("PURCHASE_ADDRESS_ENTRY_AMBIGUOUS", error.code)
assertTrue(error.message.orEmpty().contains(if (count == 0) "未找到可用" else "找到多个"))
assertEquals(0, driver.scopedSwipes)
assertEquals(0, driver.submitClicks)
}
}
@Test
fun `saved address back into spec panel restores final evidence with one back`() {
val driver = LiveDriver(savedTransitionWithoutLegacyContext = true, backReturnsToSpecPanel = true)
val automation = PurchaseLiveAutomation(driver, pause = {})
val address = automation.updateShippingAddress("_cg81")
val final = automation.finalConfirmation(input().copy(addressSuffix = "_cg81"), address)
assertEquals("_cg81", final.addressSuffix)
assertEquals(1, driver.backCount)
assertEquals(1, driver.scopedSwipes)
assertEquals(0, driver.submitClicks)
}
@Test
fun `saved address back into stuck spec panel fails without another back or submit`() {
val driver = LiveDriver(savedTransitionWithoutLegacyContext = true, backReturnsToSpecPanel = true,
savedSpecPanelRecoveryStuck = true)
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg81") }
.exceptionOrNull() as PurchaseLiveException
assertEquals("PURCHASE_ADDRESS_SAVE_TIMEOUT", error.code)
assertEquals(1, driver.backCount)
assertTrue(driver.scopedSwipes in 1..5)
assertEquals(0, driver.submitClicks)
}
@Test
fun `saved address returning to spec panel is recovered without pressing back`() {
val driver = LiveDriver(saveReturnsToSpecPanel = true)
@@ -645,6 +711,9 @@ class PurchaseLiveAutomationTest {
private val savedTransitionWithoutLegacyContext: Boolean = false,
private val savedTransitionHidesSuffix: Boolean = false,
private val saveReturnsToSpecPanel: Boolean = false,
private val backReturnsToSpecPanel: Boolean = false,
private val savedPanelBounds: NodeBounds = NodeBounds(0, 400, 1080, 2100),
private val savedPanelScrollCount: Int = 1,
private val savedSpecPanelRecoveryStuck: Boolean = false,
private val duplicatePhoneNodesSameCard: Boolean = false,
private val duplicateSemanticAddressCards: Boolean = false,
@@ -708,7 +777,7 @@ class PurchaseLiveAutomationTest {
))
"post-save-spec" -> snapshot(listOf(
node("root", "", bounds = NodeBounds(0, 0, 1080, 2200)),
node("panel", "", scrollable = true, bounds = NodeBounds(0, 400, 1080, 2100)),
node("panel", "", scrollable = true, bounds = savedPanelBounds),
node("panel-title", "确认款式", bounds = NodeBounds(20, 396, 300, 430)),
node("panel/price", "¥20.00", parentPath = "panel", bounds = NodeBounds(20, 460, 300, 520)),
node("panel/selected", "已选 黑色 XL", parentPath = "panel", bounds = NodeBounds(20, 540, 700, 600)),
@@ -720,7 +789,8 @@ class PurchaseLiveAutomationTest {
node("panel/confirm", "确定", clickable = true, parentPath = "panel", bounds = NodeBounds(20, 1200, 500, 1280)),
node("submit-parent", "", clickable = true, bounds = NodeBounds(20, 1900, 1000, 2100)),
node("submit", "提交订单", parentPath = "submit-parent", bounds = NodeBounds(520, 1940, 980, 2040)),
))
).map { if (it.path == "panel" && savedPanelScrollCount == 0) it.copy(enabled = false) else it } +
if (savedPanelScrollCount > 1) listOf(node("panel/duplicate", "", scrollable = true, bounds = savedPanelBounds, parentPath = "panel")) else emptyList())
"order" -> snapshot(listOf(node("status", "待付款"), node("order", "订单号:PDD-202608210001"), node("time", "下单时间:2026-08-21 10:30:00"), node("pay", "立即支付", clickable = true)))
"order-folded" -> snapshot(listOf(node("status", "待付款"), node("pay", "立即支付", clickable = true)))
"order-folded-payment-activity" -> UiSnapshot(PDD, "com.xunmeng.pinduoduo.app_pay.core.PayActivity", listOf(
@@ -863,6 +933,7 @@ class PurchaseLiveAutomationTest {
page = when (page) {
"chooser" -> "payment"
"payment" -> if (orderEvidenceBelowFold) "order-folded" else "order"
"saved-transition" -> if (backReturnsToSpecPanel) "post-save-spec" else "confirmation"
else -> "confirmation"
}
return true
@@ -930,12 +930,34 @@ class PurchaseRehearsalExecutorTest {
@Test
fun `changed entry identity or unsafe page never receives recovery tap`() {
for (mode in listOf("missing", "duplicate", "activity", "payment")) {
val diagnostics = mutableListOf<String>()
val driver = FakePurchaseDriver(entryActionHasEffect = false, dynamicProduct = true,
afterEntryMutation = mode, specTapResult = FreshActionResult.SUCCESS)
val outcome = PurchaseRehearsalExecutor(driver, { true }, { null }, pause = {})
val outcome = PurchaseRehearsalExecutor(driver, { true }, { null }, pause = {}, panelDiagnostic = diagnostics::add)
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals(mode, "failed", outcome.resultType)
assertEquals(mode, 0, driver.specTapCount)
val expected = mapOf("missing" to "entry_missing", "duplicate" to "entry_ambiguous",
"activity" to "identity_changed", "payment" to "payment_area").getValue(mode)
assertTrue(diagnostics.toString(), diagnostics.any { it.contains("entryRecovery=rejected;reason=$expected;") })
}
}
@Test
fun `unavailable exact color stops without selection click or searching even when selected`() {
for (selected in listOf(null, "黑色")) {
val driver = FakePurchaseDriver(colors = listOf("黑色", "白色"),
unavailableColors = setOf("黑色")).apply { color = selected }
val logs = mutableListOf<String>()
val outcome = PurchaseRehearsalExecutor(driver, { true }, { null }, pause = {}, panelDiagnostic = logs::add)
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("PURCHASE_SPEC_SAFE_TARGET_MISSING", outcome.errorCode)
assertEquals("目标颜色已售罄或当前不可选,未创建订单", outcome.message)
assertFalse(driver.clicked.contains("黑色"))
assertEquals(0, driver.specTapCount)
assertEquals(0, driver.swipeCount)
assertTrue(logs.any { it == "specLookup=unavailable;dimension=color;reason=exact_target_unavailable" })
assertFalse(logs.any { it.contains("黑色") })
}
}
@@ -1385,6 +1407,7 @@ class PurchaseRehearsalExecutorTest {
private val pullDownSucceeds: Boolean = true,
private val loseEvidenceAfterPull: Boolean = false,
private val unavailableSizes: Set<String> = emptySet(),
private val unavailableColors: Set<String> = emptySet(),
allSpecsUnavailable: Boolean = false,
private val nonScrollablePanel: Boolean = false,
private val confirmationPanelKind: String? = null,
@@ -1561,7 +1584,7 @@ class PurchaseRehearsalExecutorTest {
540,
clickable = true,
selected = selectedColor == value,
enabled = !allSpecsUnavailable,
enabled = !allSpecsUnavailable && value !in unavailableColors,
parentPath = colorParent,
)
}
+9 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Business-Rules-and-Glossary
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Business-Rules-and-Glossary.-
wiki_revision: eda7ac819c2147201d749cbef9400bd916822b87
synchronized_at: 2026-09-08T10:09:39Z
wiki_revision: fdcce403423aa799d1517ac75fb38da636ef5217
synchronized_at: 2026-09-09T01:22:58Z
<!-- gitea-wiki-mirror:end -->
# 业务规则与术语
@@ -494,3 +494,10 @@ Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态
实现 93aab6a,Android 0.9.62/versionCode 75;已构建并授权覆盖安装,真实采购效果待验收。仅在颜色维度完整精确目标已选中、但存在非目标已选候选时,识别同一卡片重复表达:卡片必须是滚动容器直接子级的可点击非滚动 android.view.ViewGroup,节点父链明确、可见启用且边界包含;其他冲突节点仅可为该卡片自身或其中的 ImageView。另一文字选项、另一张卡片、多个精确目标、目标未选中、父链/边界不明均不适用例外。额外核对内存源节点以拒绝被解析器按同名去重隐藏的跨卡片目标;不保存原始树。
不删除规格数字、不模糊匹配、不改变通用解析器、尺码确认、摘要兜底、入口/滚动/地址/价格/创建订单。没有非目标选中冲突时继续原判断;仅把同卡片容器/图片的重复选中表达从冲突中排除,不新增点击。
## 精确规格不可用与入口恢复诊断(#245)
实现 ca815c3,Android 0.9.63/versionCode 76;已通过单元测试并构建,未安装、未进行真实采购验收。入口恢复沿用 #243 条件,拒绝时通过 entryRecovery=rejected;reason=<固定原因码> 区分页面身份、商品上下文、面板/支付区域、数量/摘要、标题、入口来源/文案、歧义、规则别名及可用性;不放宽条件,不新增点击。
在规格面板识别出唯一完整精确候选且 available=false 时,立即返回既有 PURCHASE_SPEC_SAFE_TARGET_MISSING,提示目标颜色/尺码已售罄或当前不可选,未创建订单;已选中目标同样不能绕过不可用检查。只记录 dimension 和 exact_target_unavailable 固定结构证据,不记录规格原文或原始树。原有纵向与横向定位在此条件下本就停止,不能将本改动描述为修复“已识别售罄仍继续搜索”。未识别精确候选时继续既有有界搜索,不推断售罄,不替换规格;解析器、匹配规则、滚动预算、地址与下单流程不变。