Compare commits

..
Author SHA1 Message Date
QiuSWandClaude Sonnet 5.5 d45a9b9a24 feat(android): add sanitized spec-normalize trace (#379)
规格规范化冲突或作废时输出一条脱敏 trace(每维每类最多一条,仅 Log.i 过程日志):
spec-normalize dim outcome reason path pass sameFrame onlyTrailingPriceDiff
rawVariants candidates badgeCaptionMerged。只含计数与枚举值,不输出节点原文。
ParsedPddScreen 新增 badgeCaptionMerged 计数(合并的价格加徽标说明卡片数)。

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-10-12 14:53:16 +08:00
QiuSWandClaude Sonnet 5.5 b86f858cb7 fix(android): merge truncated color caption with trailing price and badge (#379)
采购1 上 PDD 说明文字形如「名称...⏎ ¥11.8 零差评」,价格后带徽标,末尾价格
规范化去不掉,导致卡片未合并、说明值含 ¥ 而整维清空颜色。

isTruncatedDisplayCard 仅在说明文字规范化后仍含货币符号时,尝试严格后缀
「空白* ¥金额 空白+ 徽标$」(徽标 1-8 个不含货币符号、数字、空白的字符),
取价格前部分并去掉末尾省略号后再做前缀比较;要求金额等于外层全名的末尾价格,
外层无价格或金额不等则不合并。#362 的结构条件全部保留,不改公共语法。

测试:按采购1 结构合成快照(3 色、无重复、无 invalid),覆盖金额不等、徽标含 ¥、
徽标超长或含空白、外层无价格、前缀不符、卡片内第二个可点文字、无徽标截断回归。

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-10-12 14:45:58 +08:00
QiuSWandClaude Sonnet 5.5 e966d0f702 fix(android): keep spec dimension on normalized-name conflict (#379)
规格值规范化后同名但原文不同时,不再整维清空,而是保留一个值并在
missing 写入 spec_dimension_conflict:size|color(任务为 completed_partial);
规范化后为空或仍含货币符号仍整维拒绝(spec_dimension_invalid)。

尺码续页路径与正常路径一致做末尾价格规范化(rawText 保留原标签),
并在按 text 去重之前由 Tracker 观察每个候选,避免冲突被 groupBy 吞掉。

测试:更新 #160 的重复尺码/颜色用例为保留并标记冲突,补充跨帧、
续页、残留货币与空值整维拒绝用例。

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-10-12 14:43:12 +08:00
QiuSWandClaude Opus 5.5 2ae3e8115f perf(android): fixed settle wait and separate reset budget for color grid (#370)
- Replace the polling settle (up to 4 extra parses) after each horizontal
  swipe with one fixed 1200ms wait; no extra parse, clicks unchanged.
- moveColorsToStart gets its own budget (2x specHorizontalSwipes); anchor
  retries no longer consume swipes (max 2 consecutive, 6 total).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-10-12 03:15:30 +08:00
QiuSWandClaude Opus 5.5 9619c38ae3 fix(android): wait for color grid to settle after horizontal swipe (#370)
After each successful horizontal swipe in color discovery, re-read the
color rows until two adjacent reads agree on text and bounds (at most four
extra reads, 200ms apart) before collecting. No extra clicks, never fails.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-10-10 17:44:49 +08:00
QiuSWandClaude Opus 5.5 037459391a fix(android): only skip snake sweep from a confirmed left edge (#370)
moveColorsToStart now reports whether the left edge was confirmed by
stable signatures and retries anchor failures with another color node
(within the horizontal swipe budget). The reverse-sweep skip is allowed
only for row 0 of the first vertical pass when the sweep began at a
confirmed left edge; otherwise the full snake sweep is kept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-10-10 17:37:54 +08:00
QiuSWandClaude Opus 5.5 74b71e2b5a fix(android): skip redundant snake sweep for shared color grids (#370)
Decouple the shared-container skip from the diagnostic uncertainty flag so
click-induced layout changes no longer force a reverse sweep, and add
color-row / color-row-end / color-click-miss trace logs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-10-10 17:06:30 +08:00
QiuSWandClaude Opus 5.5 2f24b4b5b4 fix(android): detect shared color grid movement by position (#370)
Shared-container detection compared visible color text sets, so a short
last row whose options only shifted (partly visible) looked unmoved and
triggered a reverse sweep. Compare text+bounds position signatures instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-10-10 16:19:01 +08:00
QiuSWandClaude Opus 5.5 d8b4f3a9d5 fix(android): track horizontal color rows frame to frame (#370)
Follow each horizontal color row by overlap with its previous frame (with a
position fallback for whole-page moves on multi-row grids), skip reverse
sweeps when rows share one container, and keep the earliest abnormal
COLOR_DISCOVERY reason.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-10-10 15:04:06 +08:00
5 changed files with 1149 additions and 243 deletions
@@ -69,6 +69,9 @@ interface PddCollectorDriver {
reason = FreshClickReason.UNKNOWN,
)
fun swipeSpec(direction: SwipeDirection, anchor: SnapshotNode? = null): Boolean
// Why the latest swipeSpec returned false (anchorMissing / anchorAmbiguous
// mean the gesture anchor no longer resolves to exactly one node).
fun specRowSwipeFailureReason(): String = "unknown"
fun pullDownSpecPanel(anchor: SnapshotNode): Boolean = swipeSpec(SwipeDirection.DOWN, anchor)
fun pullDownGoodsPage(): Boolean
fun back(): Boolean
@@ -169,6 +172,8 @@ data class ParsedPddScreen(
val requiredEvidenceSameContainer: Boolean = false,
/** Only populated by the final-confirmation opt-in; existing selectedSummary behavior is unchanged. */
val finalSelectionSummaries: List<String> = emptyList(),
/** #379: option cards whose truncated caption (price + badge) was merged into the outer card; count only. */
val badgeCaptionMerged: Int = 0,
) {
/** The three #331 required spec-panel items are all visible. */
val hasRequiredPanelEvidence: Boolean get() = hasAddressEntry && hasPaymentEntry && hasQuantityInput
@@ -245,6 +250,8 @@ object PddScreenParser {
private const val PDD_PACKAGE = "com.xunmeng.pinduoduo"
private data class SafeSpecEntry(val anchor: SnapshotNode, val clickTarget: SnapshotNode)
private val pricePattern = Regex("[¥¥]\\s*([0-9]+(?:\\.[0-9]{1,2})?)")
private val badgeCaptionSuffix =
Regex("[\\s\\u00a0\\u3000]*[¥¥][\\s\\u00a0\\u3000]*(\\d+(?:\\.\\d{1,2})?)[\\s\\u00a0\\u3000]+[^¥¥\\d\\s\\u00a0\\u3000]{1,8}$")
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(
@@ -371,6 +378,7 @@ object PddScreenParser {
visible.filter { it.path == region.path || it.path.startsWith("${region.path}/") }
} ?: visible
val headings = panelVisible.filter { !it.clickable && isHeading(it.label, config) }.sortedBy { it.bounds.top }
val badgeMergedBlocks = mutableSetOf<String>()
val dimensions = buildList<VisibleDimension> {
headings.forEachIndexed { index, heading ->
val lower = headings.getOrNull(index + 1)?.bounds?.top ?: Int.MAX_VALUE
@@ -394,7 +402,7 @@ object PddScreenParser {
// node is still chosen by safeOptionRank among the members.
val values = candidates
.groupBy { candidate ->
optionBlock(candidate, candidates, visibleByPath, sourceByPath, ::inValueRegion)?.path ?: candidate.path
optionBlock(candidate, candidates, visibleByPath, sourceByPath, ::inValueRegion) { badgeMergedBlocks += it }?.path ?: candidate.path
}
.map { (blockPath, members) ->
val block = visibleByPath[blockPath]
@@ -593,6 +601,7 @@ object PddScreenParser {
problem = problem,
sourceNodes = visibleNodes,
isPddPackage = snapshot.packageName == PDD_PACKAGE,
badgeCaptionMerged = badgeMergedBlocks.size,
)
}
@@ -612,6 +621,7 @@ object PddScreenParser {
visibleByPath: Map<String, SnapshotNode>,
sourceByPath: Map<String, SnapshotNode>,
inValueRegion: (SnapshotNode) -> Boolean,
onBadgeMerge: (String) -> Unit,
): SnapshotNode? {
fun compact(value: String) = value.replace(Regex("\\s+"), "")
var block: SnapshotNode? = null
@@ -626,7 +636,7 @@ object PddScreenParser {
val outer = compact(resolved.label)
val members = candidates.filter { it.path == resolved.path || it.path.startsWith("${resolved.path}/") }
val existingBlock = outer.isNotEmpty() && members.all { compact(it.label).startsWith(outer) }
if (!existingBlock && !isTruncatedDisplayCard(resolved, members, sourceByPath)) break
if (!existingBlock && !isTruncatedDisplayCard(resolved, members, sourceByPath) { onBadgeMerge(resolved.path) }) break
block = resolved
}
parentPath = raw.parentPath
@@ -656,6 +666,7 @@ object PddScreenParser {
block: SnapshotNode,
members: List<SnapshotNode>,
sourceByPath: Map<String, SnapshotNode>,
onBadgeMerge: () -> Unit,
): Boolean {
fun name(label: String) = SpecValueNormalizer.normalizeColor(label).filterNot { it.isWhitespace() }
val full = name(block.label)
@@ -664,7 +675,11 @@ object PddScreenParser {
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)
// #379: a caption such as "名称...\n ¥11.8 零差评" keeps a price plus a badge
// after its truncated name, which the terminal-price grammar cannot strip.
val plainCaption = SpecValueNormalizer.normalizeColor(caption.label)
val badgeName = if (plainCaption.any { it == '¥' || it == '¥' }) badgeCaptionName(caption.label, block.label) else null
val short = (badgeName ?: plainCaption).filterNot { it.isWhitespace() }
if (short.isEmpty() || short == full || !full.startsWith(short)) return false
val descendants = sourceByPath.values.filter { it.path.startsWith("${block.path}/") }
@@ -682,7 +697,24 @@ object PddScreenParser {
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 }
val merged = prices.isEmpty() || prices.all { it.compareTo(prices.first()) == 0 }
if (merged && badgeName != null) onBadgeMerge()
return merged
}
/**
* #379: truncated name of a caption shaped `名称... ¥金额 徽标`, or null. The
* badge is 1-8 characters without currency symbols, digits or whitespace and
* the amount must equal the outer card's terminal price.
*/
private fun badgeCaptionName(captionLabel: String, fullLabel: String): String? {
val match = badgeCaptionSuffix.find(captionLabel) ?: return null
val amount = match.groupValues[1].toBigDecimalOrNull() ?: return null
val fullAmount = SpecValueNormalizer.trailingPriceAmount(fullLabel) ?: return null
if (amount.compareTo(fullAmount) != 0) return null
val head = captionLabel.substring(0, match.range.first).filterNot { it.isWhitespace() }
.replace(Regex("(?:\\.{3}|…)+$"), "")
return head.takeIf { it.isNotEmpty() && head.none { ch -> ch == '¥' || ch == '¥' } }
}
/** The nearby clickable row holding [node], or null when there is none or it is not a plausible entry row. */
@@ -1319,7 +1351,7 @@ class PddProductDetailCollector(
val missing = linkedSetOf<String>()
val unsupported = linkedSetOf<String>()
val colorImages = linkedMapOf<String, ByteArray>()
val colorNormalization = ColorNormalizationTracker()
val colorNormalization = ColorNormalizationTracker(trace)
val specPanelContainer = openedScreen.specPanelContainer
collectColors(goodsId, config, evidence, deadline, specPanelContainer, colors, prices, missing, unsupported, colorImages, colorNormalization)?.let { return it }
@@ -1328,15 +1360,20 @@ class PddProductDetailCollector(
prices.clear()
colorImages.clear()
missing += "spec_dimension_invalid:color"
} else if (colorNormalization.conflict) {
// #379: same normalized name from different raw labels keeps one value.
missing += "spec_dimension_conflict:color"
}
moveSpecPanelToTop(goodsId, config, evidence, deadline, specPanelContainer)?.let { return it }
val sizes = linkedMapOf<String, Boolean>()
val sizeNormalization = SizeNormalizationTracker()
val sizeNormalization = SizeNormalizationTracker(trace)
collectSizes(goodsId, config, evidence, deadline, specPanelContainer, sizes, unsupported, sizeNormalization)?.let { return it }
if (sizeNormalization.invalid) {
sizes.clear()
missing += "spec_dimension_invalid:size"
} else if (sizeNormalization.conflict) {
missing += "spec_dimension_conflict:size"
}
if (colors.isEmpty()) missing += "color"
@@ -1412,6 +1449,14 @@ class PddProductDetailCollector(
// These signatures stay in memory; only the aggregate tri-state is saved.
val diagnosticHorizontalRows = mutableMapOf<Set<String>, Boolean?>()
var diagnosticTermination = AgentDiagnosticReason.COLOR_FOUND
// Keep the earliest abnormal reason; later normal ends must not hide it.
fun terminate(reason: AgentDiagnosticReason) {
if (diagnosticTermination == AgentDiagnosticReason.COLOR_FOUND ||
diagnosticTermination == AgentDiagnosticReason.COLOR_EDGE_REACHED
) {
diagnosticTermination = reason
}
}
val imageAttempts = mutableSetOf<String>()
fun collectVisibleImages(values: List<VisibleSpecValue>) {
@@ -1448,7 +1493,7 @@ class PddProductDetailCollector(
diagnosticRowValues.getOrPut(index) { linkedSetOf() }.addAll(row.map(VisibleSpecValue::text))
}
val values = rows.flatten()
values.forEach(normalization::observe)
normalization.observeFrame(values, "parsed", null, screen.badgeCaptionMerged)
diagnosticParsedValues += values.map(VisibleSpecValue::text)
diagnosticNonClickableCandidates = maxOf(
diagnosticNonClickableCandidates,
@@ -1479,7 +1524,9 @@ class PddProductDetailCollector(
}
moveSpecPanelToTop(goodsId, config, evidence, deadline, specPanelContainer)?.let { return it }
moveColorsToStart(goodsId, config, evidence, deadline)?.let { return it }
val colorStart = moveColorsToStart(goodsId, config, evidence, deadline)
colorStart.failure?.let { return it }
val colorStartConfirmed = colorStart.confirmed
val initial = parse(goodsId, config, evidence)
initial.problem?.let { return failure(it.code, it.message) }
diagnosticInitialSelectedColors = initial.dimensions.filter { it.key == "color" }.flatMap { it.values }
@@ -1530,6 +1577,7 @@ class PddProductDetailCollector(
FreshActionResult.BLOCKED -> return sizeAdviceFailure()
FreshActionResult.AMBIGUOUS -> return failure("RULE_AMBIGUOUS", "颜色“${value.text}”匹配到多个控件")
FreshActionResult.NOT_FOUND, FreshActionResult.FAILED -> {
traceColorClickMiss(screen, value, rows, clickResult)
missing += "selection:${value.text}"
continue
}
@@ -1561,7 +1609,18 @@ class PddProductDetailCollector(
// established left/right paging behavior.
if (rows.any { it.size > 1 }) {
val rowSeeds = rows.map { row -> row.map(VisibleSpecValue::text).toSet() }
// Frame-to-frame row tracking: rows are followed by overlap with
// their previous frame, not by the pass's starting frame.
val tracks = rows.map(::colorRowTrack).toMutableList()
val coveredRows = mutableSetOf<Int>()
var coveredIncomplete: Boolean? = null
rowSeeds.forEachIndexed { rowIndex, seed ->
if (rowIndex in coveredRows) {
// A shared container already swept this row's values.
diagnosticHorizontalRows[seed] = coveredIncomplete
?: diagnosticHorizontalRows[seed]?.takeIf { it }
return@forEachIndexed
}
val moveRight = rowIndex % 2 == 0
val horizontalSignatureReads = mutableMapOf<List<String>, Int>()
var diagnosticPreviousSignature: List<String>? = null
@@ -1569,11 +1628,15 @@ class PddProductDetailCollector(
var diagnosticMoved = false
var diagnosticRowUncertain = false
var diagnosticRowIncomplete: Boolean? = null
var sharedMoves = 0
var sharedBroken = tracks.size < 2
var rowEnd = "limit"
var rowSkipNote = "none"
for (horizontalPass in 0..config.limits.getValue("specHorizontalSwipes")) {
var diagnosticBeforeClicks: List<String>? = null
collectVisibleColors { observedRows ->
diagnosticBeforeClicks = observedRows.filter { row -> row.any { it.text in seed } }
.singleOrNull()?.sortedBy { it.node.bounds.left }
diagnosticBeforeClicks = matchColorRow(tracks[rowIndex], observedRows, tracks.size, requireUniqueOverlap = true)
?.sortedBy { it.node.bounds.left }
?.let { if (moveRight) it else it.reversed() }?.let(::optionSignature)
}?.let { return it }
screen = parse(goodsId, config, evidence)
@@ -1581,11 +1644,37 @@ class PddProductDetailCollector(
if (!screen.pageEvidenceMatched) return failure("RULE_NOT_MATCHED", "采集期间离开 PDD 商品详情页")
rows = colorRows(screen)
observeColorDiscovery(screen, rows)
val matchedRow = rows.maxByOrNull { row -> row.count { it.text in seed } }
?.takeIf { row -> row.any { it.text in seed } }
val matches = tracks.map { matchColorRow(it, rows, tracks.size) }
val matchedRow = matches[rowIndex]
val matchKind = colorRowMatchKind(tracks[rowIndex], rows, tracks.size)
val rowMoved = matchedRow != null && positionSignature(matchedRow) != tracks[rowIndex].signature
val othersDesc = tracks.indices.filter { it != rowIndex }.joinToString(",") { other ->
val match = matches[other]
when {
match == null -> "none"
positionSignature(match) != tracks[other].signature -> "moved"
else -> "unchanged"
}
}
if (rowMoved && matchedRow != null) {
sharedMoves++
// Position (text + bounds), not the text set: a partial
// last swipe moves options without changing which are visible.
val everyOtherRowMoved = tracks.indices.all { other ->
other == rowIndex || matches[other]?.let { match ->
positionSignature(match) != tracks[other].signature
} == true
}
if (!everyOtherRowMoved) sharedBroken = true
}
matches.forEachIndexed { index, match ->
match?.let { tracks[index] = colorRowTrack(it) }
}
if (matchedRow == null) {
trace("color-row row=$rowIndex dir=${if (moveRight) "LEFT" else "RIGHT"} match=$matchKind moved=$rowMoved others=$othersDesc sharedMoves=$sharedMoves sharedBroken=$sharedBroken")
rowEnd = "reflow"
diagnosticHorizontalUnknown = true
diagnosticTermination = AgentDiagnosticReason.COLOR_ROW_REFLOWED
terminate(AgentDiagnosticReason.COLOR_ROW_REFLOWED)
break
}
val currentRow = matchedRow.sortedBy { it.node.bounds.left }
@@ -1605,24 +1694,57 @@ class PddProductDetailCollector(
diagnosticPreviousSignature = signature
val signatureReads = (horizontalSignatureReads[signature] ?: 0) + 1
horizontalSignatureReads[signature] = signatureReads
trace("color-row row=$rowIndex dir=${if (moveRight) "LEFT" else "RIGHT"} match=$matchKind moved=$rowMoved others=$othersDesc sharedMoves=$sharedMoves sharedBroken=$sharedBroken uncertain=$diagnosticRowUncertain signatureReads=$signatureReads")
if (signatureReads > config.limits.getValue("stableEdgeReads")) {
rowEnd = "edge"
if (!diagnosticRowUncertain && diagnosticStableReads >= config.limits.getValue("stableEdgeReads")) {
diagnosticRowIncomplete = false
}
// The skip depends only on reaching this row's edge
// with every other row having followed every move,
// not on the diagnostic certainty: a click-induced
// layout change must not force a redundant sweep.
val rest = (rowIndex + 1 until tracks.size).toList()
if (rest.isNotEmpty()) {
// The reverse sweep is the only thing that recovers
// values left of an unconfirmed start, so skipping it
// needs a sweep that began at a confirmed left edge.
val skipOrigin = when {
!colorStartConfirmed -> "start_unconfirmed"
verticalPass != 0 -> "later_vertical_pass"
rowIndex != 0 -> "not_first_row"
else -> null
}
if (skipOrigin != null) {
rowSkipNote = "not_skipped reason=$skipOrigin"
} else if (!sharedBroken && sharedMoves > 0) {
coveredRows += rest
coveredIncomplete = diagnosticRowIncomplete
rowSkipNote = "skipped=${rest.joinToString(",")}"
} else {
rowSkipNote = "not_skipped reason=${if (sharedMoves == 0) "no_shared_moves" else "shared_broken"}"
}
}
break
}
if (horizontalPass == config.limits.getValue("specHorizontalSwipes")) {
rowEnd = "limit"
if (diagnosticMoved && !diagnosticRowUncertain) diagnosticRowIncomplete = true
break
}
val direction = if (moveRight) SwipeDirection.LEFT else SwipeDirection.RIGHT
if (!driver.swipeSpec(direction, currentRow.first().node)) {
rowEnd = "swipe_failed"
if (diagnosticMoved && !diagnosticRowUncertain) diagnosticRowIncomplete = true
break
}
diagnosticHorizontalSwipes++
pause(350)
val settleStartedAt = now()
pause(HORIZONTAL_SETTLE_MILLIS)
lastHorizontalSwipeDoneAt = now()
trace("color-settle waitedMs=${now() - settleStartedAt}")
}
trace("color-row-end row=$rowIndex end=$rowEnd skip=$rowSkipNote")
// An uncertain revisit cannot erase previously observed
// unresolved movement; only reliable stability clears it.
diagnosticHorizontalRows[seed] = diagnosticRowIncomplete
@@ -1640,7 +1762,7 @@ class PddProductDetailCollector(
diagnosticTrailingEmptyReads = if (rows.isEmpty()) diagnosticTrailingEmptyReads + 1 else 0
if (rows.isEmpty()) diagnosticHorizontalUnknown = true
if (!discoverVertically) {
diagnosticTermination = AgentDiagnosticReason.COLOR_EDGE_REACHED
terminate(AgentDiagnosticReason.COLOR_EDGE_REACHED)
break
}
@@ -1652,21 +1774,21 @@ class PddProductDetailCollector(
}
previousVerticalSignature = verticalSignature
if (verticalStable >= config.limits.getValue("stableEdgeReads")) {
diagnosticTermination = AgentDiagnosticReason.COLOR_EDGE_REACHED
terminate(AgentDiagnosticReason.COLOR_EDGE_REACHED)
break
}
if (verticalPass == config.limits.getValue("specVerticalSwipes")) {
diagnosticTermination = AgentDiagnosticReason.COLOR_SCAN_LIMIT
terminate(AgentDiagnosticReason.COLOR_SCAN_LIMIT)
break
}
val anchor = rows.flatten().firstOrNull()?.node ?: specPanelContainer
if (anchor == null) {
diagnosticHorizontalUnknown = true
diagnosticTermination = AgentDiagnosticReason.COLOR_CONTAINER_UNAVAILABLE
terminate(AgentDiagnosticReason.COLOR_CONTAINER_UNAVAILABLE)
break
}
if (!driver.swipeSpec(SwipeDirection.UP, anchor)) {
diagnosticTermination = AgentDiagnosticReason.COLOR_SWIPE_FAILED
terminate(AgentDiagnosticReason.COLOR_SWIPE_FAILED)
break
}
diagnosticVerticalSwipes++
@@ -1678,6 +1800,31 @@ class PddProductDetailCollector(
private fun sizeAdviceFailure() = failure("SIZE_ADVICE_CLICK_BLOCKED", "已阻止点击尺码建议入口")
private var lastHorizontalSwipeDoneAt = -1L
private fun traceColorClickMiss(screen: ParsedPddScreen, value: VisibleSpecValue, rows: List<List<VisibleSpecValue>>, result: FreshActionResult) {
val rowIndex = rows.indexOfFirst { row -> row.any { it.text == value.text } }
val column = rows.getOrNull(rowIndex).orEmpty().sortedBy { it.node.bounds.left }.indexOfFirst { it.text == value.text }
val bounds = value.node.bounds
val width = bounds.right - bounds.left
val byPath = screen.sourceNodes.associateBy(SnapshotNode::path)
var container: SnapshotNode? = byPath[value.node.parentPath]
var hops = 0
while (container != null && !container.scrollable && hops < 6) {
container = container.parentPath?.let { byPath[it] }
hops++
}
val exposed = if (width > 0 && container != null) {
val shown = (minOf(bounds.right, container.bounds.right) - maxOf(bounds.left, container.bounds.left)).coerceAtLeast(0)
"${shown * 100 / width}%"
} else {
"unknown"
}
val containerText = container?.let { "${it.bounds}" } ?: "unknown"
val sinceSwipe = if (lastHorizontalSwipeDoneAt < 0) "none" else "${now() - lastHorizontalSwipeDoneAt}ms"
trace("color-click-miss color=${traceLabel(value.text)} result=$result row=$rowIndex column=$column bounds=$bounds container=$containerText exposed=$exposed sinceSwipe=$sinceSwipe")
}
private fun clickSpecTarget(target: SnapshotNode, stage: AgentDiagnosticStage): FreshActionResult {
val outcome = driver.clickFreshDetailed(target)
if (taskId > 0) runCatching { diagnostic(specClickDiagnostic(taskId, stage, target, outcome)) }
@@ -1837,31 +1984,72 @@ class PddProductDetailCollector(
return null
}
private class ColorStart(val failure: PddCollectorResult?, val confirmed: Boolean)
private fun moveColorsToStart(
goodsId: String,
config: PddCollectorConfig,
evidence: PageEvidence,
deadline: Long,
): PddCollectorResult? {
): ColorStart {
var previous = emptyList<String>()
var stable = 0
repeat(config.limits.getValue("specHorizontalSwipes")) {
if (now() > deadline) return failure("RULE_NOT_MATCHED", "采集超过规则总超时")
var swipes = 0
var anchorRetries = 0
var anchorFailures = 0
var confirmed = false
var end = "limit"
// Reset has its own swipe budget; anchor retries do not consume it.
val maxSwipes = config.limits.getValue("specHorizontalSwipes") * 2
while (swipes < maxSwipes) {
if (now() > deadline) return ColorStart(failure("RULE_NOT_MATCHED", "采集超过规则总超时"), false)
val screen = parse(goodsId, config, evidence)
screen.problem?.let { return failure(it.code, it.message) }
if (!screen.pageEvidenceMatched) return failure("RULE_NOT_MATCHED", "采集期间离开 PDD 商品详情页")
screen.problem?.let { return ColorStart(failure(it.code, it.message), false) }
if (!screen.pageEvidenceMatched) return ColorStart(failure("RULE_NOT_MATCHED", "采集期间离开 PDD 商品详情页"), false)
val rows = colorRows(screen)
if (rows.size > 1 && rows.all { it.size == 1 }) return null
if (rows.size > 1 && rows.all { it.size == 1 }) {
end = "single_column"
break
}
val values = rows.flatten()
val signature = optionSignature(values)
stable = if (signature == previous) stable + 1 else 0
previous = signature
if (stable >= config.limits.getValue("stableEdgeReads")) return null
val anchor = values.firstOrNull()?.node ?: return null
if (!driver.swipeSpec(SwipeDirection.RIGHT, anchor)) return null
if (stable >= config.limits.getValue("stableEdgeReads")) {
confirmed = true
end = "stable"
break
}
// After an anchor failure, try a different color node of this frame.
val anchor = values.getOrNull(anchorFailures)?.node ?: values.firstOrNull()?.node
if (anchor == null) {
end = "no_anchor"
break
}
if (!driver.swipeSpec(SwipeDirection.RIGHT, anchor)) {
val reason = driver.specRowSwipeFailureReason()
anchorFailures++
// A failed gesture says nothing about the position, so the next
// equal read must not count as stability.
previous = emptyList()
stable = 0
if (
(reason == "anchorMissing" || reason == "anchorAmbiguous") &&
anchorFailures <= MAX_CONSECUTIVE_RESET_ANCHOR_FAILURES &&
anchorRetries < MAX_RESET_ANCHOR_RETRIES
) {
anchorRetries++
continue
}
end = "swipe_failed"
break
}
anchorFailures = 0
swipes++
pause(350)
}
return null
trace("color-start confirmed=$confirmed swipes=$swipes anchorRetries=$anchorRetries end=$end")
return ColorStart(null, confirmed)
}
private fun collectSizes(
@@ -1887,9 +2075,17 @@ class PddProductDetailCollector(
screen.dimensions.filter { it.key == "unsupported" }.forEach { unsupported += it.name }
val parsedSizes = screen.dimensions.filter { it.key == "size" }.flatMap { it.values }
if (parsedSizes.isNotEmpty()) continuation = buildSizeContinuation(screen, parsedSizes)
val visible = if (parsedSizes.isNotEmpty()) parsedSizes else continuationValues(screen, continuation, config)
// #379: observe every candidate before continuation de-duplication,
// otherwise a same-name conflict is swallowed by groupBy.
val continuationCandidates = if (parsedSizes.isNotEmpty()) emptyList() else continuationCandidates(screen, continuation, config)
normalization.observeFrame(
if (parsedSizes.isNotEmpty()) parsedSizes else continuationCandidates,
if (parsedSizes.isNotEmpty()) "parsed" else "continuation",
pass,
screen.badgeCaptionMerged,
)
val visible = if (parsedSizes.isNotEmpty()) parsedSizes else dedupeContinuation(continuationCandidates)
visible.forEach { value ->
normalization.observe(value)
if (SpecValueNormalizer.isSafeSize(value.text)) {
sizes[value.text] = sizes[value.text] == true || value.available
}
@@ -1934,37 +2130,75 @@ class PddProductDetailCollector(
return null
}
private class SizeNormalizationTracker {
private val sources = linkedMapOf<String, MutableSet<String>>()
/**
* #160/#379: an empty or currency-bearing normalized value rejects the whole
* dimension ([invalid]); a normalized name backed by several raw labels only
* marks a [conflict] and the dimension keeps a single value for that name.
*/
private open class SpecNormalizationTracker(
private val dim: String,
private val isSafe: (String) -> Boolean,
private val trace: (String) -> Unit,
) {
// normalized text -> raw label -> frames in which that raw label was seen
private val sources = linkedMapOf<String, LinkedHashMap<String, MutableSet<Int>>>()
private var frameCounter = 0
private var invalidTraced = false
private var conflictTraced = false
var invalid: Boolean = false
private set
var conflict: Boolean = false
private set
fun observe(value: VisibleSpecValue) {
if (!SpecValueNormalizer.isSafeSize(value.text)) {
invalid = true
return
/** [pass] identifies the frame; without it the tracker numbers its own observations. */
fun observeFrame(values: List<VisibleSpecValue>, path: String, pass: Int?, badgeMerged: Int) {
val frame = pass ?: ++frameCounter
values.forEach { value ->
if (!isSafe(value.text)) {
invalid = true
if (!invalidTraced) {
invalidTraced = true
val reason = if (value.text.isBlank()) "empty" else "unsafe_currency"
traceOutcome("invalid_rejected", reason, path, frame, false, false, 1, values.size, badgeMerged)
}
return@forEach
}
val raws = sources.getOrPut(value.text) { linkedMapOf() }
val isNewRaw = value.rawText !in raws
raws.getOrPut(value.rawText) { linkedSetOf() } += frame
if (raws.size > 1) {
conflict = true
if (isNewRaw && !conflictTraced) {
conflictTraced = true
traceOutcome(
"conflict_kept", "normalized_conflict", path, frame,
raws.values.count { frame in it } > 1,
raws.keys.map { it.replace(TRAILING_PRICE, "") }.toSet().size == 1,
raws.size, values.size, badgeMerged,
)
}
}
}
val rawValues = sources.getOrPut(value.text) { linkedSetOf() }
rawValues += value.rawText
if (rawValues.size > 1) invalid = true
}
// Counts and enum values only: never the raw node text.
private fun traceOutcome(
outcome: String, reason: String, path: String, pass: Int, sameFrame: Boolean,
onlyTrailingPriceDiff: Boolean, rawVariants: Int, candidates: Int, badgeMerged: Int,
) {
trace(
"spec-normalize dim=$dim outcome=$outcome reason=$reason path=$path pass=$pass sameFrame=$sameFrame " +
"onlyTrailingPriceDiff=$onlyTrailingPriceDiff rawVariants=$rawVariants candidates=$candidates " +
"badgeCaptionMerged=$badgeMerged",
)
}
}
private class ColorNormalizationTracker {
private val sources = linkedMapOf<String, MutableSet<String>>()
var invalid: Boolean = false
private set
private class SizeNormalizationTracker(trace: (String) -> Unit) :
SpecNormalizationTracker("size", SpecValueNormalizer::isSafeSize, trace)
fun observe(value: VisibleSpecValue) {
if (!SpecValueNormalizer.isSafeColor(value.text)) {
invalid = true
return
}
val rawValues = sources.getOrPut(value.text) { linkedSetOf() }
rawValues += value.rawText
if (rawValues.size > 1) invalid = true
}
}
private class ColorNormalizationTracker(trace: (String) -> Unit) :
SpecNormalizationTracker("color", SpecValueNormalizer::isSafeColor, trace)
private fun recordSizeDiscovery(
reason: AgentDiagnosticReason,
@@ -2044,7 +2278,8 @@ class PddProductDetailCollector(
)
}
private fun continuationValues(screen: ParsedPddScreen, context: SizeContinuation?, config: PddCollectorConfig): List<VisibleSpecValue> {
/** Normalized continuation candidates before any de-duplication; rawText keeps the original label. */
private fun continuationCandidates(screen: ParsedPddScreen, context: SizeContinuation?, config: PddCollectorConfig): List<VisibleSpecValue> {
if (context == null) return emptyList()
val headingLabels = (config.colorAliases + config.sizeAliases + config.textAliases.dimension.exactNames +
config.textAliases.dimension.adaptiveAliases).map { it.replace(" ", "") }.toSet()
@@ -2059,13 +2294,21 @@ class PddProductDetailCollector(
.filterNot { it.label in context.colorLabels || it.label.replace(" ", "") in headingLabels }
.filterNot { PddScreenParser.isExcludedOptionLabel(it.label) || priceOnly.matches(it.label.replace(" ", "")) }
.map { node ->
VisibleSpecValue(node.label, node.enabled && listOf("售罄", "缺货", "不可选").none(node.label::contains), node)
VisibleSpecValue(
SpecValueNormalizer.normalizeSize(node.label),
node.enabled && listOf("售罄", "缺货", "不可选").none(node.label::contains),
node,
rawText = node.label,
)
}
.groupBy(VisibleSpecValue::text)
.map { (_, sameLabel) -> sameLabel.minBy { it.node.bounds.top * 10_000 + it.node.bounds.left } }
.sortedWith(compareBy({ it.node.bounds.top }, { it.node.bounds.left }))
.toList()
}
private fun dedupeContinuation(candidates: List<VisibleSpecValue>): List<VisibleSpecValue> = candidates
.groupBy(VisibleSpecValue::text)
.map { (_, sameLabel) -> sameLabel.minBy { it.node.bounds.top * 10_000 + it.node.bounds.left } }
.sortedWith(compareBy({ it.node.bounds.top }, { it.node.bounds.left }))
private fun stablePrice(
goodsId: String,
color: String,
@@ -2213,12 +2456,65 @@ class PddProductDetailCollector(
private fun traceLabel(value: String): String = value.replace(Regex("[\\r\\n\\t]+"), " ").take(40)
private companion object {
/** Same terminal-price grammar as [SpecValueNormalizer]; used only to classify a trace, never to normalize. */
val TRAILING_PRICE = Regex("""[\s\u00a0\u3000]*(?:¥|¥)[\s\u00a0\u3000]*\d+(?:\.\d{1,2})?[\s\u00a0\u3000]*$""")
const val PRICE_SETTLE_MILLIS = 300L
/** Fixed wait for the color grid to stop coasting after a horizontal swipe (no extra parse). */
const val HORIZONTAL_SETTLE_MILLIS = 1200L
const val MAX_RESET_ANCHOR_RETRIES = 6
const val MAX_CONSECUTIVE_RESET_ANCHOR_FAILURES = 2
const val SPEC_PANEL_LOST_STABLE_READS = 2
const val COLOR_IMAGE_MAX_COUNT = 64
const val COLOR_IMAGE_MAX_TOTAL_BYTES = 8 * 1024 * 1024
}
/** Last observed frame of one color row, used to follow it across horizontal swipes. */
private class ColorRowTrack(val texts: Set<String>, val centerY: Int, val tolerance: Int, val signature: List<String>)
private fun colorRowTrack(row: List<VisibleSpecValue>) = ColorRowTrack(
row.map(VisibleSpecValue::text).toSet(),
row.map { it.node.bounds.centerY }.average().toInt(),
(row.first().node.bounds.height / 2).coerceIn(24, 80),
positionSignature(row),
)
private fun colorRowMatchKind(
track: ColorRowTrack,
rows: List<List<VisibleSpecValue>>,
trackedRowCount: Int,
): String = when {
rows.any { row -> row.any { it.text in track.texts } } -> "overlap"
matchColorRow(track, rows, trackedRowCount) != null -> "position"
else -> "none"
}
private fun positionSignature(row: List<VisibleSpecValue>): List<String> =
optionSignature(row.sortedBy { it.node.bounds.left })
/**
* Finds the current-frame row for a tracked row. Overlap with the previous
* frame wins. With no overlap (a whole-page move) a multi-row grid whose
* row count is unchanged may match by vertical position, but only when
* exactly one current row sits within the colorRows tolerance. Anything
* else is not the same row and stays a reflow.
*/
private fun matchColorRow(
track: ColorRowTrack,
rows: List<List<VisibleSpecValue>>,
trackedRowCount: Int,
requireUniqueOverlap: Boolean = false,
): List<VisibleSpecValue>? {
val overlapping = rows.filter { row -> row.any { it.text in track.texts } }
if (overlapping.isNotEmpty()) {
if (requireUniqueOverlap && overlapping.size > 1) return null
return overlapping.maxByOrNull { row -> row.count { it.text in track.texts } }
}
if (trackedRowCount < 2 || rows.size != trackedRowCount) return null
return rows.filter { row ->
kotlin.math.abs(row.map { it.node.bounds.centerY }.average().toInt() - track.centerY) <= track.tolerance
}.singleOrNull()
}
private fun colorRows(screen: ParsedPddScreen): List<List<VisibleSpecValue>> {
val values = screen.dimensions.filter { it.key == "color" }.flatMap { it.values }
.sortedWith(compareBy({ it.node.bounds.centerY }, { it.node.bounds.left }))
@@ -74,8 +74,6 @@ class AgentStatusFragment : Fragment() {
private lateinit var mediaPermissionDetail: TextView
private lateinit var mediaPermissionAction: android.widget.Button
private lateinit var deviceText: TextView
private lateinit var deviceWifiText: TextView
private lateinit var deviceDebugText: TextView
private val mediaPermissionRequestLauncher by lazy {
registerForActivityResult(androidx.activity.result.contract.ActivityResultContracts.RequestPermission()) {
refreshStatus()
@@ -170,20 +168,6 @@ class AgentStatusFragment : Fragment() {
}
addView(mediaPermissionAction)
}))
addView(context.card(context.cardColumn().apply {
addView(context.label("设备连接", 14f, context.getColor(R.color.agent_text_muted)))
deviceWifiText = context.label("Wi-Fi IP:—", 18f, context.getColor(R.color.agent_text), true).apply {
setPadding(0, context.dp(6), 0, 0)
}
deviceDebugText = context.label("无线调试:—", 18f, context.getColor(R.color.agent_text_muted), true).apply {
setPadding(0, context.dp(6), 0, 0)
}
addView(deviceWifiText)
addView(deviceDebugText)
addView(context.label(DeviceConnectionPolicy.PORT_HINT, 14f, context.getColor(R.color.agent_text_muted)).apply {
setPadding(0, context.dp(6), 0, 0)
})
}))
}
refreshLayout = SwipeRefreshLayout(context).apply {
setColorSchemeResources(R.color.agent_primary_light)
@@ -261,10 +245,6 @@ class AgentStatusFragment : Fragment() {
private fun refreshStatus() {
if (!isAdded || view == null) return
val context = requireContext()
val deviceConnection = DeviceConnectionPolicy.display(DeviceConnectionReader.read(context))
deviceWifiText.text = deviceConnection.wifiText
deviceDebugText.text = deviceConnection.debugText
deviceDebugText.setTextColor(context.getColor(deviceConnection.debugColor))
val state = stateStore.read()
val connected = state.code in setOf("ONLINE", "BUSY", "COLLECTION_COOLDOWN")
connectionTitle.text = when (state.code) {
@@ -1,85 +0,0 @@
package cn.ilapage.goauto.agent.ui
import android.content.Context
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.provider.Settings
import androidx.annotation.ColorRes
import cn.ilapage.goauto.agent.R
import java.net.Inet4Address
import java.net.InetAddress
/** 无线调试开关的读取结果;读取失败为 [UNKNOWN]。 */
internal enum class WirelessDebugState { ENABLED, DISABLED, UNKNOWN }
internal data class DeviceConnectionReading(
val wifiIpv4: String?,
val wirelessDebug: WirelessDebugState,
)
internal data class DeviceConnectionDisplay(
val wifiText: String,
val debugText: String,
@ColorRes val debugColor: Int,
)
/** 原始读数到显示文案/颜色的纯函数策略,不依赖 Android 系统服务。 */
internal object DeviceConnectionPolicy {
const val PORT_HINT = "端口请在 开发者选项 → 无线调试 中查看"
const val NO_WIFI_TEXT = "未连接 Wi-Fi"
/** 仅当前网络为 Wi-Fi 时,从地址列表中取第一个非回环 IPv4;否则返回 null。 */
fun selectWifiIpv4(isWifi: Boolean, addresses: List<InetAddress>): String? {
if (!isWifi) return null
return addresses.firstOrNull { it is Inet4Address && !it.isLoopbackAddress }?.hostAddress
}
/** adb_wifi_enabled 原始值:1 开启,0 未开启,其他值或读取失败(null)为未知。 */
fun debugState(raw: Int?): WirelessDebugState = when (raw) {
1 -> WirelessDebugState.ENABLED
0 -> WirelessDebugState.DISABLED
else -> WirelessDebugState.UNKNOWN
}
fun display(reading: DeviceConnectionReading): DeviceConnectionDisplay {
val (text, color) = when (reading.wirelessDebug) {
WirelessDebugState.ENABLED -> "已开启" to R.color.agent_primary_light
WirelessDebugState.DISABLED -> "未开启" to R.color.agent_warning
WirelessDebugState.UNKNOWN -> "无法读取" to R.color.agent_text_muted
}
return DeviceConnectionDisplay(
wifiText = "Wi-Fi IP:${reading.wifiIpv4 ?: NO_WIFI_TEXT}",
debugText = "无线调试:$text",
debugColor = color,
)
}
}
/** 系统读取的薄封装;任何异常都降级为「未知」,不记录日志、不持久化。 */
internal object DeviceConnectionReader {
fun read(context: Context): DeviceConnectionReading = DeviceConnectionReading(
wifiIpv4 = readWifiIpv4(context),
wirelessDebug = DeviceConnectionPolicy.debugState(readDebugRaw(context)),
)
private fun readWifiIpv4(context: Context): String? = try {
val manager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
val network = manager?.activeNetwork
val isWifi = network?.let { manager.getNetworkCapabilities(it) }
?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true
val addresses = if (isWifi && manager != null && network != null) {
manager.getLinkProperties(network)?.linkAddresses?.map { it.address }.orEmpty()
} else {
emptyList()
}
DeviceConnectionPolicy.selectWifiIpv4(isWifi, addresses)
} catch (_: Exception) {
null
}
private fun readDebugRaw(context: Context): Int? = try {
Settings.Global.getInt(context.contentResolver, "adb_wifi_enabled", 0)
} catch (_: Exception) {
null
}
}
@@ -1,63 +0,0 @@
package cn.ilapage.goauto.agent
import cn.ilapage.goauto.agent.ui.DeviceConnectionPolicy
import cn.ilapage.goauto.agent.ui.DeviceConnectionReading
import cn.ilapage.goauto.agent.ui.WirelessDebugState
import java.net.InetAddress
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class DeviceConnectionPolicyTest {
private fun ip(text: String) = InetAddress.getByName(text)
@Test fun wifiWithIpv4ReturnsAddress() {
assertEquals("192.168.0.93", DeviceConnectionPolicy.selectWifiIpv4(true, listOf(ip("192.168.0.93"))))
}
@Test fun nonWifiReturnsNull() {
assertNull(DeviceConnectionPolicy.selectWifiIpv4(false, listOf(ip("10.0.0.2"))))
}
@Test fun wifiWithOnlyIpv6ReturnsNull() {
assertNull(DeviceConnectionPolicy.selectWifiIpv4(true, listOf(ip("2001:db8::1"))))
}
@Test fun wifiWithMultipleAddressesPicksIpv4() {
assertEquals(
"172.16.1.5",
DeviceConnectionPolicy.selectWifiIpv4(true, listOf(ip("2001:db8::1"), ip("172.16.1.5"))),
)
}
@Test fun loopbackIpv4IsSkipped() {
assertNull(DeviceConnectionPolicy.selectWifiIpv4(true, listOf(ip("127.0.0.1"))))
}
@Test fun debugRawValuesMapToState() {
assertEquals(WirelessDebugState.ENABLED, DeviceConnectionPolicy.debugState(1))
assertEquals(WirelessDebugState.DISABLED, DeviceConnectionPolicy.debugState(0))
assertEquals(WirelessDebugState.UNKNOWN, DeviceConnectionPolicy.debugState(2))
assertEquals(WirelessDebugState.UNKNOWN, DeviceConnectionPolicy.debugState(null))
}
@Test fun displayEnabledUsesPrimaryColor() {
val d = DeviceConnectionPolicy.display(DeviceConnectionReading("192.168.0.93", WirelessDebugState.ENABLED))
assertEquals("Wi-Fi IP:192.168.0.93", d.wifiText)
assertEquals("无线调试:已开启", d.debugText)
assertEquals(R.color.agent_primary_light, d.debugColor)
}
@Test fun displayDisabledUsesWarningColorAndNoWifiText() {
val d = DeviceConnectionPolicy.display(DeviceConnectionReading(null, WirelessDebugState.DISABLED))
assertEquals("Wi-Fi IP:未连接 Wi-Fi", d.wifiText)
assertEquals("无线调试:未开启", d.debugText)
assertEquals(R.color.agent_warning, d.debugColor)
}
@Test fun displayUnknownUsesMutedColor() {
val d = DeviceConnectionPolicy.display(DeviceConnectionReading(null, WirelessDebugState.UNKNOWN))
assertEquals("无线调试:无法读取", d.debugText)
assertEquals(R.color.agent_text_muted, d.debugColor)
}
}
@@ -9,6 +9,7 @@ import cn.ilapage.goauto.agent.automation.NodeSelector
import cn.ilapage.goauto.agent.automation.PageEvidence
import cn.ilapage.goauto.agent.automation.PddCollectorConfig
import cn.ilapage.goauto.agent.automation.PddCollectorDriver
import cn.ilapage.goauto.agent.automation.PddCollectorResult
import cn.ilapage.goauto.agent.automation.PddProductDetailCollector
import cn.ilapage.goauto.agent.automation.PddScreenParser
import cn.ilapage.goauto.agent.automation.PurchasePanelContext
@@ -1105,10 +1106,50 @@ class PddProductDetailCollectorTest {
}
@Test
fun `duplicate sizes after price cleanup reject the whole size dimension`() {
fun `same-frame sizes with one normalized name keep a single value and mark conflict`() {
val driver = FakeCollectorDriver(
colors = listOf("黑色"),
sizePages = listOf(listOf("XL ¥10", "XL ¥12")),
sizePages = listOf(listOf("XL ¥10", "XL ¥12", "S")),
prices = mapOf("黑色" to 1000L),
)
var clock = 0L
val result = PddProductDetailCollector(driver, { clock }, { clock += it }).collect(GOODS_ID, rule())
assertTrue(result.successful)
val payload = requireNotNull(result.payload)
assertEquals("completed_partial", payload.status)
assertEquals(listOf("XL", "S"), payload.dimensions.first { it.key == "size" }.values)
assertEquals(1, payload.missing.count { it == "spec_dimension_conflict:size" })
assertFalse(payload.missing.contains("spec_dimension_invalid:size"))
assertFalse(payload.missing.contains("size"))
assertEquals(listOf("XL", "S"), payload.skus.map { it.specs.getValue("size") })
}
@Test
fun `size price appearing between passes keeps the dimension and marks conflict once`() {
val driver = FakeCollectorDriver(
colors = listOf("黑色"),
sizePages = listOf(listOf("XL", "S"), listOf("XL ¥29.9", "M")),
prices = mapOf("黑色" to 1000L),
)
var clock = 0L
val result = PddProductDetailCollector(driver, { clock }, { clock += it }).collect(GOODS_ID, rule())
assertTrue(result.successful)
val payload = requireNotNull(result.payload)
assertEquals("completed_partial", payload.status)
assertEquals(listOf("XL", "S", "M"), payload.dimensions.first { it.key == "size" }.values)
assertEquals(1, payload.missing.count { it == "spec_dimension_conflict:size" })
assertFalse(payload.missing.contains("spec_dimension_invalid:size"))
}
@Test
fun `residual currency in a size still rejects the whole size dimension`() {
val driver = FakeCollectorDriver(
colors = listOf("黑色"),
sizePages = listOf(listOf("XL ¥10 热卖", "S")),
prices = mapOf("黑色" to 1000L),
)
var clock = 0L
@@ -1120,9 +1161,151 @@ class PddProductDetailCollectorTest {
assertEquals("completed_partial", payload.status)
assertTrue(payload.dimensions.none { it.key == "size" })
assertTrue(payload.missing.contains("spec_dimension_invalid:size"))
assertFalse(payload.missing.contains("spec_dimension_conflict:size"))
assertTrue(payload.skus.all { "size" !in it.specs })
}
@Test
fun `an empty normalized size still rejects the whole size dimension`() {
val driver = FakeCollectorDriver(
colors = listOf("黑色"),
sizePages = listOf(listOf("¥10", "S")),
prices = mapOf("黑色" to 1000L),
)
var clock = 0L
val payload = requireNotNull(
PddProductDetailCollector(driver, { clock }, { clock += it }).collect(GOODS_ID, rule()).payload,
)
assertTrue(payload.dimensions.none { it.key == "size" })
assertTrue(payload.missing.contains("spec_dimension_invalid:size"))
}
@Test
fun `continuation sizes drop the trailing price like the parsed path`() {
val driver = FakeCollectorDriver(
colors = listOf("红色"),
sizePages = listOf(listOf("S", "M"), listOf("L ¥10", "XL")),
hideDimensionHeadingsAfterFirstVerticalPage = true,
)
var clock = 0L
val result = PddProductDetailCollector(driver, { clock }, { clock += it }).collect(GOODS_ID, rule())
assertTrue(result.successful)
val payload = requireNotNull(result.payload)
assertEquals(listOf("S", "M", "L", "XL"), payload.dimensions.first { it.key == "size" }.values)
assertTrue(payload.missing.none { it.startsWith("spec_dimension_") })
}
@Test
fun `continuation conflict is seen before de-duplication and keeps one value`() {
val driver = FakeCollectorDriver(
colors = listOf("红色"),
sizePages = listOf(listOf("S", "M"), listOf("XL ¥10", "XL", "L")),
hideDimensionHeadingsAfterFirstVerticalPage = true,
)
var clock = 0L
val result = PddProductDetailCollector(driver, { clock }, { clock += it }).collect(GOODS_ID, rule())
assertTrue(result.successful)
val payload = requireNotNull(result.payload)
assertEquals(listOf("S", "M", "XL", "L"), payload.dimensions.first { it.key == "size" }.values)
assertEquals(1, payload.missing.count { it == "spec_dimension_conflict:size" })
assertFalse(payload.missing.contains("spec_dimension_invalid:size"))
}
@Test
fun `continuation size with residual currency rejects the whole size dimension`() {
val driver = FakeCollectorDriver(
colors = listOf("红色"),
sizePages = listOf(listOf("S", "M"), listOf("L ¥10 热卖", "XL")),
hideDimensionHeadingsAfterFirstVerticalPage = true,
)
var clock = 0L
val payload = requireNotNull(
PddProductDetailCollector(driver, { clock }, { clock += it }).collect(GOODS_ID, rule()).payload,
)
assertTrue(payload.dimensions.none { it.key == "size" })
assertTrue(payload.missing.contains("spec_dimension_invalid:size"))
}
@Test
fun `normalization conflict trace carries only counts and enum values`() {
val traces = mutableListOf<String>()
val driver = FakeCollectorDriver(
colors = listOf("黑色"),
sizePages = listOf(listOf("XL", "S"), listOf("XL ¥29.9", "M")),
prices = mapOf("黑色" to 1000L),
)
var clock = 0L
PddProductDetailCollector(driver, { clock }, { clock += it }, trace = traces::add).collect(GOODS_ID, rule())
val lines = traces.filter { it.startsWith("spec-normalize ") }
assertEquals(1, lines.size)
assertEquals(
"spec-normalize dim=size outcome=conflict_kept reason=normalized_conflict path=parsed pass=1 sameFrame=false " +
"onlyTrailingPriceDiff=true rawVariants=2 candidates=2 badgeCaptionMerged=0",
lines.single(),
)
assertTrue(lines.none { it.contains("XL") || it.contains("¥") })
}
@Test
fun `normalization invalid trace reports reason without node text`() {
val traces = mutableListOf<String>()
val driver = FakeCollectorDriver(
colors = listOf("黑色"),
sizePages = listOf(listOf("XL ¥10 热卖", "S")),
prices = mapOf("黑色" to 1000L),
)
var clock = 0L
PddProductDetailCollector(driver, { clock }, { clock += it }, trace = traces::add).collect(GOODS_ID, rule())
val line = traces.single { it.startsWith("spec-normalize ") }
assertTrue(line.contains("dim=size outcome=invalid_rejected reason=unsafe_currency path=parsed"))
assertTrue(line.contains("candidates=2"))
assertTrue(!line.contains("热卖") && !line.contains("XL"))
}
@Test
fun `same-frame color conflict trace flags sameFrame and badge count`() {
val traces = mutableListOf<String>()
val driver = FakeCollectorDriver(
colors = listOf("红色 ¥10", "红色 ¥12"),
prices = mapOf("红色 ¥10" to 1000L),
imageColorCards = true,
)
var clock = 0L
PddProductDetailCollector(driver, { clock }, { clock += it }, trace = traces::add).collect(GOODS_ID, rule())
val line = traces.first { it.startsWith("spec-normalize dim=color") }
assertTrue(line.contains("outcome=conflict_kept"))
assertTrue(line.contains("sameFrame=true"))
assertTrue(line.contains("onlyTrailingPriceDiff=true"))
assertTrue(line.contains("rawVariants=2"))
assertTrue(line.endsWith("badgeCaptionMerged=0"))
}
@Test
fun `parser counts merged badge captions without exposing text`() {
val parsed = PddScreenParser.parse(badgeCardsSnapshot(purchase1Cards()), config(), GOODS_ID, evidence())
assertEquals(2, parsed.badgeCaptionMerged)
val plain = PddScreenParser.parse(
badgeCardsSnapshot(listOf(badgeCard(3, 36, "黑色 微喇夏天薄款长裤 ¥9.8", "黑色 微喇夏天薄款长裤 ¥9.8"))),
config(), GOODS_ID, evidence(),
)
assertEquals(0, plain.badgeCaptionMerged)
}
@Test
fun `trailing color prices are normalized across dimension price and sku`() {
val driver = FakeCollectorDriver(
@@ -1143,10 +1326,32 @@ class PddProductDetailCollectorTest {
}
@Test
fun `duplicate colors after price cleanup reject color dependents`() {
fun `duplicate colors after price cleanup keep one value and mark conflict`() {
val driver = FakeCollectorDriver(
colors = listOf("红色 ¥10", "红色 ¥12"),
prices = mapOf("红色 ¥10" to 1000L, "红色 ¥12" to 1200L),
colors = listOf("红色 ¥10", "红色 ¥12", "蓝色"),
prices = mapOf("红色 ¥10" to 1000L, "红色 ¥12" to 1200L, "蓝色" to 1500L),
imageColorCards = true,
)
var clock = 0L
val result = PddProductDetailCollector(driver, { clock }, { clock += it }).collect(GOODS_ID, rule())
assertTrue(result.successful)
val payload = requireNotNull(result.payload)
assertEquals("completed_partial", payload.status)
assertEquals(listOf("红色", "蓝色"), payload.dimensions.first { it.key == "color" }.values)
assertEquals(listOf("红色", "蓝色"), payload.colorPrices.map { it.color })
assertEquals(1000L, payload.colorPrices.first { it.color == "红色" }.priceCent)
assertEquals(1, payload.missing.count { it == "spec_dimension_conflict:color" })
assertFalse(payload.missing.contains("spec_dimension_invalid:color"))
assertEquals(listOf("红色 ¥10", "蓝色"), driver.clickedLabels)
}
@Test
fun `residual currency in a color still rejects color dependents`() {
val driver = FakeCollectorDriver(
colors = listOf("红色 ¥10 热卖", "蓝色"),
prices = mapOf("红色 ¥10 热卖" to 1000L, "蓝色" to 1500L),
imageColorCards = true,
)
var clock = 0L
@@ -1160,6 +1365,7 @@ class PddProductDetailCollectorTest {
assertTrue(payload.colorPrices.isEmpty())
assertTrue(payload.skus.isEmpty())
assertTrue(payload.missing.contains("spec_dimension_invalid:color"))
assertFalse(payload.missing.contains("spec_dimension_conflict:color"))
assertTrue(result.colorImages.isEmpty())
}
@@ -1212,7 +1418,7 @@ class PddProductDetailCollectorTest {
)
var clock = 0L
val result = PddProductDetailCollector(driver, { clock }, { clock += it }).collect(GOODS_ID, rule())
val result = PddProductDetailCollector(driver, { clock }, { clock += it }).collect(GOODS_ID, rule(config().copy(timeoutsMs = config().timeoutsMs + ("overall" to 60_000))))
assertTrue(result.successful)
assertEquals(colors, requireNotNull(result.payload).dimensions.first { it.key == "color" }.values)
@@ -2092,6 +2298,325 @@ class PddProductDetailCollectorTest {
}
}
private fun gridColors(count: Int) = (1..count).map { "款%02d色".format(it) }
private fun gridConfig() = config().copy(
timeoutsMs = config().timeoutsMs + ("overall" to 600_000),
limits = config().limits + mapOf("specHorizontalSwipes" to 12, "specVerticalSwipes" to 3, "stableEdgeReads" to 2),
)
private fun sharedGridCollects47Colors(step: Int) {
// 47 colors in 2 rows x 24 columns sharing one horizontal container.
val colors = gridColors(47)
val grid = listOf(colors.filterIndexed { i, _ -> i % 2 == 0 }, colors.filterIndexed { i, _ -> i % 2 == 1 })
assertEquals(listOf(24, 23), grid.map { it.size })
val prices = colors.mapIndexed { i, c -> c to 1000L + i * 10L }.toMap()
val events = mutableListOf<AgentDiagnosticEvent>()
val driver = FakeCollectorDriver(
colors = colors,
prices = prices,
horizontalGrid = grid,
gridVisibleColumns = 4,
gridStep = step,
sizePages = listOf(listOf("S"), listOf("M")),
hideColorHeadingAfterFirstVerticalPage = true,
)
var clock = 0L
val result = PddProductDetailCollector(driver, { clock }, { clock += it }, taskId = 370, diagnostic = events::add)
.collect(GOODS_ID, rule(gridConfig()))
assertTrue(result.successful)
val payload = requireNotNull(result.payload)
assertEquals("completed", payload.status)
assertEquals(colors.toSet(), payload.dimensions.first { it.key == "color" }.values.toSet())
assertEquals(47, payload.colorPrices.size)
assertEquals(prices, payload.colorPrices.associate { it.color to it.priceCent })
// 47 colors x 2 sizes, each SKU carrying its own color's price.
assertEquals(94, payload.skus.size)
assertEquals(setOf("S", "M"), payload.skus.map { it.specs.getValue("size") }.toSet())
payload.skus.forEach { assertEquals(prices.getValue(it.specs.getValue("color")), it.priceCent) }
assertTrue(payload.missing.isEmpty())
// The shared container is swept once: no reverse sweep away from an edge.
assertTrue(driver.gridSwipeLog.none { it.first == SwipeDirection.RIGHT && it.second > 0 })
assertTrue(driver.gridSwipeLog.count { it.first == SwipeDirection.LEFT } <= 12)
val event = events.single { it.stage == AgentDiagnosticStage.COLOR_DISCOVERY }
assertEquals(47, event.clickableColorCount)
}
@Test
fun `shared container two row grid collects all colors when swipes overlap`() = sharedGridCollects47Colors(step = 3)
@Test
fun `shared container two row grid collects all colors when swipes move whole pages`() = sharedGridCollects47Colors(step = 4)
private fun sharedPixelGridCollects(count: Int, step: Int, maxSwipes: Int, jitter: Boolean = false) {
// Shared container, ~3.5 columns per screen, pixel-sized swipes whose
// last move is shorter than one column; partly visible columns count.
val colors = gridColors(count)
val grid = listOf(colors.filterIndexed { i, _ -> i % 2 == 0 }, colors.filterIndexed { i, _ -> i % 2 == 1 })
val prices = colors.mapIndexed { i, c -> c to 1000L + i * 10L }.toMap()
val driver = FakeCollectorDriver(
colors = colors,
prices = prices,
horizontalGrid = grid,
gridPixelStep = step,
gridClickJitter = jitter,
sizePages = listOf(listOf("S"), listOf("M")),
hideColorHeadingAfterFirstVerticalPage = true,
)
var clock = 0L
val result = PddProductDetailCollector(driver, { clock }, { clock += it }, taskId = 370)
.collect(GOODS_ID, rule(gridConfig()))
assertTrue(result.successful)
val payload = requireNotNull(result.payload)
assertEquals("completed", payload.status)
assertEquals(colors.toSet(), payload.dimensions.first { it.key == "color" }.values.toSet())
assertEquals(prices, payload.colorPrices.associate { it.color to it.priceCent })
assertTrue(payload.missing.isEmpty())
assertTrue(driver.gridSwipeLog.none { it.first == SwipeDirection.RIGHT && it.second > 0 })
val horizontal = driver.gridSwipeLog.size
assertTrue("horizontal swipes $horizontal > $maxSwipes ${driver.gridSwipeLog}", horizontal <= maxSwipes)
assertEquals(count, payload.colorPrices.size)
assertEquals(count * 2, payload.skus.size)
payload.skus.forEach { assertEquals(prices.getValue(it.specs.getValue("color")), it.priceCent) }
}
@Test
fun `shared container with click layout jitter still skips the reverse sweep`() =
sharedPixelGridCollects(count = 47, step = 640, maxSwipes = 12, jitter = true)
@Test
fun `shared container nine colors with click layout jitter does not sweep back`() =
sharedPixelGridCollects(count = 9, step = 200, maxSwipes = 6, jitter = true)
private fun midStartGrid(
failAttempts: Set<Int>,
initialOffset: Int = 2000,
traces: MutableList<String> = mutableListOf(),
captureCostMillis: Long = 0L,
elapsed: LongArray = LongArray(1),
): Pair<FakeCollectorDriver, PddCollectorResult> {
val colors = gridColors(47)
val grid = listOf(colors.filterIndexed { i, _ -> i % 2 == 0 }, colors.filterIndexed { i, _ -> i % 2 == 1 })
val prices = colors.mapIndexed { i, c -> c to 1000L + i * 10L }.toMap()
var clock = 0L
val driver = FakeCollectorDriver(
colors = colors,
prices = prices,
horizontalGrid = grid,
gridPixelStep = 640,
captureCostMillis = captureCostMillis,
captureAdvance = { clock += it },
gridInitialOffset = initialOffset,
gridRightFailAttempts = failAttempts,
sizePages = listOf(listOf("S"), listOf("M")),
hideColorHeadingAfterFirstVerticalPage = true,
)
val result = PddProductDetailCollector(driver, { clock }, { clock += it }, trace = traces::add, taskId = 370)
.collect(GOODS_ID, rule(gridConfig()))
elapsed[0] = clock
return driver to result
}
private fun assertAll47Collected(result: PddCollectorResult) {
assertTrue(result.successful)
val payload = requireNotNull(result.payload)
assertEquals("completed", payload.status)
val collected = payload.dimensions.first { it.key == "color" }.values
assertEquals("collected ${collected.size}/47 colors", gridColors(47).toSet(), collected.toSet())
assertEquals(47, payload.colorPrices.size)
assertTrue(payload.missing.isEmpty())
}
private class SettleRun(
val driver: FakeCollectorDriver,
val result: PddCollectorResult,
val traces: List<String>,
val elapsedMs: Long,
)
// The grid keeps coasting until HORIZONTAL_SETTLE_MILLIS-ish of simulated
// clock time has passed since the last movement (timeBased), mirroring the
// ~1-1.3s measured on a device.
private fun inertiaGrid(timeBased: Boolean): SettleRun {
val colors = gridColors(47)
val grid = listOf(colors.filterIndexed { i, _ -> i % 2 == 0 }, colors.filterIndexed { i, _ -> i % 2 == 1 })
val prices = colors.mapIndexed { i, c -> c to 1000L + i * 10L }.toMap()
var clock = 0L
val driver = FakeCollectorDriver(
colors = colors,
prices = prices,
horizontalGrid = grid,
gridPixelStep = 640,
gridSettleClock = if (timeBased) ({ clock }) else null,
gridSettleMillis = if (timeBased) 1000L else 0L,
gridStrictClickPosition = true,
sizePages = listOf(listOf("S"), listOf("M")),
hideColorHeadingAfterFirstVerticalPage = true,
)
val traces = mutableListOf<String>()
val result = PddProductDetailCollector(driver, { clock }, { clock += it }, trace = traces::add, taskId = 370)
.collect(GOODS_ID, rule(gridConfig()))
return SettleRun(driver, result, traces, clock)
}
@Test
fun `color grid waits a fixed time after a horizontal swipe without extra parses`() {
val calm = inertiaGrid(timeBased = false)
val coasting = inertiaGrid(timeBased = true)
println("SIM_ELAPSED_47_COLORS=${coasting.elapsedMs}ms settles=${coasting.traces.count { it.startsWith("color-settle") }}")
assertAll47Collected(coasting.result)
assertTrue(coasting.traces.none { it.startsWith("color-click-miss") })
val settles = coasting.traces.filter { it.startsWith("color-settle") }
assertTrue(settles.isNotEmpty())
settles.forEach { assertEquals("color-settle waitedMs=1200", it) }
// No polling reads: the same captures and clicks as a grid that never coasts.
assertEquals(calm.driver.captureCount, coasting.driver.captureCount)
assertEquals(calm.driver.clickCount, coasting.driver.clickCount)
}
@Test
fun `reset from the right end with scattered anchor failures still confirms the left end`() {
val traces = mutableListOf<String>()
val elapsed = LongArray(1)
val (driver, result) = midStartGrid(
failAttempts = setOf(1, 3, 5, 7),
initialOffset = 4675,
traces = traces,
captureCostMillis = 900L,
elapsed = elapsed,
)
println("SIM_ELAPSED_RIGHT_START=${elapsed[0]}ms")
assertTrue("elapsed ${elapsed[0]}", elapsed[0] < BASELINE_RIGHT_START_ELAPSED_MS * 7 / 10)
assertAll47Collected(result)
assertTrue(traces.toString(), traces.any { it.startsWith("color-start confirmed=true") && it.contains("anchorRetries=4") })
val lastRightWithOffset = driver.gridSwipeLog.indexOfLast { it.first == SwipeDirection.RIGHT && it.second > 0 }
val firstLeft = driver.gridSwipeLog.indexOfFirst { it.first == SwipeDirection.LEFT }
assertTrue("no reverse sweep after the first LEFT", lastRightWithOffset < firstLeft)
}
@Test
fun `mid start with one anchor failure while resetting retries and still skips the reverse sweep`() {
val (driver, result) = midStartGrid(failAttempts = setOf(2))
assertAll47Collected(result)
// The retry used a different color node than the failed attempt.
val rights = driver.swipes.filter { it.first == SwipeDirection.RIGHT }
assertTrue(rights[0].second?.label != rights[1].second?.label)
// Reset reached the left edge, then the sweep ran left-to-right once.
val lastRightWithOffset = driver.gridSwipeLog.indexOfLast { it.first == SwipeDirection.RIGHT && it.second > 0 }
val firstLeft = driver.gridSwipeLog.indexOfFirst { it.first == SwipeDirection.LEFT }
assertTrue("no reverse sweep after the first LEFT", lastRightWithOffset < firstLeft)
}
@Test
fun `mid start whose reset cannot be confirmed keeps the full snake sweep`() {
val (driver, result) = midStartGrid(failAttempts = setOf(1, 2, 3))
assertAll47Collected(result)
// Reverse sweep from the right edge happened after the LEFT sweep.
val firstLeft = driver.gridSwipeLog.indexOfFirst { it.first == SwipeDirection.LEFT }
assertTrue(driver.gridSwipeLog.drop(firstLeft).any { it.first == SwipeDirection.RIGHT && it.second > 0 })
}
@Test
fun `shared container with a row that fails to follow keeps the full snake sweep`() {
val colors = gridColors(47)
val grid = listOf(colors.filterIndexed { i, _ -> i % 2 == 0 }, colors.filterIndexed { i, _ -> i % 2 == 1 })
val driver = FakeCollectorDriver(
colors = colors,
horizontalGrid = grid,
gridPixelStep = 640,
gridSwipeWhereOnlyFirstRowMoves = 3,
sizePages = listOf(listOf("S"), listOf("M")),
hideColorHeadingAfterFirstVerticalPage = true,
)
var clock = 0L
val result = PddProductDetailCollector(driver, { clock }, { clock += it }, taskId = 370)
.collect(GOODS_ID, rule(gridConfig()))
assertTrue(result.successful)
assertTrue(driver.gridSwipeLog.any { it.first == SwipeDirection.RIGHT && it.second > 0 })
}
@Test
fun `shared container nine colors with partial last swipe does not sweep back`() =
sharedPixelGridCollects(count = 9, step = 200, maxSwipes = 6)
@Test
fun `shared container forty seven colors with partial last swipe stays within twelve swipes`() =
sharedPixelGridCollects(count = 47, step = 640, maxSwipes = 12)
@Test
fun `independent row grid keeps the snake sweep and does not claim a shared container`() {
val colors = gridColors(20)
val grid = listOf(colors.take(10), colors.drop(10))
val driver = FakeCollectorDriver(
colors = colors,
horizontalGrid = grid,
gridVisibleColumns = 4,
gridStep = 3,
gridSharedContainer = false,
sizePages = listOf(listOf("S"), listOf("M")),
hideColorHeadingAfterFirstVerticalPage = true,
)
var clock = 0L
val result = PddProductDetailCollector(driver, { clock }, { clock += it }).collect(GOODS_ID, rule(gridConfig()))
assertTrue(result.successful)
val collected = requireNotNull(result.payload).dimensions.first { it.key == "color" }.values
// Row 0 is swept to its end; row 1 only exposes its first window because
// it starts at its own left edge, exactly as before.
assertEquals((colors.take(10) + colors.drop(10).take(4)).toSet(), collected.toSet())
assertEquals(14, collected.size)
assertTrue(driver.gridSwipeLog.any { it.first == SwipeDirection.RIGHT && it.second > 0 })
}
@Test
fun `row disappearing keeps reflow reason after later empty vertical reads`() {
val colors = listOf("A色", "B色", "C色", "D色", "E色", "F色")
val events = mutableListOf<AgentDiagnosticEvent>()
val driver = FakeCollectorDriver(
colors = colors,
colorPages = listOf(colors.take(4), colors.takeLast(2)),
rowSize = 2,
sizePages = listOf(listOf("S"), listOf("M")),
hideColorHeadingAfterFirstVerticalPage = true,
)
var clock = 0L
val result = PddProductDetailCollector(driver, { clock }, { clock += it }, taskId = 370, diagnostic = events::add)
.collect(GOODS_ID, rule(gridConfig()))
assertTrue(result.successful)
val event = events.single { it.stage == AgentDiagnosticStage.COLOR_DISCOVERY }
assertEquals(AgentDiagnosticReason.COLOR_ROW_REFLOWED, event.reason)
assertTrue(requireNotNull(event.colorTrailingEmptyReadCount) > 0)
assertEquals(null, event.colorHorizontalIncomplete)
}
@Test
fun `single row disjoint reflow keeps reflow reason`() {
val colors = listOf("A色", "B色", "C色", "D色")
val events = mutableListOf<AgentDiagnosticEvent>()
var clock = 0L
val result = PddProductDetailCollector(
FakeCollectorDriver(colors = colors, colorPages = listOf(colors.take(2), colors.takeLast(2))),
{ clock }, { clock += it }, taskId = 370, diagnostic = events::add,
).collect(GOODS_ID, rule())
assertTrue(result.successful)
assertEquals(AgentDiagnosticReason.COLOR_ROW_REFLOWED, events.single { it.stage == AgentDiagnosticStage.COLOR_DISCOVERY }.reason)
}
private class FakeCollectorDriver(
colors: List<String> = listOf("红色"),
sizes: List<String> = listOf("S"),
@@ -2146,6 +2671,39 @@ class PddProductDetailCollectorTest {
// restored to top. Heading count drops but color/size values are
// untouched, so this must NOT be treated as a collapsed panel.
private val specPanelDropsExtraDimensionAfterTopSwipe: Boolean = false,
// #370: horizontal multi-row color grid. Each swipe moves gridStep
// columns; gridSharedContainer moves all rows together, otherwise only
// the anchored row. Left/right swipes are logged with the largest
// column offset before the gesture.
private val horizontalGrid: List<List<String>>? = null,
private val gridVisibleColumns: Int = 4,
private val gridStep: Int = 3,
private val gridSharedContainer: Boolean = true,
// #370: when set, shared-container swipes move this many pixels (not whole
// columns) and columns partly inside the 835px viewport count as visible.
private val gridPixelStep: Int? = null,
// #370: after each click the grid nodes shift a few pixels, like the
// small layout change seen on a real device after selecting a color.
private val gridClickJitter: Boolean = false,
// #370: on this (1-based) horizontal swipe only the first row moves.
private val gridSwipeWhereOnlyFirstRowMoves: Int = 0,
// #370: the shared grid starts scrolled this many pixels to the right.
private val gridInitialOffset: Int = 0,
// #370: 1-based RIGHT swipe attempts that fail with anchorMissing.
private val gridRightFailAttempts: Set<Int> = emptySet(),
// #370: after a horizontal swipe that moved the grid, the next N captures
// still show it coasting (nodes drift 30px per remaining capture).
private val gridInertiaCaptures: Int = 0,
// #370: time-based coasting: captures within gridSettleMillis of the last
// grid movement (by gridSettleClock) show the nodes drifted by 30px.
private val gridSettleClock: (() -> Long)? = null,
private val gridSettleMillis: Long = 0L,
// #370: each capture() (a parse) costs this much simulated time.
private val captureCostMillis: Long = 0L,
private val captureAdvance: ((Long) -> Unit)? = null,
// #370: a click is NOT_FOUND when the node is not where the last capture
// placed it (position at click time differs from the parsed target).
private val gridStrictClickPosition: Boolean = false,
) : PddCollectorDriver {
var captureCount = 0
var clickCount = 0
@@ -2155,6 +2713,15 @@ class PddProductDetailCollectorTest {
var backCount = 0
var entryClickCount = 0
var restoreGestures = 0
val gridSwipeLog = mutableListOf<Pair<SwipeDirection, Int>>()
private val gridOffsets = IntArray(horizontalGrid?.size ?: 0) { gridInitialOffset }
private var rightAttempts = 0
private var inertiaRemaining = 0
private var currentDrift = 0
// Parses between a spec swipe and the next click (discovery) cost captureCostMillis.
private var costlyParses = false
private var lastGridMoveAt = Long.MIN_VALUE / 2
private var lastSwipeFailure = "none"
private var selected: String? = initialSelectedColor
private var previousSelected: String? = null
private var horizontalPage = 0
@@ -2167,7 +2734,15 @@ class PddProductDetailCollectorTest {
private var adviceOpen = false
override fun capture(): UiSnapshot {
if (captureCostMillis > 0 && costlyParses) captureAdvance?.invoke(captureCostMillis)
return captureFrame(advanceInertia = true)
}
private fun captureFrame(advanceInertia: Boolean): UiSnapshot {
captureCount++
currentDrift = inertiaRemaining * 30 +
if (gridSettleClock != null && gridSettleClock.invoke() - lastGridMoveAt < gridSettleMillis) 30 else 0
if (advanceInertia && inertiaRemaining > 0) inertiaRemaining--
fixedSnapshot?.let { return it }
if (adviceOpen) return UiSnapshot(PDD_PACKAGE, ACTIVITY, listOf(
node("content", "", 0, 0, 1080, 2200, resourceId = "android:id/content", className = "android.widget.FrameLayout"),
@@ -2224,21 +2799,33 @@ class PddProductDetailCollectorTest {
val continuationPage = hideDimensionHeadingsAfterFirstVerticalPage && verticalPage > 0
val panelCollapsedNow = specPanelCollapsesAfterTopSwipe && downSwipeCount >= 1
val hideColorNow = specPanelHidesColorInitially && downSwipeCount == 0
val visibleColors = colorVerticalPages?.get(verticalPage.coerceAtMost(colorVerticalPages.lastIndex))
val gridPlaced = horizontalGrid?.flatMapIndexed { r, row ->
if (gridPixelStep != null) {
row.mapIndexed { c, color -> Triple(color, r, c) }.filter { (_, _, c) ->
val left = 30 + c * 230 - gridOffsets[r]
left < GRID_VIEWPORT_RIGHT && left + 190 > 0
}
} else {
row.drop(gridOffsets[r]).take(gridVisibleColumns).mapIndexed { c, color -> Triple(color, r, c) }
}
}
val visibleColors = gridPlaced?.map { it.first }
?: colorVerticalPages?.get(verticalPage.coerceAtMost(colorVerticalPages.lastIndex))
?: colorPages[horizontalPage.coerceAtMost(colorPages.lastIndex)]
val colorRowCount = (visibleColors.size + rowSize - 1) / rowSize
val colorRowCount = horizontalGrid?.size ?: ((visibleColors.size + rowSize - 1) / rowSize)
val placedColors = gridPlaced ?: visibleColors
.filterNot { hideSelectedColorOption && it == selected }
.mapIndexed { index, color -> Triple(color, index / rowSize, index % rowSize) }
val sizeHeadingTop = maxOf(700, 470 + colorRowCount * 90 + 20)
if (!continuationPage && !panelCollapsedNow && !(hideColorHeadingAfterFirstVerticalPage && verticalPage > 0)) {
nodes += node("scroll/color-heading", "颜色分类", 20, 400, 300, 450, parentPath = "scroll")
}
if (!continuationPage && !hideColorNow && !panelCollapsedNow) {
visibleColors
.filterNot { hideSelectedColorOption && it == selected }
.forEachIndexed { index, color ->
val row = index / rowSize
val column = index % rowSize
placedColors.forEach { (color, row, column) ->
val path = "scroll/color-$color-$captureCount"
val left = 30 + column * 230
val shift = if (gridPixelStep != null) gridOffsets[row] else 0
val jitter = if (gridClickJitter && horizontalGrid != null) (clickCount % 4) * 2 else 0
val left = 30 + column * 230 - shift + jitter + currentDrift
val top = 470 + row * 90
if (imageColorCards) {
nodes += SnapshotNode(
@@ -2254,7 +2841,7 @@ class PddProductDetailCollectorTest {
)
} else {
nodes += node(
path, color, left, top, 220 + column * 230, top + 70,
path, color, left, top, 220 + column * 230 - shift + jitter + currentDrift, top + 70,
clickable = true, selected = displayedSelected() == color, parentPath = "scroll",
)
}
@@ -2312,11 +2899,13 @@ class PddProductDetailCollectorTest {
override fun clickFresh(target: SnapshotNode): FreshActionResult {
clickCount++
costlyParses = false
clickedLabels += target.label
if (target.label in failedClicks) return FreshActionResult.FAILED
val current = capture().nodes.filter { it.label == target.label && (!imageColorCards || it.clickable) }
val current = captureFrame(advanceInertia = false).nodes.filter { it.label == target.label && (!imageColorCards || it.clickable) }
if (current.isEmpty()) return FreshActionResult.NOT_FOUND
if (current.size > 1) return FreshActionResult.AMBIGUOUS
if (gridStrictClickPosition && current.single().bounds != target.bounds) return FreshActionResult.NOT_FOUND
if (!panelOpen && target.label == "单独购买") {
entryClickCount++
when {
@@ -2355,8 +2944,43 @@ class PddProductDetailCollectorTest {
private fun displayedSelected(): String? =
if (staleSelectionEvidence && previousSelected != null) previousSelected else selected
override fun specRowSwipeFailureReason(): String = lastSwipeFailure
override fun swipeSpec(direction: SwipeDirection, anchor: SnapshotNode?): Boolean {
swipes += direction to anchor
costlyParses = true
lastSwipeFailure = "none"
if (direction == SwipeDirection.RIGHT && horizontalGrid != null) {
rightAttempts++
if (rightAttempts in gridRightFailAttempts) {
lastSwipeFailure = "anchorMissing"
return false
}
}
if (horizontalGrid != null && (direction == SwipeDirection.LEFT || direction == SwipeDirection.RIGHT)) {
gridSwipeLog += direction to (gridOffsets.maxOrNull() ?: 0)
val unit = gridPixelStep ?: gridStep
val delta = if (direction == SwipeDirection.LEFT) unit else -unit
val moved = if (gridSharedContainer) horizontalGrid.indices.toList()
else listOf((((anchor?.bounds?.top ?: 470) - 470) / 90).coerceIn(0, horizontalGrid.lastIndex))
val sharedMax = if (gridPixelStep != null) {
30 + (horizontalGrid.maxOf { it.size } - 1) * 230 + 190 - GRID_VIEWPORT_RIGHT
} else {
horizontalGrid.maxOf { it.size } - gridVisibleColumns
}
val brokenSwipe = gridSwipeWhereOnlyFirstRowMoves > 0 &&
gridSwipeLog.size == gridSwipeWhereOnlyFirstRowMoves
val offsetsBefore = gridOffsets.toList()
moved.filter { !brokenSwipe || it == 0 }.forEach { r ->
val max = if (gridSharedContainer) sharedMax else horizontalGrid[r].size - gridVisibleColumns
gridOffsets[r] = (gridOffsets[r] + delta).coerceIn(0, maxOf(0, max))
}
if (gridOffsets.toList() != offsetsBefore) {
inertiaRemaining = gridInertiaCaptures
gridSettleClock?.let { lastGridMoveAt = it.invoke() }
}
return true
}
if ((direction == SwipeDirection.UP || direction == SwipeDirection.DOWN) && !verticalSwipeSucceeds) return false
when (direction) {
SwipeDirection.LEFT -> horizontalPage = (horizontalPage + 1).coerceAtMost(colorPages.lastIndex)
@@ -2391,8 +3015,162 @@ class PddProductDetailCollectorTest {
}
}
// #379: purchase-1 card shape: clickable ViewGroup > (View, full-name ImageView,
// "打开大图" ImageView, non-clickable LinearLayout > clickable caption TextView).
private fun badgeCard(index: Int, left: Int, fullName: String, caption: String): List<SnapshotNode> {
fun described(path: String, parent: String?, text: String?, description: String?, bounds: NodeBounds, className: String, clickable: Boolean) =
SnapshotNode(
path, parent, text, description, null, className, bounds,
clickable, scrollable = false, selected = false, checked = false, enabled = true, visible = true,
)
val card = "scroll/card$index"
val right = left + 316
return listOf(
described(card, "scroll", null, fullName, NodeBounds(left, 470, right, 690), "android.view.ViewGroup", true),
described("$card/bg", card, null, null, NodeBounds(left, 470, right, 690), "android.view.View", false),
described("$card/image", card, null, fullName, NodeBounds(left, 470, right, 640), "android.widget.ImageView", true),
described("$card/big", card, null, "打开大图", NodeBounds(left, 470, left + 111, 581), "android.widget.ImageView", true),
described("$card/box", card, null, null, NodeBounds(left, 630, right, 690), "android.widget.LinearLayout", false),
described("$card/box/caption", "$card/box", caption, null, NodeBounds(left, 630, right, 690), "android.widget.TextView", true),
)
}
private fun badgeCardsSnapshot(cards: List<List<SnapshotNode>>): UiSnapshot = 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, 410),
node("scroll", "", 0, 380, 1080, 1900, scrollable = true),
node("scroll/color-heading", "颜色分类", 20, 400, 300, 450, parentPath = "scroll"),
) + cards.flatten() + listOf(
node("scroll/size-heading", "尺码", 20, 700, 300, 750, parentPath = "scroll"),
node("scroll/size-s", "S 75斤内穿", 30, 770, 220, 840, clickable = true, parentPath = "scroll"),
node("scroll/size-m", "M 80-90斤", 260, 770, 450, 840, clickable = true, parentPath = "scroll"),
node("confirm", "确定", 0, 2000, 1080, 2150, clickable = true),
),
)
private fun purchase1Cards(captionOne: String = "黑色 微喇春秋加...\n ¥11.8 零差评") = listOf(
badgeCard(1, 36, "黑色 微喇春秋加薄绒长裤 (适合现在温度穿) ¥11.8", captionOne),
badgeCard(2, 382, " 黑色 微喇春夏款长裤 ¥9.8", " 黑色 微喇春夏...\n ¥9.8 最多人选择"),
badgeCard(3, 728, "黑色 微喇夏天薄款长裤 ¥9.8", "黑色 微喇夏天薄款长裤 ¥9.8"),
)
@Test
fun `price plus badge caption merges into the outer card and yields three colors`() {
val parsed = PddScreenParser.parse(badgeCardsSnapshot(purchase1Cards()), config(), GOODS_ID, evidence())
val colors = parsed.dimensions.first { it.key == "color" }.values
assertEquals(
listOf("黑色 微喇春秋加薄绒长裤 (适合现在温度穿)", "黑色 微喇春夏款长裤", "黑色 微喇夏天薄款长裤"),
colors.map { it.text },
)
assertEquals(colors.size, colors.map { it.text }.distinct().size)
assertTrue(colors.none { it.text.contains('¥') || it.text.contains('¥') })
assertEquals("黑色 微喇春秋加薄绒长裤 (适合现在温度穿) ¥11.8", colors[0].rawText)
assertTrue(colors.all { it.node.path.endsWith("/caption") || it.node.path.startsWith("scroll/card") })
// Size dimension is unaffected.
assertEquals(listOf("S 75斤内穿", "M 80-90斤"), parsed.dimensions.first { it.key == "size" }.values.map { it.text })
}
@Test
fun `price plus badge cards collect three colors without invalid marker`() {
val base = FakeCollectorDriver(colors = listOf("占位"), sizePages = listOf(listOf("S 75斤内穿", "M 80-90斤")))
val driver = object : PddCollectorDriver by base {
override fun capture(): UiSnapshot {
val snapshot = base.capture()
return snapshot.copy(
nodes = snapshot.nodes.filterNot { it.path.startsWith("scroll/color-") && it.path != "scroll/color-heading" } +
purchase1Cards().flatten(),
)
}
}
var clock = 0L
val result = PddProductDetailCollector(driver, { clock }, { clock += it }).collect(GOODS_ID, rule())
assertTrue(result.successful)
val payload = requireNotNull(result.payload)
assertEquals(
listOf("黑色 微喇春秋加薄绒长裤 (适合现在温度穿)", "黑色 微喇春夏款长裤", "黑色 微喇夏天薄款长裤"),
payload.dimensions.first { it.key == "color" }.values,
)
assertTrue(payload.missing.none { it.startsWith("spec_dimension_") })
}
@Test
fun `badge caption amount differing from the outer card price is not merged`() {
val parsed = PddScreenParser.parse(
badgeCardsSnapshot(purchase1Cards("黑色 微喇春秋加...\n ¥11.9 零差评")),
config(), GOODS_ID, evidence(),
)
val colors = parsed.dimensions.first { it.key == "color" }.values
assertEquals(4, colors.size)
assertTrue(colors.any { it.text.contains('¥') })
}
@Test
fun `badge caption with currency or an overlong badge is not merged`() {
for (caption in listOf("黑色 微喇春秋加...\n ¥11.8 零¥差评", "黑色 微喇春秋加...\n ¥11.8 零差评零差评零差评", "黑色 微喇春秋加...\n ¥11.8 零 差评")) {
val parsed = PddScreenParser.parse(badgeCardsSnapshot(purchase1Cards(caption)), config(), GOODS_ID, evidence())
val colors = parsed.dimensions.first { it.key == "color" }.values
assertEquals(caption, 4, colors.size)
assertTrue(caption, colors.any { it.text.contains('¥') })
}
}
@Test
fun `badge caption is not merged when the outer card has no price`() {
val cards = listOf(badgeCard(1, 36, "黑色 微喇春秋加薄绒长裤", "黑色 微喇春秋加...\n ¥11.8 零差评"))
val parsed = PddScreenParser.parse(badgeCardsSnapshot(cards), config(), GOODS_ID, evidence())
val colors = parsed.dimensions.first { it.key == "color" }.values
assertEquals(2, colors.size)
assertTrue(colors.any { it.text.contains('¥') })
}
@Test
fun `badge caption must still be a prefix of the outer name`() {
val parsed = PddScreenParser.parse(
badgeCardsSnapshot(purchase1Cards("红色 微喇春秋加...\n ¥11.8 零差评")),
config(), GOODS_ID, evidence(),
)
val colors = parsed.dimensions.first { it.key == "color" }.values
assertEquals(4, colors.size)
}
@Test
fun `badge caption does not merge when the card holds a second clickable text`() {
val card = badgeCard(1, 36, "黑色 微喇春秋加薄绒长裤 (适合现在温度穿) ¥11.8", "黑色 微喇春秋加...\n ¥11.8 零差评")
val extra = SnapshotNode(
"scroll/card1/box/other", "scroll/card1/box", "黑色", null, null, "android.widget.TextView",
NodeBounds(36, 630, 100, 660), clickable = true, scrollable = false, selected = false,
checked = false, enabled = true, visible = true,
)
val parsed = PddScreenParser.parse(badgeCardsSnapshot(listOf(card + extra)), config(), GOODS_ID, evidence())
val colors = parsed.dimensions.first { it.key == "color" }.values
assertTrue(colors.size > 1)
}
@Test
fun `truncated caption without badge still merges as before`() {
val cards = listOf(badgeCard(1, 36, "黑色 微喇春秋加薄绒长裤 (适合现在温度穿) ¥11.8", "黑色 微喇春秋加薄绒长裤 (适合 ¥11.8"))
val parsed = PddScreenParser.parse(badgeCardsSnapshot(cards), config(), GOODS_ID, evidence())
val colors = parsed.dimensions.first { it.key == "color" }.values
assertEquals(listOf("黑色 微喇春秋加薄绒长裤 (适合现在温度穿)"), colors.map { it.text })
}
companion object {
private const val GOODS_ID = "719834019024"
private const val GRID_VIEWPORT_RIGHT = 835
// Simulated elapsed time of the right-start 47 color scenario at 9619c38.
private const val BASELINE_RIGHT_START_ELAPSED_MS = 127_550L
private const val PDD_PACKAGE = "com.xunmeng.pinduoduo"
private const val ACTIVITY = "com.xunmeng.pinduoduo.activity.NewPageActivity"