Compare commits

..
4 changed files with 84 additions and 20 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 = 76
versionName = "0.9.63"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -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)
@@ -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 固定结构证据,不记录规格原文或原始树。原有纵向与横向定位在此条件下本就停止,不能将本改动描述为修复“已识别售罄仍继续搜索”。未识别精确候选时继续既有有界搜索,不推断售罄,不替换规格;解析器、匹配规则、滚动预算、地址与下单流程不变。