From 6f1af70d0d2a4c70c31ba42e05c4321f08c6a78e Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Fri, 9 Oct 2026 15:42:58 +0800 Subject: [PATCH] fix(android): verify scoped final purchase spec evidence (#374) --- .../automation/PddProductDetailCollector.kt | 21 +- .../automation/PurchaseFinalSpecVerifier.kt | 126 +++++++++ .../automation/PurchaseLiveAutomation.kt | 5 +- .../agent/automation/SpecValueNormalizer.kt | 14 + .../PurchaseFinalSpecConfirmationTest.kt | 241 ++++++++++++++++++ .../goauto/agent/SpecValueNormalizerTest.kt | 9 + 6 files changed, 408 insertions(+), 8 deletions(-) create mode 100644 android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseFinalSpecVerifier.kt create mode 100644 android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseFinalSpecConfirmationTest.kt diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PddProductDetailCollector.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PddProductDetailCollector.kt index 42606f3..f917b67 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PddProductDetailCollector.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PddProductDetailCollector.kt @@ -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 = 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() 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, diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseFinalSpecVerifier.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseFinalSpecVerifier.kt new file mode 100644 index 0000000..f3ba36e --- /dev/null +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseFinalSpecVerifier.kt @@ -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() + 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() + 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 + } +} diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseLiveAutomation.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseLiveAutomation.kt index 110c499..c907af4 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseLiveAutomation.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseLiveAutomation.kt @@ -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() } diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/SpecValueNormalizer.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/SpecValueNormalizer.kt index c500fa1..47a7664 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/SpecValueNormalizer.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/SpecValueNormalizer.kt @@ -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() diff --git a/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseFinalSpecConfirmationTest.kt b/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseFinalSpecConfirmationTest.kt new file mode 100644 index 0000000..aed9ef3 --- /dev/null +++ b/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseFinalSpecConfirmationTest.kt @@ -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 = listOf("黑色"), sizes: List = 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 = 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() + 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() + 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" } +} diff --git a/android/app/src/test/java/cn/ilapage/goauto/agent/SpecValueNormalizerTest.kt b/android/app/src/test/java/cn/ilapage/goauto/agent/SpecValueNormalizerTest.kt index 387ce32..76dcb49 100644 --- a/android/app/src/test/java/cn/ilapage/goauto/agent/SpecValueNormalizerTest.kt +++ b/android/app/src/test/java/cn/ilapage/goauto/agent/SpecValueNormalizerTest.kt @@ -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"))