fix: separate PDD spec panel evidence from options (#112)

This commit is contained in:
QiuSW
2026-08-27 16:57:40 +08:00
parent a10f9833e7
commit c782103b6f
4 changed files with 374 additions and 34 deletions
+2 -2
View File
@@ -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"
@@ -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<VisibleSpecValue>)
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<SnapshotNode>,
) {
@@ -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<VisibleSpecValue>): SizeContinuation? {
val byPath = screen.sourceNodes.associateBy(SnapshotNode::path)
val containers = values.mapNotNull { value ->
@@ -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,
@@ -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<AgentDiagnosticEvent>()
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<AgentDiagnosticEvent>()
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