Compare commits

...
9 changed files with 435 additions and 14 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,
@@ -0,0 +1,126 @@
package cn.ilapage.goauto.agent.automation
/** Current-capture evidence only. No clicks, historical selection proof, or whole-screen text fallback. */
internal object PurchaseFinalSpecVerifier {
private fun normalize(value: String) = SpecValueNormalizer.normalizeFinalConfirmation(value)
fun verify(snapshot: UiSnapshot, input: PurchaseExecutionInput, diagnostic: (String) -> Unit) {
val config = PurchaseRehearsalExecutor.DEFAULT_COLLECTOR
val byPath = snapshot.nodes.associateBy(SnapshotNode::path)
fun within(node: SnapshotNode, container: SnapshotNode): Boolean {
val visited = mutableSetOf<String>()
var current: SnapshotNode? = node
while (current != null && visited.add(current.path)) {
if (current.path == container.path) return true
current = current.parentPath?.let(byPath::get)
}
return false
}
fun contained(node: SnapshotNode, container: SnapshotNode) =
node.bounds.width > 0 && node.bounds.height > 0 &&
node.bounds.left >= container.bounds.left && node.bounds.right <= container.bounds.right &&
node.bounds.top >= container.bounds.top && node.bounds.bottom <= container.bounds.bottom
val original = PddScreenParser.parse(snapshot, config, input.goodsId, null, forFinalConfirmation = true)
val fullPanel = boundedQuantityPanel(snapshot).takeIf { original.specPanelOpen }
// #331/#335 sheet ancestry, as represented by SpecPanelFixtures.sheet and taskOptionDedupSheet.
// The inner scroll list is not the full sheet: summary and quantity may be its siblings.
val scoped = fullPanel?.let { panel ->
val nodes = snapshot.nodes.filter { node ->
(within(node, panel) && contained(node, panel)) ||
(node.parentPath == null && node.label.isBlank() && !node.clickable)
}
PddScreenParser.parse(snapshot.copy(nodes = nodes), config, input.goodsId, null, forFinalConfirmation = true)
.takeIf { it.specPanelOpen }
}
val optionScope = if (scoped != null) fullPanel else original.specPanelContainer
val dimensions = (scoped ?: original).dimensions.groupBy(VisibleDimension::key).mapValues { (_, groups) ->
val options = groups.flatMap(VisibleDimension::values).filter { option ->
optionScope != null && within(option.node, optionScope) && contained(option.node, optionScope)
}
// A blank clickable wrapper and its single text child may survive optionBlock separately.
// The #331 size fixture exposes exactly the same rectangle for both. Aggregate only a
// same-value ancestor chain with that physical rectangle, never same-label siblings.
options.groupBy { option ->
options.filter { other -> other.node.bounds == option.node.bounds &&
normalize(other.text) == normalize(option.text) && within(option.node, other.node)
}.minBy { it.node.path.length }.node.path
}.values.map { members ->
members.first().let { first -> first.copy(
available = members.all(VisibleSpecValue::available),
node = first.node.copy(
selected = members.any { it.node.selected },
checked = members.any { it.node.checked },
),
) }
}
}
val summaries = scoped?.finalSelectionSummaries.orEmpty().map { raw ->
// Only the anchored recognized prefix and its optional colon are removed.
val trimmed = raw.trim()
val prefix = config.textAliases.selection.selectedPrefixes.firstOrNull(trimmed::startsWith)
val value = if (prefix == null) trimmed else trimmed.removePrefix(prefix).trimStart().let {
if (it.startsWith(':') || it.startsWith(':')) it.drop(1) else it
}
normalize(value)
}.distinct()
val targets = linkedMapOf("color" to normalize(input.mappedColor), "size" to normalize(input.mappedSize))
.filterValues(String::isNotBlank)
fun reject(dimension: String, stage: String, selected: Boolean = false, conflict: Boolean = false): Nothing {
val detail = "finalSpec;dimension=$dimension;stage=$stage;summaryPresent=${if (summaries.isNotEmpty()) 1 else 0};" +
"selectedFound=${if (selected) 1 else 0};conflict=${if (conflict) 1 else 0}"
diagnostic(detail)
throw PurchaseLiveException("PURCHASE_SPEC_NOT_MATCHED", "创建订单前规格复核失败;$detail")
}
if (targets.isEmpty()) reject("none", "missing_target")
val selectedProof = mutableSetOf<String>()
targets.forEach { (dimension, target) ->
val options = dimensions[dimension].orEmpty()
val selected = options.filter { it.node.selected || it.node.checked }
// Parsed text already follows the established UI trailing-price separation contract.
// The final-only normalizer removes no additional content from values, input or summary.
val exact = options.filter { normalize(it.text) == target }
if (selected.any { normalize(it.text) != target }) reject(dimension, "selected_conflict", selected = true, conflict = true)
if (exact.size > 1) reject(dimension, "candidate_collision", selected.isNotEmpty(), conflict = true)
if (exact.any { !it.available }) reject(dimension, "unavailable", selected.isNotEmpty(), conflict = true)
if (exact.any { it.node.selected || it.node.checked }) selectedProof += dimension
}
if (summaries.size > 1) reject(targets.keys.first(), "summary_conflict", selectedProof.isNotEmpty(), conflict = true)
val summary = summaries.singleOrNull()
// Evidence only supports color then size with a space. Do not guess reverse order or comma separators.
val expected = targets.values.joinToString(" ")
if (summary != null && targets.size == 2) {
val colors = (dimensions["color"].orEmpty().map { normalize(it.text) } + targets.getValue("color")).distinct()
val sizes = (dimensions["size"].orEmpty().map { normalize(it.text) } + targets.getValue("size")).distinct()
if (colors.any { color -> sizes.any { size ->
"$color $size" == summary && (color != targets["color"] || size != targets["size"])
} }) reject("color", "composition_ambiguity", selectedProof.isNotEmpty(), conflict = true)
}
targets.keys.forEach { dimension ->
if (dimension !in selectedProof && summary != expected) reject(dimension, "missing_proof")
}
}
/** Existing #335 quantity-ancestor form, retaining identity as well as bounds for summary ownership. */
private fun boundedQuantityPanel(snapshot: UiSnapshot): SnapshotNode? {
val visible = snapshot.nodes.filter(SnapshotNode::visible)
val screenWidth = visible.maxOfOrNull { it.bounds.right } ?: return null
val screenHeight = visible.maxOfOrNull { it.bounds.bottom } ?: return null
val screenArea = screenWidth.toLong() * screenHeight
val byPath = visible.associateBy(SnapshotNode::path)
var current = visible.singleOrNull {
it.enabled && it.className == "android.widget.EditText" && it.label.toIntOrNull()?.let { value -> value > 0 } == true
} ?: return null
var panel: SnapshotNode? = null
val visited = mutableSetOf(current.path)
while (true) {
val parent = current.parentPath?.let(byPath::get) ?: break
if (!visited.add(parent.path)) return null
val area = parent.bounds.width.toLong() * parent.bounds.height
if (area <= 0 || area >= screenArea || parent.parentPath == null) break
panel = parent
current = parent
}
return panel
}
}
@@ -321,10 +321,7 @@ class PurchaseLiveAutomation(
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", "创建订单前规格复核失败")
}
PurchaseFinalSpecVerifier.verify(snapshot, input, panelDiagnostic)
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() }
@@ -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()
@@ -0,0 +1,241 @@
package cn.ilapage.goauto.agent
import cn.ilapage.goauto.agent.automation.*
import org.junit.Assert.*
import org.junit.Test
/** Final capture only; derived from the existing #331/#332 bounded purchase-sheet fixtures. */
class PurchaseFinalSpecConfirmationTest {
@Test fun `bracket display variants and offscreen color are proven by full panel summary`() {
val snapshot = sheet("已选 [香槟]RW圆领 L码[105-115斤]", colors = listOf("其他颜色"), sizes = listOf("L码[105-115斤]"))
.selected("size/o0/t")
accepts(snapshot, "【香槟】RW圆领", "L码【105-115斤】")
}
@Test fun `summary above scroll area is accepted for scrollable and non scrollable sheets`() {
for (scrollable in listOf(true, false)) {
accepts(sheet("已选 黑色 XL", scrollable = scrollable))
}
}
@Test fun `checked only exact option and physical parent child duplicates provide selection proof`() {
accepts(sheet(null).selected("color/o0/img", checked = true).selected("size/o0/t", checked = true))
}
@Test fun `selected other color or size overrides matching summary and positive target`() {
rejects(sheet("已选 黑色 XL", colors = listOf("黑色", "白色")).selected("color/o0").selected("color/o1"))
rejects(sheet("已选 黑色 XL", sizes = listOf("XL", "L")).selected("size/o1/t"))
}
@Test fun `selected bracket variants are not a conflict`() {
accepts(sheet("已选 [白色] XL", colors = listOf("[白色]")).selected("color/o0").selected("size/o0/t"), "【白色】")
}
@Test fun `distinct physical identical or normalized target options are collisions`() {
for (colors in listOf(listOf("黑色", "黑色"), listOf("【白色】", "[白色]"))) {
rejects(sheet("已选 ${colors[0]} XL", colors = colors).selected("color/o0"), colors[0])
}
}
@Test fun `explicit unavailable target cannot be rescued by summary`() {
rejects(sheet("已选 黑色 XL").mapNodes { if (it.path.contains("color/o0")) it.copy(enabled = false) else it })
}
@Test fun `all required selected proofs take priority over a single non proving summary`() {
accepts(sheet("已选 其他颜色 其他尺码").selected("color/o0/img").selected("size/o0/t"))
rejects(sheet("已选 白色 L", colors = listOf("黑色", "白色"), sizes = listOf("XL", "L"))
.selected("color/o0/img").selected("size/o0/t")) // Known alternative composition is a conflict.
}
@Test fun `selected options retain established parser separation of trailing display price`() {
accepts(sheet("已选 黑色 XL", colors = listOf("黑色 ¥19.60"), sizes = listOf("XL ¥19.60"))
.selected("color/o0/img").selected("size/o0/t"))
rejects(sheet("已选 黑色 XL", colors = listOf("黑色加绒 ¥19.60")).selected("color/o0/img"))
rejects(sheet("已选 黑色 XL", colors = listOf("黑色 ¥19.60", "黑色 ¥20.00")).selected("color/o0/img"))
}
@Test fun `unselected visible options alone and unrelated title cannot prove selection`() {
rejects(sheet(null).addText("r/title", "示例黑色 XL 商品"))
}
@Test fun `panel external prefixed summary is ignored even at same coordinates`() {
rejects(sheet(null).addText("r/fake", "已选 黑色 XL"))
accepts(sheet("已选 黑色 XL").addText("r/fake", "已选 白色 L"))
}
@Test fun `structural ancestry overrides textual path prefix`() {
rejects(sheet(null).addText("r/sheet/fake", "已选 黑色 XL", parent = "r"))
}
@Test fun `whole summary rejects substring sizes colors extra dimensions quantity and unsupported separators`() {
for (summary in listOf("已选 黑色 XL", "已选 黑色 加绒 L", "已选 黑色 L 2件", "已选 黑色,L", "已选 L 黑色")) {
rejects(sheet(summary, sizes = listOf("L")), size = "L")
}
rejects(sheet("已选 [白色+黑色]超值两件装 XL", colors = emptyList()), "【白色】")
}
@Test fun `known candidates making another full composition reject ambiguous summary`() {
rejects(sheet("已选 黑色 加绒 XL", colors = listOf("黑色", "黑色 加绒"), sizes = listOf("加绒 XL", "XL")), size = "加绒 XL")
rejects(sheet("已选 黑色 加绒 XL", colors = listOf("黑色", "黑色 加绒"), sizes = listOf("加绒 XL", "XL")), color = "黑色 加绒")
}
@Test fun `summary permits invisible color despite another unselected visible color`() {
accepts(sheet("已选 黑色 XL", colors = listOf("白色")))
}
@Test fun `required single dimension must equal whole summary and empty pair is never proof`() {
accepts(sheet("已选 黑色"), size = "")
accepts(sheet("已选 XL"), color = "")
rejects(sheet("已选 黑色 XL"), size = "")
rejects(sheet("已选 黑色 XL"), color = "")
rejects(sheet("已选 黑色 XL"), color = " ", size = "")
}
@Test fun `anchored selected prefix variants preserve internal colons commas and whitespace`() {
for (prefix in listOf("已选", "已选 ", "已选:", "已选: ", " 已选: ")) {
accepts(sheet("$prefix 黑色:加绒,款 XL", colors = emptyList()), color = "黑色:加绒,款")
}
accepts(sheet("已选 黑色 加绒 XL", colors = emptyList()), color = "黑色 加绒")
rejects(sheet("已选 黑色 加绒 XL", colors = emptyList()), color = "黑色加绒")
rejects(sheet("已选 黑色:加绒 XL", colors = emptyList()), color = "黑色加绒")
rejects(sheet("已选::黑色 XL"))
accepts(sheet("已选::黑色 XL", colors = emptyList()), color = ":黑色")
}
@Test fun `conflicting scoped summaries reject even both exact options selected`() {
rejects(sheet("已选 黑色 XL").selected("color/o0").selected("size/o0/t").addText("r/sheet/second", "已选 白色 L"))
accepts(sheet("已选 黑色 XL").addText("r/sheet/second", "已选:黑色 XL"))
}
@Test fun `display normalization is narrow and content preserving`() {
accepts(sheet("已选 [白色](加绒) RW12 XL", colors = emptyList()), color = "〔白色〕(加绒) RW12")
rejects(sheet("已选 白色 RW12 XL", colors = emptyList()), color = "白色 rw12")
rejects(sheet("已选 白色 1 XL", colors = emptyList()), color = "白色 ①")
rejects(sheet("已选 白色 XL", colors = emptyList()), color = "白色 ¥19.6")
}
@Test fun `existing structurally recognized unprefixed summary is usable`() {
accepts(prefixlessSheet())
rejects(sheet("黑色 XL")) // No quantity/header relationship in this fixture.
rejects(sheet(null).addText("r/title", "黑色 XL"))
rejects(prefixlessSheet().addText("r/sheet/body/info/second", "黑色 L"))
}
@Test fun `same dimension groups retain earlier selected conflicts and physical candidates`() {
val base = sheet("已选 黑色 XL").addText("r/sheet/body/list/extra/h", "颜色分类")
.addText("r/sheet/body/list/extra/o", "白色")
.mapNodes { when (it.path) {
"r/sheet/body/list/extra/h" -> it.copy(bounds = NodeBounds(20, 1000, 500, 1040), parentPath = "r/sheet/body/list")
"r/sheet/body/list/extra/o" -> it.copy(bounds = NodeBounds(20, 1045, 500, 1100), parentPath = "r/sheet/body/list", clickable = true, selected = true)
"r/sheet/body/list" -> it.copy(bounds = NodeBounds(0, 980, 1080, 2036))
else -> it
} }
rejects(base)
rejects(base.mapNodes { if (it.path.endsWith("extra/o")) it.copy(text = "黑色") else it })
}
@Test fun `collector default still collapses same raw labels and leaves summary behavior unchanged`() {
val snapshot = sheet("已选 黑色 XL", colors = listOf("黑色", "黑色"))
val config = PurchaseRehearsalExecutor.DEFAULT_COLLECTOR
val regular = PddScreenParser.parse(snapshot, config, "374", null)
val final = PddScreenParser.parse(snapshot, config, "374", null, forFinalConfirmation = true)
assertEquals(1, regular.dimensions.single { it.key == "color" }.values.size)
assertEquals(2, final.dimensions.single { it.key == "color" }.values.size)
assertEquals(regular.selectedSummary, final.selectedSummary)
assertTrue(regular.finalSelectionSummaries.isEmpty())
}
@Test fun `independently bounded selected options survive absent full panel boundary`() {
val snapshot = sheet(null).selected("color/o0/img").selected("size/o0/t")
.mapNodes { if (it.path == "r/sheet") it.copy(bounds = NodeBounds(0, 0, 1080, 2216)) else it }
PurchaseFinalSpecVerifier.verify(snapshot, input()) { fail(it) }
val noSelections = snapshot.mapNodes { it.copy(selected = false, checked = false) }
.addText("r/sheet/fake", "已选 黑色 XL")
try {
PurchaseFinalSpecVerifier.verify(noSelections, input()) {}
fail("unproven full panel must not supply summary")
} catch (failure: PurchaseLiveException) { assertEquals("PURCHASE_SPEC_NOT_MATCHED", failure.code) }
}
@Test fun `price quantity and address checks remain enforced`() {
rejects(sheet("已选 黑色 XL"), expectedCode = "PURCHASE_PRICE_OUT_OF_RANGE", input = input().copy(maxUnitPriceCent = 1))
rejects(sheet("已选 黑色 XL"), expectedCode = "PURCHASE_QUANTITY_MISMATCH", input = input().copy(quantity = 2))
rejects(sheet("已选 黑色 XL").mapNodes { if (it.path == "r/sheet/body/addr/a/detail") it.copy(text = "示例地址") else it }, expectedCode = "PURCHASE_ADDRESS_UPDATE_FAILED")
}
private fun sheet(
summary: String?, colors: List<String> = listOf("黑色"), sizes: List<String> = listOf("XL"), scrollable: Boolean = true,
): UiSnapshot = SpecPanelFixtures.sheet(SpecPanelFixtures.Sheet(colorLabels = colors, sizeLabels = sizes, listScrollable = scrollable))
.mapNodes { when (it.path) {
"r/sheet/body/summary" -> it.copy(text = summary, visible = summary != null)
"r/sheet/body/addr/a/detail" -> it.copy(text = ADDRESS)
else -> it
} }
/** Reuses the info-above-list shape from PddProductDetailCollectorTest.prefixlessPanel. */
private fun prefixlessSheet(): UiSnapshot {
val base = sheet("黑色 XL")
val info = base.nodes.single { it.path == "r/sheet/body/qty" }.copy(
path = "r/sheet/body/info", bounds = NodeBounds(0, 770, 1080, 1000), parentPath = "r/sheet/body",
)
return base.mapNodes { when {
it.path == "r/sheet/body/summary" -> it.copy(path = "r/sheet/body/info/summary", parentPath = info.path)
it.path == "r/sheet/body/qty" -> it.copy(path = "r/sheet/body/info/qty", parentPath = info.path)
it.path.startsWith("r/sheet/body/qty/") -> it.copy(path = it.path.replace("body/qty/", "body/info/qty/"), parentPath = "r/sheet/body/info/qty")
else -> it
} }.let { it.copy(nodes = it.nodes + info) }
}
private fun UiSnapshot.mapNodes(transform: (SnapshotNode) -> SnapshotNode) = copy(nodes = nodes.map(transform))
private fun UiSnapshot.selected(suffix: String, checked: Boolean = false) = mapNodes {
if (it.path.endsWith(suffix)) it.copy(selected = !checked, checked = checked) else it
}
private fun UiSnapshot.addText(path: String, text: String, parent: String = path.substringBeforeLast('/')) = copy(nodes = nodes + SnapshotNode(
path, parent, text, null, null, "android.widget.TextView", NodeBounds(384, 791, 1068, 852),
false, false, false, false, true, true,
))
private fun input(color: String = "黑色", size: String = "XL") = PurchaseExecutionInput(
taskId = 374, executionMode = "live", phase = "purchase", url = "https://mobile.yangkeduo.com/goods.html?goods_id=374",
goodsId = "374", mappedColor = color, mappedSize = size, quantity = 1,
minUnitPriceCent = 1, maxUnitPriceCent = 3000, addressSuffix = "_cg374",
)
private fun check(snapshot: UiSnapshot, input: PurchaseExecutionInput, diagnostics: MutableList<String> = mutableListOf()): FinalConfirmationEvidence {
val driver = object : PurchaseUiDriver {
override fun capture() = snapshot
override fun clickFresh(target: SnapshotNode): FreshActionResult = error("final confirmation must be read only")
override fun tapPurchaseFresh(target: SnapshotNode): FreshActionResult = error("unexpected tap")
override fun inputFresh(target: SnapshotNode, value: String): FreshActionResult = error("unexpected input")
override fun swipePurchase(direction: SwipeDirection, durationMs: Long): Boolean = error("unexpected swipe")
override fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long): Boolean = error("unexpected scoped swipe")
override fun backPurchase(): Boolean = error("unexpected back")
}
return PurchaseLiveAutomation(driver, pause = { error("unexpected pause") }, panelDiagnostic = diagnostics::add)
.finalConfirmation(input, ShippingAddressProof(ADDRESS, "_cg374"))
}
private fun accepts(snapshot: UiSnapshot, color: String = "黑色", size: String = "XL") {
val diagnostics = mutableListOf<String>()
val result = try { check(snapshot, input(color, size), diagnostics) } catch (failure: PurchaseLiveException) {
throw AssertionError("${failure.code}: ${diagnostics.lastOrNull()}", failure)
}
assertEquals(color, result.mappedColor)
}
private fun rejects(snapshot: UiSnapshot, color: String = "黑色", size: String = "XL", expectedCode: String = "PURCHASE_SPEC_NOT_MATCHED", input: PurchaseExecutionInput = input(color, size)) {
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") {
val detail = diagnostics.last()
assertTrue(detail, detail.matches(Regex("finalSpec;dimension=(color|size|none);stage=[a-z_]+;summaryPresent=[01];selectedFound=[01];conflict=[01]")))
assertTrue(failure.message.orEmpty(), failure.message.orEmpty().contains(detail))
assertFalse(failure.message.orEmpty().contains(ADDRESS))
assertFalse(failure.message.orEmpty().contains("黑色"))
assertFalse(failure.message.orEmpty().contains("XL"))
}
}
}
private companion object { const val ADDRESS = "示例区示例路374号_cg374" }
}
@@ -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"))
+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 -->
+17 -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: a47b6b3b7ef7745417d1c2c88932f5bb12228f27
synchronized_at: 2026-10-09T07:57:47Z
<!-- 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,17 @@ 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)
实现绑定 `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;未执行真实采购,不以合成测试代替真机效果。
+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`。
- 本次仅合并、验证和推送,未覆盖安装、未执行真实采购/付款、未发布或重启服务;不需要业务库迁移。手机仍使用此前安装版本。后续装机须另行授权,保留原签名及用户数据。