From 9619c38ae3498d7c0d60c888e78ccc84a35cf953 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Sat, 10 Oct 2026 17:44:49 +0800 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F --- .../automation/PddProductDetailCollector.kt | 30 +++++++++ .../agent/PddProductDetailCollectorTest.kt | 64 +++++++++++++++++-- 2 files changed, 90 insertions(+), 4 deletions(-) diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PddProductDetailCollector.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PddProductDetailCollector.kt index ad37911..7922af6 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PddProductDetailCollector.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PddProductDetailCollector.kt @@ -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? { + 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>, 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 } diff --git a/android/app/src/test/java/cn/ilapage/goauto/agent/PddProductDetailCollectorTest.kt b/android/app/src/test/java/cn/ilapage/goauto/agent/PddProductDetailCollectorTest.kt index 0fbc185..d73ceec 100644 --- a/android/app/src/test/java/cn/ilapage/goauto/agent/PddProductDetailCollectorTest.kt +++ b/android/app/src/test/java/cn/ilapage/goauto/agent/PddProductDetailCollectorTest.kt @@ -2217,6 +2217,47 @@ class PddProductDetailCollectorTest { assertTrue(payload.missing.isEmpty()) } + private class SettleRun(val driver: FakeCollectorDriver, val result: PddCollectorResult, val traces: List) + + 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() + 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 = 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>() 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