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
This commit is contained in:
QiuSW
2026-10-10 17:06:30 +08:00
co-authored by Claude Opus 5.5
parent 2f24b4b5b4
commit 74b71e2b5a
2 changed files with 114 additions and 10 deletions
@@ -1538,6 +1538,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
}
@@ -1590,6 +1591,8 @@ class PddProductDetailCollector(
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 ->
@@ -1604,7 +1607,17 @@ class PddProductDetailCollector(
observeColorDiscovery(screen, rows)
val matches = tracks.map { matchColorRow(it, rows, tracks.size) }
val matchedRow = matches[rowIndex]
if (matchedRow != null && positionSignature(matchedRow) != tracks[rowIndex].signature) {
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.
@@ -1619,6 +1632,8 @@ class PddProductDetailCollector(
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
terminate(AgentDiagnosticReason.COLOR_ROW_REFLOWED)
break
@@ -1640,31 +1655,44 @@ 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
// Every other row changed with every move of this
// row and this row is stable at its edge: they share
// one container and were swept along with it.
}
// 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()) {
if (!sharedBroken && sharedMoves > 0) {
coveredRows += (rowIndex + 1 until tracks.size)
coveredIncomplete = false
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)
lastHorizontalSwipeDoneAt = now()
}
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
@@ -1720,6 +1748,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)) }
@@ -2271,6 +2324,16 @@ class PddProductDetailCollector(
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 })
@@ -2144,7 +2144,7 @@ class PddProductDetailCollectorTest {
@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) {
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)
@@ -2155,6 +2155,7 @@ class PddProductDetailCollectorTest {
prices = prices,
horizontalGrid = grid,
gridPixelStep = step,
gridClickJitter = jitter,
sizePages = listOf(listOf("S"), listOf("M")),
hideColorHeadingAfterFirstVerticalPage = true,
)
@@ -2172,6 +2173,38 @@ class PddProductDetailCollectorTest {
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)
@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
@@ -2311,6 +2344,11 @@ class PddProductDetailCollectorTest {
// #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,
) : PddCollectorDriver {
var captureCount = 0
var clickCount = 0
@@ -2416,7 +2454,8 @@ class PddProductDetailCollectorTest {
placedColors.forEach { (color, row, column) ->
val path = "scroll/color-$color-$captureCount"
val shift = if (gridPixelStep != null) gridOffsets[row] else 0
val left = 30 + column * 230 - shift
val jitter = if (gridClickJitter && horizontalGrid != null) (clickCount % 4) * 2 else 0
val left = 30 + column * 230 - shift + jitter
val top = 470 + row * 90
if (imageColorCards) {
nodes += SnapshotNode(
@@ -2432,7 +2471,7 @@ class PddProductDetailCollectorTest {
)
} else {
nodes += node(
path, color, left, top, 220 + column * 230 - shift, top + 70,
path, color, left, top, 220 + column * 230 - shift + jitter, top + 70,
clickable = true, selected = displayedSelected() == color, parentPath = "scroll",
)
}
@@ -2546,7 +2585,9 @@ class PddProductDetailCollectorTest {
} else {
horizontalGrid.maxOf { it.size } - gridVisibleColumns
}
moved.forEach { r ->
val brokenSwipe = gridSwipeWhereOnlyFirstRowMoves > 0 &&
gridSwipeLog.size == gridSwipeWhereOnlyFirstRowMoves
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))
}