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 d025052..c9c2eff 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 @@ -585,8 +585,8 @@ object PddScreenParser { /** * Outermost labelled clickable ancestor inside the value region whose label - * prefixes every candidate nested in it. Rows that contain several distinct - * options never qualify because their members do not share its label. + * prefixes every candidate nested in it, or a single image/caption card with + * a truncated caption. A shared ancestor alone is not card identity. */ private fun optionBlock( candidate: SnapshotNode, @@ -606,7 +606,8 @@ object PddScreenParser { if (raw.clickable && raw.label.isNotBlank() && resolved != null && inValueRegion(resolved)) { val outer = compact(resolved.label) val members = candidates.filter { it.path == resolved.path || it.path.startsWith("${resolved.path}/") } - if (outer.isEmpty() || members.any { !compact(it.label).startsWith(outer) }) break + val existingBlock = outer.isNotEmpty() && members.all { compact(it.label).startsWith(outer) } + if (!existingBlock && !isTruncatedDisplayCard(resolved, members, sourceByPath)) break block = resolved } parentPath = raw.parentPath @@ -614,6 +615,45 @@ object PddScreenParser { return block } + /** + * #362: narrowly recognize one full-labelled image above one truncated text + * leaf. Do not reverse the old prefix rule for arbitrary nested options. + * Non-clickable layout wrappers are fine; another clickable container is + * ambiguous, even if its label/price also happens to be a prefix match. + */ + private fun isTruncatedDisplayCard( + block: SnapshotNode, + members: List, + sourceByPath: Map, + ): Boolean { + fun name(label: String) = SpecValueNormalizer.normalizeColor(label).filterNot { it.isWhitespace() } + val full = name(block.label) + if (full.isEmpty()) return false + val children = members.filter { it.path != block.path } + val image = children.singleOrNull { it.className == "android.widget.ImageView" } ?: return false + val caption = children.singleOrNull { it.className == "android.widget.TextView" } ?: return false + if (children.size != 2 || name(image.label) != full) return false + val short = name(caption.label) + if (short.isEmpty() || short == full || !full.startsWith(short)) return false + + val descendants = sourceByPath.values.filter { it.path.startsWith("${block.path}/") } + // Image and caption must be leaves, not two independently labelled cards. + if (children.any { child -> descendants.any { it.parentPath == child.path } }) return false + if (descendants.any { node -> + node.clickable && node.label.isNotBlank() && + descendants.any { it.parentPath == node.path } + }) return false + if (children.any { child -> + child.bounds.left < block.bounds.left || child.bounds.right > block.bounds.right || + child.bounds.top < block.bounds.top || child.bounds.bottom > block.bounds.bottom + }) return false + if (image.bounds.top >= caption.bounds.top || image.bounds.bottom >= caption.bounds.bottom || + minOf(image.bounds.right, caption.bounds.right) <= maxOf(image.bounds.left, caption.bounds.left)) return false + + val prices = members.mapNotNull { SpecValueNormalizer.trailingPriceAmount(it.label) } + return prices.isEmpty() || prices.all { it.compareTo(prices.first()) == 0 } + } + /** The nearby clickable row holding [node], or null when there is none or it is not a plausible entry row. */ private fun clickableEntryRow(node: SnapshotNode, sourceByPath: Map, screenHeight: Int): SnapshotNode? { var current: SnapshotNode? = node 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 f011e4f..c500fa1 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,10 @@ object SpecValueNormalizer { fun normalizeSize(value: String): String = normalizeTrailingPrice(value) + /** 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() + fun isSafeColor(value: String): Boolean = isSafeValue(value) fun isSafeSize(value: String): Boolean = isSafeValue(value) diff --git a/android/app/src/test/java/cn/ilapage/goauto/agent/TruncatedSpecCardTest.kt b/android/app/src/test/java/cn/ilapage/goauto/agent/TruncatedSpecCardTest.kt new file mode 100644 index 0000000..3968849 --- /dev/null +++ b/android/app/src/test/java/cn/ilapage/goauto/agent/TruncatedSpecCardTest.kt @@ -0,0 +1,226 @@ +package cn.ilapage.goauto.agent + +import cn.ilapage.goauto.agent.automation.* +import org.junit.Assert.* +import org.junit.Test + +/** Synthetic cards only: no device tree, address or order data. */ +class TruncatedSpecCardTest { + private val card = "r/sheet/body/list/color/o0" + private val other = "r/sheet/body/list/color/o1" + private val full = "黑色示例长裤【有抽绳】 有口袋不起球" + private val short = "黑色示例长裤【有抽绳】 有口袋" + private val size = "2XL建议130-150斤" + + private fun sheet( + outer: String = "$full ¥18.8", + caption: String = "$short ¥18.8", + image: String = outer, + selected: Boolean = true, + otherSelected: Boolean = false, + ): UiSnapshot = SpecPanelFixtures.taskOptionDedupSheet().let { snapshot -> + snapshot.copy(nodes = snapshot.nodes.map { node -> + when (node.path) { + card, "$card/img" -> node.copy(text = null, contentDescription = if (node.path == card) outer else image, selected = selected) + "$card/l/t" -> node.copy(text = caption, selected = selected) + "r/sheet/body/selected" -> node.copy(text = "请选择: 尺码") + else -> when { + node.path.startsWith("$card/") -> node.copy(selected = selected) + node.path == other || node.path.startsWith("$other/") -> node.copy(selected = otherSelected) + else -> node + } + } + }) + } + + private fun parse(snapshot: UiSnapshot) = PddScreenParser.parse( + snapshot, PurchaseRehearsalExecutor.DEFAULT_COLLECTOR, "123456789", null, + ) + + private fun colors(snapshot: UiSnapshot) = parse(snapshot).dimensions.single { it.key == "color" }.values + + @Test fun `full image and truncated clickable caption are one selected color`() { + val result = colors(sheet()) + assertEquals(listOf(full, "白条纹"), result.map { it.text }) + assertTrue(result.first().node.selected) + assertFalse(result.last().node.selected) + assertEquals(5, parse(sheet()).dimensions.single { it.key == "size" }.values.size) + } + + @Test fun `checked display state survives grouping`() { + val snapshot = sheet(selected = false).let { it.copy(nodes = it.nodes.map { n -> + if (n.path == "$card/l/t") n.copy(checked = true) else n + }) } + assertTrue(colors(snapshot).single { it.text == full }.node.checked) + assertFalse(colors(snapshot).any { it.text == short }) + } + + @Test fun `whitespace and equivalent terminal prices do not change full identity`() { + assertEquals(listOf(full, "白条纹"), colors(sheet(caption = "黑色示例长裤【有抽绳】\n有口袋 ¥18.80")).map { it.text }) + } + + @Test fun `one or both absent prices still require card evidence`() { + for ((outer, caption) in listOf(full to short, "$full ¥18.8" to short, full to "$short ¥18.8")) { + assertEquals(listOf(full, "白条纹"), colors(sheet(outer, caption)).map { it.text }) + } + } + + @Test fun `different prices refuse truncated merge`() { + assertTrue(colors(sheet(caption = "$short ¥28.8")).any { it.text == short }) + } + + @Test fun `non prefix caption remains independent`() { + assertTrue(colors(sheet(caption = "灰色示例 ¥18.8")).any { it.text == "灰色示例" }) + } + + @Test fun `empty price only and missing complete image are not prefix evidence`() { + for (caption in listOf("", "¥18.8")) { + val snapshot = sheet(caption = caption, selected = false).let { it.copy(nodes = it.nodes.map { n -> + if (n.path == "$card/l/t") n.copy(checked = true) else n + }) } + // An empty label may be omitted by the parser; a pure-price value + // must not be promoted into the full card via empty-prefix matching. + if (caption.isNotEmpty()) assertTrue(colors(snapshot).any { it.text.isEmpty() && it.node.checked }) + } + assertTrue(colors(sheet(image = "$short ¥18.8")).any { it.text == short }) + assertTrue(colors(sheet(outer = "¥18.8")).any { it.text == short }) + } + + @Test fun `truncated leaf outside card geometry is not merged`() { + val snapshot = sheet().let { it.copy(nodes = it.nodes.map { n -> + if (n.path == "$card/l/t") n.copy(bounds = NodeBounds(710, 1340, 1040, 1447)) else n + }) } + assertTrue(colors(snapshot).any { it.text == short }) + } + + @Test fun `clickable caption wrapper is not silently assumed to be a display leaf`() { + val snapshot = sheet().let { it.copy(nodes = it.nodes.map { n -> + if (n.path == "$card/l") n.copy(text = "$short ¥18.8", clickable = true) else n + }) } + assertTrue(colors(snapshot).any { it.text == short }) + } + + @Test fun `adjacent prefix named cards stay distinct even at same price`() { + val snapshot = sheet(outer = "黑色加绒 ¥18.8", caption = "黑色加 ¥18.8").let { it.copy(nodes = it.nodes.map { n -> + if (n.path == other || n.path == "$other/img" || n.path == "$other/l/t") + n.copy(text = "黑色 ¥18.8", contentDescription = null) else n + }) } + assertEquals(listOf("黑色加绒", "黑色"), colors(snapshot).map { it.text }) + } + + @Test fun `labelled clickable common parent cannot swallow independent cards`() { + val snapshot = sheet().let { it.copy(nodes = it.nodes.map { n -> + if (n.path == "r/sheet/body/list/color") n.copy(text = "$full ¥18.8", clickable = true, + bounds = NodeBounds(0, 1044, 1080, 1447)) else n + }) } + assertTrue(colors(snapshot).any { it.text == "白条纹" }) + } + + @Test fun `nested same price prefix card with own image and caption remains independent`() { + val base = sheet(outer = "黑色加绒 ¥18.8", caption = "黑色加 ¥18.8") + val parent = base.nodes.single { it.path == card } + val nested = listOf( + parent.copy(path = "$card/nested", parentPath = card, text = "黑色 ¥18.8", contentDescription = null), + parent.copy(path = "$card/nested/img", parentPath = "$card/nested", text = "黑色 ¥18.8", contentDescription = null, className = "android.widget.ImageView"), + parent.copy(path = "$card/nested/text", parentPath = "$card/nested", text = "黑色 ¥18.8", contentDescription = null, className = "android.widget.TextView"), + ) + assertTrue(colors(base.copy(nodes = base.nodes + nested)).any { it.text == "黑色" }) + } + + @Test fun `numeric and weight size values and badge regression remain intact`() { + val baseline = SpecPanelFixtures.taskOptionDedupSheet() + assertEquals(listOf("兰条纹", "白条纹"), colors(baseline).map { it.text }) + val snapshot = sheet().let { it.copy(nodes = it.nodes.map { n -> + if (n.path.endsWith("size/o0/t")) n.copy(text = "42 ¥18.8") else n + }) } + val sizes = parse(snapshot).dimensions.single { it.key == "size" }.values.map { it.text } + assertTrue(sizes.containsAll(listOf("42", size, "3XL建议150-170斤"))) + } + + @Test fun `collector used by collection and spec probe emits only full color values`() { + var selectedColor = full + val driver = object : PddCollectorDriver { + override fun capture() = sheet(selected = selectedColor == full, otherSelected = selectedColor == "白条纹") + override fun clickFresh(target: SnapshotNode): FreshActionResult { + selectedColor = if (target.path.startsWith(card)) full else "白条纹" + return FreshActionResult.SUCCESS + } + override fun swipeSpec(direction: SwipeDirection, anchor: SnapshotNode?) = false + override fun pullDownGoodsPage() = false + override fun back() = false + } + var clock = 0L + val rule = CollectionRule( + schemaVersion = 2, steps = emptyList(), ruleType = "pddProductDetail", + pageEvidence = PageEvidence(SpecPanelFixtures.PDD, SpecPanelFixtures.ACTIVITY, NodeSelector(text = "颜色分类")), + collector = PurchaseRehearsalExecutor.DEFAULT_COLLECTOR, + ) + val result = PddProductDetailCollector(driver, { clock }, { clock += it }).collect("123456789", rule) + assertTrue(result.message, result.successful) + assertEquals(listOf(full, "白条纹"), requireNotNull(result.payload).dimensions.single { it.key == "color" }.values) + } + + private fun executor(driver: PurchaseUiDriver) = PurchaseRehearsalExecutor(driver, { true }, { null }, pause = {}) + + private class Driver(private val snapshot: UiSnapshot) : PurchaseUiDriver { + var opened = false + var sizeSelected = false + val clicks = mutableListOf() + 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 + }) else SpecPanelFixtures.productDetailPage() + override fun clickFresh(target: SnapshotNode): FreshActionResult { + clicks += target.label + if (target.label.startsWith("请选择")) opened = true + if (target.label == "2XL建议130-150斤") sizeSelected = true + return FreshActionResult.SUCCESS + } + override fun tapPurchaseFresh(target: SnapshotNode) = FreshActionResult.FAILED + override fun inputFresh(target: SnapshotNode, value: String) = FreshActionResult.FAILED + override fun swipePurchase(direction: SwipeDirection, durationMs: Long) = false + override fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long) = false + override fun backPurchase() = false + } + + private fun run(snapshot: UiSnapshot, target: String = full, driver: Driver = Driver(snapshot)) = executor(driver).execute( + PurchaseExecutionInput(362, "rehearsal", "purchase", "https://mobile.yangkeduo.com/goods.html?goods_id=123456789", + "123456789", target, size, quantity = 1, minUnitPriceCent = 1000, maxUnitPriceCent = 3000), + PurchaseRuleParser.parse("""{"schemaVersion":1,"ruleType":"pddPurchase","requiredCapabilities":["purchase.rehearsal.v1"], + "actions":[{"type":"openProduct"},{"type":"verifyProduct"},{"type":"openSpecPanel"},{"type":"selectSpec"}, + {"type":"setQuantity"},{"type":"verifyUnitPrice"},{"type":"verifyOrderSummary"}]}"""), + PurchaseAgentCapabilities.supported, + ) + + @Test fun `dedup lets existing confirmation complete color size and rehearsal`() { + val snapshot = sheet() + val driver = Driver(snapshot) + val result = run(snapshot, driver = driver) + assertEquals(result.message, "rehearsal_completed", result.resultType) + assertEquals(1, driver.clicks.count { it == size }) + assertFalse(driver.clicks.any { it.contains("订单") || it.contains("支付") }) + } + + @Test fun `other selected card still fails immediate confirmation`() { + for (selected in listOf(false, true)) { + val result = run(sheet(selected = selected, otherSelected = true)) + assertEquals("PURCHASE_SPEC_SELECTION_UNCONFIRMED", result.errorCode) + } + } + + @Test fun `old truncated mapped target is not silently upgraded`() { + assertEquals("PURCHASE_SPEC_TARGET_NOT_VISIBLE", run(sheet(), short).errorCode) + } + + @Test fun `existing final confirmation target first ordering is documented not changed`() { + val screen = parse(sheet(otherSelected = true)) + val executor = executor(Driver(sheet())) + val immediate = PurchaseRehearsalExecutor::class.java.declaredMethods.single { it.name == "isExactSpecSelected" } + immediate.isAccessible = true + assertEquals(false, immediate.invoke(executor, screen, "color", full)) + val final = PurchaseRehearsalExecutor::class.java.declaredMethods.single { it.name == "verifyExactSpecSelection" } + final.isAccessible = true + val result = final.invoke(executor, screen, "color", full, null) + val confirmed = result.javaClass.getDeclaredField("confirmed").apply { isAccessible = true } + assertEquals(true, confirmed.get(result)) // Existing baseline; NOT a newly approved multi-selection behavior. + } +}