From c782103b6f0e65c275361daa0bab9b5aeef04764 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Thu, 27 Aug 2026 16:57:40 +0800 Subject: [PATCH] fix: separate PDD spec panel evidence from options (#112) --- android/app/build.gradle.kts | 4 +- .../automation/PddProductDetailCollector.kt | 224 +++++++++++++++--- .../agent/persistence/AgentDiagnosticStore.kt | 7 + .../agent/PddProductDetailCollectorTest.kt | 173 +++++++++++++- 4 files changed, 374 insertions(+), 34 deletions(-) diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 7ad0ee1..4fa8faf 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -11,8 +11,8 @@ android { applicationId = "cn.ilapage.goauto.agent" minSdk = 23 targetSdk = 34 - versionCode = 21 - versionName = "0.9.8" + versionCode = 22 + versionName = "0.9.9" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" 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 3609a35..515e01d 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 @@ -71,6 +71,7 @@ interface PddCollectorDriver { data class VisibleSpecValue(val text: String, val available: Boolean, val node: SnapshotNode) data class VisibleDimension(val key: String, val name: String, val values: List) +enum class SpecPanelType { UNKNOWN, NORMAL_SCROLLABLE, NON_SCROLLABLE_CONFIRMATION, QUICK_CONFIRMATION } data class ParsedPddScreen( val summary: ProductSummary, @@ -78,6 +79,7 @@ data class ParsedPddScreen( val selectedSummary: String?, val priceCent: Long?, val specPanelOpen: Boolean, + val specPanelType: SpecPanelType, val specEntry: SnapshotNode?, val specEntrySource: String?, val quickConfirmationEntry: SnapshotNode?, @@ -88,6 +90,11 @@ data class ParsedPddScreen( val packageMatched: Boolean, val activityMatched: Boolean, val selectorMatchCount: Int, + val panelScrollableCount: Int, + val panelHeadingCount: Int, + val panelOptionCount: Int, + val hasSelectionSummary: Boolean, + val hasQuantityControls: Boolean, val problem: PageProblem?, val sourceNodes: List, ) { @@ -111,10 +118,12 @@ object PddScreenParser { private val salesPattern = Regex("已拼\\s*[0-9]+(?:\\.[0-9]+)?\\s*(?:万|亿)?\\s*\\+?\\s*(?:件|人)?") private val reviewPattern = Regex("(?:商品评价\\s*[((]?\\s*[0-9]+(?:\\.[0-9]+)?\\s*(?:万|亿)?|[0-9]+(?:\\.[0-9]+)?\\s*(?:万|亿)?\\s*\\+?\\s*条?评价|评价\\s*[0-9]+)") private val excludedOptionWords = listOf( - "确定", "确认", "购买", "下单", "订单", "支付", "付款", + "确定", "确认", "购买", "现在买", "下单", "订单", "支付", "付款", "已选", "请选择", "数量", "打开大图", "查看大图", "一次选多款", + "確定", "確認", "購買", "現在買", "下單", "訂單", "付款", + "已選", "請選擇", "數量", "打開大圖", "查看大圖", "一次選多款", ) - private val excludedExactOptionLabels = setOf("客服", "联系客服", "店铺", "收藏", "关闭") + private val excludedExactOptionLabels = setOf("客服", "联系客服", "聯繫客服", "店铺", "店鋪", "收藏", "关闭", "關閉") private val reviewWords = listOf("商品评价", "全部评价", "评价详情", "评论", "晒单", "问答") fun parse(snapshot: UiSnapshot, config: PddCollectorConfig, goodsId: String, evidence: PageEvidence?): ParsedPddScreen { @@ -136,22 +145,53 @@ object PddScreenParser { val labels = visible.map(SnapshotNode::label) val problem = PddPageClassifier.classify(snapshot.packageName, snapshot.activityName, labels) val compactLabels = labels.map { it.replace(" ", "") } - val hasSelectionSummary = compactLabels.any { it.startsWith("已选") || it.startsWith("请选择") } + val hasSelectionSummary = compactLabels.any { + it.startsWith("已选") || it.startsWith("请选择") || it.startsWith("已選") || it.startsWith("請選擇") + } val hasSubmitHint = compactLabels.any { label -> - label.contains("提交订单") && listOf("选择", "颜色", "尺码", "规格").any(label::contains) + (label.contains("提交订单") || label.contains("提交訂單")) && + listOf("选择", "選擇", "颜色", "顏色", "尺码", "尺碼", "规格", "規格").any(label::contains) } val allHeadings = visible.filter { !it.clickable && isHeading(it.label, config) } val screenWidth = visibleNodes.maxOfOrNull { it.bounds.right } ?: 0 val screenHeight = visibleNodes.maxOfOrNull { it.bounds.bottom } ?: 0 val screenArea = screenWidth.toLong() * screenHeight.toLong() - val panelScrollable = visibleNodes.asSequence() + val boundedScrollables = visibleNodes.asSequence() .filter { it.visible && it.scrollable && it.bounds.width > 0 && it.bounds.height > 0 } .filter { region -> screenArea == 0L || (region.bounds.width.toLong() * region.bounds.height).toDouble() < screenArea * 0.9 } + .toList() + val headedPanelScrollable = boundedScrollables.asSequence() .filter { region -> allHeadings.any { heading -> heading.path.startsWith("${region.path}/") } } .maxByOrNull { it.bounds.width.toLong() * it.bounds.height } + val hasPanelTitle = compactLabels.any { it in setOf("确认款式", "確認款式", "关闭", "關閉") } + val hasPanelAction = visible.any { node -> + val compact = node.label.replace(" ", "") + node.clickable && ( + compact in setOf("确定", "確定", "确认", "確認") || + ((compact.contains("提交订单") || compact.contains("提交訂單")) && + listOf("选择", "選擇", "颜色", "顏色", "尺码", "尺碼", "规格", "規格").any(compact::contains)) + ) + } + val quantityInputs = visibleNodes.filter { + it.className == "android.widget.EditText" && it.label.toIntOrNull()?.let { value -> value > 0 } == true + } + val decreaseControls = visible.filter { it.clickable && it.label.replace(" ", "") in setOf("减少数量", "減少數量") } + val increaseControls = visible.filter { it.clickable && it.label.replace(" ", "") in setOf("增加数量", "增加數量") } + val hasQuantityControls = quantityInputs.size == 1 && decreaseControls.size == 1 && increaseControls.size == 1 + val hasPaymentArea = compactLabels.any { label -> listOf("微信支付", "先用后付", "先用後付", "支付方式").any(label::contains) } + val hasQuickBuy = visible.any { node -> + node.label.replace(" ", "").let { it.contains("现在买") || it.contains("現在買") } && + node.bounds.centerY.toDouble() >= screenHeight * 0.75 + } + val hasClose = compactLabels.any { it in setOf("关闭", "關閉") } + val quickConfirmationEvidence = hasClose && hasSelectionSummary && hasPaymentArea && hasQuickBuy && hasQuantityControls + val fallbackScrollable = boundedScrollables.singleOrNull().takeIf { + allHeadings.isEmpty() && hasSelectionSummary && hasPanelTitle && hasPanelAction + } + val panelScrollable = headedPanelScrollable ?: fallbackScrollable val panelVisible = panelScrollable?.let { region -> visible.filter { it.path == region.path || it.path.startsWith("${region.path}/") } } ?: visible @@ -176,21 +216,16 @@ object PddScreenParser { if (values.isNotEmpty()) add(VisibleDimension(dimensionKey(heading.label, config, any { it.key == "color" }), heading.label, values)) } } - val hasPanelTitle = compactLabels.any { it in setOf("确认款式", "关闭") } - val hasPanelAction = visible.any { node -> - val compact = node.label.replace(" ", "") - node.clickable && ( - compact in setOf("确定", "确认") || - (compact.contains("提交订单") && listOf("选择", "颜色", "尺码", "规格").any(compact::contains)) - ) + // Opening evidence is intentionally independent from whether the current + // viewport still exposes a clickable spec value. PDD may hide the only + // selected value or restore a previously scrolled confirmation panel. + val specPanelType = when { + quickConfirmationEvidence -> SpecPanelType.QUICK_CONFIRMATION + panelScrollable != null && (hasSelectionSummary || hasSubmitHint || (hasPanelTitle && hasPanelAction)) -> SpecPanelType.NORMAL_SCROLLABLE + hasSelectionSummary && hasPanelTitle && hasPanelAction -> SpecPanelType.NON_SCROLLABLE_CONFIRMATION + else -> SpecPanelType.UNKNOWN } - // Scrollable panels must expose a bounded nested region containing a real - // dimension heading. Custom-drawn panels use the independent title, - // selection summary and action evidence instead. - val panelOpen = dimensions.isNotEmpty() && ( - (panelScrollable != null && (hasSelectionSummary || hasSubmitHint)) || - (hasSelectionSummary && hasPanelTitle && hasPanelAction) - ) + val panelOpen = specPanelType != SpecPanelType.UNKNOWN val firstHeadingTop = headings.firstOrNull()?.bounds?.top ?: Int.MAX_VALUE val price = visible.asSequence() .filter { it.bounds.top < firstHeadingTop } @@ -207,7 +242,7 @@ object PddScreenParser { else -> null } val reviewPageOpen = isReviewPage(visibleNodes, visible, screenHeight, specEntry) - val quickConfirmationEntry = if (panelOpen) null else quickConfirmationSpecEntry(visibleNodes, visible, config) + val quickConfirmationEntry = if (quickConfirmationEvidence) quickConfirmationSpecEntry(visibleNodes, visible, config) else null val rootAvailable = snapshot.packageName != null || snapshot.activityName != null || snapshot.nodes.isNotEmpty() val packageMatched = evidence == null || snapshot.packageName == evidence.packageName val activityMatched = evidence == null || snapshot.activityName == evidence.activityName @@ -224,9 +259,13 @@ object PddScreenParser { else labels.firstNotNullOfOrNull { label -> reviewPattern.find(label)?.value?.let(CollectionAssembler::parseCount) }, ), dimensions = dimensions, - selectedSummary = labels.firstOrNull { it.startsWith("已选") }, + selectedSummary = labels.firstOrNull { + val compact = it.replace(" ", "") + compact.startsWith("已选") || compact.startsWith("已選") + }, priceCent = price, specPanelOpen = panelOpen, + specPanelType = specPanelType, specEntry = specEntry, specEntrySource = specEntrySource, quickConfirmationEntry = quickConfirmationEntry, @@ -237,6 +276,11 @@ object PddScreenParser { packageMatched = packageMatched, activityMatched = activityMatched, selectorMatchCount = selectorMatchCount, + panelScrollableCount = boundedScrollables.size, + panelHeadingCount = headings.size, + panelOptionCount = dimensions.sumOf { it.values.size }, + hasSelectionSummary = hasSelectionSummary, + hasQuantityControls = hasQuantityControls, problem = problem, sourceNodes = visibleNodes, ) @@ -367,13 +411,14 @@ object PddScreenParser { val hasSummary = compactLabels.any { it.startsWith("已选") || it.startsWith("已選") } val hasPaymentArea = compactLabels.any { label -> listOf("微信支付", "先用后付", "支付方式").any(label::contains) } val hasQuickBuy = visible.any { node -> - node.label.replace(" ", "").contains("现在买") && node.bounds.centerY.toDouble() >= screenBottom * 0.75 + node.label.replace(" ", "").let { it.contains("现在买") || it.contains("現在買") } && + node.bounds.centerY.toDouble() >= screenBottom * 0.75 } val hasQuantity = source.any { it.className == "android.widget.EditText" && it.label.toIntOrNull()?.let { value -> value > 0 } == true } - val hasDecrease = visible.any { it.clickable && it.label.replace(" ", "") == "减少数量" } - val hasIncrease = visible.any { it.clickable && it.label.replace(" ", "") == "增加数量" } + val hasDecrease = visible.any { it.clickable && it.label.replace(" ", "") in setOf("减少数量", "減少數量") } + val hasIncrease = visible.any { it.clickable && it.label.replace(" ", "") in setOf("增加数量", "增加數量") } if (!hasClose || !hasSummary || !hasPaymentArea || !hasQuickBuy || !hasQuantity || !hasDecrease || !hasIncrease) return null val headings = visible.filter { !it.clickable && isHeading(it.label, config) }.sortedBy { it.bounds.top } @@ -526,28 +571,89 @@ class PddProductDetailCollector( } val panelDeadline = now() + config.timeoutsMs.getValue("specPanel") + val panelStartedAt = now() var quickConfirmationRecovered = false + var quickConfirmationAttempted = false var screenChanged = false var reviewPage: ParsedPddScreen? = null + var panelAttempts = 0 + var lastPanelScreen: ParsedPddScreen? = null do { val screen = parse(goodsId, config, evidence) + panelAttempts++ + lastPanelScreen = screen screen.problem?.let { return failure(it.code, it.message) } screenChanged = screenChanged || screenSignature(screen) != beforeClick if (screen.reviewPageOpen) { reviewPage = screen break } + if ( + screen.pageEvidenceMatched && + screen.specPanelType == SpecPanelType.QUICK_CONFIRMATION && + !quickConfirmationAttempted + ) { + quickConfirmationAttempted = true + val recoveryEntry = screen.quickConfirmationEntry + if (recoveryEntry == null) { + recordSpecPanelEntry( + AgentDiagnosticReason.SPEC_PANEL_RECOVERY_UNAVAILABLE, + screen, + panelAttempts, + now() - panelStartedAt, + screenChanged, + quickConfirmationAttempted, + false, + ) + opened = screen + break + } + when (driver.clickFresh(recoveryEntry)) { + FreshActionResult.SUCCESS -> { + quickConfirmationRecovered = true + pause(100) + continue + } + FreshActionResult.AMBIGUOUS -> { + recordSpecPanelEntry( + AgentDiagnosticReason.SPEC_PANEL_RECOVERY_FAILED, + screen, + panelAttempts, + now() - panelStartedAt, + screenChanged, + true, + false, + ) + return failure("RULE_AMBIGUOUS", "快速确认页主规格匹配到多个控件") + } + else -> { + recordSpecPanelEntry( + AgentDiagnosticReason.SPEC_PANEL_RECOVERY_FAILED, + screen, + panelAttempts, + now() - panelStartedAt, + screenChanged, + true, + false, + ) + return failure("RULE_ACTION_FAILED", "快速确认页主规格点击失败") + } + } + } if (screen.pageEvidenceMatched && screen.specPanelOpen) { opened = screen + recordSpecPanelEntry( + if (quickConfirmationRecovered) AgentDiagnosticReason.SPEC_PANEL_RECOVERED + else AgentDiagnosticReason.SPEC_PANEL_OPENED, + screen, + panelAttempts, + now() - panelStartedAt, + screenChanged, + quickConfirmationAttempted, + quickConfirmationRecovered, + ) break } - if (screen.pageEvidenceMatched && !quickConfirmationRecovered && screen.quickConfirmationEntry != null) { - when (driver.clickFresh(screen.quickConfirmationEntry)) { - FreshActionResult.SUCCESS -> quickConfirmationRecovered = true - FreshActionResult.AMBIGUOUS -> return failure("RULE_AMBIGUOUS", "快速确认页主规格匹配到多个控件") - else -> return failure("RULE_ACTION_FAILED", "快速确认页主规格点击失败") - } - } pause(100) } while (now() <= panelDeadline) if (opened != null) break @@ -580,8 +686,26 @@ class PddProductDetailCollector( } return if (!screenChanged) { + recordSpecPanelEntry( + AgentDiagnosticReason.SPEC_ENTRY_CLICK_NO_EFFECT, + lastPanelScreen, + panelAttempts, + now() - panelStartedAt, + false, + quickConfirmationAttempted, + quickConfirmationRecovered, + ) failure("SPEC_ENTRY_CLICK_NO_EFFECT", "规格入口点击未生效,请稍后重试") } else { + recordSpecPanelEntry( + AgentDiagnosticReason.SPEC_PANEL_EVIDENCE_NOT_MATCHED, + lastPanelScreen, + panelAttempts, + now() - panelStartedAt, + true, + quickConfirmationAttempted, + quickConfirmationRecovered, + ) failure("SPEC_PANEL_EVIDENCE_NOT_MATCHED", "规格面板页面结构无法确认") } } @@ -854,6 +978,44 @@ class PddProductDetailCollector( ) } + private fun recordSpecPanelEntry( + reason: AgentDiagnosticReason, + screen: ParsedPddScreen?, + attempts: Int, + elapsedMs: Long, + screenChanged: Boolean, + recoveryAttempted: Boolean, + recoverySucceeded: Boolean, + ) { + if (taskId <= 0) return + val shape = screen?.let { + "type=${it.specPanelType.name};scroll=${it.panelScrollableCount};head=${it.panelHeadingCount};" + + "option=${it.panelOptionCount};summary=${it.hasSelectionSummary.toInt()};" + + "quantity=${it.hasQuantityControls.toInt()};changed=${screenChanged.toInt()};" + + "recovery=${when { + recoverySucceeded -> "success" + reason == AgentDiagnosticReason.SPEC_PANEL_RECOVERY_UNAVAILABLE -> "unavailable" + recoveryAttempted -> "failed" + else -> "none" + }}" + } + diagnostic( + AgentDiagnosticEvent( + taskId = taskId, + stage = AgentDiagnosticStage.SPEC_PANEL_ENTRY, + reason = reason, + attempt = attempts, + elapsedMs = elapsedMs, + candidateCount = screen?.panelOptionCount, + targetClassName = shape, + targetClickable = screen?.hasSelectionSummary, + clickableAncestorDepth = screen?.panelHeadingCount, + ), + ) + } + + private fun Boolean.toInt(): Int = if (this) 1 else 0 + private fun buildSizeContinuation(screen: ParsedPddScreen, values: List): SizeContinuation? { val byPath = screen.sourceNodes.associateBy(SnapshotNode::path) val containers = values.mapNotNull { value -> diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/persistence/AgentDiagnosticStore.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/persistence/AgentDiagnosticStore.kt index 5524507..5676b57 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/persistence/AgentDiagnosticStore.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/persistence/AgentDiagnosticStore.kt @@ -8,6 +8,7 @@ import cn.ilapage.goauto.agent.BuildConfig enum class AgentDiagnosticStage { DETAIL_ENTRY, + SPEC_PANEL_ENTRY, SIZE_DISCOVERY, PAGE_STABILITY, SHARE_CLICK, @@ -18,6 +19,12 @@ enum class AgentDiagnosticStage { enum class AgentDiagnosticReason { DETAIL_ENTRY_MATCHED, + SPEC_PANEL_OPENED, + SPEC_PANEL_RECOVERED, + SPEC_PANEL_RECOVERY_UNAVAILABLE, + SPEC_PANEL_RECOVERY_FAILED, + SPEC_PANEL_EVIDENCE_NOT_MATCHED, + SPEC_ENTRY_CLICK_NO_EFFECT, SIZE_FOUND, SIZE_EDGE_REACHED, SIZE_SCAN_LIMIT, diff --git a/android/app/src/test/java/cn/ilapage/goauto/agent/PddProductDetailCollectorTest.kt b/android/app/src/test/java/cn/ilapage/goauto/agent/PddProductDetailCollectorTest.kt index c4a43d4..bcb2289 100644 --- a/android/app/src/test/java/cn/ilapage/goauto/agent/PddProductDetailCollectorTest.kt +++ b/android/app/src/test/java/cn/ilapage/goauto/agent/PddProductDetailCollectorTest.kt @@ -11,6 +11,7 @@ import cn.ilapage.goauto.agent.automation.PddCollectorDriver import cn.ilapage.goauto.agent.automation.PddProductDetailCollector import cn.ilapage.goauto.agent.automation.PddScreenParser import cn.ilapage.goauto.agent.automation.SnapshotNode +import cn.ilapage.goauto.agent.automation.SpecPanelType import cn.ilapage.goauto.agent.automation.SwipeDirection import cn.ilapage.goauto.agent.automation.TransientSoldOutRecovery import cn.ilapage.goauto.agent.automation.UiSnapshot @@ -235,10 +236,105 @@ class PddProductDetailCollectorTest { val parsed = PddScreenParser.parse(snapshot, config(), GOODS_ID, evidence()) - assertFalse(parsed.specPanelOpen) + assertTrue(parsed.specPanelOpen) + assertEquals(SpecPanelType.QUICK_CONFIRMATION, parsed.specPanelType) assertEquals("current-color", parsed.quickConfirmationEntry?.path) } + @Test + fun scrollablePanelCanBeOpenBeforeAnySpecValueIsVisible() { + val snapshot = UiSnapshot( + PDD_PACKAGE, + ACTIVITY, + listOf( + node("content", "", 0, 0, 1080, 2200, resourceId = "android:id/content", className = "android.widget.FrameLayout"), + node("summary", "已选:A色", 20, 300, 700, 360), + node("title", "确认款式", 20, 370, 300, 420), + node("panel", "", 0, 430, 1080, 1800, scrollable = true), + node("confirm", "确定", 0, 2000, 1080, 2150, clickable = true), + ), + ) + + val parsed = PddScreenParser.parse(snapshot, config(), GOODS_ID, evidence()) + + assertTrue(parsed.specPanelOpen) + assertEquals(SpecPanelType.NORMAL_SCROLLABLE, parsed.specPanelType) + assertTrue(parsed.dimensions.isEmpty()) + assertEquals("panel", parsed.specPanelContainer?.path) + } + + @Test + fun quickConfirmationRemainsOpenWhenSelectedUniqueColorIsNoLongerClickable() { + val snapshot = UiSnapshot( + PDD_PACKAGE, + ACTIVITY, + listOf( + node("content", "", 0, 0, 1080, 2200, resourceId = "android:id/content", className = "android.widget.FrameLayout"), + node("close", "關閉", 980, 20, 1060, 100, clickable = true), + node("summary", "已選:A色", 20, 300, 700, 360), + node("color-heading", "顏色", 20, 420, 300, 470), + node("quantity", "1", 480, 1400, 600, 1480, className = "android.widget.EditText"), + node("decrease", "減少數量", 360, 1400, 470, 1480, clickable = true), + node("increase", "增加數量", 610, 1400, 720, 1480, clickable = true), + node("payment", "支付方式", 20, 1550, 400, 1620), + node("buy-now", "現在買", 500, 1800, 1080, 2180, clickable = true), + ), + ) + + val parsed = PddScreenParser.parse(snapshot, config(), GOODS_ID, evidence()) + + assertTrue(parsed.specPanelOpen) + assertEquals(SpecPanelType.QUICK_CONFIRMATION, parsed.specPanelType) + assertTrue(parsed.dimensions.isEmpty()) + assertEquals(null, parsed.quickConfirmationEntry) + assertTrue(parsed.hasQuantityControls) + } + + @Test + fun nonScrollableConfirmationUsesStrongTitleSummaryAndActionEvidence() { + val snapshot = UiSnapshot( + PDD_PACKAGE, + ACTIVITY, + listOf( + node("content", "", 0, 0, 1080, 2200, resourceId = "android:id/content", className = "android.widget.FrameLayout"), + node("summary", "請選擇:顏色", 20, 300, 700, 360), + node("title", "確認款式", 20, 370, 300, 420), + node("confirm", "確定", 0, 2000, 1080, 2150, clickable = true), + ), + ) + + val parsed = PddScreenParser.parse(snapshot, config(), GOODS_ID, evidence()) + + assertTrue(parsed.specPanelOpen) + assertEquals(SpecPanelType.NON_SCROLLABLE_CONFIRMATION, parsed.specPanelType) + assertTrue(parsed.dimensions.isEmpty()) + } + + @Test + fun genericQuantityAndBuyControlsDoNotProveQuickConfirmation() { + val snapshot = UiSnapshot( + PDD_PACKAGE, + ACTIVITY, + listOf( + node("content", "", 0, 0, 1080, 2200, resourceId = "android:id/content", className = "android.widget.FrameLayout"), + node("close", "关闭", 980, 20, 1060, 100, clickable = true), + node("summary", "已选:A色", 20, 300, 700, 360), + node("quantity-1", "1", 480, 1300, 600, 1380, className = "android.widget.EditText"), + node("quantity-2", "2", 480, 1400, 600, 1480, className = "android.widget.EditText"), + node("decrease", "减少数量", 360, 1400, 470, 1480, clickable = true), + node("increase", "增加数量", 610, 1400, 720, 1480, clickable = true), + node("payment", "微信支付", 20, 1550, 400, 1620), + node("buy-now", "现在买", 500, 1800, 1080, 2180, clickable = true), + ), + ) + + val parsed = PddScreenParser.parse(snapshot, config(), GOODS_ID, evidence()) + + assertFalse(parsed.specPanelOpen) + assertEquals(SpecPanelType.UNKNOWN, parsed.specPanelType) + assertEquals(null, parsed.quickConfirmationEntry) + } + @Test fun boundedScrollablePanelUsesHeadingsInsideItsOwnRegion() { val snapshot = UiSnapshot( @@ -675,6 +771,60 @@ class PddProductDetailCollectorTest { assertEquals(1, driver.entryClickCount) } + @Test + fun quickConfirmationWithoutClickableCurrentSpecContinuesWithoutDangerousClick() { + val events = mutableListOf() + val driver = FakeCollectorDriver( + startWithPanelClosed = true, + quickConfirmationAfterEntry = true, + hideQuickConfirmationSpec = true, + ) + var clock = 0L + + val result = PddProductDetailCollector( + driver, + { clock }, + { clock += it }, + taskId = 80, + diagnostic = events::add, + ).collect(GOODS_ID, rule()) + + assertTrue(result.successful) + assertEquals(listOf("单独购买"), driver.clickedLabels) + assertTrue(result.payload?.missing?.containsAll(listOf("color", "size")) == true) + val event = events.single { it.stage == AgentDiagnosticStage.SPEC_PANEL_ENTRY } + assertEquals(AgentDiagnosticReason.SPEC_PANEL_RECOVERY_UNAVAILABLE, event.reason) + assertTrue(event.targetClassName?.contains("type=QUICK_CONFIRMATION") == true) + assertTrue(event.targetClassName?.contains("recovery=unavailable") == true) + } + + @Test + fun quickConfirmationClicksOnlyUniqueCurrentSpecOnceThenUsesNormalPanel() { + val events = mutableListOf() + val driver = FakeCollectorDriver( + colors = listOf("红色"), + startWithPanelClosed = true, + quickConfirmationAfterEntry = true, + ) + var clock = 0L + + val result = PddProductDetailCollector( + driver, + { clock }, + { clock += it }, + taskId = 81, + diagnostic = events::add, + ).collect(GOODS_ID, rule()) + + assertTrue(result.successful) + assertEquals("单独购买", driver.clickedLabels.first()) + assertEquals(2, driver.clickedLabels.count { it == "红色" }) + assertFalse(driver.clickedLabels.any { it.contains("现在买") || it.contains("订单") || it.contains("支付") }) + val event = events.single { it.stage == AgentDiagnosticStage.SPEC_PANEL_ENTRY } + assertEquals(AgentDiagnosticReason.SPEC_PANEL_RECOVERED, event.reason) + assertTrue(event.targetClassName?.contains("recovery=success") == true) + } + @Test fun repeatedReviewMisrouteStopsAfterSingleRecovery() { val driver = FakeCollectorDriver(startWithPanelClosed = true, alwaysEntryOpensReview = true) @@ -779,6 +929,8 @@ class PddProductDetailCollectorTest { private val fixedSnapshot: UiSnapshot? = null, private val scrollablePanel: Boolean = true, private val verticalSwipeSucceeds: Boolean = true, + private val quickConfirmationAfterEntry: Boolean = false, + private val hideQuickConfirmationSpec: Boolean = false, ) : PddCollectorDriver { var captureCount = 0 var clickCount = 0 @@ -794,6 +946,7 @@ class PddProductDetailCollectorTest { private var priceRead = 0 private var panelOpen = !startWithPanelClosed private var reviewOpen = false + private var quickConfirmationRecovered = false override fun capture(): UiSnapshot { captureCount++ @@ -819,6 +972,20 @@ class PddProductDetailCollectorTest { node("buy/price", "¥10.00", 560, 1840, 760, 1910, parentPath = "buy"), node("buy/label", "单独购买", 780, 1840, 1040, 1910, parentPath = "buy"), )) + if (quickConfirmationAfterEntry && !quickConfirmationRecovered) { + return UiSnapshot(PDD_PACKAGE, ACTIVITY, buildList { + add(node("content", "", 0, 0, 1080, 2200, resourceId = "android:id/content", className = "android.widget.FrameLayout")) + add(node("close", "关闭", 980, 20, 1060, 100, clickable = true)) + add(node("summary", "已选:红色", 20, 300, 700, 360)) + add(node("color-heading", "颜色分类", 20, 420, 300, 470)) + if (!hideQuickConfirmationSpec) add(node("current-color", "红色", 36, 500, 340, 570, clickable = true)) + add(node("quantity", "1", 480, 1400, 600, 1480, className = "android.widget.EditText")) + add(node("decrease", "减少数量", 360, 1400, 470, 1480, clickable = true)) + add(node("increase", "增加数量", 610, 1400, 720, 1480, clickable = true)) + add(node("payment", "微信支付", 20, 1550, 400, 1620)) + add(node("buy-now", "现在买", 500, 1800, 1080, 2180, clickable = true)) + }) + } val nodes = mutableListOf( node("content", "", 0, 0, 1080, 2200, resourceId = "android:id/content", className = "android.widget.FrameLayout"), node("title", "这是一个足够长的拼多多测试商品标题", 20, 100, 1000, 180), @@ -894,6 +1061,10 @@ class PddProductDetailCollectorTest { } return FreshActionResult.SUCCESS } + if (quickConfirmationAfterEntry && !quickConfirmationRecovered && target.label == "红色") { + quickConfirmationRecovered = true + return FreshActionResult.SUCCESS + } if (target.label in acceptedClicksWithoutEffect) return FreshActionResult.SUCCESS previousSelected = selected selected = target.label