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:
+30
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user