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
This commit is contained in:
QiuSW
2026-10-10 17:44:49 +08:00
co-authored by Claude Opus 5.5
parent 037459391a
commit 9619c38ae3
2 changed files with 90 additions and 4 deletions
@@ -1707,6 +1707,7 @@ class PddProductDetailCollector(
diagnosticHorizontalSwipes++
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
@@ -1766,6 +1767,35 @@ class PddProductDetailCollector(
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 }
@@ -2217,6 +2217,47 @@ class PddProductDetailCollectorTest {
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))
@@ -2408,6 +2449,12 @@ class PddProductDetailCollectorTest {
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 {
var captureCount = 0
var clickCount = 0
@@ -2420,6 +2467,8 @@ class PddProductDetailCollectorTest {
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 previousSelected: String? = null
@@ -2432,8 +2481,12 @@ class PddProductDetailCollectorTest {
private var quickConfirmationRecovered = false
private var adviceOpen = false
override fun capture(): UiSnapshot {
override fun capture(): UiSnapshot = captureFrame(advanceInertia = true)
private fun captureFrame(advanceInertia: Boolean): UiSnapshot {
captureCount++
currentDrift = inertiaRemaining * 30
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"),
@@ -2516,7 +2569,7 @@ class PddProductDetailCollectorTest {
val path = "scroll/color-$color-$captureCount"
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
val left = 30 + column * 230 - shift + jitter + currentDrift
val top = 470 + row * 90
if (imageColorCards) {
nodes += SnapshotNode(
@@ -2532,7 +2585,7 @@ class PddProductDetailCollectorTest {
)
} else {
nodes += node(
path, color, left, top, 220 + column * 230 - shift + jitter, top + 70,
path, color, left, top, 220 + column * 230 - shift + jitter + currentDrift, top + 70,
clickable = true, selected = displayedSelected() == color, parentPath = "scroll",
)
}
@@ -2592,9 +2645,10 @@ class PddProductDetailCollectorTest {
clickCount++
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 {
@@ -2658,10 +2712,12 @@ class PddProductDetailCollectorTest {
}
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