fix: separate PDD spec panel evidence from options (#112)
This commit is contained in:
@@ -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"
|
||||
|
||||
|
||||
+193
-31
@@ -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,
|
||||
|
||||
+172
-1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user