Compare commits

...
15 changed files with 561 additions and 40 deletions
@@ -167,6 +167,8 @@ data class ParsedPddScreen(
val quantityInputCount: Int = 0,
/** #331: address entry, payment entry and quantity input share one bounded panel container. */
val requiredEvidenceSameContainer: Boolean = false,
/** Only populated by the final-confirmation opt-in; existing selectedSummary behavior is unchanged. */
val finalSelectionSummaries: List<String> = emptyList(),
) {
/** The three #331 required spec-panel items are all visible. */
val hasRequiredPanelEvidence: Boolean get() = hasAddressEntry && hasPaymentEntry && hasQuantityInput
@@ -265,7 +267,7 @@ object PddScreenParser {
/** Whole-sheet containers are clickable too; a real entry row stays well below this share of the screen height. */
private const val ENTRY_ROW_MAX_HEIGHT_PERCENT = 30
fun parse(snapshot: UiSnapshot, config: PddCollectorConfig, goodsId: String, evidence: PageEvidence?, purchaseContext: PurchasePanelContext? = null): ParsedPddScreen {
fun parse(snapshot: UiSnapshot, config: PddCollectorConfig, goodsId: String, evidence: PageEvidence?, purchaseContext: PurchasePanelContext? = null, forFinalConfirmation: Boolean = false): ParsedPddScreen {
val visibleNodes = snapshot.nodes.filter { it.visible }
val visible = visibleNodes.mapNotNull { node ->
val descendants = descendants(node, visibleNodes)
@@ -422,9 +424,12 @@ object PddScreenParser {
)
}
.toList()
.groupBy(VisibleSpecValue::rawText)
.map { (_, sameLabel) ->
sameLabel.minBy { option -> safeOptionRank(option.node, visibleNodes) }
.let { physicalOptions ->
// Keep optionBlock's physical parent/child aggregation in both modes.
// Only final confirmation must detect separate same-label option blocks.
if (forFinalConfirmation) physicalOptions else physicalOptions
.groupBy(VisibleSpecValue::rawText)
.map { (_, sameLabel) -> sameLabel.minBy { option -> safeOptionRank(option.node, visibleNodes) } }
}
if (values.isNotEmpty()) add(VisibleDimension(dimensionKey, heading.label, values))
}
@@ -471,6 +476,7 @@ object PddScreenParser {
else -> SpecPanelType.UNKNOWN
}
val panelOpen = specPanelType != SpecPanelType.UNKNOWN
var unprefixedSummaryCandidates = emptyList<String>()
val unprefixedSummary = if (structuredSelectionPanel) {
val quantity = quantityInputs.single()
val knownColors = (dimensions.filter { it.key == "color" }.flatMap { it.values }.map { it.text } +
@@ -486,6 +492,7 @@ object PddScreenParser {
knownColors.any { SpecValueNormalizer.summaryHasExactToken(node.label, it) }
}.map { it.label }.distinct()
if (candidates.isNotEmpty()) {
unprefixedSummaryCandidates = candidates
summary = candidates.singleOrNull()
break
}
@@ -574,6 +581,12 @@ object PddScreenParser {
hasQuantityAdjustControls = hasQuantityAdjustControls,
quantityInputCount = quantityInputs.size,
requiredEvidenceSameContainer = requiredEvidenceSameContainer,
finalSelectionSummaries = if (forFinalConfirmation) {
// Use own node labels, not labels propagated into clickable ancestors.
(visibleNodes.map(SnapshotNode::label).filter { label ->
textAliases.selection.selectedPrefixes.any { label.startsWith(it) }
} + unprefixedSummaryCandidates).distinct()
} else emptyList(),
explicitSpecEntryCount = explicitSpecEntries.size,
nestedSpecEntryCount = nestedSpecEntries.size,
bottomPurchaseEntryCount = bottomSpecEntries.size,
@@ -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,21 +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)
val selected = listOf(input.mappedColor, input.mappedSize).filter(String::isNotBlank)
if (selected.any { value -> screen.selectedSummary?.contains(value) != true && snapshot.nodes.none { it.visible && it.label.contains(value) } }) {
fail("PURCHASE_SPEC_NOT_MATCHED", "创建订单前规格复核失败")
}
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() {
@@ -340,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", "创建订单点击结果不明确,请人工检查")
@@ -882,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? {
@@ -951,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
}
}
}
@@ -13,6 +13,20 @@ object SpecValueNormalizer {
fun normalizeSize(value: String): String = normalizeTrailingPrice(value)
/** Final purchase confirmation only: display equivalents, never semantic/content removal. */
fun normalizeFinalConfirmation(value: String): String = buildString {
value.forEach { ch ->
append(when (ch) {
'【', '〔', '[' -> '['
'】', '〕', ']' -> ']'
'(' -> '('
')' -> ')'
in 'A'..'Z', in 'a'..'z', in '0'..'9' -> (ch.code - 0xFEE0).toChar()
else -> if (ch.isWhitespace()) ' ' else ch
})
}
}.replace(Regex(" +"), " ").trim()
/** The same terminal-price grammar as normalization; never reads numbers inside a spec. */
internal fun trailingPriceAmount(value: String): java.math.BigDecimal? =
trailingPrice.find(value)?.value?.replace(currency, "")?.trim()?.toBigDecimalOrNull()
@@ -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)
},
@@ -0,0 +1,185 @@
package cn.ilapage.goauto.agent
import cn.ilapage.goauto.agent.automation.*
import org.junit.Assert.*
import org.junit.Test
import org.json.JSONObject
/** #376 replaces #374's final value-proof contract. All fixtures are synthetic. */
class PurchaseFinalSpecConfirmationTest {
@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 `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 `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() = 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 backPurchase(): Boolean = error("unexpected back")
}
val live = PurchaseLiveAutomation(driver, pause = {})
for (prompt in listOf("请选择颜色", "请 选择:颜色", "請選擇: 顏色")) {
assertTrue(live.selectionPromptPresent(sheet(prompt)))
}
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 }))
}
@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)
fail("expected $expectedCode")
} catch (failure: PurchaseLiveException) {
assertEquals(expectedCode, failure.code)
if (expectedCode == "PURCHASE_SPEC_NOT_MATCHED") {
assertEquals("finalSpec;stage=selection_prompt", diagnostics.last())
assertTrue(failure.message.orEmpty().contains(diagnostics.last()))
assertFalse(failure.message.orEmpty().contains(ADDRESS))
}
}
}
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
@@ -8,6 +8,15 @@ import org.junit.Assert.assertTrue
import org.junit.Test
class SpecValueNormalizerTest {
@Test
fun `final confirmation maps only display brackets alphanumerics and whitespace`() {
assertEquals("[白][黑][灰](薄款) RW12 XL", SpecValueNormalizer.normalizeFinalConfirmation(" 【白】〔黑〕[灰](薄款) RW12\t\nXL "))
assertEquals("白色,加绒:XL ¥19.60 ① 2件", SpecValueNormalizer.normalizeFinalConfirmation("白色,加绒:XL ¥19.60 ① 2件"))
assertEquals("xl", SpecValueNormalizer.normalizeFinalConfirmation("xl"))
assertEquals("【白色】 XL", SpecValueNormalizer.normalizeColor("【白色】 XL"))
assertEquals("【白色】 XL", SpecValueNormalizer.normalizeSize("【白色】 XL"))
}
@Test
fun `only trailing price is removed`() {
assertEquals("紫色", SpecValueNormalizer.normalizeColor("紫色 ¥15.78"))
@@ -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
+1 -1
View File
@@ -3,7 +3,7 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Architecture-and-Code-Map
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Architecture-and-Code-Map.-
wiki_revision: 76854d900b2372ddedd46a2b82b5677d8bf5cb31
synchronized_at: 2026-10-09T06:46:07Z
synchronized_at: 2026-10-09T07:45:10Z
<!-- gitea-wiki-mirror:end -->
<!-- gitea-wiki-mirror:start -->
+34 -3
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: 6c1a317ef67a0b015bb16a615b8c490b384188a0
synchronized_at: 2026-10-09T07:21:39Z
wiki_revision: f8bb75d1fc0b5e8822186c740ac2034fbd562057
synchronized_at: 2026-10-09T09:25:31Z
<!-- gitea-wiki-mirror:end -->
<!-- gitea-wiki-mirror:start -->
@@ -891,7 +891,7 @@ Android 0.9.64 / versionCode 77,源码 `6550b9f`(分支实现,尚未安装
## 采购页面一次性交接与导航稳定等待(#373 v4)
实现绑定分支 `fix/373-navigation-diagnostics` 提交 `dbf9a5f`,调试版本 `0.9.69-373-fix1`;2026-10-09 尚未合并main、发布或安装该修复版。此节是该提交行为,不代表线上旧版本已生效;替代此前仅凭商品结构复用页面的方式。
实现绑定分支 `fix/373-navigation-diagnostics` 提交 `dbf9a5f`;2026-10-09 与 #374 依次合并并推送 main,集成提交 `555a954605a83c12457ed1ada86eb732fe3bb023`。集成APK仍标识 `0.9.69-373-fix1 / code82`,须以哈希区分;未安装或发布。此节不代表线上旧版本已生效;替代此前仅凭商品结构复用页面的方式。
- Agent在每次采购执行开始时先取出清空服务进程内的交接凭证。凭证只能来自同任务spec_probe正常完成、结果安全落盘、首次提交返回pending且非replayed,并且本地markUploaded成功之后;失败/重传不生成或延长。缺省没有凭证,不因当前有商品结构就复用。
- 凭证绑定taskId、goodsId、源探测attemptId、本地执行代次、无障碍实例、前台变更序号和探测完成时的单调时钟。仅紧接的同任务同商品purchase可消费一次;源/目标attempt本来不同。有效年龄为0至不足30000ms;慢AI或网络等待超过上限只导致重开链接。30秒是基于15秒心跳的保守复用政策,不是实际AI耗时保证。
@@ -902,3 +902,34 @@ Android 0.9.64 / versionCode 77,源码 `6550b9f`(分支实现,尚未安装
- 导航总预算为单调时钟30秒、最多300轮、轮间100ms;耗时系统读取可能超过预算,但返回后检查时限,不再继续点击。浏览器入口重试从点击调用返回后至少间隔1秒;原生点击前在新鲜节点重新校验浏览器与剩余预算。超时返回PDD_DETAIL_ENTRY_FAILED,不继续规格、地址或创建订单步骤。
- 用户明确拒绝新增分享/复制链接操作,并接受本版仅解决跨任务无条件复用与导航抢跑。**稳定商品页不是goodsId身份核验;PDD忽略深链而稳定停在旧页、或无可观察变化的同包人工换页,仍可能无法识别。** 不得将任务携带goodsId当作页面回读,不能用本版宣称绝对排除错误商品。
- 不改服务端/API线协议/数据库、采集器、规格选择、地址与下单逻辑、重购批次发现/停止逻辑;不涉及任何付款动作。仅增加Android对既有结果响应status/replayed的本地解析(replayed=false在服务端可省略)。普通流程不新增原始树、截图、分享URL或页面文本落盘。
## 下单前最终规格复核(#374 v3)
> 以下为 #374 版本行为;包含 #376 的分支构建已按本文后续“采购改地址前复核与提交提示检查”章节替代最终复核策略,尚未合并或发布。不把新规则归于未更新的旧 APK。
实现绑定 `6f1af70d0d2a4c70c31ba42e05c4321f08c6a78e`(原独立工单分支基于 `e26743c`);2026-10-09 已与 #373 集成并推送 main `555a954605a83c12457ed1ada86eb732fe3bb023`。集成版本同时包含导航等待与最终规格复核,未安装或发布,真机采购未验收。
- 仅替换 PurchaseLiveAutomation.finalConfirmation 的最终规格检查,不修改之前的规格选择/verifySummary、地址、单价、数量、提交目标与不可逆边界。没有 Server/Web、API 或数据库变化。
- 最终专用规范化只统一【】〔〕[]、()、全角字母数字与空白;压缩连续空白,不折叠大小写、不改同义词、不用通用 NFKC、不删除括号内容。选项使用解析器既有的规格 text(沿用 UI 尾价分离),目标和摘要不新增尾价或数量删除。
- 复用既有数量输入有界祖先的完整面板形态,但保留真实祖先关系与边界双重归属;滚动列表不等于整个弹层,摘要可位于列表上方/兄弟区域。范围内重新确认面板结构,不将全屏/root 当面板,不用仅坐标重叠的面板外文字。完整范围不可靠时禁用摘要,只使用可独立确认的有界规格滚动区域内选项。
- 最终确认专用解析参数默认 false,普通采集及既有调用不变。仅该路径保留同名独立物理选项;沿用原 optionBlock selected/checked 聚合,并合并同值、同矩形、真实祖先链的同一选项,不吞并同名兄弟候选。同一维度多组统一检查。
- 只检查下发非空维度,先拒绝同维度明确选中非目标、目标不可用、不同实际候选归一到同一目标或已知候选组合歧义;随后当前面板内规范化完全相等且 selected/checked 的目标可证明该维度。没有选中证明但也没有冲突时才用可信摘要,即使其他未选中候选仍可见也允许摘要补证,不使用历史 attempt 证明。
- 摘要仅剥离开头已识别的已选前缀和紧随的一个可选冒号,保留正文标点与内容。无前缀摘要仅沿用已有 quantity/header 结构识别且须属于完整面板。当前双维证据只覆盖“颜色 空格 尺码”,逗号分隔、反序、数量后缀或未知格式不猜测支持;仅一个维度时整段须等于该值,两维均空不作为证明。
- 多个不一致的可信摘要、已知组合歧义均拒绝。单个不匹配的摘要不能补证缺失维度;若所有必需维度已有当前精确选中证明且无上述冲突,则按选中证明优先通过。不声称候选缺失时能证明全部潜在 SKU 唯一性。
- 拒绝仍为 PURCHASE_SPEC_NOT_MATCHED,错误信息与诊断只附固定 dimension/stage/summaryPresent/selectedFound/conflict,不附页面文字、规格原文或地址。原本靠任意可见控件含有规格子串的通过路径已移除。
- 自动化证据:8 个相关套件共366项通过,0失败/错误/跳过;debug构建、release单测源码编译通过。完整单测因未修改的图搜返回测试忙循环而中止,不能宣称全量通过。结构夹具依据为 SpecPanelFixtures.sheet/taskOptionDedupSheet 及 PddProductDetailCollectorTest.prefixlessPanel;未执行真实采购,不以合成测试代替真机效果。
## 采购改地址前复核与提交提示检查(#376 v3)
实现绑定工单分支 `fix/376-pre-address-spec-verification` 提交 `9c7357609cee7c73b57edd030136ed12ef9485b6`(2026-10-09),尚未合并 main 或发布;此规则仅适用于包含 #376 的 Android 构建,旧版 #374 行为不因此改变。没有 Server/Web、业务库迁移或 API 边界协议变化。
- 改地址前,verifySummary 在每个目标维度和既有重定位滑动后的当前面板中检查未选提示。面板摘要的“请选择/請選擇”或唯一提交按钮的选择提示导致 PURCHASE_SPEC_SELECTION_UNCONFIRMED、reason=selection_prompt,停止后续地址动作;无唯一提交按钮时仍可检查摘要,不增加改地址前缺少按钮的门禁。
- 本次选择历史证明仅用于既有可见性缺口;目标当前可见、可选却未选中,同维无其他选中项且没有已选摘要时,不再由历史证明路径放行,reason=target_visible_unselected。目标不可见时保留原历史证明行为;不暴露选中属性且无摘要的布局可能提前失败。此前规格选择点击、数量设置、改地址前价格/数量复核不变。
- 修改地址后的 finalConfirmation 不再比较颜色/尺码值,不要求选中证据,也不核对当前数量;保留地址、提交目标唯一性和单价区间检查。摘要未选提示在此只记录,不拦截。唯一提交按钮的完整文字去空白后匹配 `^(选择|選擇).+(后|後)[,,](提交订单|提交訂單)$` 才新增拒绝 PURCHASE_SPEC_NOT_MATCHED、finalSpec;stage=selection_prompt。
- 按钮来源沿用原面板底部行 row 和点击文字节点 chosen;提示只组合该行内可见、自身带文字且没有带文字子孙、最近可点击祖先恰为 row 的节点,按 top/left 排序,不重复拼父子汇总、不跨独立按钮/摘要/规格列表或不可见节点。实际点击目标、行/叶并列选择和支付排除保持不变;enabled=true 不是已选规格的证明。
- finalConfirmation 位于 beforeOrderSubmit 之前,拒绝时不写提交边界、不点击,普通失败可让重购继续。submitOrderOnce 重新取屏后的同类提示只作兜底,零点击但边界已写入,仍为 order_result_unknown,重购停止等待人工核对;不移动持久化边界、不增加重试或付款。
- 本地 final_confirmation_json 新记录以 targetQuantity 表示下发数量,追加 observedQuantity(唯一可见可用数字数量输入,否则 null)、selectedSummaryPresent 与 summaryPromptPresent。仅记录数字/布尔观察值,不新增页面文字或个人信息;不用于拦截、不上传,旧记录不改写。
- 用户明确接受剩余风险:修改地址后规格被换成其他值,或数量重置,但按钮仍正常时会放行;价格与地址检查不能替代规格/数量一致性。观察值仅供事后核对,不证明采购结果正确。缺少提示也不构成 PDD 已选正确的正向证明。
- 不新增分享、OCR/VLM、滑动取证、自动重新选规格或付款;合成测试与 APK 构建不代表真实订单验证。安装、真实采购、合并和发布须按相应授权执行。
验证:19 个相关套件共418项通过,0失败/错误/跳过;Debug APK与release单测源码编译通过。执行器测试模拟提交边界回调,并验证本地JSON序列化;未执行真实SQLite/服务端边界端到端或真实采购验证。不宣称全量Android测试通过(既有图搜返回忙循环用例不在本次回归范围)。
+9 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Deployment-and-Operations
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Deployment-and-Operations.-
wiki_revision: 01a3a9cd968510c9435ec1fb2be0040fe17d0282
synchronized_at: 2026-10-09T07:21:53Z
wiki_revision: 3f69f2e39675752dfd43dc38de41cb7defb74a42
synchronized_at: 2026-10-09T07:58:00Z
<!-- gitea-wiki-mirror:end -->
<!-- gitea-wiki-mirror:start -->
@@ -365,3 +365,10 @@ Provider 故障日志只允许记录调用关联 ID、操作类型、耗时、
- 主Agent独立执行相关测试451项(23套件)零失败/错误,compileReleaseUnitTestKotlin与assembleDebug通过;规格及代码质量两轮独立只读审查通过。覆盖一次性交接/首传回放/上传标记失败/慢读取过期、浏览器出现前旧帧、diag3时序、3秒缓冲/1秒稳定、慢Binder点击冷却与超时零后续动作,以及采购、采集、原诊断相关回归。不是全量测试或真机采购验收。
- 修复版的真实采购/重购未执行,页面身份识别限制见业务规则。原debug导航探针依然用于旧判据时序取证,不把其legacy_would_accept日志冒充新版执行器通过证据;新版导航就绪由实际采购执行器及其纯策略判断。
- 诊断仍沿用已有本地记录;无需新schema、服务端权限或接口。运维排查先分清实际DIRECT/BROWSER路径、是否发生浏览器前旧页抢跑、是否缓冲/稳定超时,以及复用凭证是否有效;不能仅因startActivity接受就认定商品已加载。
## #373 + #374 合并 main 与集成构建(2026-10-09)
- 按用户授权的顺序先合并 #373(`1e924fa41195281a3223003a69c8c16ae37f8aa5`),再合并 #374(`555a954605a83c12457ed1ada86eb732fe3bb023`),保留两单提交历史;main已推送。源码无冲突,三份Wiki镜像选择包含两单内容的较新已导出版本,未手改规则正文解决冲突。
- 集成验证25套件478项通过,0失败/错误/跳过;compileReleaseUnitTestKotlin与assembleDebug通过。不是全量单测结论;#374记录的未修改图搜返回测试忙循环未在本次重跑。未进行真机采购。
- 集成APK位于本机 `D:/OPC/goauto-worktrees/release-373-374/android/app/build/outputs/apk/debug/app-debug.apk`,版本仍为 `0.9.69-373-fix1 / code82`,**已同时包含#374,不能只靠版本文字区分前一#373单独包**。SHA256 `6e2d0e0ed1eb943b3f168bb16d628d2c2bb4ce3b7c7c8b9e567dcdf8259150f8`;apksigner校验通过,沿用原签名证书 `bf86d7465c6092be74ce9c4187eb30c9a7d045c3b91891a547e2598429766dec`。
- 本次仅合并、验证和推送,未覆盖安装、未执行真实采购/付款、未发布或重启服务;不需要业务库迁移。手机仍使用此前安装版本。后续装机须另行授权,保留原签名及用户数据。