Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9619c38ae3 | ||
|
|
037459391a | ||
|
|
74b71e2b5a | ||
|
|
2f24b4b5b4 | ||
|
|
d8b4f3a9d5 |
+244
-21
@@ -69,6 +69,9 @@ interface PddCollectorDriver {
|
|||||||
reason = FreshClickReason.UNKNOWN,
|
reason = FreshClickReason.UNKNOWN,
|
||||||
)
|
)
|
||||||
fun swipeSpec(direction: SwipeDirection, anchor: SnapshotNode? = null): Boolean
|
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 pullDownSpecPanel(anchor: SnapshotNode): Boolean = swipeSpec(SwipeDirection.DOWN, anchor)
|
||||||
fun pullDownGoodsPage(): Boolean
|
fun pullDownGoodsPage(): Boolean
|
||||||
fun back(): Boolean
|
fun back(): Boolean
|
||||||
@@ -1412,6 +1415,14 @@ class PddProductDetailCollector(
|
|||||||
// These signatures stay in memory; only the aggregate tri-state is saved.
|
// These signatures stay in memory; only the aggregate tri-state is saved.
|
||||||
val diagnosticHorizontalRows = mutableMapOf<Set<String>, Boolean?>()
|
val diagnosticHorizontalRows = mutableMapOf<Set<String>, Boolean?>()
|
||||||
var diagnosticTermination = AgentDiagnosticReason.COLOR_FOUND
|
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>()
|
val imageAttempts = mutableSetOf<String>()
|
||||||
|
|
||||||
fun collectVisibleImages(values: List<VisibleSpecValue>) {
|
fun collectVisibleImages(values: List<VisibleSpecValue>) {
|
||||||
@@ -1479,7 +1490,9 @@ class PddProductDetailCollector(
|
|||||||
}
|
}
|
||||||
|
|
||||||
moveSpecPanelToTop(goodsId, config, evidence, deadline, specPanelContainer)?.let { return it }
|
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)
|
val initial = parse(goodsId, config, evidence)
|
||||||
initial.problem?.let { return failure(it.code, it.message) }
|
initial.problem?.let { return failure(it.code, it.message) }
|
||||||
diagnosticInitialSelectedColors = initial.dimensions.filter { it.key == "color" }.flatMap { it.values }
|
diagnosticInitialSelectedColors = initial.dimensions.filter { it.key == "color" }.flatMap { it.values }
|
||||||
@@ -1530,6 +1543,7 @@ class PddProductDetailCollector(
|
|||||||
FreshActionResult.BLOCKED -> return sizeAdviceFailure()
|
FreshActionResult.BLOCKED -> return sizeAdviceFailure()
|
||||||
FreshActionResult.AMBIGUOUS -> return failure("RULE_AMBIGUOUS", "颜色“${value.text}”匹配到多个控件")
|
FreshActionResult.AMBIGUOUS -> return failure("RULE_AMBIGUOUS", "颜色“${value.text}”匹配到多个控件")
|
||||||
FreshActionResult.NOT_FOUND, FreshActionResult.FAILED -> {
|
FreshActionResult.NOT_FOUND, FreshActionResult.FAILED -> {
|
||||||
|
traceColorClickMiss(screen, value, rows, clickResult)
|
||||||
missing += "selection:${value.text}"
|
missing += "selection:${value.text}"
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -1561,7 +1575,18 @@ class PddProductDetailCollector(
|
|||||||
// established left/right paging behavior.
|
// established left/right paging behavior.
|
||||||
if (rows.any { it.size > 1 }) {
|
if (rows.any { it.size > 1 }) {
|
||||||
val rowSeeds = rows.map { row -> row.map(VisibleSpecValue::text).toSet() }
|
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 ->
|
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 moveRight = rowIndex % 2 == 0
|
||||||
val horizontalSignatureReads = mutableMapOf<List<String>, Int>()
|
val horizontalSignatureReads = mutableMapOf<List<String>, Int>()
|
||||||
var diagnosticPreviousSignature: List<String>? = null
|
var diagnosticPreviousSignature: List<String>? = null
|
||||||
@@ -1569,11 +1594,15 @@ class PddProductDetailCollector(
|
|||||||
var diagnosticMoved = false
|
var diagnosticMoved = false
|
||||||
var diagnosticRowUncertain = false
|
var diagnosticRowUncertain = false
|
||||||
var diagnosticRowIncomplete: Boolean? = null
|
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")) {
|
for (horizontalPass in 0..config.limits.getValue("specHorizontalSwipes")) {
|
||||||
var diagnosticBeforeClicks: List<String>? = null
|
var diagnosticBeforeClicks: List<String>? = null
|
||||||
collectVisibleColors { observedRows ->
|
collectVisibleColors { observedRows ->
|
||||||
diagnosticBeforeClicks = observedRows.filter { row -> row.any { it.text in seed } }
|
diagnosticBeforeClicks = matchColorRow(tracks[rowIndex], observedRows, tracks.size, requireUniqueOverlap = true)
|
||||||
.singleOrNull()?.sortedBy { it.node.bounds.left }
|
?.sortedBy { it.node.bounds.left }
|
||||||
?.let { if (moveRight) it else it.reversed() }?.let(::optionSignature)
|
?.let { if (moveRight) it else it.reversed() }?.let(::optionSignature)
|
||||||
}?.let { return it }
|
}?.let { return it }
|
||||||
screen = parse(goodsId, config, evidence)
|
screen = parse(goodsId, config, evidence)
|
||||||
@@ -1581,11 +1610,37 @@ class PddProductDetailCollector(
|
|||||||
if (!screen.pageEvidenceMatched) return failure("RULE_NOT_MATCHED", "采集期间离开 PDD 商品详情页")
|
if (!screen.pageEvidenceMatched) return failure("RULE_NOT_MATCHED", "采集期间离开 PDD 商品详情页")
|
||||||
rows = colorRows(screen)
|
rows = colorRows(screen)
|
||||||
observeColorDiscovery(screen, rows)
|
observeColorDiscovery(screen, rows)
|
||||||
val matchedRow = rows.maxByOrNull { row -> row.count { it.text in seed } }
|
val matches = tracks.map { matchColorRow(it, rows, tracks.size) }
|
||||||
?.takeIf { row -> row.any { it.text in seed } }
|
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) {
|
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
|
diagnosticHorizontalUnknown = true
|
||||||
diagnosticTermination = AgentDiagnosticReason.COLOR_ROW_REFLOWED
|
terminate(AgentDiagnosticReason.COLOR_ROW_REFLOWED)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
val currentRow = matchedRow.sortedBy { it.node.bounds.left }
|
val currentRow = matchedRow.sortedBy { it.node.bounds.left }
|
||||||
@@ -1605,24 +1660,56 @@ class PddProductDetailCollector(
|
|||||||
diagnosticPreviousSignature = signature
|
diagnosticPreviousSignature = signature
|
||||||
val signatureReads = (horizontalSignatureReads[signature] ?: 0) + 1
|
val signatureReads = (horizontalSignatureReads[signature] ?: 0) + 1
|
||||||
horizontalSignatureReads[signature] = signatureReads
|
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")) {
|
if (signatureReads > config.limits.getValue("stableEdgeReads")) {
|
||||||
|
rowEnd = "edge"
|
||||||
if (!diagnosticRowUncertain && diagnosticStableReads >= config.limits.getValue("stableEdgeReads")) {
|
if (!diagnosticRowUncertain && diagnosticStableReads >= config.limits.getValue("stableEdgeReads")) {
|
||||||
diagnosticRowIncomplete = false
|
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
|
break
|
||||||
}
|
}
|
||||||
if (horizontalPass == config.limits.getValue("specHorizontalSwipes")) {
|
if (horizontalPass == config.limits.getValue("specHorizontalSwipes")) {
|
||||||
|
rowEnd = "limit"
|
||||||
if (diagnosticMoved && !diagnosticRowUncertain) diagnosticRowIncomplete = true
|
if (diagnosticMoved && !diagnosticRowUncertain) diagnosticRowIncomplete = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
val direction = if (moveRight) SwipeDirection.LEFT else SwipeDirection.RIGHT
|
val direction = if (moveRight) SwipeDirection.LEFT else SwipeDirection.RIGHT
|
||||||
if (!driver.swipeSpec(direction, currentRow.first().node)) {
|
if (!driver.swipeSpec(direction, currentRow.first().node)) {
|
||||||
|
rowEnd = "swipe_failed"
|
||||||
if (diagnosticMoved && !diagnosticRowUncertain) diagnosticRowIncomplete = true
|
if (diagnosticMoved && !diagnosticRowUncertain) diagnosticRowIncomplete = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
diagnosticHorizontalSwipes++
|
diagnosticHorizontalSwipes++
|
||||||
pause(350)
|
pause(350)
|
||||||
|
lastHorizontalSwipeDoneAt = now()
|
||||||
|
settleColorGrid(goodsId, config, evidence)
|
||||||
}
|
}
|
||||||
|
trace("color-row-end row=$rowIndex end=$rowEnd skip=$rowSkipNote")
|
||||||
// An uncertain revisit cannot erase previously observed
|
// An uncertain revisit cannot erase previously observed
|
||||||
// unresolved movement; only reliable stability clears it.
|
// unresolved movement; only reliable stability clears it.
|
||||||
diagnosticHorizontalRows[seed] = diagnosticRowIncomplete
|
diagnosticHorizontalRows[seed] = diagnosticRowIncomplete
|
||||||
@@ -1640,7 +1727,7 @@ class PddProductDetailCollector(
|
|||||||
diagnosticTrailingEmptyReads = if (rows.isEmpty()) diagnosticTrailingEmptyReads + 1 else 0
|
diagnosticTrailingEmptyReads = if (rows.isEmpty()) diagnosticTrailingEmptyReads + 1 else 0
|
||||||
if (rows.isEmpty()) diagnosticHorizontalUnknown = true
|
if (rows.isEmpty()) diagnosticHorizontalUnknown = true
|
||||||
if (!discoverVertically) {
|
if (!discoverVertically) {
|
||||||
diagnosticTermination = AgentDiagnosticReason.COLOR_EDGE_REACHED
|
terminate(AgentDiagnosticReason.COLOR_EDGE_REACHED)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1652,21 +1739,21 @@ class PddProductDetailCollector(
|
|||||||
}
|
}
|
||||||
previousVerticalSignature = verticalSignature
|
previousVerticalSignature = verticalSignature
|
||||||
if (verticalStable >= config.limits.getValue("stableEdgeReads")) {
|
if (verticalStable >= config.limits.getValue("stableEdgeReads")) {
|
||||||
diagnosticTermination = AgentDiagnosticReason.COLOR_EDGE_REACHED
|
terminate(AgentDiagnosticReason.COLOR_EDGE_REACHED)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
if (verticalPass == config.limits.getValue("specVerticalSwipes")) {
|
if (verticalPass == config.limits.getValue("specVerticalSwipes")) {
|
||||||
diagnosticTermination = AgentDiagnosticReason.COLOR_SCAN_LIMIT
|
terminate(AgentDiagnosticReason.COLOR_SCAN_LIMIT)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
val anchor = rows.flatten().firstOrNull()?.node ?: specPanelContainer
|
val anchor = rows.flatten().firstOrNull()?.node ?: specPanelContainer
|
||||||
if (anchor == null) {
|
if (anchor == null) {
|
||||||
diagnosticHorizontalUnknown = true
|
diagnosticHorizontalUnknown = true
|
||||||
diagnosticTermination = AgentDiagnosticReason.COLOR_CONTAINER_UNAVAILABLE
|
terminate(AgentDiagnosticReason.COLOR_CONTAINER_UNAVAILABLE)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
if (!driver.swipeSpec(SwipeDirection.UP, anchor)) {
|
if (!driver.swipeSpec(SwipeDirection.UP, anchor)) {
|
||||||
diagnosticTermination = AgentDiagnosticReason.COLOR_SWIPE_FAILED
|
terminate(AgentDiagnosticReason.COLOR_SWIPE_FAILED)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
diagnosticVerticalSwipes++
|
diagnosticVerticalSwipes++
|
||||||
@@ -1678,6 +1765,60 @@ class PddProductDetailCollector(
|
|||||||
|
|
||||||
private fun sizeAdviceFailure() = failure("SIZE_ADVICE_CLICK_BLOCKED", "已阻止点击尺码建议入口")
|
private fun sizeAdviceFailure() = failure("SIZE_ADVICE_CLICK_BLOCKED", "已阻止点击尺码建议入口")
|
||||||
|
|
||||||
|
private var lastHorizontalSwipeDoneAt = -1L
|
||||||
|
|
||||||
|
/**
|
||||||
|
* After a horizontal swipe the grid may still be coasting, so positions
|
||||||
|
* parsed now can differ from the ones at click time. Re-read the color rows
|
||||||
|
* until two adjacent reads agree (text + bounds), at most four more reads
|
||||||
|
* 200ms apart. Never fails the task and never clicks.
|
||||||
|
*/
|
||||||
|
private fun settleColorGrid(goodsId: String, config: PddCollectorConfig, evidence: PageEvidence) {
|
||||||
|
val startedAt = now()
|
||||||
|
fun read(): List<String>? {
|
||||||
|
val screen = parse(goodsId, config, evidence)
|
||||||
|
if (screen.problem != null || !screen.pageEvidenceMatched) return null
|
||||||
|
return optionSignature(colorRows(screen).flatten())
|
||||||
|
}
|
||||||
|
var previous = read()
|
||||||
|
var reads = 0
|
||||||
|
var stable = false
|
||||||
|
while (previous != null && reads < 4) {
|
||||||
|
pause(200)
|
||||||
|
reads++
|
||||||
|
val current = read() ?: break
|
||||||
|
if (current == previous) {
|
||||||
|
stable = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
previous = current
|
||||||
|
}
|
||||||
|
trace("color-settle reads=$reads stable=$stable waitedMs=${now() - startedAt}")
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
private fun clickSpecTarget(target: SnapshotNode, stage: AgentDiagnosticStage): FreshActionResult {
|
||||||
val outcome = driver.clickFreshDetailed(target)
|
val outcome = driver.clickFreshDetailed(target)
|
||||||
if (taskId > 0) runCatching { diagnostic(specClickDiagnostic(taskId, stage, target, outcome)) }
|
if (taskId > 0) runCatching { diagnostic(specClickDiagnostic(taskId, stage, target, outcome)) }
|
||||||
@@ -1837,31 +1978,66 @@ class PddProductDetailCollector(
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private class ColorStart(val failure: PddCollectorResult?, val confirmed: Boolean)
|
||||||
|
|
||||||
private fun moveColorsToStart(
|
private fun moveColorsToStart(
|
||||||
goodsId: String,
|
goodsId: String,
|
||||||
config: PddCollectorConfig,
|
config: PddCollectorConfig,
|
||||||
evidence: PageEvidence,
|
evidence: PageEvidence,
|
||||||
deadline: Long,
|
deadline: Long,
|
||||||
): PddCollectorResult? {
|
): ColorStart {
|
||||||
var previous = emptyList<String>()
|
var previous = emptyList<String>()
|
||||||
var stable = 0
|
var stable = 0
|
||||||
repeat(config.limits.getValue("specHorizontalSwipes")) {
|
var swipes = 0
|
||||||
if (now() > deadline) return failure("RULE_NOT_MATCHED", "采集超过规则总超时")
|
var anchorRetries = 0
|
||||||
|
var anchorFailures = 0
|
||||||
|
var confirmed = false
|
||||||
|
var end = "limit"
|
||||||
|
for (attempt in 0 until config.limits.getValue("specHorizontalSwipes")) {
|
||||||
|
if (now() > deadline) return ColorStart(failure("RULE_NOT_MATCHED", "采集超过规则总超时"), false)
|
||||||
val screen = parse(goodsId, config, evidence)
|
val screen = parse(goodsId, config, evidence)
|
||||||
screen.problem?.let { return failure(it.code, it.message) }
|
screen.problem?.let { return ColorStart(failure(it.code, it.message), false) }
|
||||||
if (!screen.pageEvidenceMatched) return failure("RULE_NOT_MATCHED", "采集期间离开 PDD 商品详情页")
|
if (!screen.pageEvidenceMatched) return ColorStart(failure("RULE_NOT_MATCHED", "采集期间离开 PDD 商品详情页"), false)
|
||||||
val rows = colorRows(screen)
|
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 values = rows.flatten()
|
||||||
val signature = optionSignature(values)
|
val signature = optionSignature(values)
|
||||||
stable = if (signature == previous) stable + 1 else 0
|
stable = if (signature == previous) stable + 1 else 0
|
||||||
previous = signature
|
previous = signature
|
||||||
if (stable >= config.limits.getValue("stableEdgeReads")) return null
|
if (stable >= config.limits.getValue("stableEdgeReads")) {
|
||||||
val anchor = values.firstOrNull()?.node ?: return null
|
confirmed = true
|
||||||
if (!driver.swipeSpec(SwipeDirection.RIGHT, anchor)) return null
|
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 <= 2) {
|
||||||
|
anchorRetries++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
end = "swipe_failed"
|
||||||
|
break
|
||||||
|
}
|
||||||
|
anchorFailures = 0
|
||||||
|
swipes++
|
||||||
pause(350)
|
pause(350)
|
||||||
}
|
}
|
||||||
return null
|
trace("color-start confirmed=$confirmed swipes=$swipes anchorRetries=$anchorRetries end=$end")
|
||||||
|
return ColorStart(null, confirmed)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun collectSizes(
|
private fun collectSizes(
|
||||||
@@ -2219,6 +2395,53 @@ class PddProductDetailCollector(
|
|||||||
const val COLOR_IMAGE_MAX_TOTAL_BYTES = 8 * 1024 * 1024
|
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>> {
|
private fun colorRows(screen: ParsedPddScreen): List<List<VisibleSpecValue>> {
|
||||||
val values = screen.dimensions.filter { it.key == "color" }.flatMap { it.values }
|
val values = screen.dimensions.filter { it.key == "color" }.flatMap { it.values }
|
||||||
.sortedWith(compareBy({ it.node.bounds.centerY }, { it.node.bounds.left }))
|
.sortedWith(compareBy({ it.node.bounds.centerY }, { it.node.bounds.left }))
|
||||||
|
|||||||
+375
-11
@@ -9,6 +9,7 @@ import cn.ilapage.goauto.agent.automation.NodeSelector
|
|||||||
import cn.ilapage.goauto.agent.automation.PageEvidence
|
import cn.ilapage.goauto.agent.automation.PageEvidence
|
||||||
import cn.ilapage.goauto.agent.automation.PddCollectorConfig
|
import cn.ilapage.goauto.agent.automation.PddCollectorConfig
|
||||||
import cn.ilapage.goauto.agent.automation.PddCollectorDriver
|
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.PddProductDetailCollector
|
||||||
import cn.ilapage.goauto.agent.automation.PddScreenParser
|
import cn.ilapage.goauto.agent.automation.PddScreenParser
|
||||||
import cn.ilapage.goauto.agent.automation.PurchasePanelContext
|
import cn.ilapage.goauto.agent.automation.PurchasePanelContext
|
||||||
@@ -2092,6 +2093,288 @@ 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>): 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()
|
||||||
|
val driver = FakeCollectorDriver(
|
||||||
|
colors = colors,
|
||||||
|
prices = prices,
|
||||||
|
horizontalGrid = grid,
|
||||||
|
gridPixelStep = 640,
|
||||||
|
gridInitialOffset = 2000,
|
||||||
|
gridRightFailAttempts = failAttempts,
|
||||||
|
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()))
|
||||||
|
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>)
|
||||||
|
|
||||||
|
private fun inertiaGrid(inertia: Int): 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()
|
||||||
|
val driver = FakeCollectorDriver(
|
||||||
|
colors = colors,
|
||||||
|
prices = prices,
|
||||||
|
horizontalGrid = grid,
|
||||||
|
gridPixelStep = 640,
|
||||||
|
gridInertiaCaptures = inertia,
|
||||||
|
gridStrictClickPosition = true,
|
||||||
|
sizePages = listOf(listOf("S"), listOf("M")),
|
||||||
|
hideColorHeadingAfterFirstVerticalPage = true,
|
||||||
|
)
|
||||||
|
var clock = 0L
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `color grid is settled after a horizontal swipe so coasting never misses a click`() {
|
||||||
|
val calm = inertiaGrid(inertia = 0)
|
||||||
|
val coasting = inertiaGrid(inertia = 2)
|
||||||
|
|
||||||
|
assertAll47Collected(coasting.result)
|
||||||
|
assertTrue(coasting.traces.none { it.startsWith("color-click-miss") })
|
||||||
|
// Waiting adds no clicks and never exceeds four extra reads.
|
||||||
|
assertEquals(calm.driver.clickCount, coasting.driver.clickCount)
|
||||||
|
val settles = coasting.traces.filter { it.startsWith("color-settle") }
|
||||||
|
assertTrue(settles.isNotEmpty())
|
||||||
|
settles.forEach { line ->
|
||||||
|
val reads = Regex("reads=([0-9]+)").find(line)!!.groupValues[1].toInt()
|
||||||
|
assertTrue(line, reads <= 4)
|
||||||
|
}
|
||||||
|
assertTrue(settles.any { it.contains("stable=true") })
|
||||||
|
}
|
||||||
|
|
||||||
|
@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(
|
private class FakeCollectorDriver(
|
||||||
colors: List<String> = listOf("红色"),
|
colors: List<String> = listOf("红色"),
|
||||||
sizes: List<String> = listOf("S"),
|
sizes: List<String> = listOf("S"),
|
||||||
@@ -2146,6 +2429,32 @@ class PddProductDetailCollectorTest {
|
|||||||
// restored to top. Heading count drops but color/size values are
|
// restored to top. Heading count drops but color/size values are
|
||||||
// untouched, so this must NOT be treated as a collapsed panel.
|
// untouched, so this must NOT be treated as a collapsed panel.
|
||||||
private val specPanelDropsExtraDimensionAfterTopSwipe: Boolean = false,
|
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: 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 {
|
) : PddCollectorDriver {
|
||||||
var captureCount = 0
|
var captureCount = 0
|
||||||
var clickCount = 0
|
var clickCount = 0
|
||||||
@@ -2155,6 +2464,12 @@ class PddProductDetailCollectorTest {
|
|||||||
var backCount = 0
|
var backCount = 0
|
||||||
var entryClickCount = 0
|
var entryClickCount = 0
|
||||||
var restoreGestures = 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
|
||||||
|
private var lastSwipeFailure = "none"
|
||||||
private var selected: String? = initialSelectedColor
|
private var selected: String? = initialSelectedColor
|
||||||
private var previousSelected: String? = null
|
private var previousSelected: String? = null
|
||||||
private var horizontalPage = 0
|
private var horizontalPage = 0
|
||||||
@@ -2166,8 +2481,12 @@ class PddProductDetailCollectorTest {
|
|||||||
private var quickConfirmationRecovered = false
|
private var quickConfirmationRecovered = false
|
||||||
private var adviceOpen = false
|
private var adviceOpen = false
|
||||||
|
|
||||||
override fun capture(): UiSnapshot {
|
override fun capture(): UiSnapshot = captureFrame(advanceInertia = true)
|
||||||
|
|
||||||
|
private fun captureFrame(advanceInertia: Boolean): UiSnapshot {
|
||||||
captureCount++
|
captureCount++
|
||||||
|
currentDrift = inertiaRemaining * 30
|
||||||
|
if (advanceInertia && inertiaRemaining > 0) inertiaRemaining--
|
||||||
fixedSnapshot?.let { return it }
|
fixedSnapshot?.let { return it }
|
||||||
if (adviceOpen) return UiSnapshot(PDD_PACKAGE, ACTIVITY, listOf(
|
if (adviceOpen) return UiSnapshot(PDD_PACKAGE, ACTIVITY, listOf(
|
||||||
node("content", "", 0, 0, 1080, 2200, resourceId = "android:id/content", className = "android.widget.FrameLayout"),
|
node("content", "", 0, 0, 1080, 2200, resourceId = "android:id/content", className = "android.widget.FrameLayout"),
|
||||||
@@ -2224,21 +2543,33 @@ class PddProductDetailCollectorTest {
|
|||||||
val continuationPage = hideDimensionHeadingsAfterFirstVerticalPage && verticalPage > 0
|
val continuationPage = hideDimensionHeadingsAfterFirstVerticalPage && verticalPage > 0
|
||||||
val panelCollapsedNow = specPanelCollapsesAfterTopSwipe && downSwipeCount >= 1
|
val panelCollapsedNow = specPanelCollapsesAfterTopSwipe && downSwipeCount >= 1
|
||||||
val hideColorNow = specPanelHidesColorInitially && downSwipeCount == 0
|
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)]
|
?: 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)
|
val sizeHeadingTop = maxOf(700, 470 + colorRowCount * 90 + 20)
|
||||||
if (!continuationPage && !panelCollapsedNow && !(hideColorHeadingAfterFirstVerticalPage && verticalPage > 0)) {
|
if (!continuationPage && !panelCollapsedNow && !(hideColorHeadingAfterFirstVerticalPage && verticalPage > 0)) {
|
||||||
nodes += node("scroll/color-heading", "颜色分类", 20, 400, 300, 450, parentPath = "scroll")
|
nodes += node("scroll/color-heading", "颜色分类", 20, 400, 300, 450, parentPath = "scroll")
|
||||||
}
|
}
|
||||||
if (!continuationPage && !hideColorNow && !panelCollapsedNow) {
|
if (!continuationPage && !hideColorNow && !panelCollapsedNow) {
|
||||||
visibleColors
|
placedColors.forEach { (color, row, column) ->
|
||||||
.filterNot { hideSelectedColorOption && it == selected }
|
|
||||||
.forEachIndexed { index, color ->
|
|
||||||
val row = index / rowSize
|
|
||||||
val column = index % rowSize
|
|
||||||
val path = "scroll/color-$color-$captureCount"
|
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
|
val top = 470 + row * 90
|
||||||
if (imageColorCards) {
|
if (imageColorCards) {
|
||||||
nodes += SnapshotNode(
|
nodes += SnapshotNode(
|
||||||
@@ -2254,7 +2585,7 @@ class PddProductDetailCollectorTest {
|
|||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
nodes += node(
|
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",
|
clickable = true, selected = displayedSelected() == color, parentPath = "scroll",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -2314,9 +2645,10 @@ class PddProductDetailCollectorTest {
|
|||||||
clickCount++
|
clickCount++
|
||||||
clickedLabels += target.label
|
clickedLabels += target.label
|
||||||
if (target.label in failedClicks) return FreshActionResult.FAILED
|
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.isEmpty()) return FreshActionResult.NOT_FOUND
|
||||||
if (current.size > 1) return FreshActionResult.AMBIGUOUS
|
if (current.size > 1) return FreshActionResult.AMBIGUOUS
|
||||||
|
if (gridStrictClickPosition && current.single().bounds != target.bounds) return FreshActionResult.NOT_FOUND
|
||||||
if (!panelOpen && target.label == "单独购买") {
|
if (!panelOpen && target.label == "单独购买") {
|
||||||
entryClickCount++
|
entryClickCount++
|
||||||
when {
|
when {
|
||||||
@@ -2355,8 +2687,39 @@ class PddProductDetailCollectorTest {
|
|||||||
private fun displayedSelected(): String? =
|
private fun displayedSelected(): String? =
|
||||||
if (staleSelectionEvidence && previousSelected != null) previousSelected else selected
|
if (staleSelectionEvidence && previousSelected != null) previousSelected else selected
|
||||||
|
|
||||||
|
override fun specRowSwipeFailureReason(): String = lastSwipeFailure
|
||||||
|
|
||||||
override fun swipeSpec(direction: SwipeDirection, anchor: SnapshotNode?): Boolean {
|
override fun swipeSpec(direction: SwipeDirection, anchor: SnapshotNode?): Boolean {
|
||||||
swipes += direction to anchor
|
swipes += direction to anchor
|
||||||
|
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
|
||||||
|
return true
|
||||||
|
}
|
||||||
if ((direction == SwipeDirection.UP || direction == SwipeDirection.DOWN) && !verticalSwipeSucceeds) return false
|
if ((direction == SwipeDirection.UP || direction == SwipeDirection.DOWN) && !verticalSwipeSucceeds) return false
|
||||||
when (direction) {
|
when (direction) {
|
||||||
SwipeDirection.LEFT -> horizontalPage = (horizontalPage + 1).coerceAtMost(colorPages.lastIndex)
|
SwipeDirection.LEFT -> horizontalPage = (horizontalPage + 1).coerceAtMost(colorPages.lastIndex)
|
||||||
@@ -2393,6 +2756,7 @@ class PddProductDetailCollectorTest {
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val GOODS_ID = "719834019024"
|
private const val GOODS_ID = "719834019024"
|
||||||
|
private const val GRID_VIEWPORT_RIGHT = 835
|
||||||
private const val PDD_PACKAGE = "com.xunmeng.pinduoduo"
|
private const val PDD_PACKAGE = "com.xunmeng.pinduoduo"
|
||||||
private const val ACTIVITY = "com.xunmeng.pinduoduo.activity.NewPageActivity"
|
private const val ACTIVITY = "com.xunmeng.pinduoduo.activity.NewPageActivity"
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user